Refactor: organize ui screens to separate packages (#1982)

This commit is contained in:
James Rich 2025-05-29 18:18:45 -05:00 committed by GitHub
parent 32d9f29d7e
commit ad1897c564
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
108 changed files with 475 additions and 569 deletions

View file

@ -0,0 +1,904 @@
/*
* 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 androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.automirrored.outlined.VolumeMute
import androidx.compose.material.icons.filled.Air
import androidx.compose.material.icons.filled.BlurOn
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.ChargingStation
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Height
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.KeyOff
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.Map
import androidx.compose.material.icons.filled.Memory
import androidx.compose.material.icons.filled.Numbers
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Power
import androidx.compose.material.icons.filled.Route
import androidx.compose.material.icons.filled.Router
import androidx.compose.material.icons.filled.Scale
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.SignalCellularAlt
import androidx.compose.material.icons.filled.Speed
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.StarBorder
import androidx.compose.material.icons.filled.Thermostat
import androidx.compose.material.icons.filled.WaterDrop
import androidx.compose.material.icons.filled.Work
import androidx.compose.material.icons.outlined.Navigation
import androidx.compose.material.icons.outlined.NoCell
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.twotone.Person
import androidx.compose.material.icons.twotone.Verified
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.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.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import coil3.request.ErrorResult
import coil3.request.ImageRequest
import coil3.request.SuccessResult
import com.geeksville.mesh.R
import com.geeksville.mesh.android.BuildUtils.debug
import com.geeksville.mesh.model.DeviceHardware
import com.geeksville.mesh.model.MetricsState
import com.geeksville.mesh.model.MetricsViewModel
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.model.isUnmessageableRole
import com.geeksville.mesh.navigation.Route
import com.geeksville.mesh.service.ServiceAction
import com.geeksville.mesh.ui.common.components.PreferenceCategory
import com.geeksville.mesh.ui.common.preview.NodePreviewParameterProvider
import com.geeksville.mesh.ui.common.theme.AppTheme
import com.geeksville.mesh.ui.node.components.NodeActionDialogs
import com.geeksville.mesh.ui.node.components.NodeMenuAction
import com.geeksville.mesh.ui.radioconfig.NavCard
import com.geeksville.mesh.ui.sharing.SharedContactDialog
import com.geeksville.mesh.util.UnitConversions.calculateDewPoint
import com.geeksville.mesh.util.UnitConversions.toTempString
import com.geeksville.mesh.util.formatAgo
import com.geeksville.mesh.util.formatUptime
import com.geeksville.mesh.util.thenIf
import com.geeksville.mesh.util.toSpeedString
private enum class LogsType(
val titleRes: Int,
val icon: ImageVector,
val route: Route
) {
DEVICE(R.string.device_metrics_log, Icons.Default.ChargingStation, Route.DeviceMetrics),
NODE_MAP(R.string.node_map, Icons.Default.Map, Route.NodeMap),
POSITIONS(R.string.position_log, Icons.Default.LocationOn, Route.PositionLog),
ENVIRONMENT(R.string.env_metrics_log, Icons.Default.Thermostat, Route.EnvironmentMetrics),
SIGNAL(R.string.sig_metrics_log, Icons.Default.SignalCellularAlt, Route.SignalMetrics),
POWER(R.string.power_metrics_log, Icons.Default.Power, Route.PowerMetrics),
TRACEROUTE(R.string.traceroute_log, Icons.Default.Route, Route.TracerouteLog),
HOST(R.string.host_metrics_log, Icons.Default.Memory, Route.HostMetricsLog),
}
@Composable
fun NodeDetailScreen(
modifier: Modifier = Modifier,
viewModel: MetricsViewModel = hiltViewModel(),
uiViewModel: UIViewModel = hiltViewModel(),
onNavigate: (Route) -> Unit = {},
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val environmentState by viewModel.environmentState.collectAsStateWithLifecycle()
/* The order is with respect to the enum above: LogsType */
val availabilities = remember(key1 = state, key2 = environmentState) {
booleanArrayOf(
state.hasDeviceMetrics(),
state.hasPositionLogs(),
state.hasPositionLogs(),
environmentState.hasEnvironmentMetrics(),
state.hasSignalMetrics(),
state.hasPowerMetrics(),
state.hasTracerouteLogs(),
state.hasHostMetrics(),
)
}
if (state.node != null) {
val node = state.node ?: return
uiViewModel.setTitle(node.user.longName)
var share by remember { mutableStateOf<Boolean>(false) }
if (share) {
SharedContactDialog(node) {
share = false
}
}
NodeDetailList(
node = node,
metricsState = state,
onAction = { action ->
when (action) {
is Route -> onNavigate(action)
is ServiceAction -> viewModel.onServiceAction(action)
is NodeMenuAction -> {
uiViewModel.handleNodeMenuAction(action)
}
else -> debug("Unhandled action: $action")
}
},
modifier = modifier,
metricsAvailability = availabilities,
onShared = {
share = true
}
)
} else {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
}
@Suppress("LongMethod")
@Composable
private fun NodeDetailList(
modifier: Modifier = Modifier,
node: Node,
metricsState: MetricsState,
onAction: (Any) -> Unit = {},
metricsAvailability: BooleanArray,
onShared: () -> Unit = {}
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp),
) {
if (metricsState.deviceHardware != null) {
item {
PreferenceCategory(stringResource(R.string.device)) {
DeviceDetailsContent(metricsState)
}
}
}
item {
PreferenceCategory(stringResource(R.string.details)) {
NodeDetailsContent(node)
}
}
node.metadata?.firmwareVersion?.let { firmwareVersion ->
item {
PreferenceCategory(stringResource(R.string.firmware)) {
val latestStableFirmware = metricsState.latestStableFirmware
val latestAlphaFirmware = metricsState.latestAlphaFirmware
NodeDetailRow(
label = "Installed",
icon = Icons.Default.Memory,
value = firmwareVersion.substringBeforeLast(".")
)
latestStableFirmware?.let { stable ->
NodeDetailRow(
label = "Latest stable",
icon = Icons.Default.Memory,
value = stable.id.substringBeforeLast(".").replace("v", "")
)
}
latestAlphaFirmware?.let { alpha ->
NodeDetailRow(
label = "Latest alpha",
icon = Icons.Default.Memory,
value = alpha.id.substringBeforeLast(".").replace("v", "")
)
}
}
}
}
item {
DeviceActions(
isLocal = metricsState.isLocal,
node = node,
onShared = onShared,
onAction = onAction
)
}
if (node.hasEnvironmentMetrics) {
item {
PreferenceCategory(stringResource(R.string.environment))
EnvironmentMetrics(node, metricsState.isFahrenheit)
Spacer(modifier = Modifier.height(8.dp))
}
}
if (node.hasPowerMetrics) {
item {
PreferenceCategory(stringResource(R.string.power))
PowerMetrics(node)
Spacer(modifier = Modifier.height(8.dp))
}
}
/* Metric Logs Navigation */
item {
PreferenceCategory(stringResource(id = R.string.logs))
for (type in LogsType.entries) {
NavCard(
title = stringResource(type.titleRes),
icon = type.icon,
enabled = metricsAvailability[type.ordinal]
) {
onAction(type.route)
}
}
}
if (!metricsState.isManaged) {
item {
PreferenceCategory(stringResource(id = R.string.administration))
NavCard(
title = stringResource(id = R.string.remote_admin),
icon = Icons.Default.Settings,
enabled = true
) {
onAction(Route.RadioConfig(node.num))
}
}
}
}
}
@Composable
private fun NodeDetailRow(
modifier: Modifier = Modifier,
label: String,
icon: ImageVector,
value: String,
iconTint: Color = MaterialTheme.colorScheme.onSurface
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = icon,
contentDescription = label,
modifier = Modifier.size(24.dp),
tint = iconTint
)
Text(label)
Spacer(modifier = Modifier.weight(1f))
Text(textAlign = TextAlign.End, text = value)
}
}
@Suppress("LongMethod")
@Composable
private fun DeviceActions(
isLocal: Boolean = false,
node: Node,
onShared: () -> Unit,
onAction: (Any) -> Unit,
) {
var displayFavoriteDialog by remember { mutableStateOf(false) }
var displayIgnoreDialog by remember { mutableStateOf(false) }
var displayRemoveDialog by remember { mutableStateOf(false) }
NodeActionDialogs(
node = node,
displayFavoriteDialog = displayFavoriteDialog,
displayIgnoreDialog = displayIgnoreDialog,
displayRemoveDialog = displayRemoveDialog,
onDismissMenuRequest = {
displayFavoriteDialog = false
displayIgnoreDialog = false
displayRemoveDialog = false
},
onAction = onAction,
)
PreferenceCategory(text = stringResource(R.string.actions))
NodeActionButton(
title = stringResource(id = R.string.share_contact),
icon = Icons.Default.Share,
enabled = true,
onClick = onShared
)
if (!isLocal) {
NodeActionButton(
title = stringResource(id = R.string.request_metadata),
icon = Icons.Default.Memory,
enabled = true,
onClick = { onAction(ServiceAction.GetDeviceMetadata(node.num)) }
)
NodeActionButton(
title = stringResource(id = R.string.exchange_position),
icon = Icons.Default.LocationOn,
enabled = true,
onClick = { onAction(NodeMenuAction.RequestPosition(node)) }
)
NodeActionButton(
title = stringResource(id = R.string.exchange_userinfo),
icon = Icons.Default.Person,
enabled = true,
onClick = { onAction(NodeMenuAction.RequestUserInfo(node)) }
)
NodeActionButton(
title = stringResource(id = R.string.traceroute),
icon = Icons.Default.Route,
enabled = true,
onClick = { onAction(NodeMenuAction.TraceRoute(node)) }
)
NodeActionSwitch(
title = stringResource(R.string.favorite),
icon = if (node.isFavorite) {
Icons.Default.Star
} else {
Icons.Default.StarBorder
},
iconTint = if (node.isFavorite) {
Color.Yellow
} else {
LocalContentColor.current
},
enabled = true,
checked = node.isFavorite,
onClick = { displayFavoriteDialog = true }
)
NodeActionSwitch(
title = stringResource(R.string.ignore),
icon = if (node.isIgnored) {
Icons.AutoMirrored.Outlined.VolumeMute
} else {
Icons.AutoMirrored.Default.VolumeUp
},
enabled = true,
checked = node.isIgnored,
onClick = { displayIgnoreDialog = true }
)
NodeActionButton(
title = stringResource(id = R.string.remove),
icon = Icons.Default.Delete,
enabled = true,
onClick = { displayRemoveDialog = true }
)
}
}
@Composable
private fun DeviceDetailsContent(
state: MetricsState
) {
val node = state.node ?: return
val deviceHardware = state.deviceHardware ?: return
val hwModelName = deviceHardware.displayName
val isSupported = deviceHardware.activelySupported
Box(
modifier = Modifier
.size(100.dp)
.padding(4.dp)
.clip(CircleShape)
.background(
color = Color(node.colors.second).copy(alpha = .5f),
shape = CircleShape
),
contentAlignment = Alignment.Center
) {
DeviceHardwareImage(deviceHardware, Modifier.fillMaxSize())
}
NodeDetailRow(
label = stringResource(R.string.hardware),
icon = Icons.Default.Router,
value = hwModelName
)
NodeDetailRow(
label = if (isSupported) stringResource(R.string.supported) else "Supported by Community",
icon = if (isSupported) Icons.TwoTone.Verified else ImageVector.vectorResource(R.drawable.unverified),
value = "",
iconTint = if (isSupported) Color.Green else Color.Red
)
}
@Composable
fun DeviceHardwareImage(
deviceHardware: DeviceHardware,
modifier: Modifier = Modifier,
) {
val hwImg =
deviceHardware.images?.getOrNull(1) ?: deviceHardware.images?.getOrNull(0)
?: "unknown.svg"
val imageUrl = "https://flasher.meshtastic.org/img/devices/$hwImg"
val listener = object : ImageRequest.Listener {
override fun onStart(request: ImageRequest) {
super.onStart(request)
debug("Image request started")
}
override fun onError(request: ImageRequest, result: ErrorResult) {
super.onError(request, result)
debug("Image request failed: ${result.throwable.message}")
}
override fun onSuccess(request: ImageRequest, result: SuccessResult) {
super.onSuccess(request, result)
debug("Image request succeeded: ${result.dataSource.name}")
}
}
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.listener(listener)
.data(imageUrl)
.build(),
contentScale = ContentScale.Inside,
contentDescription = deviceHardware.displayName,
placeholder = painterResource(R.drawable.hw_unknown),
error = painterResource(R.drawable.hw_unknown),
fallback = painterResource(R.drawable.hw_unknown),
modifier = modifier
.padding(16.dp)
)
}
@Suppress("LongMethod")
@Composable
private fun NodeDetailsContent(
node: Node,
) {
if (node.mismatchKey) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.KeyOff,
contentDescription = stringResource(id = R.string.encryption_error),
tint = Color.Red,
)
Spacer(Modifier.width(12.dp))
Text(
text = stringResource(id = R.string.encryption_error),
style = MaterialTheme.typography.titleLarge.copy(color = Color.Red),
textAlign = TextAlign.Center,
)
}
Spacer(Modifier.height(16.dp))
Text(
text = stringResource(id = R.string.encryption_error_text),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(16.dp))
}
NodeDetailRow(
label = stringResource(R.string.long_name),
icon = Icons.TwoTone.Person,
value = node.user.longName.ifEmpty { "???" }
)
NodeDetailRow(
label = stringResource(R.string.short_name),
icon = Icons.Outlined.Person,
value = node.user.shortName.ifEmpty { "???" }
)
NodeDetailRow(
label = stringResource(R.string.node_number),
icon = Icons.Default.Numbers,
value = node.num.toUInt().toString()
)
NodeDetailRow(
label = stringResource(R.string.user_id),
icon = Icons.Default.Person,
value = node.user.id
)
NodeDetailRow(
label = stringResource(R.string.role),
icon = Icons.Default.Work,
value = node.user.role.name
)
val unmessageable = if (node.user.hasIsUnmessagable()) {
node.user.isUnmessagable
} else {
node.user.role?.isUnmessageableRole() == true
}
if (unmessageable) {
NodeDetailRow(
label = stringResource(R.string.unmonitored_or_infrastructure),
icon = Icons.Outlined.NoCell,
value = ""
)
}
if (node.deviceMetrics.uptimeSeconds > 0) {
NodeDetailRow(
label = stringResource(R.string.uptime),
icon = Icons.Default.CheckCircle,
value = formatUptime(node.deviceMetrics.uptimeSeconds)
)
}
NodeDetailRow(
label = stringResource(R.string.node_sort_last_heard),
icon = Icons.Default.History,
value = formatAgo(node.lastHeard)
)
}
@Composable
private fun InfoCard(
icon: ImageVector,
text: String,
value: String,
rotateIcon: Float = 0f,
) {
Card(
modifier = Modifier
.padding(4.dp)
.width(100.dp)
.height(100.dp),
) {
Box(
modifier = Modifier
.padding(4.dp)
.width(100.dp)
.height(100.dp),
contentAlignment = Alignment.Center
) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = icon,
contentDescription = text,
modifier = Modifier
.size(24.dp)
.thenIf(rotateIcon != 0f) { rotate(rotateIcon) },
)
Text(
textAlign = TextAlign.Center,
text = text,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.labelSmall
)
Text(
text = value,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleMedium,
)
}
}
}
}
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
private fun EnvironmentMetrics(
node: Node,
isFahrenheit: Boolean = false,
) = with(node.environmentMetrics) {
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalArrangement = Arrangement.SpaceEvenly,
) {
if (hasTemperature()) {
InfoCard(
icon = Icons.Default.Thermostat,
text = stringResource(R.string.temperature),
value = temperature.toTempString(isFahrenheit)
)
}
if (hasRelativeHumidity()) {
InfoCard(
icon = Icons.Default.WaterDrop,
text = stringResource(R.string.humidity),
value = "%.0f%%".format(relativeHumidity)
)
}
if (hasTemperature() && hasRelativeHumidity()) {
val dewPoint = calculateDewPoint(temperature, relativeHumidity)
InfoCard(
icon = ImageVector.vectorResource(R.drawable.ic_outlined_dew_point_24),
text = stringResource(R.string.dew_point),
value = dewPoint.toTempString(isFahrenheit)
)
}
if (hasBarometricPressure()) {
InfoCard(
icon = Icons.Default.Speed,
text = stringResource(R.string.pressure),
value = "%.0f hPa".format(barometricPressure)
)
}
if (hasGasResistance()) {
InfoCard(
icon = Icons.Default.BlurOn,
text = stringResource(R.string.gas_resistance),
value = "%.0f MΩ".format(gasResistance)
)
}
if (hasVoltage()) {
InfoCard(
icon = Icons.Default.Bolt,
text = stringResource(R.string.voltage),
value = "%.2fV".format(voltage)
)
}
if (hasCurrent()) {
InfoCard(
icon = Icons.Default.Power,
text = stringResource(R.string.current),
value = "%.1fmA".format(current)
)
}
if (hasIaq()) {
InfoCard(
icon = Icons.Default.Air,
text = stringResource(R.string.iaq),
value = iaq.toString()
)
}
if (hasDistance()) {
InfoCard(
icon = Icons.Default.Height,
text = stringResource(R.string.distance),
value = "%.0f mm".format(distance)
)
}
if (hasLux()) {
InfoCard(
icon = Icons.Default.LightMode,
text = stringResource(R.string.lux),
value = "%.0f lx".format(lux)
)
}
if (hasWindSpeed()) {
@Suppress("MagicNumber")
val normalizedBearing = (windDirection % 360 + 360) % 360
InfoCard(
icon = Icons.Outlined.Navigation,
text = stringResource(R.string.wind),
value = windSpeed.toSpeedString(),
rotateIcon = normalizedBearing.toFloat(),
)
}
if (hasWeight()) {
InfoCard(
icon = Icons.Default.Scale,
text = stringResource(R.string.weight),
value = "%.2f kg".format(weight)
)
}
if (hasRadiation()) {
InfoCard(
icon = ImageVector.vectorResource(R.drawable.ic_filled_radioactive_24),
text = stringResource(R.string.radiation),
value = "%.1f µR/h".format(radiation)
)
}
}
}
@Composable
private fun PowerMetrics(node: Node) = with(node.powerMetrics) {
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalArrangement = Arrangement.SpaceEvenly,
) {
if (ch1Voltage != 0f) {
Column {
InfoCard(
icon = Icons.Default.Bolt,
text = stringResource(R.string.channel_1),
value = "%.2fV".format(ch1Voltage)
)
InfoCard(
icon = Icons.Default.Power,
text = stringResource(R.string.channel_1),
value = "%.1fmA".format(ch1Current)
)
}
}
if (ch2Voltage != 0f) {
Column {
InfoCard(
icon = Icons.Default.Bolt,
text = stringResource(R.string.channel_2),
value = "%.2fV".format(ch2Voltage)
)
InfoCard(
icon = Icons.Default.Power,
text = stringResource(R.string.channel_2),
value = "%.1fmA".format(ch2Current)
)
}
}
if (ch3Voltage != 0f) {
Column {
InfoCard(
icon = Icons.Default.Bolt,
text = stringResource(R.string.channel_3),
value = "%.2fV".format(ch3Voltage)
)
InfoCard(
icon = Icons.Default.Power,
text = stringResource(R.string.channel_3),
value = "%.1fmA".format(ch3Current)
)
}
}
}
}
@Composable
fun NodeActionButton(
title: String,
enabled: Boolean,
icon: ImageVector? = null,
iconTint: Color? = null,
onClick: () -> Unit
) {
Button(
onClick = onClick,
enabled = enabled,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.height(48.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = title,
modifier = Modifier.size(24.dp),
tint = iconTint ?: LocalContentColor.current,
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
)
}
}
}
@Composable
fun NodeActionSwitch(
title: String,
enabled: Boolean,
checked: Boolean,
icon: ImageVector? = null,
iconTint: Color? = null,
onClick: () -> Unit,
) {
val interactionSource = remember { MutableInteractionSource() }
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.height(48.dp)
.toggleable(
value = checked,
enabled = enabled,
role = Role.Switch,
onValueChange = { onClick() }
),
shape = MaterialTheme.shapes.large,
interactionSource = interactionSource,
onClick = onClick,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp, horizontal = 16.dp)
) {
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = title,
modifier = Modifier.size(24.dp),
tint = iconTint ?: LocalContentColor.current,
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
)
Switch(
checked = checked,
onCheckedChange = null,
)
}
}
}
@Preview(showBackground = true)
@Composable
private fun NodeDetailsPreview(
@PreviewParameter(NodePreviewParameterProvider::class)
node: Node
) {
AppTheme {
NodeDetailList(
node = node,
metricsState = MetricsState.Empty,
metricsAvailability = BooleanArray(LogsType.entries.size) { false },
)
}
}

View file

@ -0,0 +1,62 @@
/*
* 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 androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.model.MetricsViewModel
import com.geeksville.mesh.ui.map.rememberMapViewWithLifecycle
import com.geeksville.mesh.util.addCopyright
import com.geeksville.mesh.util.addPolyline
import com.geeksville.mesh.util.addPositionMarkers
import com.geeksville.mesh.util.addScaleBarOverlay
import org.osmdroid.util.BoundingBox
import org.osmdroid.util.GeoPoint
private const val DegD = 1e-7
@Composable
fun NodeMapScreen(
viewModel: MetricsViewModel = hiltViewModel(),
) {
val density = LocalDensity.current
val state by viewModel.state.collectAsStateWithLifecycle()
val geoPoints = state.positionLogs.map { GeoPoint(it.latitudeI * DegD, it.longitudeI * DegD) }
val cameraView = remember { BoundingBox.fromGeoPoints(geoPoints) }
val mapView = rememberMapViewWithLifecycle(cameraView, viewModel.tileSource)
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { mapView },
update = { map ->
map.overlays.clear()
map.addCopyright()
map.addScaleBarOverlay(density)
map.addPolyline(density, geoPoints) {}
map.addPositionMarkers(state.positionLogs) {}
}
)
}

View file

@ -0,0 +1,166 @@
/*
* 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 androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.MaterialTheme
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.graphicsLayer
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.DataPacket
import com.geeksville.mesh.model.DeviceVersion
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.ui.common.components.rememberTimeTickWithLifecycle
import com.geeksville.mesh.ui.node.components.NodeFilterTextField
import com.geeksville.mesh.ui.node.components.NodeItem
import com.geeksville.mesh.ui.node.components.NodeMenuAction
import com.geeksville.mesh.ui.sharing.AddContactFAB
import com.geeksville.mesh.ui.sharing.SharedContactDialog
import com.geeksville.mesh.ui.sharing.supportsQrCodeSharing
@OptIn(ExperimentalFoundationApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun NodeScreen(
model: UIViewModel = hiltViewModel(),
navigateToMessages: (String) -> Unit,
navigateToNodeDetails: (Int) -> Unit,
) {
val state by model.nodesUiState.collectAsStateWithLifecycle()
val nodes by model.nodeList.collectAsStateWithLifecycle()
val ourNode by model.ourNodeInfo.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
val currentTimeMillis = rememberTimeTickWithLifecycle()
val connectionState by model.connectionState.collectAsStateWithLifecycle()
var showSharedContact: Node? by remember { mutableStateOf(null) }
if (showSharedContact != null) {
SharedContactDialog(
contact = showSharedContact,
onDismiss = { showSharedContact = null }
)
}
Box(
modifier = Modifier
.fillMaxSize()
) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
) {
stickyHeader {
val animatedAlpha by animateFloatAsState(
targetValue = if (!listState.isScrollInProgress) 1.0f else 0f,
label = "alpha"
)
NodeFilterTextField(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceDim.copy(alpha = animatedAlpha))
.graphicsLayer(alpha = animatedAlpha)
.padding(8.dp),
filterText = state.filter,
onTextChange = model::setNodeFilterText,
currentSortOption = state.sort,
onSortSelect = model::setSortOption,
includeUnknown = state.includeUnknown,
onToggleIncludeUnknown = model::toggleIncludeUnknown,
showDetails = state.showDetails,
onToggleShowDetails = model::toggleShowDetails,
)
}
items(nodes, key = { it.num }) { node ->
NodeItem(
modifier = Modifier.animateContentSize(),
thisNode = ourNode,
thatNode = node,
gpsFormat = state.gpsFormat,
distanceUnits = state.distanceUnits,
tempInFahrenheit = state.tempInFahrenheit,
onAction = { menuItem ->
when (menuItem) {
is NodeMenuAction.Remove -> model.removeNode(node.num)
is NodeMenuAction.Ignore -> model.ignoreNode(node)
is NodeMenuAction.Favorite -> model.favoriteNode(node)
is NodeMenuAction.DirectMessage -> {
val hasPKC = model.ourNodeInfo.value?.hasPKC == true && node.hasPKC
val channel =
if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else node.channel
navigateToMessages("$channel${node.user.id}")
}
is NodeMenuAction.RequestUserInfo -> model.requestUserInfo(node.num)
is NodeMenuAction.RequestPosition -> model.requestPosition(node.num)
is NodeMenuAction.TraceRoute -> model.requestTraceroute(node.num)
is NodeMenuAction.MoreDetails -> navigateToNodeDetails(node.num)
is NodeMenuAction.Share -> showSharedContact = node
}
},
expanded = state.showDetails,
currentTimeMillis = currentTimeMillis,
isConnected = connectionState.isConnected(),
)
}
}
val firmwareVersion = DeviceVersion(ourNode?.metadata?.firmwareVersion ?: "0.0.0")
val shareCapable = firmwareVersion.supportsQrCodeSharing()
AnimatedVisibility(
modifier = Modifier.align(Alignment.BottomEnd),
visible = !listState.isScrollInProgress &&
connectionState.isConnected() &&
shareCapable
) {
@Suppress("NewApi")
(
AddContactFAB(
model = model,
onSharedContactImport = { contact ->
model.addSharedContact(contact)
}
)
)
}
}
}

View file

@ -0,0 +1,64 @@
/*
* 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.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig.DisplayUnits
import com.geeksville.mesh.util.metersIn
import com.geeksville.mesh.util.toString
@Composable
fun ElevationInfo(
modifier: Modifier = Modifier,
altitude: Int,
system: DisplayUnits,
suffix: String
) {
val annotatedString = buildAnnotatedString {
append(altitude.metersIn(system).toString(system))
MaterialTheme.typography.labelSmall.toSpanStyle().let { style ->
withStyle(style) {
append(" $suffix")
}
}
}
Text(
modifier = modifier,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
text = annotatedString,
)
}
@Composable
@Preview
fun ElevationInfoPreview() {
MaterialTheme {
ElevationInfo(
altitude = 100,
system = DisplayUnits.METRIC,
suffix = "ASL"
)
}
}

View file

@ -0,0 +1,70 @@
/*
* 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.height
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.res.vectorResource
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.R
import com.geeksville.mesh.ui.common.theme.AppTheme
import com.geeksville.mesh.util.formatAgo
@Composable
fun LastHeardInfo(
modifier: Modifier = Modifier,
lastHeard: Int,
currentTimeMillis: Long,
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
Icon(
modifier = Modifier.height(18.dp),
imageVector = ImageVector.vectorResource(id = R.drawable.ic_antenna_24),
contentDescription = null,
)
Text(
text = formatAgo(lastHeard, currentTimeMillis),
fontSize = MaterialTheme.typography.labelLarge.fontSize
)
}
}
@PreviewLightDark
@Composable
fun LastHeardInfoPreview() {
AppTheme {
LastHeardInfo(
lastHeard = (System.currentTimeMillis() / 1000).toInt() - 8600,
currentTimeMillis = System.currentTimeMillis()
)
}
}

View file

@ -0,0 +1,165 @@
/*
* 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.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.core.net.toUri
import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig.GpsCoordinateFormat
import com.geeksville.mesh.android.BuildUtils.debug
import com.geeksville.mesh.ui.common.theme.AppTheme
import com.geeksville.mesh.ui.common.theme.HyperlinkBlue
import com.geeksville.mesh.util.GPSFormat
import kotlinx.coroutines.launch
import java.net.URLEncoder
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LinkedCoordinates(
modifier: Modifier = Modifier,
latitude: Double,
longitude: Double,
format: Int,
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, format, nodeName, style)
Text(
modifier = modifier.combinedClickable(
onClick = {
handleClick(context, annotatedString)
},
onLongClick = {
coroutineScope.launch {
clipboard.setClipEntry(
ClipEntry(
ClipData.newPlainText("", annotatedString)
)
)
debug("Copied to clipboard")
}
}
),
text = annotatedString
)
}
@Composable
private fun rememberAnnotatedString(
latitude: Double,
longitude: Double,
format: Int,
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 = when (format) {
GpsCoordinateFormat.DEC_VALUE -> GPSFormat.toDEC(latitude, longitude)
GpsCoordinateFormat.DMS_VALUE -> GPSFormat.toDMS(latitude, longitude)
GpsCoordinateFormat.UTM_VALUE -> GPSFormat.toUTM(latitude, longitude)
GpsCoordinateFormat.MGRS_VALUE -> GPSFormat.toMGRS(latitude, longitude)
else -> 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) {
debug("Failed to open geo intent: $ex")
}
}
}
@PreviewLightDark
@Composable
fun LinkedCoordinatesPreview(
@PreviewParameter(GPSFormatPreviewParameterProvider::class) format: Int
) {
AppTheme {
LinkedCoordinates(
latitude = 37.7749,
longitude = -122.4194,
format = format,
nodeName = "Test Node Name"
)
}
}
class GPSFormatPreviewParameterProvider : PreviewParameterProvider<Int> {
override val values: Sequence<Int>
get() = sequenceOf(0, 1, 2)
}

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 com.geeksville.mesh.ui.node.components
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
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.width
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.MaterialTheme
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.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.model.Node
@Composable
fun NodeChip(
modifier: Modifier = Modifier,
node: Node,
isThisNode: Boolean,
isConnected: Boolean,
onAction: (NodeMenuAction) -> Unit,
) {
val isIgnored = node.isIgnored
val (textColor, nodeColor) = node.colors
var menuExpanded by remember { mutableStateOf(false) }
val inputChipInteractionSource = remember { MutableInteractionSource() }
Box {
AssistChip(
modifier = modifier
.width(IntrinsicSize.Min)
.defaultMinSize(minHeight = 32.dp, minWidth = 72.dp),
colors = AssistChipDefaults.assistChipColors(
containerColor = Color(nodeColor),
labelColor = Color(textColor),
),
label = {
Text(
modifier = Modifier.Companion
.fillMaxWidth(),
text = node.user.shortName.ifEmpty { "???" },
fontSize = MaterialTheme.typography.labelLarge.fontSize,
textDecoration = TextDecoration.Companion.LineThrough.takeIf { isIgnored },
textAlign = TextAlign.Companion.Center,
)
},
onClick = {},
interactionSource = inputChipInteractionSource,
)
Box(
modifier = Modifier.Companion
.matchParentSize()
.combinedClickable(
onClick = { onAction(NodeMenuAction.MoreDetails(node)) },
onLongClick = { menuExpanded = true },
interactionSource = inputChipInteractionSource,
indication = null,
)
)
}
NodeMenu(
expanded = menuExpanded,
node = node,
showFullMenu = !isThisNode && isConnected,
onDismissMenuRequest = { menuExpanded = false },
onAction = {
menuExpanded = false
onAction(it)
}
)
}

View file

@ -0,0 +1,250 @@
/*
* 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.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
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.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.OutlinedTextField
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.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.R
import com.geeksville.mesh.model.NodeSortOption
import com.geeksville.mesh.ui.common.preview.LargeFontPreview
import com.geeksville.mesh.ui.common.theme.AppTheme
@Composable
fun NodeFilterTextField(
modifier: Modifier = Modifier,
filterText: String,
onTextChange: (String) -> Unit,
currentSortOption: NodeSortOption,
onSortSelect: (NodeSortOption) -> Unit,
includeUnknown: Boolean,
onToggleIncludeUnknown: () -> Unit,
showDetails: Boolean,
onToggleShowDetails: () -> Unit,
) {
Row(
modifier = modifier.background(MaterialTheme.colorScheme.background),
) {
NodeFilterTextField(
filterText = filterText,
onTextChange = onTextChange,
modifier = Modifier.weight(1f)
)
NodeSortButton(
modifier = Modifier.align(Alignment.CenterVertically),
currentSortOption = currentSortOption,
onSortSelect = onSortSelect,
includeUnknown = includeUnknown,
onToggleIncludeUnknown = onToggleIncludeUnknown,
showDetails = showDetails,
onToggleShowDetails = onToggleShowDetails,
)
}
}
@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,
includeUnknown: Boolean,
onToggleIncludeUnknown: () -> Unit,
showDetails: Boolean,
onToggleShowDetails: () -> Unit,
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))
) {
NodeSortOption.entries.forEach { sort ->
DropdownMenuItem(
onClick = {
onSortSelect(sort)
expanded = false
},
text = {
Text(
text = stringResource(id = sort.stringRes),
fontWeight = if (sort == currentSortOption) FontWeight.Bold else null,
)
}
)
}
HorizontalDivider()
DropdownMenuItem(
onClick = {
onToggleIncludeUnknown()
expanded = false
},
text = {
Row {
AnimatedVisibility(visible = includeUnknown) {
Icon(
imageVector = Icons.Default.Done,
contentDescription = null,
modifier = Modifier.padding(end = 4.dp),
)
}
Text(
text = stringResource(id = R.string.node_filter_include_unknown),
)
}
}
)
HorizontalDivider()
DropdownMenuItem(
onClick = {
onToggleShowDetails()
expanded = false
},
text = {
Row {
AnimatedVisibility(visible = showDetails) {
Icon(
imageVector = Icons.Default.Done,
contentDescription = null,
modifier = Modifier.padding(end = 4.dp),
)
}
Text(
text = stringResource(id = R.string.node_filter_show_details),
)
}
}
)
}
}
@PreviewLightDark
@LargeFontPreview
@Composable
private fun NodeFilterTextFieldPreview() {
AppTheme {
NodeFilterTextField(
filterText = "Filter text",
onTextChange = {},
currentSortOption = NodeSortOption.LAST_HEARD,
onSortSelect = {},
includeUnknown = false,
onToggleIncludeUnknown = {},
showDetails = false,
onToggleShowDetails = {},
)
}
}

View file

@ -0,0 +1,321 @@
/*
* 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.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
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.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.ConfigProtos.Config.DeviceConfig
import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.R
import com.geeksville.mesh.model.DeviceVersion
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.model.isUnmessageableRole
import com.geeksville.mesh.ui.common.components.BatteryInfo
import com.geeksville.mesh.ui.common.components.SignalInfo
import com.geeksville.mesh.ui.common.preview.NodePreviewParameterProvider
import com.geeksville.mesh.ui.common.theme.AppTheme
import com.geeksville.mesh.util.toDistanceString
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun NodeItem(
thisNode: Node?,
thatNode: Node,
gpsFormat: Int,
distanceUnits: Int,
tempInFahrenheit: Boolean,
modifier: Modifier = Modifier,
onAction: (NodeMenuAction) -> Unit = {},
expanded: Boolean = false,
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)
}
val hwInfoString = when (val hwModel = thatNode.user.hwModel) {
MeshProtos.HardwareModel.UNSET -> MeshProtos.HardwareModel.UNSET.name
else -> hwModel.name.replace('_', '-').replace('p', '.').lowercase()
}
val roleName = if (thatNode.isUnknownUser) {
DeviceConfig.Role.UNRECOGNIZED.name
} else {
thatNode.user.role.name
}
val style = if (thatNode.isUnknownUser) {
LocalTextStyle.current.copy(fontStyle = FontStyle.Italic)
} else {
LocalTextStyle.current
}
val (detailsShown, showDetails) = remember { mutableStateOf(expanded) }
val unmessageable = remember(thatNode) {
when {
thatNode.user.hasIsUnmessagable() -> thatNode.user.isUnmessagable
thatNode.user.role.isUnmessageableRole() ->
thatNode.metadata?.firmwareVersion?.let {
DeviceVersion(it) < DeviceVersion("2.6.8")
} ?: true
else -> false
}
}
Card(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
.defaultMinSize(minHeight = 80.dp),
onClick = { showDetails(!detailsShown) },
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
NodeChip(
node = thatNode,
isThisNode = isThisNode,
isConnected = isConnected,
onAction = onAction,
)
NodeKeyStatusIcon(
hasPKC = thatNode.hasPKC,
mismatchKey = thatNode.mismatchKey,
publicKey = thatNode.user.publicKey,
modifier = Modifier.size(32.dp)
)
Text(
modifier = Modifier.weight(1f),
text = longName,
style = style,
textDecoration = TextDecoration.LineThrough.takeIf { isIgnored },
softWrap = true,
)
LastHeardInfo(
lastHeard = thatNode.lastHeard,
currentTimeMillis = currentTimeMillis
)
NodeStatusIcons(
isThisNode = isThisNode,
isFavorite = isFavorite,
isUnmessageable = unmessageable
)
}
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
if (distance != null) {
Text(
text = distance,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
} else {
Spacer(modifier = Modifier.width(16.dp))
}
BatteryInfo(
batteryLevel = thatNode.batteryLevel,
voltage = thatNode.voltage
)
}
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
SignalInfo(
node = thatNode,
isThisNode = isThisNode
)
thatNode.validPosition?.let { position ->
val satCount = position.satsInView
if (satCount > 0) {
SatelliteCountInfo(satCount = satCount)
}
}
}
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
val telemetryString = thatNode.getTelemetryString(tempInFahrenheit)
if (telemetryString.isNotEmpty()) {
Text(
text = telemetryString,
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
}
if (detailsShown || expanded) {
Spacer(modifier = Modifier.height(8.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
thatNode.validPosition?.let {
LinkedCoordinates(
latitude = thatNode.latitude,
longitude = thatNode.longitude,
format = gpsFormat,
nodeName = longName
)
}
thatNode.validPosition?.let { position ->
ElevationInfo(
altitude = position.altitude,
system = system,
suffix = stringResource(id = R.string.elevation_suffix)
)
}
}
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
) {
Text(
modifier = Modifier.weight(1f),
text = hwInfoString,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
style = style,
)
Text(
modifier = Modifier.weight(1f),
text = roleName,
textAlign = TextAlign.Center,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
style = style,
)
Text(
modifier = Modifier.weight(1f),
text = thatNode.user.id.ifEmpty { "???" },
textAlign = TextAlign.End,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
style = style,
)
}
}
}
}
}
@Composable
@Preview(showBackground = false)
fun NodeInfoSimplePreview() {
AppTheme {
val thisNode = NodePreviewParameterProvider().values.first()
val thatNode = NodePreviewParameterProvider().values.last()
NodeItem(
thisNode = thisNode,
thatNode = thatNode,
1,
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()
Column {
Text(
text = "Details Collapsed",
color = MaterialTheme.colorScheme.onBackground
)
NodeItem(
thisNode = thisNode,
thatNode = thatNode,
gpsFormat = 0,
distanceUnits = 1,
tempInFahrenheit = true,
expanded = false,
currentTimeMillis = System.currentTimeMillis(),
)
Text(
text = "Details Shown",
color = MaterialTheme.colorScheme.onBackground
)
NodeItem(
thisNode = thisNode,
thatNode = thatNode,
gpsFormat = 0,
distanceUnits = 1,
tempInFahrenheit = true,
expanded = true,
currentTimeMillis = System.currentTimeMillis(),
)
}
}
}

View file

@ -0,0 +1,196 @@
/*
* 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.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.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.Color
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.geeksville.mesh.R
import com.geeksville.mesh.model.Channel
import com.geeksville.mesh.ui.common.theme.AppTheme
import com.google.protobuf.ByteString
@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(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 Color.Red
hasPKC -> Icons.Default.Lock to Color(color = 0xFF30C047)
else -> ImageVector.vectorResource(R.drawable.ic_lock_open_right_24) to Color(color = 0xFFFEC30A)
}
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,244 @@
/*
* 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.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.twotone.StarBorder
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.MaterialTheme
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.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.R
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.model.isUnmessageableRole
import com.geeksville.mesh.ui.common.components.SimpleAlertDialog
@Suppress("LongMethod")
@Composable
fun NodeMenu(
expanded: Boolean,
node: Node,
showFullMenu: Boolean = false,
onDismissMenuRequest: () -> Unit,
onAction: (NodeMenuAction) -> Unit,
) {
val isUnmessageable = if (node.user.hasIsUnmessagable()) {
node.user.isUnmessagable
} else {
// for older firmwares
node.user.role?.isUnmessageableRole() == true
}
var displayFavoriteDialog by remember { mutableStateOf(false) }
var displayIgnoreDialog by remember { mutableStateOf(false) }
var displayRemoveDialog by remember { mutableStateOf(false) }
val dialogDismissRequest = {
displayFavoriteDialog = false
displayIgnoreDialog = false
displayRemoveDialog = false
onDismissMenuRequest()
}
val onMenuAction: (NodeMenuAction) -> Unit = {
dialogDismissRequest()
onDismissMenuRequest()
onAction(it)
}
NodeActionDialogs(
node = node,
displayFavoriteDialog = displayFavoriteDialog,
displayIgnoreDialog = displayIgnoreDialog,
displayRemoveDialog = displayRemoveDialog,
onDismissMenuRequest = dialogDismissRequest,
onAction = onMenuAction
)
DropdownMenu(
modifier = Modifier.background(MaterialTheme.colorScheme.background.copy(alpha = 1f)),
expanded = expanded,
onDismissRequest = onDismissMenuRequest,
) {
if (showFullMenu) {
if (!isUnmessageable) {
DropdownMenuItem(
onClick = {
dialogDismissRequest()
onMenuAction(NodeMenuAction.DirectMessage(node))
},
text = { Text(stringResource(R.string.direct_message)) }
)
}
DropdownMenuItem(
onClick = {
dialogDismissRequest()
onMenuAction(NodeMenuAction.RequestUserInfo(node))
},
text = { Text(stringResource(R.string.exchange_userinfo)) }
)
DropdownMenuItem(
onClick = {
dialogDismissRequest()
onMenuAction(NodeMenuAction.RequestPosition(node))
},
text = { Text(stringResource(R.string.exchange_position)) }
)
DropdownMenuItem(
onClick = {
dialogDismissRequest()
onMenuAction(NodeMenuAction.TraceRoute(node))
},
text = { Text(stringResource(R.string.traceroute)) }
)
DropdownMenuItem(
onClick = {
dialogDismissRequest()
displayFavoriteDialog = true
},
enabled = !node.isIgnored,
text = {
Text(stringResource(R.string.favorite))
},
trailingIcon = {
Icon(
imageVector = if (node.isFavorite) Icons.Filled.Star else Icons.TwoTone.StarBorder,
contentDescription = stringResource(R.string.favorite),
)
}
)
DropdownMenuItem(
onClick = {
dialogDismissRequest()
displayIgnoreDialog = true
},
text = {
Text(stringResource(R.string.ignore))
},
trailingIcon = {
Checkbox(
checked = node.isIgnored,
onCheckedChange = {
dialogDismissRequest()
displayIgnoreDialog = true
},
modifier = Modifier.size(24.dp),
)
}
)
DropdownMenuItem(
onClick = {
dialogDismissRequest()
displayRemoveDialog = true
},
enabled = !node.isIgnored,
text = { Text(stringResource(R.string.remove)) }
)
HorizontalDivider(Modifier.padding(vertical = 8.dp))
}
DropdownMenuItem(
onClick = {
dialogDismissRequest()
onMenuAction(NodeMenuAction.Share(node))
},
text = { Text(stringResource(R.string.share_contact)) }
)
DropdownMenuItem(
onClick = {
dialogDismissRequest()
onMenuAction(NodeMenuAction.MoreDetails(node))
},
text = { Text(stringResource(R.string.more_details)) }
)
}
}
@Composable
fun NodeActionDialogs(
node: Node,
displayFavoriteDialog: Boolean,
displayIgnoreDialog: Boolean,
displayRemoveDialog: Boolean,
onDismissMenuRequest: () -> Unit,
onAction: (NodeMenuAction) -> 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()
onAction(NodeMenuAction.Favorite(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()
onAction(NodeMenuAction.Ignore(node))
},
onDismiss = onDismissMenuRequest
)
}
if (displayRemoveDialog) {
SimpleAlertDialog(
title = R.string.remove,
text = R.string.remove_node_text,
onConfirm = {
onDismissMenuRequest()
onAction(NodeMenuAction.Remove(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,114 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <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.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Text
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NodeStatusIcons(
isThisNode: Boolean,
isUnmessageable: Boolean,
isFavorite: Boolean,
) {
Row(
modifier = Modifier.padding(4.dp)
) {
if (isUnmessageable) {
TooltipBox(
positionProvider = TooltipDefaults.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
tint = MaterialTheme.colorScheme.error,
)
}
}
}
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 = Color(color = 0xFFFEC30A)
)
}
}
}
}
}
@Preview
@Composable
fun StatusIconsPreview() {
NodeStatusIcons(
isThisNode = false,
isUnmessageable = true,
isFavorite = true,
)
}

View file

@ -0,0 +1,67 @@
/*
* 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.material.icons.Icons
import androidx.compose.material.icons.twotone.SatelliteAlt
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.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.ui.common.theme.AppTheme
@Composable
fun SatelliteCountInfo(
modifier: Modifier = Modifier,
satCount: Int,
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(
modifier = Modifier.size(18.dp),
imageVector = Icons.TwoTone.SatelliteAlt,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "$satCount",
fontSize = MaterialTheme.typography.labelLarge.fontSize,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
@PreviewLightDark
@Composable
fun SatelliteCountInfoPreview() {
AppTheme {
SatelliteCountInfo(
satCount = 5,
)
}
}