Add :feature:node (#3275)

This commit is contained in:
Phil Oliver 2025-10-01 19:26:41 -04:00 committed by GitHub
parent 5a6cd5acbc
commit d553cdfee6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 164 additions and 178 deletions

View file

@ -50,8 +50,8 @@ import com.geeksville.mesh.ui.metrics.PowerMetricsScreen
import com.geeksville.mesh.ui.metrics.SignalMetricsScreen
import com.geeksville.mesh.ui.metrics.TracerouteLogScreen
import com.geeksville.mesh.ui.node.NodeDetailScreen
import com.geeksville.mesh.ui.node.NodeListScreen
import com.geeksville.mesh.ui.node.NodeMapScreen
import com.geeksville.mesh.ui.node.NodeScreen
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.navigation.NodeDetailRoutes
@ -64,7 +64,7 @@ fun NavGraphBuilder.nodesGraph(navController: NavHostController, uiViewModel: UI
composable<NodesRoutes.Nodes>(
deepLinks = listOf(navDeepLink<NodesRoutes.Nodes>(basePath = "$DEEP_LINK_BASE_URI/nodes")),
) {
NodeScreen(navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) })
NodeListScreen(navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) })
}
nodeDetailGraph(navController, uiViewModel)
}

View file

@ -43,11 +43,11 @@ import androidx.compose.ui.unit.dp
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
import com.geeksville.mesh.ui.node.components.NodeChip
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.NodeChip
import org.meshtastic.core.ui.theme.AppTheme
@Suppress("CyclomaticComplexMethod")

View file

@ -1,134 +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 <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.common.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
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.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.ui.common.preview.NodePreviewParameterProvider
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.Rssi
import org.meshtastic.core.ui.component.Snr
import org.meshtastic.core.ui.component.determineSignalQuality
import org.meshtastic.core.ui.theme.AppTheme
const val MAX_VALID_SNR = 100F
const val MAX_VALID_RSSI = 0
@Suppress("LongMethod")
@Composable
fun SignalInfo(modifier: Modifier = Modifier, node: Node, isThisNode: Boolean) {
val text =
if (isThisNode) {
stringResource(R.string.channel_air_util)
.format(node.deviceMetrics.channelUtilization, node.deviceMetrics.airUtilTx)
} else {
buildList {
val hopsString =
"%s: %s"
.format(
stringResource(R.string.hops_away),
if (node.hopsAway == -1) {
"?"
} else {
node.hopsAway.toString()
},
)
if (node.channel > 0) {
add("ch:${node.channel}")
}
if (node.hopsAway != 0) add(hopsString)
}
.joinToString(" ")
}
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
if (text.isNotEmpty()) {
Text(text = text, color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.labelSmall)
}
/* We only know the Signal Quality from direct nodes aka 0 hop. */
if (node.hopsAway <= 0) {
if (node.snr < MAX_VALID_SNR && node.rssi < MAX_VALID_RSSI) {
val quality = determineSignalQuality(node.snr, node.rssi)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Snr(node.snr)
Rssi(node.rssi)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
modifier = Modifier.size(20.dp),
imageVector = quality.imageVector,
contentDescription = stringResource(R.string.signal_quality),
tint = quality.color.invoke(),
)
Text(
text = "${stringResource(R.string.signal)} ${stringResource(quality.nameRes)}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
)
}
}
}
}
}
@Composable
@Preview(showBackground = true)
fun SignalInfoSimplePreview() {
AppTheme {
SignalInfo(
node = Node(num = 1, lastHeard = 0, channel = 0, snr = 12.5F, rssi = -42, hopsAway = 0),
isThisNode = false,
)
}
}
@PreviewLightDark
@Composable
fun SignalInfoPreview(@PreviewParameter(NodePreviewParameterProvider::class) node: Node) {
AppTheme { SignalInfo(node = node, isThisNode = false) }
}
@Composable
@PreviewLightDark
fun SignalInfoSelfPreview(@PreviewParameter(NodePreviewParameterProvider::class) node: Node) {
AppTheme { SignalInfo(node = node, isThisNode = true) }
}

View file

@ -1,23 +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 <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.common.preview
import androidx.compose.ui.tooling.preview.Preview
@Preview(name = "Large Font", fontScale = 2f)
annotation class LargeFontPreview

View file

@ -1,158 +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 <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.common.preview
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.geeksville.mesh.ConfigProtos
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.deviceMetrics
import com.geeksville.mesh.environmentMetrics
import com.geeksville.mesh.paxcount
import com.geeksville.mesh.position
import com.geeksville.mesh.user
import com.google.protobuf.ByteString
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DeviceMetrics.Companion.currentTime
import kotlin.random.Random
class NodePreviewParameterProvider : PreviewParameterProvider<Node> {
val mickeyMouse =
Node(
num = 1955,
user =
user {
id = "mickeyMouseId"
longName = "Mickey Mouse"
shortName = "MM"
hwModel = MeshProtos.HardwareModel.TBEAM
role = ConfigProtos.Config.DeviceConfig.Role.ROUTER
},
position =
position {
latitudeI = 338125110
longitudeI = -1179189760
altitude = 138
satsInView = 4
},
lastHeard = currentTime(),
channel = 0,
snr = 12.5F,
rssi = -42,
deviceMetrics =
deviceMetrics {
channelUtilization = 2.4F
airUtilTx = 3.5F
batteryLevel = 85
voltage = 3.7F
uptimeSeconds = 3600
},
isFavorite = true,
hopsAway = 0,
)
val minnieMouse =
mickeyMouse.copy(
num = Random.nextInt(),
user =
user {
longName = "Minnie Mouse"
shortName = "MiMo"
id = "minnieMouseId"
hwModel = MeshProtos.HardwareModel.HELTEC_V3
},
snr = 12.5F,
rssi = -42,
position = position {},
hopsAway = 1,
)
private val donaldDuck =
Node(
num = Random.nextInt(),
position =
position {
latitudeI = 338052347
longitudeI = -1179208460
altitude = 121
satsInView = 66
},
lastHeard = currentTime() - 300,
channel = 0,
snr = 12.5F,
rssi = -42,
deviceMetrics =
deviceMetrics {
channelUtilization = 2.4F
airUtilTx = 3.5F
batteryLevel = 85
voltage = 3.7F
uptimeSeconds = 3600
},
user =
user {
id = "donaldDuckId"
longName = "Donald Duck, the Grand Duck of the Ducks"
shortName = "DoDu"
hwModel = MeshProtos.HardwareModel.HELTEC_V3
publicKey = ByteString.copyFrom(ByteArray(32) { 1 })
},
environmentMetrics =
environmentMetrics {
temperature = 28.0F
relativeHumidity = 50.0F
barometricPressure = 1013.25F
gasResistance = 0.0F
voltage = 3.7F
current = 0.0F
iaq = 100
},
paxcounter =
paxcount {
wifi = 30
ble = 39
uptime = 420
},
isFavorite = true,
hopsAway = 2,
)
private val unknown =
donaldDuck.copy(
user =
user {
id = "myId"
longName = "Meshtastic myId"
shortName = "myId"
hwModel = MeshProtos.HardwareModel.UNSET
},
environmentMetrics = environmentMetrics {},
paxcounter = paxcount {},
)
private val almostNothing = Node(num = Random.nextInt())
override val values: Sequence<Node>
get() =
sequenceOf(
mickeyMouse, // "this" node
unknown,
almostNothing,
minnieMouse,
donaldDuck,
)
}

View file

@ -70,7 +70,6 @@ import com.geeksville.mesh.ui.connections.components.ConnectionsSegmentedBar
import com.geeksville.mesh.ui.connections.components.CurrentlyConnectedInfo
import com.geeksville.mesh.ui.connections.components.NetworkDevices
import com.geeksville.mesh.ui.connections.components.UsbDevices
import com.geeksville.mesh.ui.settings.components.SettingsItem
import com.geeksville.mesh.ui.settings.radio.RadioConfigViewModel
import com.geeksville.mesh.ui.settings.radio.components.PacketResponseStateDialog
import com.google.accompanist.permissions.ExperimentalPermissionsApi
@ -79,6 +78,7 @@ 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.SettingsItem
import org.meshtastic.core.ui.component.TitledCard
fun String?.isIPAddress(): Boolean = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {

View file

@ -39,11 +39,11 @@ import androidx.compose.ui.unit.dp
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.PaxcountProtos
import com.geeksville.mesh.TelemetryProtos
import com.geeksville.mesh.ui.node.components.NodeChip
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.MaterialBatteryInfo
import org.meshtastic.core.ui.component.MaterialBluetoothSignalInfo
import org.meshtastic.core.ui.component.NodeChip
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.StatusColors.StatusRed

View file

@ -96,7 +96,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.AppOnlyProtos
import com.geeksville.mesh.ui.common.components.SecurityIcon
import com.geeksville.mesh.ui.node.components.NodeKeyStatusIcon
import com.geeksville.mesh.ui.sharing.SharedContactDialog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@ -107,6 +106,7 @@ import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.getChannel
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.feature.node.component.NodeKeyStatusIcon
import java.nio.charset.StandardCharsets
private const val MESSAGE_CHARACTER_LIMIT_BYTES = 200

View file

@ -49,16 +49,16 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.ui.common.preview.NodePreviewParameterProvider
import com.geeksville.mesh.ui.node.components.NodeChip
import org.meshtastic.core.database.entity.Reaction
import org.meshtastic.core.database.model.Message
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.MDText
import org.meshtastic.core.ui.component.NodeChip
import org.meshtastic.core.ui.component.Rssi
import org.meshtastic.core.ui.component.Snr
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.MessageItemColors

View file

@ -134,13 +134,6 @@ import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.model.MetricsState
import com.geeksville.mesh.model.MetricsViewModel
import com.geeksville.mesh.ui.common.components.MainAppBar
import com.geeksville.mesh.ui.common.preview.NodePreviewParameterProvider
import com.geeksville.mesh.ui.node.components.NodeActionDialogs
import com.geeksville.mesh.ui.node.components.NodeMenuAction
import com.geeksville.mesh.ui.node.components.TracerouteButton
import com.geeksville.mesh.ui.settings.components.SettingsItem
import com.geeksville.mesh.ui.settings.components.SettingsItemDetail
import com.geeksville.mesh.ui.settings.components.SettingsItemSwitch
import com.geeksville.mesh.ui.sharing.SharedContactDialog
import com.geeksville.mesh.util.thenIf
import com.mikepenz.markdown.m3.Markdown
@ -163,12 +156,20 @@ import org.meshtastic.core.navigation.Route
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.SettingsItemSwitch
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
import org.meshtastic.feature.node.component.NodeActionDialogs
import org.meshtastic.feature.node.component.NodeMenuAction
import org.meshtastic.feature.node.component.TracerouteButton
import org.meshtastic.feature.node.detail.NodeDetailViewModel
import timber.log.Timber
private data class VectorMetricInfo(

View file

@ -1,131 +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 <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.node
import android.os.RemoteException
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.geeksville.mesh.ui.node.components.NodeMenuAction
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.Position
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class NodeDetailViewModel
@Inject
constructor(
private val nodeRepository: NodeRepository,
private val serviceRepository: ServiceRepository,
) : ViewModel() {
val ourNodeInfo: StateFlow<Node?> = nodeRepository.ourNodeInfo
private val _lastTraceRouteTime = MutableStateFlow<Long?>(null)
val lastTraceRouteTime: StateFlow<Long?> = _lastTraceRouteTime.asStateFlow()
fun handleNodeMenuAction(action: NodeMenuAction) {
when (action) {
is NodeMenuAction.Remove -> removeNode(action.node.num)
is NodeMenuAction.Ignore -> ignoreNode(action.node)
is NodeMenuAction.Favorite -> favoriteNode(action.node)
is NodeMenuAction.RequestUserInfo -> requestUserInfo(action.node.num)
is NodeMenuAction.RequestPosition -> requestPosition(action.node.num)
is NodeMenuAction.TraceRoute -> {
requestTraceroute(action.node.num)
_lastTraceRouteTime.value = System.currentTimeMillis()
}
else -> {}
}
}
fun setNodeNotes(nodeNum: Int, notes: String) = viewModelScope.launch(Dispatchers.IO) {
try {
nodeRepository.setNodeNotes(nodeNum, notes)
} catch (ex: java.io.IOException) {
Timber.e("Set node notes IO error: ${ex.message}")
} catch (ex: java.sql.SQLException) {
Timber.e("Set node notes SQL error: ${ex.message}")
}
}
private fun removeNode(nodeNum: Int) = viewModelScope.launch(Dispatchers.IO) {
Timber.i("Removing node '$nodeNum'")
try {
val packetId = serviceRepository.meshService?.packetId ?: return@launch
serviceRepository.meshService?.removeByNodenum(packetId, nodeNum)
nodeRepository.deleteNode(nodeNum)
} catch (ex: RemoteException) {
Timber.e("Remove node error: ${ex.message}")
}
}
private fun ignoreNode(node: Node) = viewModelScope.launch {
try {
serviceRepository.onServiceAction(ServiceAction.Ignore(node))
} catch (ex: RemoteException) {
Timber.e(ex, "Ignore node error")
}
}
private fun favoriteNode(node: Node) = viewModelScope.launch {
try {
serviceRepository.onServiceAction(ServiceAction.Favorite(node))
} catch (ex: RemoteException) {
Timber.e(ex, "Favorite node error")
}
}
private fun requestUserInfo(destNum: Int) {
Timber.i("Requesting UserInfo for '$destNum'")
try {
serviceRepository.meshService?.requestUserInfo(destNum)
} catch (ex: RemoteException) {
Timber.e("Request NodeInfo error: ${ex.message}")
}
}
private fun requestPosition(destNum: Int, position: Position = Position(0.0, 0.0, 0)) {
Timber.i("Requesting position for '$destNum'")
try {
serviceRepository.meshService?.requestPosition(destNum, position)
} catch (ex: RemoteException) {
Timber.e("Request position error: ${ex.message}")
}
}
private fun requestTraceroute(destNum: Int) {
Timber.i("Requesting traceroute for '$destNum'")
try {
val packetId = serviceRepository.meshService?.packetId ?: return
serviceRepository.meshService?.requestTraceroute(packetId, destNum)
} catch (ex: RemoteException) {
Timber.e("Request traceroute error: ${ex.message}")
}
}
}

View file

@ -59,9 +59,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.AdminProtos
import com.geeksville.mesh.ui.common.components.MainAppBar
import com.geeksville.mesh.ui.node.components.NodeActionDialogs
import com.geeksville.mesh.ui.node.components.NodeFilterTextField
import com.geeksville.mesh.ui.node.components.NodeItem
import com.geeksville.mesh.ui.sharing.AddContactFAB
import com.geeksville.mesh.ui.sharing.supportsQrCodeSharing
import org.meshtastic.core.database.model.Node
@ -70,24 +67,28 @@ import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.rememberTimeTickWithLifecycle
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.feature.node.component.NodeActionDialogs
import org.meshtastic.feature.node.component.NodeFilterTextField
import org.meshtastic.feature.node.component.NodeItem
import org.meshtastic.feature.node.list.NodeListViewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun NodeScreen(nodesViewModel: NodesViewModel = hiltViewModel(), navigateToNodeDetails: (Int) -> Unit) {
val state by nodesViewModel.nodesUiState.collectAsStateWithLifecycle()
fun NodeListScreen(viewModel: NodeListViewModel = hiltViewModel(), navigateToNodeDetails: (Int) -> Unit) {
val state by viewModel.nodesUiState.collectAsStateWithLifecycle()
val nodes by nodesViewModel.nodeList.collectAsStateWithLifecycle()
val ourNode by nodesViewModel.ourNodeInfo.collectAsStateWithLifecycle()
val onlineNodeCount by nodesViewModel.onlineNodeCount.collectAsStateWithLifecycle(0)
val totalNodeCount by nodesViewModel.totalNodeCount.collectAsStateWithLifecycle(0)
val unfilteredNodes by nodesViewModel.unfilteredNodeList.collectAsStateWithLifecycle()
val nodes by viewModel.nodeList.collectAsStateWithLifecycle()
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
val onlineNodeCount by viewModel.onlineNodeCount.collectAsStateWithLifecycle(0)
val totalNodeCount by viewModel.totalNodeCount.collectAsStateWithLifecycle(0)
val unfilteredNodes by viewModel.unfilteredNodeList.collectAsStateWithLifecycle()
val ignoredNodeCount = unfilteredNodes.count { it.isIgnored }
val listState = rememberLazyListState()
val currentTimeMillis = rememberTimeTickWithLifecycle()
val connectionState by nodesViewModel.connectionState.collectAsStateWithLifecycle()
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val isScrollInProgress by remember {
derivedStateOf { listState.isScrollInProgress && (listState.canScrollForward || listState.canScrollBackward) }
@ -109,7 +110,7 @@ fun NodeScreen(nodesViewModel: NodesViewModel = hiltViewModel(), navigateToNodeD
val firmwareVersion = DeviceVersion(ourNode?.metadata?.firmwareVersion ?: "0.0.0")
val shareCapable = firmwareVersion.supportsQrCodeSharing()
val scannedContact: AdminProtos.SharedContact? by
nodesViewModel.sharedContactRequested.collectAsStateWithLifecycle(null)
viewModel.sharedContactRequested.collectAsStateWithLifecycle(null)
AddContactFAB(
unfilteredNodes = unfilteredNodes,
scannedContact = scannedContact,
@ -118,8 +119,8 @@ fun NodeScreen(nodesViewModel: NodesViewModel = hiltViewModel(), navigateToNodeD
visible = !isScrollInProgress && connectionState == ConnectionState.CONNECTED && shareCapable,
alignment = Alignment.BottomEnd,
),
onSharedContactImport = { contact -> nodesViewModel.addSharedContact(contact) },
onSharedContactRequested = { contact -> nodesViewModel.setSharedContactRequested(contact) },
onSharedContactImport = { contact -> viewModel.addSharedContact(contact) },
onSharedContactRequested = { contact -> viewModel.setSharedContactRequested(contact) },
)
},
) { contentPadding ->
@ -135,17 +136,17 @@ fun NodeScreen(nodesViewModel: NodesViewModel = hiltViewModel(), navigateToNodeD
.background(MaterialTheme.colorScheme.surfaceDim)
.padding(8.dp),
filterText = state.filter.filterText,
onTextChange = nodesViewModel::setNodeFilterText,
onTextChange = viewModel::setNodeFilterText,
currentSortOption = state.sort,
onSortSelect = nodesViewModel::setSortOption,
onSortSelect = viewModel::setSortOption,
includeUnknown = state.filter.includeUnknown,
onToggleIncludeUnknown = nodesViewModel::toggleIncludeUnknown,
onToggleIncludeUnknown = viewModel::toggleIncludeUnknown,
onlyOnline = state.filter.onlyOnline,
onToggleOnlyOnline = nodesViewModel::toggleOnlyOnline,
onToggleOnlyOnline = viewModel::toggleOnlyOnline,
onlyDirect = state.filter.onlyDirect,
onToggleOnlyDirect = nodesViewModel::toggleOnlyDirect,
onToggleOnlyDirect = viewModel::toggleOnlyDirect,
showIgnored = state.filter.showIgnored,
onToggleShowIgnored = nodesViewModel::toggleShowIgnored,
onToggleShowIgnored = viewModel::toggleShowIgnored,
ignoredNodeCount = ignoredNodeCount,
)
}
@ -165,9 +166,9 @@ fun NodeScreen(nodesViewModel: NodesViewModel = hiltViewModel(), navigateToNodeD
displayIgnoreDialog = false
displayRemoveDialog = false
},
onConfirmFavorite = nodesViewModel::favoriteNode,
onConfirmIgnore = nodesViewModel::ignoreNode,
onConfirmRemove = { nodesViewModel.removeNode(it.num) },
onConfirmFavorite = viewModel::favoriteNode,
onConfirmIgnore = viewModel::ignoreNode,
onConfirmRemove = { viewModel.removeNode(it.num) },
)
var expanded by remember { mutableStateOf(false) }

View file

@ -1,212 +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 <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.node
import android.os.RemoteException
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.geeksville.mesh.AdminProtos
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.database.model.NodeSortOption
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class NodesViewModel
@Inject
constructor(
private val nodeRepository: NodeRepository,
radioConfigRepository: RadioConfigRepository,
private val serviceRepository: ServiceRepository,
private val uiPreferencesDataSource: UiPreferencesDataSource,
) : ViewModel() {
val ourNodeInfo: StateFlow<Node?> = nodeRepository.ourNodeInfo
val onlineNodeCount =
nodeRepository.onlineNodeCount.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = 0,
)
val totalNodeCount =
nodeRepository.totalNodeCount.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = 0,
)
val connectionState = serviceRepository.connectionState
private val _sharedContactRequested: MutableStateFlow<AdminProtos.SharedContact?> = MutableStateFlow(null)
val sharedContactRequested = _sharedContactRequested.asStateFlow()
private val nodeSortOption =
uiPreferencesDataSource.nodeSort.map { NodeSortOption.entries.getOrElse(it) { NodeSortOption.VIA_FAVORITE } }
private val nodeFilterText = MutableStateFlow("")
private val includeUnknown = uiPreferencesDataSource.includeUnknown
private val onlyOnline = uiPreferencesDataSource.onlyOnline
private val onlyDirect = uiPreferencesDataSource.onlyDirect
private val showIgnored = uiPreferencesDataSource.showIgnored
private val nodeFilter: Flow<NodeFilterState> =
combine(nodeFilterText, includeUnknown, onlyOnline, onlyDirect, showIgnored) {
filterText,
includeUnknown,
onlyOnline,
onlyDirect,
showIgnored,
->
NodeFilterState(filterText, includeUnknown, onlyOnline, onlyDirect, showIgnored)
}
val nodesUiState: StateFlow<NodesUiState> =
combine(nodeSortOption, nodeFilter, radioConfigRepository.deviceProfileFlow) { sort, nodeFilter, profile ->
NodesUiState(
sort = sort,
filter = nodeFilter,
distanceUnits = profile.config.display.units.number,
tempInFahrenheit = profile.moduleConfig.telemetry.environmentDisplayFahrenheit,
)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = NodesUiState(),
)
val nodeList: StateFlow<List<Node>> =
combine(nodeFilter, nodeSortOption, ::Pair)
.flatMapLatest { (filter, sort) ->
nodeRepository
.getNodes(
sort = sort,
filter = filter.filterText,
includeUnknown = filter.includeUnknown,
onlyOnline = filter.onlyOnline,
onlyDirect = filter.onlyDirect,
)
.map { list -> list.filter { it.isIgnored == filter.showIgnored } }
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList(),
)
val unfilteredNodeList: StateFlow<List<Node>> =
nodeRepository
.getNodes()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList(),
)
fun setNodeFilterText(text: String) {
nodeFilterText.value = text
}
fun toggleIncludeUnknown() {
uiPreferencesDataSource.setIncludeUnknown(!includeUnknown.value)
}
fun toggleOnlyOnline() {
uiPreferencesDataSource.setOnlyOnline(!onlyOnline.value)
}
fun toggleOnlyDirect() {
uiPreferencesDataSource.setOnlyDirect(!onlyDirect.value)
}
fun toggleShowIgnored() {
uiPreferencesDataSource.setShowIgnored(!showIgnored.value)
}
fun setSortOption(sort: NodeSortOption) {
uiPreferencesDataSource.setNodeSort(sort.ordinal)
}
fun addSharedContact(sharedContact: AdminProtos.SharedContact) =
viewModelScope.launch { serviceRepository.onServiceAction(ServiceAction.AddSharedContact(sharedContact)) }
fun setSharedContactRequested(sharedContact: AdminProtos.SharedContact?) {
_sharedContactRequested.value = sharedContact
}
fun favoriteNode(node: Node) = viewModelScope.launch {
try {
serviceRepository.onServiceAction(ServiceAction.Favorite(node))
} catch (ex: RemoteException) {
Timber.e(ex, "Favorite node error")
}
}
fun ignoreNode(node: Node) = viewModelScope.launch {
try {
serviceRepository.onServiceAction(ServiceAction.Ignore(node))
} catch (ex: RemoteException) {
Timber.e(ex, "Ignore node error")
}
}
fun removeNode(nodeNum: Int) = viewModelScope.launch(Dispatchers.IO) {
Timber.i("Removing node '$nodeNum'")
try {
val packetId = serviceRepository.meshService?.packetId ?: return@launch
serviceRepository.meshService?.removeByNodenum(packetId, nodeNum)
nodeRepository.deleteNode(nodeNum)
} catch (ex: RemoteException) {
Timber.e("Remove node error: ${ex.message}")
}
}
}
data class NodesUiState(
val sort: NodeSortOption = NodeSortOption.LAST_HEARD,
val filter: NodeFilterState = NodeFilterState(),
val distanceUnits: Int = 0,
val tempInFahrenheit: Boolean = false,
)
data class NodeFilterState(
val filterText: String = "",
val includeUnknown: Boolean = false,
val onlyOnline: Boolean = false,
val onlyDirect: Boolean = false,
val showIgnored: Boolean = false,
)

View file

@ -1,43 +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 <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.node.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.SocialDistance
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewLightDark
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.theme.AppTheme
@Composable
fun DistanceInfo(distance: String, modifier: Modifier = Modifier) {
IconInfo(
modifier = modifier,
icon = Icons.Rounded.SocialDistance,
contentDescription = stringResource(R.string.distance),
text = distance,
)
}
@PreviewLightDark
@Composable
private fun DistanceInfoPreview() {
AppTheme { DistanceInfo(distance = "423 mi.") }
}

View file

@ -1,51 +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 <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.node.components
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig.DisplayUnits
import org.meshtastic.core.model.util.metersIn
import org.meshtastic.core.model.util.toString
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.icon.Elevation
import org.meshtastic.core.ui.icon.MeshtasticIcons
@Composable
fun ElevationInfo(
modifier: Modifier = Modifier,
altitude: Int,
system: DisplayUnits,
suffix: String = stringResource(R.string.elevation_suffix),
) {
IconInfo(
modifier = modifier,
icon = MeshtasticIcons.Elevation,
contentDescription = stringResource(R.string.altitude),
text = altitude.metersIn(system).toString(system) + " " + suffix,
)
}
@Composable
@Preview
fun ElevationInfoPreview() {
MaterialTheme { ElevationInfo(altitude = 100, system = DisplayUnits.METRIC, suffix = "ASL") }
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.node.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
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.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.meshtastic.core.ui.icon.Elevation
import org.meshtastic.core.ui.icon.MeshtasticIcons
private const val SIZE_ICON = 20
@Composable
fun IconInfo(
icon: ImageVector,
contentDescription: String,
modifier: Modifier = Modifier,
text: String? = null,
content: @Composable () -> Unit = {},
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
Icon(
modifier = Modifier.size(SIZE_ICON.dp),
imageVector = icon,
contentDescription = contentDescription,
tint = MaterialTheme.colorScheme.onSurface,
)
text?.let {
Text(text = it, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurface)
}
content()
}
}
@Composable
@Preview
private fun IconInfoPreview() {
MaterialTheme {
IconInfo(icon = MeshtasticIcons.Elevation, contentDescription = "Elevation", content = { Text(text = "100") })
}
}

View file

@ -1,49 +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 <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.node.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.PreviewLightDark
import com.geeksville.mesh.R
import org.meshtastic.core.model.util.formatAgo
import org.meshtastic.core.ui.theme.AppTheme
@Composable
fun LastHeardInfo(modifier: Modifier = Modifier, lastHeard: Int, currentTimeMillis: Long) {
IconInfo(
modifier = modifier,
icon = ImageVector.vectorResource(id = R.drawable.ic_antenna_24),
contentDescription = stringResource(org.meshtastic.core.strings.R.string.node_sort_last_heard),
text = formatAgo(lastHeard, currentTimeMillis),
)
}
@PreviewLightDark
@Composable
fun LastHeardInfoPreview() {
AppTheme {
LastHeardInfo(
lastHeard = (System.currentTimeMillis() / 1000).toInt() - 8600,
currentTimeMillis = System.currentTimeMillis(),
)
}
}

View file

@ -1,118 +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 <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.node.components
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,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
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,
)
}
@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
fun LinkedCoordinatesPreview() {
AppTheme { LinkedCoordinates(latitude = 37.7749, longitude = -122.4194, nodeName = "Test Node Name") }
}

View file

@ -1,94 +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 <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.node.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
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.graphics.Color
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.PaxcountProtos
import com.geeksville.mesh.TelemetryProtos
import org.meshtastic.core.database.model.Node
@Composable
fun NodeChip(modifier: Modifier = Modifier, node: Node, onClick: ((Node) -> Unit)? = null) {
val (textColor, nodeColor) = node.colors
val colors = CardDefaults.cardColors(containerColor = Color(nodeColor), contentColor = Color(textColor))
val content: @Composable () -> Unit = {
Box(
modifier =
Modifier.width(IntrinsicSize.Min)
.defaultMinSize(minWidth = 72.dp, minHeight = 32.dp)
.padding(horizontal = 8.dp)
.semantics { contentDescription = node.user.shortName.ifEmpty { "Node" } },
contentAlignment = Alignment.Center,
) {
Text(
modifier = Modifier.fillMaxWidth(),
text = node.user.shortName.ifEmpty { "???" },
fontSize = MaterialTheme.typography.labelLarge.fontSize,
textDecoration = TextDecoration.LineThrough.takeIf { node.isIgnored },
textAlign = TextAlign.Center,
maxLines = 1,
)
}
}
if (onClick == null) {
Card(modifier = modifier, shape = MaterialTheme.shapes.small, colors = colors) { content() }
} else {
Card(modifier = modifier, shape = MaterialTheme.shapes.small, colors = colors, onClick = { onClick(node) }) {
content()
}
}
}
@Suppress("MagicNumber")
@Preview
@Composable
fun NodeChipPreview() {
val user = MeshProtos.User.newBuilder().setShortName("\uD83E\uDEE0").setLongName("John Doe").build()
val node =
Node(
num = 13444,
user = user,
isIgnored = false,
paxcounter = PaxcountProtos.Paxcount.newBuilder().setBle(10).setWifi(5).build(),
environmentMetrics =
TelemetryProtos.EnvironmentMetrics.newBuilder().setTemperature(25f).setRelativeHumidity(60f).build(),
)
NodeChip(node = node)
}

View file

@ -1,313 +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 <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.node.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Sort
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuDefaults
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusEvent
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.ui.common.preview.LargeFontPreview
import org.meshtastic.core.database.model.NodeSortOption
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.theme.AppTheme
@Suppress("LongParameterList")
@Composable
fun NodeFilterTextField(
modifier: Modifier = Modifier,
filterText: String,
onTextChange: (String) -> Unit,
currentSortOption: NodeSortOption,
onSortSelect: (NodeSortOption) -> Unit,
includeUnknown: Boolean,
onToggleIncludeUnknown: () -> Unit,
onlyOnline: Boolean,
onToggleOnlyOnline: () -> Unit,
onlyDirect: Boolean,
onToggleOnlyDirect: () -> Unit,
showIgnored: Boolean,
onToggleShowIgnored: () -> Unit,
ignoredNodeCount: Int,
) {
Column(modifier = modifier.background(MaterialTheme.colorScheme.background)) {
Row {
NodeFilterTextField(filterText = filterText, onTextChange = onTextChange, modifier = Modifier.weight(1f))
NodeSortButton(
modifier = Modifier.align(Alignment.CenterVertically),
currentSortOption = currentSortOption,
onSortSelect = onSortSelect,
toggles =
NodeFilterToggles(
includeUnknown = includeUnknown,
onToggleIncludeUnknown = onToggleIncludeUnknown,
onlyOnline = onlyOnline,
onToggleOnlyOnline = onToggleOnlyOnline,
onlyDirect = onlyDirect,
onToggleOnlyDirect = onToggleOnlyDirect,
showIgnored = showIgnored,
onToggleShowIgnored = onToggleShowIgnored,
ignoredNodeCount = ignoredNodeCount,
),
)
}
if (showIgnored) {
Box(
modifier =
Modifier.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceDim)
.clickable { onToggleShowIgnored() }
.padding(vertical = 16.dp, horizontal = 24.dp),
) {
Text(
text = stringResource(id = R.string.node_filter_ignored),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.fillMaxWidth(),
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
}
}
}
}
@Composable
private fun NodeFilterTextField(filterText: String, onTextChange: (String) -> Unit, modifier: Modifier = Modifier) {
val focusManager = LocalFocusManager.current
var isFocused by remember { mutableStateOf(false) }
OutlinedTextField(
modifier = modifier.defaultMinSize(minHeight = 48.dp).onFocusEvent { isFocused = it.isFocused },
value = filterText,
placeholder = {
Text(
text = stringResource(id = R.string.node_filter_placeholder),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.35F),
)
},
leadingIcon = {
Icon(Icons.Default.Search, contentDescription = stringResource(id = R.string.node_filter_placeholder))
},
onValueChange = onTextChange,
trailingIcon = {
if (filterText.isNotEmpty() || isFocused) {
Icon(
Icons.Default.Clear,
contentDescription = stringResource(id = R.string.desc_node_filter_clear),
modifier =
Modifier.clickable {
onTextChange("")
focusManager.clearFocus()
},
)
}
},
textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onBackground),
maxLines = 1,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
)
}
@Suppress("LongMethod")
@Composable
private fun NodeSortButton(
currentSortOption: NodeSortOption,
onSortSelect: (NodeSortOption) -> Unit,
toggles: NodeFilterToggles,
modifier: Modifier = Modifier,
) = Box(modifier) {
var expanded by remember { mutableStateOf(false) }
IconButton(onClick = { expanded = true }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Sort,
contentDescription = stringResource(R.string.node_sort_button),
modifier = Modifier.heightIn(max = 48.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
modifier = Modifier.background(MaterialTheme.colorScheme.background.copy(alpha = 1f)),
) {
DropdownMenuTitle(text = stringResource(R.string.node_sort_title))
NodeSortOption.entries.forEach { sort ->
DropdownMenuRadio(
text = stringResource(id = sort.stringRes),
selected = sort == currentSortOption,
onClick = { onSortSelect(sort) },
)
}
HorizontalDivider(modifier = Modifier.padding(MenuDefaults.DropdownMenuItemContentPadding))
DropdownMenuTitle(text = stringResource(R.string.node_filter_title))
DropdownMenuCheck(
text = stringResource(R.string.node_filter_include_unknown),
checked = toggles.includeUnknown,
onClick = toggles.onToggleIncludeUnknown,
)
DropdownMenuCheck(
text = stringResource(R.string.node_filter_only_online),
checked = toggles.onlyOnline,
onClick = toggles.onToggleOnlyOnline,
)
DropdownMenuCheck(
text = stringResource(R.string.node_filter_only_direct),
checked = toggles.onlyDirect,
onClick = toggles.onToggleOnlyDirect,
)
DropdownMenuCheck(
text = stringResource(R.string.node_filter_show_ignored),
checked = toggles.showIgnored,
onClick = toggles.onToggleShowIgnored,
trailing =
if (toggles.ignoredNodeCount > 0) {
{
Text(
text = " (${toggles.ignoredNodeCount})",
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 4.dp),
)
}
} else {
null
},
)
}
}
@Composable
private fun DropdownMenuTitle(text: String) {
Text(
text = text,
modifier =
Modifier.height(48.dp)
.padding(MenuDefaults.DropdownMenuItemContentPadding)
.wrapContentHeight(align = Alignment.CenterVertically),
fontWeight = FontWeight.ExtraBold,
)
}
@Composable
private fun DropdownMenuRadio(text: String, selected: Boolean, onClick: () -> Unit) {
DropdownMenuItem(
onClick = onClick,
leadingIcon = { RadioButton(selected = selected, onClick = null) },
text = { Text(text = text) },
)
}
@Composable
private fun DropdownMenuCheck(
text: String,
checked: Boolean,
onClick: () -> Unit,
trailing: @Composable (() -> Unit)? = null,
) {
DropdownMenuItem(
onClick = onClick,
leadingIcon = { Checkbox(checked = checked, onCheckedChange = null) },
trailingIcon = trailing,
text = { Text(text = text) },
)
}
@PreviewLightDark
@LargeFontPreview
@Composable
private fun NodeFilterTextFieldPreview() {
AppTheme {
NodeFilterTextField(
filterText = "Filter text",
onTextChange = {},
currentSortOption = NodeSortOption.LAST_HEARD,
onSortSelect = {},
includeUnknown = false,
onToggleIncludeUnknown = {},
onlyOnline = false,
onToggleOnlyOnline = {},
onlyDirect = false,
onToggleOnlyDirect = {},
showIgnored = false,
onToggleShowIgnored = {},
ignoredNodeCount = 0,
)
}
}
data class NodeFilterToggles(
val includeUnknown: Boolean,
val onToggleIncludeUnknown: () -> Unit,
val onlyOnline: Boolean,
val onToggleOnlyOnline: () -> Unit,
val onlyDirect: Boolean,
val onToggleOnlyDirect: () -> Unit,
val showIgnored: Boolean,
val onToggleShowIgnored: () -> Unit,
val ignoredNodeCount: Int,
)

View file

@ -1,240 +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 <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.node.components
import android.content.res.Configuration
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig
import com.geeksville.mesh.ui.common.components.SignalInfo
import com.geeksville.mesh.ui.common.preview.NodePreviewParameterProvider
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.database.model.isUnmessageableRole
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.MaterialBatteryInfo
import org.meshtastic.core.ui.theme.AppTheme
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun NodeItem(
thisNode: Node?,
thatNode: Node,
distanceUnits: Int,
tempInFahrenheit: Boolean,
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
onLongClick: (() -> Unit)? = null,
currentTimeMillis: Long,
isConnected: Boolean = false,
) {
val isFavorite = remember(thatNode) { thatNode.isFavorite }
val isIgnored = thatNode.isIgnored
val longName = thatNode.user.longName.ifEmpty { stringResource(id = R.string.unknown_username) }
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) }
var contentColor = MaterialTheme.colorScheme.onSurface
val cardColors =
if (isThisNode) {
thisNode?.colors?.second
} else {
thatNode.colors.second
}
?.let {
val containerColor = Color(it).copy(alpha = 0.2f)
contentColor = contentColorFor(containerColor)
CardDefaults.cardColors().copy(containerColor = containerColor, contentColor = contentColor)
} ?: (CardDefaults.cardColors())
val style =
if (thatNode.isUnknownUser) {
LocalTextStyle.current.copy(fontStyle = FontStyle.Italic)
} else {
LocalTextStyle.current
}
val unmessageable =
remember(thatNode) {
when {
thatNode.user.hasIsUnmessagable() -> thatNode.user.isUnmessagable
else -> thatNode.user.role.isUnmessageableRole()
}
}
Card(modifier = modifier.fillMaxWidth().defaultMinSize(minHeight = 80.dp), colors = cardColors) {
Column(
modifier =
Modifier.combinedClickable(onClick = onClick, onLongClick = onLongClick).fillMaxWidth().padding(8.dp),
) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
NodeChip(node = thatNode)
NodeKeyStatusIcon(
hasPKC = thatNode.hasPKC,
mismatchKey = thatNode.mismatchKey,
publicKey = thatNode.user.publicKey,
modifier = Modifier.size(32.dp),
)
Text(
modifier = Modifier.weight(1f),
text = longName,
style =
MaterialTheme.typography.titleMediumEmphasized.copy(
color = MaterialTheme.colorScheme.onSurface,
),
textDecoration = TextDecoration.LineThrough.takeIf { isIgnored },
softWrap = true,
)
LastHeardInfo(lastHeard = thatNode.lastHeard, currentTimeMillis = currentTimeMillis)
NodeStatusIcons(
isThisNode = isThisNode,
isFavorite = isFavorite,
isUnmessageable = unmessageable,
isConnected = isConnected,
)
}
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
MaterialBatteryInfo(level = thatNode.batteryLevel, voltage = thatNode.voltage)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (distance != null) {
DistanceInfo(distance = distance)
}
thatNode.validPosition?.let { position ->
ElevationInfo(
altitude = position.altitude,
system = system,
suffix = stringResource(id = R.string.elevation_suffix),
)
val satCount = position.satsInView
if (satCount > 0) {
SatelliteCountInfo(satCount = satCount)
}
}
}
}
Spacer(modifier = Modifier.height(4.dp))
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
itemVerticalAlignment = Alignment.CenterVertically,
) {
SignalInfo(node = thatNode, isThisNode = isThisNode)
}
val telemetryStrings = thatNode.getTelemetryStrings(tempInFahrenheit)
if (telemetryStrings.isNotEmpty()) {
Spacer(modifier = Modifier.height(2.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
telemetryStrings.forEach { telemetryString ->
Text(
text = telemetryString,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodySmall,
)
}
}
}
Spacer(modifier = Modifier.height(2.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
val labelStyle =
if (thatNode.isUnknownUser) {
MaterialTheme.typography.labelSmall.copy(
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.onSurface,
)
} else {
MaterialTheme.typography.labelSmall.copy(color = MaterialTheme.colorScheme.onSurface)
}
Text(text = thatNode.user.hwModel.name, style = labelStyle)
Text(text = thatNode.user.role.name, style = labelStyle)
Text(text = thatNode.user.id.ifEmpty { "???" }, style = labelStyle)
}
}
}
}
@Composable
@Preview(showBackground = false, uiMode = Configuration.UI_MODE_NIGHT_YES)
fun NodeInfoSimplePreview() {
AppTheme {
val thisNode = NodePreviewParameterProvider().values.first()
val thatNode = NodePreviewParameterProvider().values.last()
NodeItem(thisNode = thisNode, thatNode = thatNode, 0, true, currentTimeMillis = System.currentTimeMillis())
}
}
@Composable
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
fun NodeInfoPreview(@PreviewParameter(NodePreviewParameterProvider::class) thatNode: Node) {
AppTheme {
val thisNode = NodePreviewParameterProvider().values.first()
NodeItem(
thisNode = thisNode,
thatNode = thatNode,
distanceUnits = 1,
tempInFahrenheit = true,
currentTimeMillis = System.currentTimeMillis(),
)
}
}

View file

@ -1,176 +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 <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.node.components
import android.util.Base64
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyOff
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.google.protobuf.ByteString
import org.meshtastic.core.model.Channel
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.CopyIconButton
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
@Composable
private fun KeyStatusDialog(@StringRes title: Int, @StringRes text: Int, key: ByteString?, onDismiss: () -> Unit = {}) =
Dialog(onDismissRequest = onDismiss) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.background,
) {
LazyColumn(
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
item {
Text(text = stringResource(id = title), textAlign = TextAlign.Center)
Spacer(Modifier.height(16.dp))
Text(text = stringResource(id = text), textAlign = TextAlign.Center)
Spacer(Modifier.height(16.dp))
if (key != null && title == R.string.encryption_pkc) {
val keyString = Base64.encodeToString(key.toByteArray(), Base64.NO_WRAP)
Text(
text = stringResource(id = R.string.config_security_public_key) + ":",
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(8.dp))
SelectionContainer { Text(text = keyString, textAlign = TextAlign.Center) }
Spacer(Modifier.height(8.dp))
CopyIconButton(valueToCopy = keyString, modifier = Modifier.padding(start = 8.dp))
Spacer(Modifier.height(16.dp))
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton(
onClick = onDismiss,
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.onSurface,
),
) {
Text(text = stringResource(id = R.string.close))
}
}
}
}
}
}
@Composable
fun NodeKeyStatusIcon(
hasPKC: Boolean,
mismatchKey: Boolean,
publicKey: ByteString? = null,
modifier: Modifier = Modifier,
) {
var showEncryptionDialog by remember { mutableStateOf(false) }
if (showEncryptionDialog) {
val (title, text) =
when {
mismatchKey -> R.string.encryption_error to R.string.encryption_error_text
hasPKC -> R.string.encryption_pkc to R.string.encryption_pkc_text
else -> R.string.encryption_psk to R.string.encryption_psk_text
}
KeyStatusDialog(title, text, publicKey) { showEncryptionDialog = false }
}
val (icon, tint) =
when {
mismatchKey -> Icons.Default.KeyOff to colorScheme.StatusRed
hasPKC -> Icons.Default.Lock to colorScheme.StatusGreen
else ->
ImageVector.vectorResource(com.geeksville.mesh.R.drawable.ic_lock_open_right_24) to
colorScheme.StatusYellow
}
IconButton(onClick = { showEncryptionDialog = true }, modifier = modifier) {
Icon(
imageVector = icon,
contentDescription =
stringResource(
id =
when {
mismatchKey -> R.string.encryption_error
hasPKC -> R.string.encryption_pkc
else -> R.string.encryption_psk
},
),
tint = tint,
)
}
}
@PreviewLightDark
@Composable
private fun KeyStatusDialogErrorPreview() {
AppTheme { KeyStatusDialog(title = R.string.encryption_error, text = R.string.encryption_error_text, key = null) }
}
@PreviewLightDark
@Composable
private fun KeyStatusDialogPkcPreview() {
AppTheme {
KeyStatusDialog(
title = R.string.encryption_pkc,
text = R.string.encryption_pkc_text,
key = Channel.getRandomKey(),
)
}
}
@PreviewLightDark
@Composable
private fun KeyStatusDialogPskPreview() {
AppTheme { KeyStatusDialog(title = R.string.encryption_psk, text = R.string.encryption_psk_text, key = null) }
}

View file

@ -1,98 +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 <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.node.components
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.SimpleAlertDialog
@Composable
fun NodeActionDialogs(
node: Node,
displayFavoriteDialog: Boolean,
displayIgnoreDialog: Boolean,
displayRemoveDialog: Boolean,
onDismissMenuRequest: () -> Unit,
onConfirmFavorite: (Node) -> Unit,
onConfirmIgnore: (Node) -> Unit,
onConfirmRemove: (Node) -> Unit,
) {
if (displayFavoriteDialog) {
SimpleAlertDialog(
title = R.string.favorite,
text =
stringResource(
id = if (node.isFavorite) R.string.favorite_remove else R.string.favorite_add,
node.user.longName,
),
onConfirm = {
onDismissMenuRequest()
onConfirmFavorite(node)
},
onDismiss = onDismissMenuRequest,
)
}
if (displayIgnoreDialog) {
SimpleAlertDialog(
title = R.string.ignore,
text =
stringResource(
id = if (node.isIgnored) R.string.ignore_remove else R.string.ignore_add,
node.user.longName,
),
onConfirm = {
onDismissMenuRequest()
onConfirmIgnore(node)
},
onDismiss = onDismissMenuRequest,
)
}
if (displayRemoveDialog) {
SimpleAlertDialog(
title = R.string.remove,
text = R.string.remove_node_text,
onConfirm = {
onDismissMenuRequest()
onConfirmRemove(node)
},
onDismiss = onDismissMenuRequest,
)
}
}
sealed class NodeMenuAction {
data class Remove(val node: Node) : NodeMenuAction()
data class Ignore(val node: Node) : NodeMenuAction()
data class Favorite(val node: Node) : NodeMenuAction()
data class DirectMessage(val node: Node) : NodeMenuAction()
data class RequestUserInfo(val node: Node) : NodeMenuAction()
data class RequestPosition(val node: Node) : NodeMenuAction()
data class TraceRoute(val node: Node) : NodeMenuAction()
data class MoreDetails(val node: Node) : NodeMenuAction()
data class Share(val node: Node) : NodeMenuAction()
}

View file

@ -1,128 +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 <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.node.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.material.icons.twotone.CloudDone
import androidx.compose.material.icons.twotone.CloudOff
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.TooltipAnchorPosition
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.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.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NodeStatusIcons(isThisNode: Boolean, isUnmessageable: Boolean, isFavorite: Boolean, isConnected: Boolean) {
Row(modifier = Modifier.padding(4.dp)) {
if (isThisNode) {
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
tooltip = {
PlainTooltip {
Text(
stringResource(
if (isConnected) {
R.string.connected
} else {
R.string.disconnected
},
),
)
}
},
state = rememberTooltipState(),
) {
if (isConnected) {
@Suppress("MagicNumber")
Icon(
imageVector = Icons.TwoTone.CloudDone,
contentDescription = stringResource(R.string.connected),
modifier = Modifier.size(24.dp), // Smaller size for badge
tint = MaterialTheme.colorScheme.StatusGreen,
)
} else {
Icon(
imageVector = Icons.TwoTone.CloudOff,
contentDescription = stringResource(R.string.not_connected),
modifier = Modifier.size(24.dp), // Smaller size for badge
tint = MaterialTheme.colorScheme.StatusRed,
)
}
}
}
if (isUnmessageable) {
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(),
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
)
}
}
}
if (isFavorite && !isThisNode) {
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(),
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 = MaterialTheme.colorScheme.StatusYellow,
)
}
}
}
}
}
@Preview
@Composable
fun StatusIconsPreview() {
NodeStatusIcons(isThisNode = true, isUnmessageable = true, isFavorite = true, isConnected = false)
}

View file

@ -1,42 +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 <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.node.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.SatelliteAlt
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewLightDark
import org.meshtastic.core.ui.theme.AppTheme
@Composable
fun SatelliteCountInfo(modifier: Modifier = Modifier, satCount: Int) {
IconInfo(
modifier = modifier,
icon = Icons.TwoTone.SatelliteAlt,
contentDescription = stringResource(org.meshtastic.core.strings.R.string.sats),
text = "$satCount",
)
}
@PreviewLightDark
@Composable
fun SatelliteCountInfoPreview() {
AppTheme { SatelliteCountInfo(satCount = 5) }
}

View file

@ -1,100 +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 <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.node.components
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Route
import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.ui.settings.components.SettingsItem
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.theme.AppTheme
private const val COOL_DOWN_TIME_MS = 30000L
@Composable
fun TracerouteButton(
text: String = stringResource(id = R.string.traceroute),
lastTracerouteTime: Long?,
onClick: () -> Unit,
) {
val progress = remember { Animatable(0f) }
LaunchedEffect(lastTracerouteTime) {
val timeSinceLast = System.currentTimeMillis() - (lastTracerouteTime ?: 0)
if (timeSinceLast < COOL_DOWN_TIME_MS) {
val remainingTime = COOL_DOWN_TIME_MS - timeSinceLast
progress.snapTo(remainingTime / COOL_DOWN_TIME_MS.toFloat())
progress.animateTo(
targetValue = 0f,
animationSpec = tween(durationMillis = remainingTime.toInt(), easing = { it }),
)
}
}
TracerouteButton(text = text, progress = progress.value, onClick = onClick)
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun TracerouteButton(text: String, progress: Float, onClick: () -> Unit) {
val isCoolingDown = progress > 0f
val stroke = Stroke(width = with(LocalDensity.current) { 2.dp.toPx() }, cap = StrokeCap.Round)
SettingsItem(
text = text,
enabled = !isCoolingDown,
leadingIcon = Icons.Default.Route,
trailingContent = {
if (isCoolingDown) {
CircularWavyProgressIndicator(
progress = { progress },
modifier = Modifier.size(24.dp),
stroke = stroke,
trackStroke = stroke,
wavelength = 8.dp,
)
}
},
onClick = {
if (!isCoolingDown) {
onClick()
}
},
)
}
@Preview(showBackground = true)
@Composable
private fun TracerouteButtonPreview() {
AppTheme { TracerouteButton(text = "Traceroute", progress = .6f, onClick = {}) }
}

View file

@ -60,9 +60,6 @@ import com.geeksville.mesh.ClientOnlyProtos.DeviceProfile
import com.geeksville.mesh.android.gpsDisabled
import com.geeksville.mesh.navigation.getNavRouteFrom
import com.geeksville.mesh.ui.common.components.MainAppBar
import com.geeksville.mesh.ui.settings.components.SettingsItem
import com.geeksville.mesh.ui.settings.components.SettingsItemDetail
import com.geeksville.mesh.ui.settings.components.SettingsItemSwitch
import com.geeksville.mesh.ui.settings.radio.RadioConfigItemList
import com.geeksville.mesh.ui.settings.radio.RadioConfigViewModel
import com.geeksville.mesh.ui.settings.radio.components.EditDeviceProfileDialog
@ -75,6 +72,9 @@ import kotlinx.coroutines.delay
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.strings.R
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.TitledCard
import org.meshtastic.core.ui.theme.MODE_DYNAMIC
import java.text.SimpleDateFormat

View file

@ -1,185 +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 <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.settings.components
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.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.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 button item. */
@Composable
fun SettingsItem(
text: String,
textColor: Color = LocalContentColor.current,
enabled: Boolean = true,
leadingIcon: ImageVector? = null,
leadingIconTint: Color = LocalContentColor.current,
trailingIcon: ImageVector? = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
trailingIconTint: Color = LocalContentColor.current,
onClick: () -> Unit,
) {
SettingsItem(
text = text,
textColor = textColor,
enabled = enabled,
leadingIcon = leadingIcon,
leadingIconTint = leadingIconTint,
trailingContent = { trailingIcon.Icon(trailingIconTint) },
onClick = onClick,
)
}
/** A clickable settings button item. */
@Composable
fun SettingsItem(
text: String,
textColor: Color = LocalContentColor.current,
enabled: Boolean = true,
leadingIcon: ImageVector? = null,
leadingIconTint: Color = LocalContentColor.current,
trailingContent: @Composable (() -> Unit),
onClick: () -> Unit,
) {
ClickableWrapper(enabled = enabled, onClick = onClick) {
Content(
leading = { leadingIcon.Icon(leadingIconTint) },
text = text,
textColor = textColor,
trailing = trailingContent,
)
}
}
/** 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,
) {
ClickableWrapper(enabled = enabled, onClick = onClick) {
Content(
leading = { leadingIcon.Icon(leadingIconTint) },
text = text,
textColor = textColor,
trailing = { Switch(checked = checked, enabled = enabled, onCheckedChange = null) },
)
}
}
/** A settings detail item. */
@Composable
fun SettingsItemDetail(
text: String,
textColor: Color = LocalContentColor.current,
icon: ImageVector? = null,
iconTint: Color = LocalContentColor.current,
trailingText: String? = null,
enabled: Boolean = true,
onClick: (() -> Unit)? = null,
) {
val content: @Composable ColumnScope.() -> Unit = {
Content(
leading = { icon.Icon(iconTint) },
text = text,
textColor = textColor,
trailing = { trailingText?.let { Text(text = it) } },
)
}
if (onClick != null) {
ClickableWrapper(enabled = enabled, onClick = onClick, content = content)
} else {
Column(content = content)
}
}
/** A clickable Card wrapper used for all clickable settings items. */
@Composable
private fun ClickableWrapper(enabled: Boolean, onClick: () -> Unit, content: @Composable ColumnScope.() -> Unit) {
Card(
onClick = onClick,
enabled = enabled,
colors =
CardDefaults.cardColors(containerColor = Color.Transparent, disabledContainerColor = Color.Transparent),
content = content,
)
}
/** The row content to display for a settings item. */
@Composable
private fun Content(leading: @Composable () -> Unit, text: String, textColor: Color, trailing: @Composable () -> Unit) {
ListItem(
modifier = Modifier.padding(horizontal = 8.dp),
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
headlineContent = { Text(text = text, color = textColor) },
leadingContent = { leading() },
trailingContent = { trailing() },
)
}
@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, trailingText = "Text2") }
}

View file

@ -46,9 +46,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.geeksville.mesh.ui.node.components.NodeChip
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.NodeChip
/**
* Composable screen for cleaning the node database. Allows users to specify criteria for deleting nodes. The list of

View file

@ -44,11 +44,11 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.navigation.ConfigRoute
import com.geeksville.mesh.navigation.ModuleRoute
import com.geeksville.mesh.ui.settings.components.SettingsItem
import com.geeksville.mesh.ui.settings.radio.components.WarningDialog
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.TitledCard
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.StatusColors.StatusRed