From 51ccc59b24f9f1d11cb090e69a85462cc8c0222c Mon Sep 17 00:00:00 2001 From: Phil Oliver <3497406+poliver@users.noreply.github.com> Date: Tue, 14 Oct 2025 14:37:59 -0400 Subject: [PATCH] Clean up list item component API (#3465) --- .../mesh/ui/connections/ConnectionsScreen.kt | 4 +- .../geeksville/mesh/ui/node/NodeDetailList.kt | 1 + core/ui/detekt-baseline.xml | 3 - .../meshtastic/core/ui/component/ListItem.kt | 181 +++++++++++++++ .../core/ui/component/SettingsItem.kt | 211 ------------------ feature/node/detekt-baseline.xml | 3 +- .../node/component/AdministrationSection.kt | 39 ++-- .../feature/node/component/DeviceActions.kt | 16 +- .../node/component/DeviceDetailsSection.kt | 16 +- .../node/component/LinkedCoordinates.kt | 119 ---------- .../node/component/LinkedCoordinatesItem.kt | 88 ++++++++ .../feature/node/component/MetricsSection.kt | 4 +- .../node/component/NodeDetailsSection.kt | 47 ++-- .../feature/node/component/PositionSection.kt | 42 +--- .../node/component/RemoteDeviceActions.kt | 10 +- .../node/component/TracerouteButton.kt | 4 +- .../feature/settings/SettingsScreen.kt | 69 +++--- .../feature/settings/radio/RadioConfig.kt | 26 +-- 18 files changed, 407 insertions(+), 476 deletions(-) create mode 100644 core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ListItem.kt delete mode 100644 core/ui/src/main/kotlin/org/meshtastic/core/ui/component/SettingsItem.kt delete mode 100644 feature/node/src/main/kotlin/org/meshtastic/feature/node/component/LinkedCoordinates.kt create mode 100644 feature/node/src/main/kotlin/org/meshtastic/feature/node/component/LinkedCoordinatesItem.kt diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt index 899a0a4f7..dcd4b3301 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt @@ -65,8 +65,8 @@ import org.meshtastic.core.navigation.Route import org.meshtastic.core.navigation.SettingsRoutes import org.meshtastic.core.service.ConnectionState import org.meshtastic.core.strings.R +import org.meshtastic.core.ui.component.ListItem import org.meshtastic.core.ui.component.MainAppBar -import org.meshtastic.core.ui.component.SettingsItem import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.feature.settings.navigation.ConfigRoute import org.meshtastic.feature.settings.navigation.getNavRouteFrom @@ -197,7 +197,7 @@ fun ConnectionsScreen( if (regionUnset && selectedDevice != "m") { TitledCard(title = null) { - SettingsItem( + ListItem( leadingIcon = Icons.Rounded.Language, text = stringResource(id = R.string.set_your_region), ) { diff --git a/app/src/main/java/com/geeksville/mesh/ui/node/NodeDetailList.kt b/app/src/main/java/com/geeksville/mesh/ui/node/NodeDetailList.kt index 07415b55d..81b327589 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/node/NodeDetailList.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/node/NodeDetailList.kt @@ -120,6 +120,7 @@ fun NodeDetailList( } NodeDetailsSection(node) + NotesSection(node = node, onSaveNotes = onSaveNotes) DeviceActions( diff --git a/core/ui/detekt-baseline.xml b/core/ui/detekt-baseline.xml index 259bc856c..aa0daf951 100644 --- a/core/ui/detekt-baseline.xml +++ b/core/ui/detekt-baseline.xml @@ -35,9 +35,6 @@ ModifierMissing:LoraSignalIndicator.kt$SnrAndRssi ModifierMissing:PreferenceDivider.kt$PreferenceDivider ModifierMissing:SecurityIcon.kt$SecurityIcon - ModifierMissing:SettingsItem.kt$SettingsItem - ModifierMissing:SettingsItem.kt$SettingsItemDetail - ModifierMissing:SettingsItem.kt$SettingsItemSwitch ModifierMissing:SharedContactDialog.kt$SharedContactDialog ModifierMissing:SimpleAlertDialog.kt$SimpleAlertDialog ModifierMissing:SlidingSelector.kt$OptionLabel diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ListItem.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ListItem.kt new file mode 100644 index 000000000..b2d1d2f80 --- /dev/null +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ListItem.kt @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2025 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.meshtastic.core.ui.component + +import android.content.ClipData +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight +import androidx.compose.material.icons.rounded.Android +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.Clipboard +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import org.meshtastic.core.ui.theme.AppTheme + +/** + * A list item with an optional [leadingIcon], headline [text], optional [supportingText], and optional [trailingIcon]. + */ +@Composable +fun ListItem( + text: String, + modifier: Modifier = Modifier, + supportingText: String? = null, + textColor: Color = LocalContentColor.current, + supportingTextColor: Color = LocalContentColor.current, + copyable: Boolean = false, + enabled: Boolean = true, + leadingIcon: ImageVector? = null, + leadingIconTint: Color = LocalContentColor.current, + trailingIcon: ImageVector? = Icons.AutoMirrored.Rounded.KeyboardArrowRight, + trailingIconTint: Color = LocalContentColor.current, + onClick: (() -> Unit)? = null, +) { + val clipboard: Clipboard = LocalClipboard.current + val coroutineScope = rememberCoroutineScope() + + BasicListItem( + text = text, + modifier = modifier, + textColor = textColor, + supportingText = supportingText, + supportingTextColor = supportingTextColor, + enabled = enabled, + leadingIcon = leadingIcon, + leadingIconTint = leadingIconTint, + trailingContent = trailingIcon.icon(trailingIconTint), + onClick = onClick, + onLongClick = + if (!supportingText.isNullOrBlank() && copyable) { + { + coroutineScope.launch { + clipboard.setClipEntry(ClipEntry(ClipData.newPlainText("", supportingText))) + } + } + } else { + null + }, + ) +} + +/** A toggleable switch list item. */ +@Composable +fun SwitchListItem( + checked: Boolean, + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + textColor: Color = LocalContentColor.current, + enabled: Boolean = true, + leadingIcon: ImageVector? = null, + leadingIconTint: Color = LocalContentColor.current, +) { + BasicListItem( + text = text, + modifier = modifier, + textColor = textColor, + enabled = enabled, + leadingIcon = leadingIcon, + leadingIconTint = leadingIconTint, + trailingContent = { Switch(checked = checked, enabled = enabled, onCheckedChange = null) }, + onClick = onClick, + ) +} + +/** + * The foundational list item. It supports a [leadingIcon] (optional), headline [text] and [supportingText] (optional), + * and a [trailingContent] composable (optional). + * + * This is a core component that should facilitate most list item use cases. Please carefully consider if modifying this + * is really necessary before doing so. + * + * @see [LinkedCoordinatesItem] for example usage + */ +@Suppress("UnusedParameter") +@Composable +fun BasicListItem( + text: String, + modifier: Modifier = Modifier, + textColor: Color = LocalContentColor.current, + supportingText: String? = null, + supportingTextColor: Color = LocalContentColor.current, + enabled: Boolean = true, + leadingIcon: ImageVector? = null, + leadingIconTint: Color = LocalContentColor.current, + trailingContent: @Composable (() -> Unit)? = null, + onClick: (() -> Unit)? = null, + onLongClick: (() -> Unit)? = null, +) { + ListItem( + modifier = + if (onLongClick != null || onClick != null) { + modifier.combinedClickable(onLongClick = onLongClick, onClick = onClick ?: {}) + } else { + modifier + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + headlineContent = { Text(text = text, color = textColor) }, + supportingContent = supportingText?.let { { Text(text = it, color = supportingTextColor) } }, + leadingContent = leadingIcon.icon(leadingIconTint), + trailingContent = trailingContent, + ) +} + +@Composable +fun ImageVector?.icon(tint: Color = LocalContentColor.current): @Composable (() -> Unit)? = + this?.let { { Icon(imageVector = it, contentDescription = null, modifier = Modifier.size(24.dp), tint = tint) } } + +@Preview(showBackground = true) +@Composable +private fun ListItemPreview() { + AppTheme { ListItem(text = "Text", leadingIcon = Icons.Rounded.Android, enabled = true) {} } +} + +@Preview(showBackground = true) +@Composable +private fun ListItemDisabledPreview() { + AppTheme { ListItem(text = "Text", leadingIcon = Icons.Rounded.Android, enabled = false) {} } +} + +@Preview(showBackground = true) +@Composable +private fun SwitchListItemPreview() { + AppTheme { SwitchListItem(text = "Text", leadingIcon = Icons.Rounded.Android, checked = true, onClick = {}) } +} + +@Preview(showBackground = true) +@Composable +private fun ListItemPreviewSupportingText() { + AppTheme { + ListItem(text = "Text 1", leadingIcon = Icons.Rounded.Android, supportingText = "Text2", trailingIcon = null) + } +} diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/SettingsItem.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/SettingsItem.kt deleted file mode 100644 index 7460b2cc3..000000000 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/SettingsItem.kt +++ /dev/null @@ -1,211 +0,0 @@ -/* - * Copyright (c) 2025 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.meshtastic.core.ui.component - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight -import androidx.compose.material.icons.rounded.Android -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.ListItem -import androidx.compose.material3.ListItemDefaults -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import org.meshtastic.core.ui.theme.AppTheme - -/** - * A clickable settings item with optional supporting text and trailing content. Defaults to a trailing arrow icon if no - * custom trailing content is provided. - */ -@Composable -fun SettingsItem( - text: String, - supportingText: String? = null, - textColor: Color = LocalContentColor.current, - enabled: Boolean = true, - leadingIcon: ImageVector? = null, - leadingIconTint: Color = LocalContentColor.current, - trailingContent: @Composable (() -> Unit)? = null, - onClick: () -> Unit, -) { - val finalTrailingContent: @Composable (() -> Unit) = - trailingContent ?: { Icons.AutoMirrored.Rounded.KeyboardArrowRight.Icon(LocalContentColor.current) } - - SettingsListItem( - text = text, - textColor = textColor, - enabled = enabled, - onClick = onClick, - leadingContent = { leadingIcon.Icon(leadingIconTint) }, - supportingContent = { supportingText?.let { Text(text = it, style = MaterialTheme.typography.titleMedium) } }, - trailingContent = finalTrailingContent, - ) -} - -/** A toggleable settings switch item. */ -@Composable -fun SettingsItemSwitch( - checked: Boolean, - text: String, - textColor: Color = LocalContentColor.current, - enabled: Boolean = true, - leadingIcon: ImageVector? = null, - leadingIconTint: Color = LocalContentColor.current, - onClick: () -> Unit, -) { - SettingsListItem( - text = text, - textColor = textColor, - enabled = enabled, - onClick = onClick, - leadingContent = { leadingIcon.Icon(leadingIconTint) }, - trailingContent = { Switch(checked = checked, enabled = enabled, onCheckedChange = null) }, - ) -} - -/** A settings detail item. */ -@Composable -fun SettingsItemDetail( - text: String, - supportingText: String?, - textColor: Color = LocalContentColor.current, - icon: ImageVector? = null, - iconTint: Color = LocalContentColor.current, - enabled: Boolean = true, - onClick: (() -> Unit)? = null, -) { - SettingsListItem( - text = text, - textColor = textColor, - enabled = enabled, - onClick = onClick, - leadingContent = { icon.Icon(iconTint) }, - supportingContent = { - supportingText?.let { - Text( - text = it, - style = MaterialTheme.typography.titleLarge, - color = textColor, // Detail style explicitly sets color - ) - } - }, - trailingContent = {}, - ) -} - -/** A settings detail item with composable content for the detail. */ -@Composable -fun SettingsItemDetail( - text: String, - textColor: Color = LocalContentColor.current, - icon: ImageVector? = null, - iconTint: Color = LocalContentColor.current, - enabled: Boolean = true, - onClick: (() -> Unit)? = null, - supportingContent: @Composable () -> Unit, -) { - SettingsListItem( - text = text, - textColor = textColor, - enabled = enabled, - onClick = onClick, - leadingContent = { icon.Icon(iconTint) }, - supportingContent = supportingContent, - trailingContent = {}, - ) -} - -/** - * Base composable for all settings screen list items. It handles the Material3 [ListItem] structure and the conditional - * click wrapper. - */ -@Composable -private fun SettingsListItem( - text: String, - textColor: Color, - enabled: Boolean, - onClick: (() -> Unit)?, - leadingContent: @Composable (() -> Unit)? = null, - supportingContent: @Composable (() -> Unit)? = null, - trailingContent: @Composable (() -> Unit)? = null, -) { - val listItemContent: @Composable ColumnScope.() -> Unit = { - ListItem( - modifier = Modifier.padding(horizontal = 8.dp), - colors = ListItemDefaults.colors(containerColor = Color.Transparent), - headlineContent = { Text(text = text, color = textColor) }, - supportingContent = { SelectionContainer { supportingContent?.invoke() } }, - leadingContent = leadingContent, - trailingContent = trailingContent, - ) - } - - if (onClick != null) { - Card( - onClick = onClick, - enabled = enabled, - colors = - CardDefaults.cardColors(containerColor = Color.Transparent, disabledContainerColor = Color.Transparent), - content = listItemContent, - ) - } else { - Column(content = listItemContent) - } -} - -@Composable -private fun ImageVector?.Icon(tint: Color = LocalContentColor.current) = - this?.let { Icon(imageVector = it, contentDescription = null, modifier = Modifier.size(24.dp), tint = tint) } - -@Preview(showBackground = true) -@Composable -private fun SettingsItemPreview() { - AppTheme { SettingsItem(text = "Text", leadingIcon = Icons.Rounded.Android, enabled = true) {} } -} - -@Preview(showBackground = true) -@Composable -private fun SettingsItemDisabledPreview() { - AppTheme { SettingsItem(text = "Text", leadingIcon = Icons.Rounded.Android, enabled = false) {} } -} - -@Preview(showBackground = true) -@Composable -private fun SettingsItemSwitchPreview() { - AppTheme { SettingsItemSwitch(text = "Text", leadingIcon = Icons.Rounded.Android, checked = true) {} } -} - -@Preview(showBackground = true) -@Composable -private fun SettingsItemDetailPreview() { - AppTheme { SettingsItemDetail(text = "Text 1", icon = Icons.Rounded.Android, supportingText = "Text2") } -} diff --git a/feature/node/detekt-baseline.xml b/feature/node/detekt-baseline.xml index 921b8a5c5..c0e8546a6 100644 --- a/feature/node/detekt-baseline.xml +++ b/feature/node/detekt-baseline.xml @@ -4,12 +4,13 @@ ComposableParamOrder:ElevationInfo.kt$ElevationInfo ComposableParamOrder:LastHeardInfo.kt$LastHeardInfo - ComposableParamOrder:LinkedCoordinates.kt$LinkedCoordinates ComposableParamOrder:NodeFilterTextField.kt$NodeFilterTextField ComposableParamOrder:NodeItem.kt$NodeItem ComposableParamOrder:SatelliteCountInfo.kt$SatelliteCountInfo ComposableParamOrder:TracerouteButton.kt$TracerouteButton ModifierMissing:NodeStatusIcons.kt$NodeStatusIcons + MultipleEmitters:NodeDetailsSection.kt$MainNodeDetails + MultipleEmitters:RemoteDeviceActions.kt$RemoteDeviceActions ParameterNaming:NodeFilterTextField.kt$onToggleShowIgnored PreviewPublic:NodeItem.kt$NodeInfoPreview PreviewPublic:NodeItem.kt$NodeInfoSimplePreview diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt index 19f58552a..fdc5ea9e0 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt @@ -35,8 +35,7 @@ import org.meshtastic.core.model.DeviceVersion import org.meshtastic.core.navigation.SettingsRoutes import org.meshtastic.core.service.ServiceAction import org.meshtastic.core.strings.R -import org.meshtastic.core.ui.component.SettingsItem -import org.meshtastic.core.ui.component.SettingsItemDetail +import org.meshtastic.core.ui.component.ListItem import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.core.ui.theme.StatusColors.StatusGreen import org.meshtastic.core.ui.theme.StatusColors.StatusOrange @@ -56,13 +55,13 @@ fun AdministrationSection( modifier: Modifier = Modifier, ) { TitledCard(stringResource(id = R.string.administration), modifier = modifier) { - SettingsItem( + ListItem( text = stringResource(id = R.string.request_metadata), leadingIcon = Icons.Default.Memory, - trailingContent = {}, + trailingIcon = null, onClick = { onAction(NodeDetailAction.TriggerServiceAction(ServiceAction.GetDeviceMetadata(node.num))) }, ) - SettingsItem( + ListItem( text = stringResource(id = R.string.remote_admin), leadingIcon = Icons.Default.Settings, enabled = metricsState.isLocal || node.metadata != null, @@ -81,10 +80,12 @@ fun AdministrationSection( else -> Icons.Default.ForkLeft } - SettingsItemDetail( + ListItem( text = stringResource(R.string.firmware_edition), - icon = icon, + leadingIcon = icon, supportingText = it.name, + copyable = true, + trailingIcon = null, ) } firmwareVersion?.let { firmwareVersion -> @@ -94,25 +95,31 @@ fun AdministrationSection( val deviceVersion = DeviceVersion(firmwareVersion.substringBeforeLast(".")) val statusColor = deviceVersion.determineFirmwareStatusColor(latestStable, latestAlpha) - SettingsItemDetail( + ListItem( text = stringResource(R.string.installed_firmware_version), - icon = Icons.Default.Memory, + leadingIcon = Icons.Default.Memory, supportingText = firmwareVersion.substringBeforeLast("."), - iconTint = statusColor, + copyable = true, + leadingIconTint = statusColor, + trailingIcon = null, ) HorizontalDivider() - SettingsItemDetail( + ListItem( text = stringResource(R.string.latest_stable_firmware), - icon = Icons.Default.Memory, + leadingIcon = Icons.Default.Memory, supportingText = latestStable.id.substringBeforeLast(".").replace("v", ""), - iconTint = MaterialTheme.colorScheme.StatusGreen, + copyable = true, + leadingIconTint = MaterialTheme.colorScheme.StatusGreen, + trailingIcon = null, onClick = { onFirmwareSelect(latestStable) }, ) - SettingsItemDetail( + ListItem( text = stringResource(R.string.latest_alpha_firmware), - icon = Icons.Default.Memory, + leadingIcon = Icons.Default.Memory, supportingText = latestAlpha.id.substringBeforeLast(".").replace("v", ""), - iconTint = MaterialTheme.colorScheme.StatusYellow, + copyable = true, + leadingIconTint = MaterialTheme.colorScheme.StatusYellow, + trailingIcon = null, onClick = { onFirmwareSelect(latestAlpha) }, ) } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt index de4a76204..3402f9e98 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt @@ -35,8 +35,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import org.meshtastic.core.database.model.Node import org.meshtastic.core.strings.R -import org.meshtastic.core.ui.component.SettingsItem -import org.meshtastic.core.ui.component.SettingsItemSwitch +import org.meshtastic.core.ui.component.ListItem +import org.meshtastic.core.ui.component.SwitchListItem import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.feature.node.model.NodeDetailAction @@ -67,33 +67,33 @@ fun DeviceActions( onConfirmRemove = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Remove(it))) }, ) TitledCard(title = stringResource(R.string.actions), modifier = modifier) { - SettingsItem( + ListItem( text = stringResource(id = R.string.share_contact), leadingIcon = Icons.Rounded.QrCode2, - trailingContent = {}, + trailingIcon = null, onClick = { onAction(NodeDetailAction.ShareContact) }, ) if (!isLocal) { RemoteDeviceActions(node = node, lastTracerouteTime = lastTracerouteTime, onAction = onAction) } - SettingsItemSwitch( + SwitchListItem( text = stringResource(R.string.favorite), leadingIcon = if (node.isFavorite) Icons.Default.Star else Icons.Default.StarBorder, leadingIconTint = if (node.isFavorite) Color.Yellow else LocalContentColor.current, checked = node.isFavorite, onClick = { displayFavoriteDialog = true }, ) - SettingsItemSwitch( + SwitchListItem( text = stringResource(R.string.ignore), leadingIcon = if (node.isIgnored) Icons.AutoMirrored.Outlined.VolumeMute else Icons.AutoMirrored.Default.VolumeUp, checked = node.isIgnored, onClick = { displayIgnoreDialog = true }, ) - SettingsItem( + ListItem( text = stringResource(id = R.string.remove), leadingIcon = Icons.Rounded.Delete, - trailingContent = {}, + trailingIcon = null, onClick = { displayRemoveDialog = true }, ) } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceDetailsSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceDetailsSection.kt index 8bb4d7fc6..319a9950e 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceDetailsSection.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceDetailsSection.kt @@ -45,7 +45,7 @@ import coil3.compose.AsyncImage import coil3.request.ImageRequest import org.meshtastic.core.model.DeviceHardware import org.meshtastic.core.strings.R -import org.meshtastic.core.ui.component.SettingsItemDetail +import org.meshtastic.core.ui.component.ListItem import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.core.ui.theme.StatusColors.StatusGreen import org.meshtastic.core.ui.theme.StatusColors.StatusRed @@ -71,26 +71,28 @@ fun DeviceDetailsSection(state: MetricsState, modifier: Modifier = Modifier) { Spacer(modifier = Modifier.height(16.dp)) - SettingsItemDetail( + ListItem( text = stringResource(R.string.hardware), - icon = Icons.Default.Router, + leadingIcon = Icons.Default.Router, supportingText = hwModelName, + copyable = true, + trailingIcon = null, ) - SettingsItemDetail( + ListItem( text = if (isSupported) { stringResource(R.string.supported) } else { stringResource(R.string.supported_by_community) }, - icon = + leadingIcon = if (isSupported) { Icons.TwoTone.Verified } else { ImageVector.vectorResource(org.meshtastic.feature.node.R.drawable.unverified) }, - supportingText = null, - iconTint = if (isSupported) colorScheme.StatusGreen else colorScheme.StatusRed, + leadingIconTint = if (isSupported) colorScheme.StatusGreen else colorScheme.StatusRed, + trailingIcon = null, ) } } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/LinkedCoordinates.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/LinkedCoordinates.kt deleted file mode 100644 index 2cc69f50f..000000000 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/LinkedCoordinates.kt +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Copyright (c) 2025 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.meshtastic.feature.node.component - -import android.content.ActivityNotFoundException -import android.content.ClipData -import android.content.Context -import android.content.Intent -import android.widget.Toast -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.combinedClickable -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.ClipEntry -import androidx.compose.ui.platform.Clipboard -import androidx.compose.ui.platform.LocalClipboard -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.tooling.preview.PreviewLightDark -import androidx.core.net.toUri -import kotlinx.coroutines.launch -import org.meshtastic.core.model.util.GPSFormat -import org.meshtastic.core.ui.theme.AppTheme -import org.meshtastic.core.ui.theme.HyperlinkBlue -import timber.log.Timber -import java.net.URLEncoder - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun LinkedCoordinates(modifier: Modifier = Modifier, latitude: Double, longitude: Double, nodeName: String) { - val context = LocalContext.current - val clipboard: Clipboard = LocalClipboard.current - val coroutineScope = rememberCoroutineScope() - val style = - SpanStyle( - color = HyperlinkBlue, - fontStyle = MaterialTheme.typography.titleLarge.fontStyle, - textDecoration = TextDecoration.Underline, - ) - - val annotatedString = rememberAnnotatedString(latitude, longitude, nodeName, style) - - Text( - modifier = - modifier.combinedClickable( - onClick = { handleClick(context, annotatedString) }, - onLongClick = { - coroutineScope.launch { - clipboard.setClipEntry(ClipEntry(ClipData.newPlainText("", annotatedString))) - Timber.d("Copied to clipboard") - } - }, - ), - text = annotatedString, - style = MaterialTheme.typography.titleLarge, - ) -} - -@Composable -private fun rememberAnnotatedString(latitude: Double, longitude: Double, nodeName: String, style: SpanStyle) = - buildAnnotatedString { - pushStringAnnotation( - tag = "gps", - annotation = - "geo:0,0?q=$latitude,$longitude&z=17&label=${ - URLEncoder.encode(nodeName, "utf-8") - }", - ) - withStyle(style = style) { - val gpsString = GPSFormat.toDec(latitude, longitude) - append(gpsString) - } - pop() - } - -private fun handleClick(context: Context, annotatedString: AnnotatedString) { - annotatedString.getStringAnnotations(tag = "gps", start = 0, end = annotatedString.length).firstOrNull()?.let { - val uri = it.item.toUri() - val intent = Intent(Intent.ACTION_VIEW, uri).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } - - try { - if (intent.resolveActivity(context.packageManager) != null) { - context.startActivity(intent) - } else { - Toast.makeText(context, "No application available to open this location!", Toast.LENGTH_LONG).show() - } - } catch (ex: ActivityNotFoundException) { - Timber.d("Failed to open geo intent: $ex") - } - } -} - -@PreviewLightDark -@Composable -private fun LinkedCoordinatesPreview() { - AppTheme { LinkedCoordinates(latitude = 37.7749, longitude = -122.4194, nodeName = "Test Node Name") } -} diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/LinkedCoordinatesItem.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/LinkedCoordinatesItem.kt new file mode 100644 index 000000000..3552646e1 --- /dev/null +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/LinkedCoordinatesItem.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.meshtastic.feature.node.component + +import android.content.ActivityNotFoundException +import android.content.ClipData +import android.content.Intent +import android.widget.Toast +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.Clipboard +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.core.net.toUri +import kotlinx.coroutines.launch +import org.meshtastic.core.database.model.Node +import org.meshtastic.core.model.util.GPSFormat +import org.meshtastic.core.model.util.formatAgo +import org.meshtastic.core.strings.R +import org.meshtastic.core.ui.component.BasicListItem +import org.meshtastic.core.ui.component.icon +import org.meshtastic.core.ui.theme.AppTheme +import timber.log.Timber +import java.net.URLEncoder + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LinkedCoordinatesItem(node: Node) { + val context = LocalContext.current + val clipboard: Clipboard = LocalClipboard.current + val coroutineScope = rememberCoroutineScope() + + val ago = formatAgo(node.position.time) + val coordinates = GPSFormat.toDec(node.latitude, node.longitude) + + BasicListItem( + text = stringResource(R.string.last_position_update), + leadingIcon = Icons.Default.LocationOn, + supportingText = "$ago • $coordinates", + trailingContent = Icons.AutoMirrored.Rounded.KeyboardArrowRight.icon(), + onClick = { + val label = URLEncoder.encode(node.user.longName, "utf-8") + val uri = "geo:0,0?q=${node.latitude},${node.longitude}&z=17&label=$label".toUri() + val intent = Intent(Intent.ACTION_VIEW, uri).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } + + try { + if (intent.resolveActivity(context.packageManager) != null) { + context.startActivity(intent) + } else { + Toast.makeText(context, "No application available to open this location!", Toast.LENGTH_LONG).show() + } + } catch (ex: ActivityNotFoundException) { + Timber.d("Failed to open geo intent: $ex") + } + }, + onLongClick = { + coroutineScope.launch { clipboard.setClipEntry(ClipEntry(ClipData.newPlainText("", coordinates))) } + }, + ) +} + +@PreviewLightDark +@Composable +private fun LinkedCoordinatesPreview() { + AppTheme { LinkedCoordinatesItem(Node(0)) } +} diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/MetricsSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/MetricsSection.kt index 1d31c1744..0c3e8a933 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/MetricsSection.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/MetricsSection.kt @@ -25,7 +25,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import org.meshtastic.core.database.model.Node import org.meshtastic.core.strings.R -import org.meshtastic.core.ui.component.SettingsItem +import org.meshtastic.core.ui.component.ListItem import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.feature.node.model.LogsType import org.meshtastic.feature.node.model.MetricsState @@ -57,7 +57,7 @@ fun MetricsSection( if (nonPositionLogs.isNotEmpty()) { TitledCard(title = stringResource(id = R.string.logs), modifier = modifier) { nonPositionLogs.forEach { type -> - SettingsItem(text = stringResource(type.titleRes), leadingIcon = type.icon) { + ListItem(text = stringResource(type.titleRes), leadingIcon = type.icon) { onAction(NodeDetailAction.Navigate(type.route)) } } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt index e17e6f9f7..450b18f28 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt @@ -45,7 +45,7 @@ import org.meshtastic.core.database.model.Node import org.meshtastic.core.model.util.formatAgo import org.meshtastic.core.model.util.formatUptime import org.meshtastic.core.strings.R -import org.meshtastic.core.ui.component.SettingsItemDetail +import org.meshtastic.core.ui.component.ListItem import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.feature.node.model.isEffectivelyUnmessageable @@ -80,48 +80,59 @@ fun NodeDetailsSection(node: Node, modifier: Modifier = Modifier) { @Composable private fun MainNodeDetails(node: Node) { - SettingsItemDetail( + ListItem( text = stringResource(R.string.long_name), - icon = Icons.TwoTone.Person, + leadingIcon = Icons.TwoTone.Person, supportingText = node.user.longName.ifEmpty { "???" }, + copyable = true, + trailingIcon = null, ) - SettingsItemDetail( + ListItem( text = stringResource(R.string.short_name), - icon = Icons.Outlined.Person, + leadingIcon = Icons.Outlined.Person, supportingText = node.user.shortName.ifEmpty { "???" }, + copyable = true, + trailingIcon = null, ) - SettingsItemDetail( + ListItem( text = stringResource(R.string.node_number), - icon = Icons.Default.Numbers, + leadingIcon = Icons.Default.Numbers, supportingText = node.num.toUInt().toString(), + copyable = true, + trailingIcon = null, ) - SettingsItemDetail( + ListItem( text = stringResource(R.string.user_id), - icon = Icons.Default.Person, + leadingIcon = Icons.Default.Person, supportingText = node.user.id, + copyable = true, + trailingIcon = null, ) - SettingsItemDetail( + ListItem( text = stringResource(R.string.role), - icon = Icons.Default.Work, + leadingIcon = Icons.Default.Work, supportingText = node.user.role.name, + trailingIcon = null, ) if (node.isEffectivelyUnmessageable) { - SettingsItemDetail( + ListItem( text = stringResource(R.string.unmonitored_or_infrastructure), - icon = Icons.Outlined.NoCell, - supportingText = null, + leadingIcon = Icons.Outlined.NoCell, + trailingIcon = null, ) } if (node.deviceMetrics.uptimeSeconds > 0) { - SettingsItemDetail( + ListItem( text = stringResource(R.string.uptime), - icon = Icons.Default.CheckCircle, + leadingIcon = Icons.Default.CheckCircle, supportingText = formatUptime(node.deviceMetrics.uptimeSeconds), + trailingIcon = null, ) } - SettingsItemDetail( + ListItem( text = stringResource(R.string.node_sort_last_heard), - icon = Icons.Default.History, + leadingIcon = Icons.Default.History, supportingText = formatAgo(node.lastHeard), + trailingIcon = null, ) } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt index 0e05963aa..9712dc5be 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt @@ -17,26 +17,19 @@ package org.meshtastic.feature.node.component -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.LocationOn import androidx.compose.material.icons.filled.SocialDistance -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import org.meshtastic.core.database.model.Node -import org.meshtastic.core.model.util.formatAgo import org.meshtastic.core.model.util.toDistanceString import org.meshtastic.core.strings.R -import org.meshtastic.core.ui.component.SettingsItem -import org.meshtastic.core.ui.component.SettingsItemDetail +import org.meshtastic.core.ui.component.ListItem import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.feature.node.model.LogsType import org.meshtastic.feature.node.model.MetricsState @@ -61,52 +54,39 @@ fun PositionSection( // Current position coordinates (linked) if (hasValidPosition) { InlineMap(node = node, Modifier.fillMaxWidth().height(200.dp)) - SettingsItemDetail( - text = stringResource(R.string.last_position_update), - icon = Icons.Default.LocationOn, - supportingContent = { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text(formatAgo(node.position.time), style = MaterialTheme.typography.titleLarge) - LinkedCoordinates( - latitude = node.latitude, - longitude = node.longitude, - nodeName = node.user.longName, - ) - } - }, - ) + + LinkedCoordinatesItem(node) } // Distance (if available) if (distance != null && distance.isNotEmpty()) { - SettingsItemDetail( + ListItem( text = stringResource(R.string.node_sort_distance), - icon = Icons.Default.SocialDistance, + leadingIcon = Icons.Default.SocialDistance, supportingText = distance, + copyable = true, + trailingIcon = null, ) } // Exchange position action - SettingsItem( + ListItem( text = stringResource(id = R.string.exchange_position), leadingIcon = Icons.Default.LocationOn, - trailingContent = {}, + trailingIcon = null, onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestPosition(node))) }, ) // Node Map log if (availableLogs.contains(LogsType.NODE_MAP)) { - SettingsItem(text = stringResource(LogsType.NODE_MAP.titleRes), leadingIcon = LogsType.NODE_MAP.icon) { + ListItem(text = stringResource(LogsType.NODE_MAP.titleRes), leadingIcon = LogsType.NODE_MAP.icon) { onAction(NodeDetailAction.Navigate(LogsType.NODE_MAP.route)) } } // Positions Log if (availableLogs.contains(LogsType.POSITIONS)) { - SettingsItem(text = stringResource(LogsType.POSITIONS.titleRes), leadingIcon = LogsType.POSITIONS.icon) { + ListItem(text = stringResource(LogsType.POSITIONS.titleRes), leadingIcon = LogsType.POSITIONS.icon) { onAction(NodeDetailAction.Navigate(LogsType.POSITIONS.route)) } } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/RemoteDeviceActions.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/RemoteDeviceActions.kt index e1c00f6d6..dae9cc32a 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/RemoteDeviceActions.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/RemoteDeviceActions.kt @@ -24,24 +24,24 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import org.meshtastic.core.database.model.Node import org.meshtastic.core.strings.R -import org.meshtastic.core.ui.component.SettingsItem +import org.meshtastic.core.ui.component.ListItem import org.meshtastic.feature.node.model.NodeDetailAction import org.meshtastic.feature.node.model.isEffectivelyUnmessageable @Composable internal fun RemoteDeviceActions(node: Node, lastTracerouteTime: Long?, onAction: (NodeDetailAction) -> Unit) { if (!node.isEffectivelyUnmessageable) { - SettingsItem( + ListItem( text = stringResource(id = R.string.direct_message), leadingIcon = Icons.AutoMirrored.TwoTone.Message, - trailingContent = {}, + trailingIcon = null, onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.DirectMessage(node))) }, ) } - SettingsItem( + ListItem( text = stringResource(id = R.string.exchange_userinfo), leadingIcon = Icons.Default.Person, - trailingContent = {}, + trailingIcon = null, onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestUserInfo(node))) }, ) TracerouteButton( diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/TracerouteButton.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/TracerouteButton.kt index 76efe380b..65f9d2702 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/TracerouteButton.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/TracerouteButton.kt @@ -35,7 +35,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.meshtastic.core.strings.R -import org.meshtastic.core.ui.component.SettingsItem +import org.meshtastic.core.ui.component.BasicListItem import org.meshtastic.core.ui.theme.AppTheme private const val COOL_DOWN_TIME_MS = 30000L @@ -70,7 +70,7 @@ private fun TracerouteButton(text: String, progress: Float, onClick: () -> Unit) val stroke = Stroke(width = with(LocalDensity.current) { 2.dp.toPx() }, cap = StrokeCap.Round) - SettingsItem( + BasicListItem( text = text, enabled = !isCoolingDown, leadingIcon = Icons.Default.Route, diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt index e6eac39c7..8ded4d2ad 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt @@ -34,6 +34,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.rounded.AppSettingsAlt import androidx.compose.material.icons.rounded.FormatPaint @@ -52,6 +53,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.net.toUri @@ -63,11 +65,10 @@ import kotlinx.coroutines.delay import org.meshtastic.core.common.gpsDisabled import org.meshtastic.core.navigation.Route import org.meshtastic.core.strings.R +import org.meshtastic.core.ui.component.ListItem import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.component.MultipleChoiceAlertDialog -import org.meshtastic.core.ui.component.SettingsItem -import org.meshtastic.core.ui.component.SettingsItemDetail -import org.meshtastic.core.ui.component.SettingsItemSwitch +import org.meshtastic.core.ui.component.SwitchListItem import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.core.ui.theme.MODE_DYNAMIC import org.meshtastic.feature.settings.navigation.getNavRouteFrom @@ -230,11 +231,12 @@ fun SettingsScreen( ) val context = LocalContext.current + val resources = LocalResources.current TitledCard(title = stringResource(R.string.app_settings), modifier = Modifier.padding(top = 16.dp)) { if (state.analyticsAvailable) { val allowed by viewModel.analyticsAllowedFlow.collectAsStateWithLifecycle(false) - SettingsItemSwitch( + SwitchListItem( text = stringResource(R.string.analytics_okay), checked = allowed, leadingIcon = Icons.Default.BugReport, @@ -255,7 +257,7 @@ fun SettingsScreen( } else { Toast.makeText( context, - context.getString(R.string.location_disabled), + resources.getString(R.string.location_disabled), Toast.LENGTH_LONG, ) .show() @@ -269,14 +271,13 @@ fun SettingsScreen( } } - SettingsItemSwitch( + SwitchListItem( text = stringResource(R.string.provide_location_to_mesh), leadingIcon = Icons.Rounded.LocationOn, enabled = !isGpsDisabled, checked = provideLocation, - ) { - settingsViewModel.setProvideLocation(!provideLocation) - } + onClick = { settingsViewModel.setProvideLocation(!provideLocation) }, + ) val settingsLauncher = rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) {} @@ -284,15 +285,10 @@ fun SettingsScreen( // On Android 12 and below, system app settings for language are not available. Use the in-app language // picker for these devices. val useInAppLangPicker = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU - SettingsItem( + ListItem( text = stringResource(R.string.preferences_language), leadingIcon = Icons.Rounded.Language, - trailingContent = - if (useInAppLangPicker) { - null - } else { - {} - }, + trailingIcon = if (useInAppLangPicker) null else Icons.AutoMirrored.Rounded.KeyboardArrowRight, ) { if (useInAppLangPicker) { showLanguagePickerDialog = true @@ -307,10 +303,10 @@ fun SettingsScreen( } } - SettingsItem( + ListItem( text = stringResource(R.string.theme), leadingIcon = Icons.Rounded.FormatPaint, - trailingContent = {}, + trailingIcon = null, ) { showThemePickerDialog = true } @@ -322,10 +318,10 @@ fun SettingsScreen( it.data?.data?.let { uri -> settingsViewModel.saveDataCsv(uri) } } } - SettingsItem( + ListItem( text = stringResource(R.string.save_rangetest), leadingIcon = Icons.Rounded.Output, - trailingContent = {}, + trailingIcon = null, ) { val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { @@ -342,10 +338,10 @@ fun SettingsScreen( it.data?.data?.let { uri -> settingsViewModel.saveDataCsv(uri) } } } - SettingsItem( + ListItem( text = stringResource(R.string.export_data_csv), leadingIcon = Icons.Rounded.Output, - trailingContent = {}, + trailingIcon = null, ) { val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { @@ -356,18 +352,18 @@ fun SettingsScreen( exportDataLauncher.launch(intent) } - SettingsItem( + ListItem( text = stringResource(R.string.intro_show), leadingIcon = Icons.Rounded.WavingHand, - trailingContent = {}, + trailingIcon = null, ) { settingsViewModel.showAppIntro() } - SettingsItem( + ListItem( text = stringResource(R.string.system_settings), leadingIcon = Icons.Rounded.AppSettingsAlt, - trailingContent = null, + trailingIcon = null, ) { val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) intent.data = Uri.fromParts("package", context.packageName, null) @@ -397,6 +393,7 @@ private fun AppVersionButton( onUnlockExcludedModules: () -> Unit, ) { val context = LocalContext.current + val resources = LocalResources.current var clickCount by remember { mutableIntStateOf(0) } LaunchedEffect(clickCount) { @@ -406,23 +403,25 @@ private fun AppVersionButton( } } - SettingsItemDetail( + ListItem( text = stringResource(R.string.app_version), - icon = Icons.Rounded.Memory, + leadingIcon = Icons.Rounded.Memory, supportingText = appVersionName, + trailingIcon = null, ) { clickCount = clickCount.inc().coerceIn(0, UNLOCK_CLICK_COUNT) when { clickCount == UNLOCKED_CLICK_COUNT && excludedModulesUnlocked -> { clickCount = 0 - Toast.makeText(context, context.getString(R.string.modules_already_unlocked), Toast.LENGTH_LONG).show() + Toast.makeText(context, resources.getString(R.string.modules_already_unlocked), Toast.LENGTH_LONG) + .show() } clickCount == UNLOCK_CLICK_COUNT -> { clickCount = 0 onUnlockExcludedModules() - Toast.makeText(context, context.getString(R.string.modules_unlocked), Toast.LENGTH_LONG).show() + Toast.makeText(context, resources.getString(R.string.modules_unlocked), Toast.LENGTH_LONG).show() } } } @@ -448,13 +447,13 @@ private fun LanguagePickerDialog(onDismiss: () -> Unit) { @Composable private fun ThemePickerDialog(onClickTheme: (Int) -> Unit, onDismiss: () -> Unit) { - val context = LocalContext.current + val resources = LocalResources.current val themeMap = remember { mapOf( - context.getString(R.string.dynamic) to MODE_DYNAMIC, - context.getString(R.string.theme_light) to AppCompatDelegate.MODE_NIGHT_NO, - context.getString(R.string.theme_dark) to AppCompatDelegate.MODE_NIGHT_YES, - context.getString(R.string.theme_system) to AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM, + resources.getString(R.string.dynamic) to MODE_DYNAMIC, + resources.getString(R.string.theme_light) to AppCompatDelegate.MODE_NIGHT_NO, + resources.getString(R.string.theme_dark) to AppCompatDelegate.MODE_NIGHT_YES, + resources.getString(R.string.theme_system) to AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM, ) .mapValues { (_, value) -> { onClickTheme(value) } } } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt index e2c747a39..d93d2cf19 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt @@ -45,7 +45,7 @@ import androidx.compose.ui.unit.dp import org.meshtastic.core.navigation.Route import org.meshtastic.core.navigation.SettingsRoutes import org.meshtastic.core.strings.R -import org.meshtastic.core.ui.component.SettingsItem +import org.meshtastic.core.ui.component.ListItem import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.core.ui.theme.AppTheme import org.meshtastic.core.ui.theme.StatusColors.StatusRed @@ -81,9 +81,7 @@ fun RadioConfigItemList( ManagedMessage() } ConfigRoute.radioConfigRoutes.forEach { - SettingsItem(text = stringResource(it.title), leadingIcon = it.icon, enabled = enabled) { - onRouteClick(it) - } + ListItem(text = stringResource(it.title), leadingIcon = it.icon, enabled = enabled) { onRouteClick(it) } } } @@ -92,9 +90,7 @@ fun RadioConfigItemList( ManagedMessage() } ConfigRoute.deviceConfigRoutes(state.metadata).forEach { - SettingsItem(text = stringResource(it.title), leadingIcon = it.icon, enabled = enabled) { - onRouteClick(it) - } + ListItem(text = stringResource(it.title), leadingIcon = it.icon, enabled = enabled) { onRouteClick(it) } } } @@ -104,9 +100,7 @@ fun RadioConfigItemList( } modules.forEach { - SettingsItem(text = stringResource(it.title), leadingIcon = it.icon, enabled = enabled) { - onRouteClick(it) - } + ListItem(text = stringResource(it.title), leadingIcon = it.icon, enabled = enabled) { onRouteClick(it) } } } } @@ -117,13 +111,13 @@ fun RadioConfigItemList( ManagedMessage() } - SettingsItem( + ListItem( text = stringResource(R.string.import_configuration), leadingIcon = Icons.Default.Download, enabled = enabled, onClick = onImport, ) - SettingsItem( + ListItem( text = stringResource(R.string.export_configuration), leadingIcon = Icons.Default.Upload, enabled = enabled, @@ -143,11 +137,11 @@ fun RadioConfigItemList( ) } - SettingsItem( + ListItem( enabled = enabled, text = stringResource(route.title), leadingIcon = route.icon, - trailingContent = {}, + trailingIcon = null, ) { showDialog = true } @@ -159,14 +153,14 @@ fun RadioConfigItemList( ManagedMessage() } - SettingsItem( + ListItem( text = stringResource(R.string.clean_node_database_title), leadingIcon = Icons.Rounded.CleaningServices, enabled = enabled, onClick = { onNavigate(SettingsRoutes.CleanNodeDb) }, ) - SettingsItem( + ListItem( text = stringResource(R.string.debug_panel), leadingIcon = Icons.Rounded.BugReport, enabled = enabled,