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
44 changed files with 164 additions and 178 deletions

View File

@@ -0,0 +1,43 @@
/*
* 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 org.meshtastic.feature.node.component
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

@@ -0,0 +1,51 @@
/*
* 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 org.meshtastic.feature.node.component
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
private fun ElevationInfoPreview() {
MaterialTheme { ElevationInfo(altitude = 100, system = DisplayUnits.METRIC, suffix = "ASL") }
}

View File

@@ -0,0 +1,69 @@
/*
* 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 org.meshtastic.feature.node.component
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

@@ -0,0 +1,49 @@
/*
* 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 org.meshtastic.feature.node.component
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 org.meshtastic.core.model.util.formatAgo
import org.meshtastic.core.ui.R
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
private fun LastHeardInfoPreview() {
AppTheme {
LastHeardInfo(
lastHeard = (System.currentTimeMillis() / 1000).toInt() - 8600,
currentTimeMillis = System.currentTimeMillis(),
)
}
}

View File

@@ -0,0 +1,118 @@
/*
* 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 org.meshtastic.feature.node.component
import android.content.ActivityNotFoundException
import android.content.ClipData
import android.content.Context
import android.content.Intent
import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.Clipboard
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.core.net.toUri
import kotlinx.coroutines.launch
import org.meshtastic.core.model.util.GPSFormat
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.HyperlinkBlue
import timber.log.Timber
import java.net.URLEncoder
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LinkedCoordinates(modifier: Modifier = Modifier, latitude: Double, longitude: Double, nodeName: String) {
val context = LocalContext.current
val clipboard: Clipboard = LocalClipboard.current
val coroutineScope = rememberCoroutineScope()
val style =
SpanStyle(
color = HyperlinkBlue,
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
private fun LinkedCoordinatesPreview() {
AppTheme { LinkedCoordinates(latitude = 37.7749, longitude = -122.4194, nodeName = "Test Node Name") }
}

View File

@@ -0,0 +1,312 @@
/*
* 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 org.meshtastic.feature.node.component
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.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.Preview
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
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 = 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
@Preview(name = "Large Font", fontScale = 2f)
@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

@@ -0,0 +1,240 @@
/*
* 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 org.meshtastic.feature.node.component
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.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 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.component.NodeChip
import org.meshtastic.core.ui.component.SignalInfo
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
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

@@ -0,0 +1,176 @@
/*
* 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 org.meshtastic.feature.node.component
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(org.meshtastic.core.ui.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

@@ -0,0 +1,98 @@
/*
* 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 org.meshtastic.feature.node.component
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

@@ -0,0 +1,128 @@
/*
* 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 org.meshtastic.feature.node.component
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
private fun StatusIconsPreview() {
NodeStatusIcons(isThisNode = true, isUnmessageable = true, isFavorite = true, isConnected = false)
}

View File

@@ -0,0 +1,42 @@
/*
* 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 org.meshtastic.feature.node.component
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
private fun SatelliteCountInfoPreview() {
AppTheme { SatelliteCountInfo(satCount = 5) }
}

View File

@@ -0,0 +1,100 @@
/*
* 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 org.meshtastic.feature.node.component
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 org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.SettingsItem
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

@@ -0,0 +1,131 @@
/*
* 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 org.meshtastic.feature.node.detail
import android.os.RemoteException
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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 org.meshtastic.feature.node.component.NodeMenuAction
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

@@ -0,0 +1,212 @@
/*
* 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 org.meshtastic.feature.node.list
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 NodeListViewModel
@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,
)