From 9b1f27cf17d8db208f6064786a03a5a52b2c90b8 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Fri, 23 May 2025 18:08:45 -0500
Subject: [PATCH] refactor: add tooltips, unmessageable tweaks (#1925)
---
.../java/com/geeksville/mesh/model/UIState.kt | 2 +-
.../java/com/geeksville/mesh/ui/NodeItem.kt | 27 +++--
.../mesh/ui/components/NodeStatusIcons.kt | 114 ++++++++++++++++++
.../mesh/ui/components/StatusIcons.kt | 69 -----------
.../components/UserConfigItemList.kt | 13 +-
5 files changed, 145 insertions(+), 80 deletions(-)
create mode 100644 app/src/main/java/com/geeksville/mesh/ui/components/NodeStatusIcons.kt
delete mode 100644 app/src/main/java/com/geeksville/mesh/ui/components/StatusIcons.kt
diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt
index bbd86f6db..33c4eccdb 100644
--- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt
+++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt
@@ -260,7 +260,7 @@ class UIViewModel @Inject constructor(
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private val nodeFilterText = MutableStateFlow("")
- private val nodeSortOption = MutableStateFlow(NodeSortOption.LAST_HEARD)
+ private val nodeSortOption = MutableStateFlow(NodeSortOption.VIA_FAVORITE)
private val includeUnknown = MutableStateFlow(preferences.getBoolean("include-unknown", false))
private val showDetails = MutableStateFlow(preferences.getBoolean("show-details", false))
diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeItem.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeItem.kt
index 6d43119d7..512b51aa9 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/NodeItem.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/NodeItem.kt
@@ -17,6 +17,7 @@
package com.geeksville.mesh.ui
+import android.content.res.Configuration
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
@@ -57,13 +58,14 @@ import com.geeksville.mesh.ConfigProtos.Config.DeviceConfig
import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.R
+import com.geeksville.mesh.model.DeviceVersion
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.model.isUnmessageableRole
import com.geeksville.mesh.ui.components.NodeKeyStatusIcon
import com.geeksville.mesh.ui.components.NodeMenu
import com.geeksville.mesh.ui.components.NodeMenuAction
+import com.geeksville.mesh.ui.components.NodeStatusIcons
import com.geeksville.mesh.ui.components.SignalInfo
-import com.geeksville.mesh.ui.components.StatusIcons
import com.geeksville.mesh.ui.compose.ElevationInfo
import com.geeksville.mesh.ui.compose.SatelliteCountInfo
import com.geeksville.mesh.ui.preview.NodePreviewParameterProvider
@@ -84,11 +86,11 @@ fun NodeItem(
currentTimeMillis: Long,
isConnected: Boolean = false,
) {
- val isFavorite = thatNode.isFavorite
+ val isFavorite = remember(thatNode) { thatNode.isFavorite }
val isIgnored = thatNode.isIgnored
val longName = thatNode.user.longName.ifEmpty { stringResource(id = R.string.unknown_username) }
- val isThisNode = thisNode?.num == thatNode.num
+ val isThisNode = remember(thatNode) { thisNode?.num == thatNode.num }
val system = remember(distanceUnits) { DisplayConfig.DisplayUnits.forNumber(distanceUnits) }
val distance = remember(thisNode, thatNode) {
thisNode?.distance(thatNode)?.takeIf { it > 0 }?.toDistanceString(system)
@@ -112,10 +114,13 @@ fun NodeItem(
}
val (detailsShown, showDetails) = remember { mutableStateOf(expanded) }
- val unmessageable = if (thatNode.user.hasIsUnmessagable()) {
- thatNode.user.isUnmessagable
- } else {
- thatNode.user.role?.isUnmessageableRole() == true
+ val unmessageable = remember(thatNode) {
+ val firmwareVersion = DeviceVersion(thatNode.metadata?.firmwareVersion ?: "")
+ if (firmwareVersion >= DeviceVersion("2.6.8")) {
+ thatNode.user.isUnmessagable
+ } else {
+ thatNode.user.role?.isUnmessageableRole() == true
+ }
}
var menuExpanded by remember { mutableStateOf(false) }
@@ -193,7 +198,11 @@ fun NodeItem(
lastHeard = thatNode.lastHeard,
currentTimeMillis = currentTimeMillis
)
- StatusIcons(isFavorite = isFavorite, unmessageable = unmessageable)
+ NodeStatusIcons(
+ isThisNode = isThisNode,
+ isFavorite = isFavorite,
+ isUnmessageable = unmessageable
+ )
}
Row(
modifier = Modifier
@@ -320,7 +329,7 @@ fun NodeInfoSimplePreview() {
@Composable
@Preview(
showBackground = true,
- uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES,
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
)
fun NodeInfoPreview(
@PreviewParameter(NodePreviewParameterProvider::class)
diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/NodeStatusIcons.kt b/app/src/main/java/com/geeksville/mesh/ui/components/NodeStatusIcons.kt
new file mode 100644
index 000000000..c8002c3a3
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/ui/components/NodeStatusIcons.kt
@@ -0,0 +1,114 @@
+/*
+ * 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 com.geeksville.mesh.ui.components
+
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.NoCell
+import androidx.compose.material.icons.rounded.Star
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.PlainTooltip
+import androidx.compose.material3.Text
+import androidx.compose.material3.TooltipBox
+import androidx.compose.material3.TooltipDefaults
+import androidx.compose.material3.rememberTooltipState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.geeksville.mesh.R
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun NodeStatusIcons(
+ isThisNode: Boolean,
+ isUnmessageable: Boolean,
+ isFavorite: Boolean,
+) {
+ Row(
+ modifier = Modifier.padding(4.dp)
+ ) {
+ if (isUnmessageable) {
+ TooltipBox(
+ positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
+ tooltip = {
+ PlainTooltip {
+ Text(stringResource(R.string.unmonitored_or_infrastructure))
+ }
+ },
+ state = rememberTooltipState()
+ ) {
+ IconButton(
+ onClick = {},
+ modifier = Modifier
+ .size(24.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.NoCell,
+ contentDescription = stringResource(R.string.unmessageable),
+ modifier = Modifier
+ .size(24.dp), // Smaller size for badge
+ tint = MaterialTheme.colorScheme.error,
+ )
+ }
+ }
+ }
+ if (isFavorite && !isThisNode) {
+ TooltipBox(
+ positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
+ tooltip = {
+ PlainTooltip {
+ Text(stringResource(R.string.favorite))
+ }
+ },
+ state = rememberTooltipState()
+ ) {
+ IconButton(
+ onClick = {},
+ modifier = Modifier
+ .size(24.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Star,
+ contentDescription = stringResource(R.string.favorite),
+ modifier = Modifier
+ .size(24.dp), // Smaller size for badge
+ tint = Color(color = 0xFFFEC30A)
+ )
+ }
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+fun StatusIconsPreview() {
+ NodeStatusIcons(
+ isThisNode = false,
+ isUnmessageable = true,
+ isFavorite = true,
+ )
+}
diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/StatusIcons.kt b/app/src/main/java/com/geeksville/mesh/ui/components/StatusIcons.kt
deleted file mode 100644
index 565049f65..000000000
--- a/app/src/main/java/com/geeksville/mesh/ui/components/StatusIcons.kt
+++ /dev/null
@@ -1,69 +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 com.geeksville.mesh.ui.components
-
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Star
-import androidx.compose.material.icons.outlined.NoCell
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import com.geeksville.mesh.R
-
-@Composable
-fun StatusIcons(
- isFavorite: Boolean,
- unmessageable: Boolean
-) {
- Row(
- modifier = Modifier.padding(5.dp)
- ) {
- if (isFavorite) {
- Icon(
- imageVector = Icons.Filled.Star,
- contentDescription = stringResource(R.string.favorite),
- modifier = Modifier
- .size(25.dp), // Smaller size for badge
- tint = Color(color = 0xFFFEC30A)
- )
- }
- if (unmessageable) {
- Icon(
- imageVector = Icons.Outlined.NoCell,
- contentDescription = stringResource(R.string.unmessageable),
- modifier = Modifier
- .size(25.dp), // Smaller size for badge
- tint = MaterialTheme.colorScheme.error,
- )
- }
- }
-}
-
-@Preview
-@Composable
-fun StatusIconsPreview() {
- StatusIcons(isFavorite = true, unmessageable = true)
-}
diff --git a/app/src/main/java/com/geeksville/mesh/ui/radioconfig/components/UserConfigItemList.kt b/app/src/main/java/com/geeksville/mesh/ui/radioconfig/components/UserConfigItemList.kt
index bbd70fb2d..74cd458d3 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/radioconfig/components/UserConfigItemList.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/radioconfig/components/UserConfigItemList.kt
@@ -38,6 +38,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.R
import com.geeksville.mesh.copy
+import com.geeksville.mesh.deviceMetadata
+import com.geeksville.mesh.model.DeviceVersion
import com.geeksville.mesh.ui.components.EditTextPreference
import com.geeksville.mesh.ui.components.PreferenceCategory
import com.geeksville.mesh.ui.components.PreferenceFooter
@@ -63,17 +65,21 @@ fun UserConfigScreen(
userConfig = state.userConfig,
enabled = true,
onSaveClicked = viewModel::setOwner,
+ metadata = state.metadata,
)
}
+@Suppress("LongMethod")
@Composable
fun UserConfigItemList(
+ metadata: MeshProtos.DeviceMetadata?,
userConfig: MeshProtos.User,
enabled: Boolean,
onSaveClicked: (MeshProtos.User) -> Unit,
) {
val focusManager = LocalFocusManager.current
var userInput by rememberSaveable { mutableStateOf(userConfig) }
+ val firmwareVersion = DeviceVersion(metadata?.firmwareVersion ?: "")
LazyColumn(
modifier = Modifier.fillMaxSize()
@@ -135,11 +141,13 @@ fun UserConfigItemList(
title = stringResource(R.string.unmessageable),
summary = stringResource(R.string.unmonitored_or_infrastructure),
checked = userInput.isUnmessagable,
- enabled = userInput.hasIsUnmessagable(),
+ enabled = firmwareVersion >= DeviceVersion("2.6.8"),
onCheckedChange = { userInput = userInput.copy { isUnmessagable = it } }
)
}
+ item { HorizontalDivider() }
+
item {
SwitchPreference(
title = stringResource(R.string.licensed_amateur_radio),
@@ -179,5 +187,8 @@ private fun UserConfigPreview() {
},
enabled = true,
onSaveClicked = { },
+ metadata = deviceMetadata {
+ firmwareVersion = "2.8.0"
+ }
)
}