feat: Implement iOS support and unify Compose Multiplatform infrastructure (#4876)

This commit is contained in:
James Rich
2026-03-21 18:19:13 -05:00
committed by GitHub
parent f04924ded5
commit d136b162a4
170 changed files with 2208 additions and 2432 deletions

View File

@@ -70,14 +70,14 @@ private sealed interface NodeDetailOverlay {
}
@Composable
fun NodeDetailScreen(
actual fun NodeDetailScreen(
nodeId: Int,
modifier: Modifier = Modifier,
modifier: Modifier,
viewModel: NodeDetailViewModel,
navigateToMessages: (String) -> Unit = {},
onNavigate: (Route) -> Unit = {},
onNavigateUp: () -> Unit = {},
compassViewModel: CompassViewModel? = null,
navigateToMessages: (String) -> Unit,
onNavigate: (Route) -> Unit,
onNavigateUp: () -> Unit,
compassViewModel: CompassViewModel?,
) {
LaunchedEffect(nodeId) { viewModel.start(nodeId) }

View File

@@ -102,7 +102,7 @@ private fun ActionButtons(
@Suppress("LongMethod")
@Composable
fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
actual fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }

View File

@@ -0,0 +1,36 @@
/*
* Copyright (c) 2025-2026 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.navigation
import androidx.compose.runtime.Composable
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
import org.meshtastic.feature.node.metrics.MetricsViewModel
import org.meshtastic.feature.node.metrics.TracerouteMapScreen as AndroidTracerouteMapScreen
@Composable
actual fun TracerouteMapScreen(destNum: Int, requestId: Int, logUuid: String?, onNavigateUp: () -> Unit) {
val metricsViewModel = koinViewModel<MetricsViewModel>(key = "metrics-$destNum") { parametersOf(destNum) }
metricsViewModel.setNodeId(destNum)
AndroidTracerouteMapScreen(
metricsViewModel = metricsViewModel,
requestId = requestId,
logUuid = logUuid,
onNavigateUp = onNavigateUp,
)
}

View File

@@ -29,6 +29,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.bearing
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.common.util.latLongToMeter
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
@@ -119,7 +120,7 @@ class CompassViewModel(
val bearingDegrees = calculateBearing(locationState, target)
val trueHeading = applyTrueNorthCorrection(headingState.heading, locationState)
val distanceText = distanceMeters?.toDistanceString(current.displayUnits)
val bearingText = bearingDegrees?.let { BEARING_FORMAT.format(it) }
val bearingText = bearingDegrees?.let { formatString(BEARING_FORMAT, it) }
val isAligned = isAligned(trueHeading, bearingDegrees)
val lastUpdateText = targetPositionTimeSec?.let { formatElapsed(it) }
val angularErrorDeg = calculateAngularError(positionalAccuracyMeters, distanceMeters)

View File

@@ -55,6 +55,7 @@ import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.compass_bearing
import org.meshtastic.core.resources.compass_bearing_na
@@ -71,6 +72,7 @@ import org.meshtastic.core.resources.exchange_position
import org.meshtastic.core.resources.last_position_update
import org.meshtastic.feature.node.compass.CompassUiState
import org.meshtastic.feature.node.compass.CompassWarning
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
@@ -126,7 +128,7 @@ fun CompassSheetContent(
Text(
text =
uiState.errorRadiusText?.let { radius ->
val angle = uiState.angularErrorDeg?.let { "%.0f°".format(it) } ?: "?"
val angle = uiState.angularErrorDeg?.let { formatString("%.0f°", it) } ?: "?"
stringResource(Res.string.compass_uncertainty, radius, angle)
} ?: stringResource(Res.string.compass_uncertainty_unknown),
style = MaterialTheme.typography.bodyMedium,
@@ -279,7 +281,7 @@ private fun CompassDial(
else -> 1.dp.toPx()
}
val angle = Math.toRadians(deg.toDouble())
val angle = (deg * PI / 180.0)
val outer = Offset(center.x + radius * sin(angle).toFloat(), center.y - radius * cos(angle).toFloat())
val inner =
Offset(
@@ -310,7 +312,7 @@ private fun CompassDial(
)
for ((label, deg, color) in cardinals) {
val angle = Math.toRadians(deg.toDouble())
val angle = (deg * PI / 180.0)
val x = center.x + cardinalRadius * sin(angle).toFloat()
val y = center.y - cardinalRadius * cos(angle).toFloat()
@@ -327,7 +329,7 @@ private fun CompassDial(
// Degree labels
val degRadius = radius * 0.72f
for (d in 0 until 360 step 30) {
val angle = Math.toRadians(d.toDouble())
val angle = (d * PI / 180.0)
val x = center.x + degRadius * sin(angle).toFloat()
val y = center.y - degRadius * cos(angle).toFloat()
@@ -363,8 +365,8 @@ private fun CompassDial(
// Cone edge lines for clarity
val edgeRadius = arcRadius
val startRad = Math.toRadians(startAngleNorth.toDouble())
val endRad = Math.toRadians((startAngleNorth + sweep).toDouble())
val startRad = (startAngleNorth * PI / 180.0)
val endRad = ((startAngleNorth + sweep) * PI / 180.0)
val startEnd =
Offset(
center.x + edgeRadius * sin(startRad).toFloat(),
@@ -376,7 +378,7 @@ private fun CompassDial(
drawLine(color = faint, start = center, end = endEnd, strokeWidth = 6f, cap = StrokeCap.Round)
}
if (bearingForDraw != null) {
val angle = Math.toRadians(bearingForDraw.toDouble())
val angle = (bearingForDraw * PI / 180.0)
val dot =
Offset(
center.x + (radius * 0.95f) * sin(angle).toFloat(),

View File

@@ -52,6 +52,7 @@ import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.Base64Factory
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.formatUptime
@@ -261,7 +262,7 @@ private fun SignalRow(node: Node) {
if (node.snr != Float.MAX_VALUE) {
InfoItem(
label = stringResource(Res.string.snr),
value = "%.1f dB".format(node.snr),
value = formatString("%.1f dB", node.snr),
icon = MeshtasticIcons.ChannelUtilization,
modifier = Modifier.weight(1f),
)
@@ -271,7 +272,7 @@ private fun SignalRow(node: Node) {
if (node.rssi != Int.MAX_VALUE) {
InfoItem(
label = stringResource(Res.string.rssi),
value = "%d dBm".format(node.rssi),
value = formatString("%d dBm", node.rssi),
icon = MeshtasticIcons.ChannelUtilization,
modifier = Modifier.weight(1f),
)

View File

@@ -47,6 +47,7 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.isUnmessageableRole
@@ -256,14 +257,14 @@ private fun NodeSignalRow(thatNode: Node, isThisNode: Boolean, contentColor: Col
icon = MeshtasticIcons.ChannelUtilization,
contentDescription = stringResource(Res.string.channel_utilization),
label = stringResource(Res.string.channel_utilization),
text = "%.1f%%".format(thatNode.deviceMetrics.channel_utilization),
text = formatString("%.1f%%", thatNode.deviceMetrics.channel_utilization),
contentColor = contentColor,
)
IconInfo(
icon = MeshtasticIcons.AirUtilization,
contentDescription = stringResource(Res.string.air_utilization),
label = stringResource(Res.string.air_utilization),
text = "%.1f%%".format(thatNode.deviceMetrics.air_util_tx),
text = formatString("%.1f%%", thatNode.deviceMetrics.air_util_tx),
contentColor = contentColor,
)
}
@@ -318,26 +319,28 @@ private fun gatherSensors(node: Node, tempInFahrenheit: Boolean, contentColor: C
if ((env.temperature ?: 0f) != 0f) {
val temp =
if (tempInFahrenheit) {
"%.1f°F".format(celsiusToFahrenheit(env.temperature ?: 0f))
formatString("%.1f°F", celsiusToFahrenheit(env.temperature ?: 0f))
} else {
"%.1f°C".format(env.temperature ?: 0f)
formatString("%.1f°C", env.temperature ?: 0f)
}
items.add { TemperatureInfo(temp = temp, contentColor = contentColor) }
}
if ((env.relative_humidity ?: 0f) != 0f) {
items.add { HumidityInfo(humidity = "%.0f%%".format(env.relative_humidity ?: 0f), contentColor = contentColor) }
items.add {
HumidityInfo(humidity = formatString("%.0f%%", env.relative_humidity ?: 0f), contentColor = contentColor)
}
}
if ((env.barometric_pressure ?: 0f) != 0f) {
items.add {
PressureInfo(pressure = "%.1fhPa".format(env.barometric_pressure ?: 0f), contentColor = contentColor)
PressureInfo(pressure = formatString("%.1fhPa", env.barometric_pressure ?: 0f), contentColor = contentColor)
}
}
if ((env.soil_temperature ?: 0f) != 0f) {
val temp =
if (tempInFahrenheit) {
"%.1f°F".format(celsiusToFahrenheit(env.soil_temperature ?: 0f))
formatString("%.1f°F", celsiusToFahrenheit(env.soil_temperature ?: 0f))
} else {
"%.1f°C".format(env.soil_temperature ?: 0f)
formatString("%.1f°C", env.soil_temperature ?: 0f)
}
items.add { SoilTemperatureInfo(temp = temp, contentColor = contentColor) }
}
@@ -347,7 +350,7 @@ private fun gatherSensors(node: Node, tempInFahrenheit: Boolean, contentColor: C
if ((env.voltage ?: 0f) != 0f) {
items.add {
PowerInfo(
value = "%.2fV".format(env.voltage ?: 0f),
value = formatString("%.2fV", env.voltage ?: 0f),
label = stringResource(Res.string.voltage),
contentColor = contentColor,
)
@@ -356,7 +359,7 @@ private fun gatherSensors(node: Node, tempInFahrenheit: Boolean, contentColor: C
if ((env.current ?: 0f) != 0f) {
items.add {
PowerInfo(
value = "%.1fmA".format(env.current ?: 0f),
value = formatString("%.1fmA", env.current ?: 0f),
label = stringResource(Res.string.current),
contentColor = contentColor,
)

View File

@@ -18,7 +18,6 @@ package org.meshtastic.feature.node.detail
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
@@ -28,6 +27,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.RadioController
@@ -60,7 +60,7 @@ class CommonNodeRequestActions constructor(private val radioController: RadioCon
override val lastRequestNeighborTimes: StateFlow<Map<Int, Long>> = _lastRequestNeighborTimes.asStateFlow()
override fun requestUserInfo(scope: CoroutineScope, destNum: Int, longName: String) {
scope.launch(Dispatchers.IO) {
scope.launch(ioDispatcher) {
Logger.i { "Requesting UserInfo for '$destNum'" }
radioController.requestUserInfo(destNum)
_effects.emit(
@@ -72,7 +72,7 @@ class CommonNodeRequestActions constructor(private val radioController: RadioCon
}
override fun requestNeighborInfo(scope: CoroutineScope, destNum: Int, longName: String) {
scope.launch(Dispatchers.IO) {
scope.launch(ioDispatcher) {
Logger.i { "Requesting NeighborInfo for '$destNum'" }
val packetId = radioController.getPacketId()
radioController.requestNeighborInfo(packetId, destNum)
@@ -86,7 +86,7 @@ class CommonNodeRequestActions constructor(private val radioController: RadioCon
}
override fun requestPosition(scope: CoroutineScope, destNum: Int, longName: String, position: Position) {
scope.launch(Dispatchers.IO) {
scope.launch(ioDispatcher) {
Logger.i { "Requesting position for '$destNum'" }
radioController.requestPosition(destNum, position)
_effects.emit(
@@ -98,7 +98,7 @@ class CommonNodeRequestActions constructor(private val radioController: RadioCon
}
override fun requestTelemetry(scope: CoroutineScope, destNum: Int, longName: String, type: TelemetryType) {
scope.launch(Dispatchers.IO) {
scope.launch(ioDispatcher) {
Logger.i { "Requesting telemetry for '$destNum'" }
val packetId = radioController.getPacketId()
radioController.requestTelemetry(packetId, destNum, type.ordinal)
@@ -121,7 +121,7 @@ class CommonNodeRequestActions constructor(private val radioController: RadioCon
}
override fun requestTraceroute(scope: CoroutineScope, destNum: Int, longName: String) {
scope.launch(Dispatchers.IO) {
scope.launch(ioDispatcher) {
Logger.i { "Requesting traceroute for '$destNum'" }
val packetId = radioController.getPacketId()
radioController.requestTraceroute(packetId, destNum)

View File

@@ -0,0 +1,33 @@
/*
* Copyright (c) 2025-2026 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 androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import org.meshtastic.core.navigation.Route
import org.meshtastic.feature.node.compass.CompassViewModel
@Composable
expect fun NodeDetailScreen(
nodeId: Int,
modifier: Modifier = Modifier,
viewModel: NodeDetailViewModel,
navigateToMessages: (String) -> Unit = {},
onNavigate: (Route) -> Unit = {},
onNavigateUp: () -> Unit = {},
compassViewModel: CompassViewModel? = null,
)

View File

@@ -18,10 +18,10 @@ package org.meshtastic.feature.node.detail
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.getString
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.service.ServiceAction
@@ -59,7 +59,7 @@ constructor(
}
open fun removeNode(scope: CoroutineScope, nodeNum: Int) {
scope.launch(Dispatchers.IO) {
scope.launch(ioDispatcher) {
Logger.i { "Removing node '$nodeNum'" }
val packetId = radioController.getPacketId()
radioController.removeByNodenum(packetId, nodeNum)
@@ -80,7 +80,7 @@ constructor(
}
open fun ignoreNode(scope: CoroutineScope, node: Node) {
scope.launch(Dispatchers.IO) { serviceRepository.onServiceAction(ServiceAction.Ignore(node)) }
scope.launch(ioDispatcher) { serviceRepository.onServiceAction(ServiceAction.Ignore(node)) }
}
open fun requestMuteNode(scope: CoroutineScope, node: Node) {
@@ -96,7 +96,7 @@ constructor(
}
open fun muteNode(scope: CoroutineScope, node: Node) {
scope.launch(Dispatchers.IO) { serviceRepository.onServiceAction(ServiceAction.Mute(node)) }
scope.launch(ioDispatcher) { serviceRepository.onServiceAction(ServiceAction.Mute(node)) }
}
open fun requestFavoriteNode(scope: CoroutineScope, node: Node) {
@@ -115,11 +115,11 @@ constructor(
}
open fun favoriteNode(scope: CoroutineScope, node: Node) {
scope.launch(Dispatchers.IO) { serviceRepository.onServiceAction(ServiceAction.Favorite(node)) }
scope.launch(ioDispatcher) { serviceRepository.onServiceAction(ServiceAction.Favorite(node)) }
}
open fun setNodeNotes(scope: CoroutineScope, nodeNum: Int, notes: String) {
scope.launch(Dispatchers.IO) {
scope.launch(ioDispatcher) {
try {
nodeRepository.setNodeNotes(nodeNum, notes)
} catch (ex: Exception) {

View File

@@ -42,7 +42,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.Flow
@@ -59,7 +58,6 @@ import org.meshtastic.core.ui.component.MeshtasticImportFAB
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.component.smartScrollToTop
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.node.component.NodeContextMenu
import org.meshtastic.feature.node.component.NodeFilterTextField
import org.meshtastic.feature.node.component.NodeItem
@@ -74,7 +72,7 @@ fun NodeListScreen(
scrollToTopEvents: Flow<ScrollToTopEvent>? = null,
activeNodeId: Int? = null,
) {
val context = LocalContext.current
val showToast = org.meshtastic.core.ui.util.rememberShowToastResource()
val scope = rememberCoroutineScope()
val state by viewModel.nodesUiState.collectAsStateWithLifecycle()
@@ -125,9 +123,7 @@ fun NodeListScreen(
MeshtasticImportFAB(
sharedContact = sharedContact,
onImport = { uriString ->
viewModel.handleScannedUri(uriString) {
scope.launch { context.showToast(Res.string.channel_invalid) }
}
viewModel.handleScannedUri(uriString) { scope.launch { showToast(Res.string.channel_invalid) } }
},
onDismissSharedContact = { viewModel.setSharedContactRequested(null) },
isContactContext = true,

View File

@@ -61,6 +61,7 @@ import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.formatUptime
@@ -256,11 +257,11 @@ private fun DeviceMetricsChart(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
when (color.copy(alpha = 1f)) {
batteryColor -> percentValueTemplate.format(batteryLabel, value)
voltageColor -> voltageValueTemplate.format(voltageLabel, value)
chUtilColor -> percentValueTemplate.format(channelUtilizationLabel, value)
airUtilColor -> percentValueTemplate.format(airUtilizationLabel, value)
else -> numericValueTemplate.format(value)
batteryColor -> formatString(percentValueTemplate, batteryLabel, value)
voltageColor -> formatString(voltageValueTemplate, voltageLabel, value)
chUtilColor -> formatString(percentValueTemplate, channelUtilizationLabel, value)
airUtilColor -> formatString(percentValueTemplate, airUtilizationLabel, value)
else -> formatString(numericValueTemplate, value)
}
},
)
@@ -366,7 +367,7 @@ private fun DeviceMetricsChart(
if (leftLayer != null) {
VerticalAxis.rememberStart(
label = ChartStyling.rememberAxisLabel(color = batteryColor),
valueFormatter = { _, value, _ -> "%.0f%%".format(value) },
valueFormatter = { _, value, _ -> formatString("%.0f%%", value) },
)
} else {
null
@@ -375,7 +376,7 @@ private fun DeviceMetricsChart(
if (rightLayer != null) {
VerticalAxis.rememberEnd(
label = ChartStyling.rememberAxisLabel(color = voltageColor),
valueFormatter = { _, value, _ -> "%.1f V".format(value) },
valueFormatter = { _, value, _ -> formatString("%.1f V", value) },
)
} else {
null
@@ -488,7 +489,8 @@ private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick
Spacer(Modifier.width(4.dp))
Text(
text =
percentValueTemplate.format(
formatString(
percentValueTemplate,
channelUtilizationLabel,
deviceMetrics.channel_utilization ?: 0f,
),
@@ -502,7 +504,8 @@ private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick
Spacer(Modifier.width(4.dp))
Text(
text =
percentValueTemplate.format(
formatString(
percentValueTemplate,
airUtilizationLabel,
deviceMetrics.air_util_tx ?: 0f,
),
@@ -513,7 +516,8 @@ private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick
}
Text(
text =
labelValueTemplate.format(
formatString(
labelValueTemplate,
uptimeLabel,
formatUptime(deviceMetrics?.uptime_seconds ?: 0),
),

View File

@@ -33,6 +33,7 @@ import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.baro_pressure
import org.meshtastic.core.resources.humidity
@@ -187,7 +188,7 @@ fun EnvironmentMetricsChart(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
val label = colorToLabel[color.copy(alpha = 1f)] ?: ""
"%s: %.1f".format(label, value)
formatString("%s: %.1f", label, value)
},
)
@@ -229,7 +230,7 @@ fun EnvironmentMetricsChart(
if (shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal] && pressureData.isNotEmpty()) {
VerticalAxis.rememberStart(
label = ChartStyling.rememberAxisLabel(color = Environment.BAROMETRIC_PRESSURE.color),
valueFormatter = { _, value, _ -> "%.0f hPa".format(value) },
valueFormatter = { _, value, _ -> formatString("%.0f hPa", value) },
)
} else {
null
@@ -237,7 +238,7 @@ fun EnvironmentMetricsChart(
endAxis =
VerticalAxis.rememberEnd(
label = ChartStyling.rememberAxisLabel(color = endAxisColor),
valueFormatter = { _, value, _ -> "%.0f".format(value) },
valueFormatter = { _, value, _ -> formatString("%.0f", value) },
),
bottomAxis =
HorizontalAxis.rememberBottom(

View File

@@ -49,6 +49,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.resources.Res
@@ -146,7 +147,7 @@ private fun TemperatureDisplay(
MetricIndicator(Environment.TEMPERATURE.color)
Spacer(Modifier.width(4.dp))
Text(
text = textFormat.format(stringResource(Res.string.temperature), temperature),
text = formatString(textFormat, stringResource(Res.string.temperature), temperature),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
@@ -171,7 +172,7 @@ private fun HumidityAndBarometricPressureDisplay(envMetrics: org.meshtastic.prot
MetricIndicator(Environment.HUMIDITY.color)
Spacer(Modifier.width(4.dp))
Text(
text = "%s %.2f%%".format(stringResource(Res.string.humidity), humidity),
text = formatString("%s %.2f%%", stringResource(Res.string.humidity), humidity),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
modifier = Modifier.padding(vertical = 0.dp),
@@ -184,7 +185,7 @@ private fun HumidityAndBarometricPressureDisplay(envMetrics: org.meshtastic.prot
MetricIndicator(Environment.BAROMETRIC_PRESSURE.color)
Spacer(Modifier.width(4.dp))
Text(
text = "%.2f hPa".format(pressure),
text = formatString("%.2f hPa", pressure),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
modifier = Modifier.padding(vertical = 0.dp),
@@ -214,7 +215,8 @@ private fun SoilMetricsDisplay(
Spacer(Modifier.width(4.dp))
Text(
text =
soilMoistureTextFormat.format(
formatString(
soilMoistureTextFormat,
stringResource(Res.string.soil_moisture),
soilMoistureValue,
),
@@ -231,7 +233,8 @@ private fun SoilMetricsDisplay(
Spacer(Modifier.width(4.dp))
Text(
text =
soilTemperatureTextFormat.format(
formatString(
soilTemperatureTextFormat,
stringResource(Res.string.soil_temperature),
soilTemperature,
),
@@ -258,7 +261,7 @@ private fun LuxUVLuxDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics)
MetricIndicator(Environment.LUX.color)
Spacer(Modifier.width(4.dp))
Text(
text = "%s %.0f lx".format(stringResource(Res.string.lux), luxValue),
text = formatString("%s %.0f lx", stringResource(Res.string.lux), luxValue),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
@@ -270,7 +273,7 @@ private fun LuxUVLuxDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics)
MetricIndicator(Environment.UV_LUX.color)
Spacer(Modifier.width(4.dp))
Text(
text = "%s %.0f UVlx".format(stringResource(Res.string.uv_lux), uvLuxValue),
text = formatString("%s %.0f UVlx", stringResource(Res.string.uv_lux), uvLuxValue),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
@@ -290,7 +293,7 @@ private fun VoltageCurrentDisplay(envMetrics: org.meshtastic.proto.EnvironmentMe
if (hasVoltage) {
val voltage = envMetrics.voltage!!
Text(
text = "%s %.2f V".format(stringResource(Res.string.voltage), voltage),
text = formatString("%s %.2f V", stringResource(Res.string.voltage), voltage),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
@@ -298,7 +301,7 @@ private fun VoltageCurrentDisplay(envMetrics: org.meshtastic.proto.EnvironmentMe
if (hasCurrent) {
val currentValue = envMetrics.current!!
Text(
text = "%s %.2f mA".format(stringResource(Res.string.current), currentValue),
text = formatString("%s %.2f mA", stringResource(Res.string.current), currentValue),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
@@ -332,7 +335,7 @@ private fun GasCompositionDisplay(envMetrics: org.meshtastic.proto.EnvironmentMe
MetricIndicator(Environment.GAS_RESISTANCE.color)
Spacer(Modifier.width(4.dp))
Text(
text = "%s %.2f Ohm".format(stringResource(Res.string.gas_resistance), gasResistance),
text = formatString("%s %.2f Ohm", stringResource(Res.string.gas_resistance), gasResistance),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
@@ -348,7 +351,7 @@ private fun RadiationDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics
if (!radiation.isNaN() && radiation > 0f) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = "%s %.2f µR/h".format(stringResource(Res.string.radiation), radiation),
text = formatString("%s %.2f µR/h", stringResource(Res.string.radiation), radiation),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)

View File

@@ -43,6 +43,7 @@ import org.jetbrains.compose.resources.StringResource
import org.koin.core.annotation.InjectedParam
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.MeshtasticUri
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.MeshLog
@@ -341,9 +342,7 @@ open class MetricsViewModel(
val altitude = position.altitude
val satsInView = position.sats_in_view
val speed = position.ground_speed
// Kotlin string format is available in common code on 1.9.20+ via String.format,
// but we can just do basic string manipulation if needed.
val heading = "%.2f".format((position.ground_track ?: 0) * 1e-5)
val heading = formatString("%.2f", (position.ground_track ?: 0) * 1e-5)
sink.writeUtf8(
"$rxDateTime,\"$latitude\",\"$longitude\",\"$altitude\",\"$satsInView\",\"$speed\",\"$heading\"\n",

View File

@@ -54,6 +54,7 @@ import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLa
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.formatUptime
@@ -120,10 +121,10 @@ private fun PaxMetricsChart(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
when (color.copy(alpha = 1f)) {
bleColor -> "BLE: %.0f".format(value)
wifiColor -> "WiFi: %.0f".format(value)
paxColor -> "PAX: %.0f".format(value)
else -> "%.0f".format(value)
bleColor -> formatString("BLE: %.0f", value)
wifiColor -> formatString("WiFi: %.0f", value)
paxColor -> formatString("PAX: %.0f", value)
else -> formatString("%.0f", value)
}
},
)

View File

@@ -32,6 +32,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.util.metersIn
import org.meshtastic.core.model.util.toString
import org.meshtastic.core.resources.Res
@@ -86,13 +87,13 @@ fun PositionItem(compactWidth: Boolean, position: Position, system: Config.Displ
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
PositionText("%.5f".format((position.latitude_i ?: 0) * DEG_D), WEIGHT_20)
PositionText("%.5f".format((position.longitude_i ?: 0) * DEG_D), WEIGHT_20)
PositionText(formatString("%.5f", (position.latitude_i ?: 0) * DEG_D), WEIGHT_20)
PositionText(formatString("%.5f", (position.longitude_i ?: 0) * DEG_D), WEIGHT_20)
PositionText(position.sats_in_view.toString(), WEIGHT_10)
PositionText((position.altitude ?: 0).metersIn(system).toString(system), WEIGHT_15)
if (!compactWidth) {
PositionText("${position.ground_speed ?: 0} Km/h", WEIGHT_15)
PositionText("%.0f°".format((position.ground_track ?: 0) * HEADING_DEG), WEIGHT_15)
PositionText(formatString("%.0f°", (position.ground_track ?: 0) * HEADING_DEG), WEIGHT_15)
}
PositionText(position.formatPositionTime(), WEIGHT_40)
}

View File

@@ -0,0 +1,21 @@
/*
* Copyright (c) 2025-2026 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.metrics
import androidx.compose.runtime.Composable
@Composable expect fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit)

View File

@@ -62,6 +62,7 @@ import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.channel_1
@@ -201,9 +202,9 @@ private fun PowerMetricsChart(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
when (color.copy(alpha = 1f)) {
currentColor -> "Current: %.0f mA".format(value)
voltageColor -> "Voltage: %.1f V".format(value)
else -> "%.1f".format(value)
currentColor -> formatString("Current: %.0f mA", value)
voltageColor -> formatString("Voltage: %.1f V", value)
else -> formatString("%.1f", value)
}
},
)
@@ -275,7 +276,7 @@ private fun PowerMetricsChart(
if (currentData.isNotEmpty()) {
VerticalAxis.rememberStart(
label = ChartStyling.rememberAxisLabel(color = currentColor),
valueFormatter = { _, value, _ -> "%.0f mA".format(value) },
valueFormatter = { _, value, _ -> formatString("%.0f mA", value) },
)
} else {
null
@@ -284,7 +285,7 @@ private fun PowerMetricsChart(
if (voltageData.isNotEmpty()) {
VerticalAxis.rememberEnd(
label = ChartStyling.rememberAxisLabel(color = voltageColor),
valueFormatter = { _, value, _ -> "%.1f V".format(value) },
valueFormatter = { _, value, _ -> formatString("%.1f V", value) },
)
} else {
null
@@ -372,7 +373,7 @@ private fun PowerChannelColumn(titleRes: StringResource, voltage: Float, current
MetricIndicator(PowerMetric.VOLTAGE.color)
Spacer(Modifier.width(4.dp))
Text(
text = "%.2fV".format(voltage),
text = formatString("%.2fV", voltage),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
@@ -381,7 +382,7 @@ private fun PowerChannelColumn(titleRes: StringResource, voltage: Float, current
MetricIndicator(PowerMetric.CURRENT.color)
Spacer(Modifier.width(4.dp))
Text(
text = "%.1fmA".format(current),
text = formatString("%.1fmA", current),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)

View File

@@ -56,6 +56,7 @@ import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProdu
import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.rssi
@@ -182,9 +183,9 @@ private fun SignalMetricsChart(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
if (color.copy(alpha = 1f) == rssiColor) {
"RSSI: %.0f dBm".format(value)
formatString("RSSI: %.0f dBm", value)
} else {
"SNR: %.1f dB".format(value)
formatString("SNR: %.1f dB", value)
}
},
)
@@ -226,7 +227,7 @@ private fun SignalMetricsChart(
if (rssiData.isNotEmpty()) {
VerticalAxis.rememberStart(
label = ChartStyling.rememberAxisLabel(color = rssiColor),
valueFormatter = { _, value, _ -> "%.0f dBm".format(value) },
valueFormatter = { _, value, _ -> formatString("%.0f dBm", value) },
)
} else {
null
@@ -235,7 +236,7 @@ private fun SignalMetricsChart(
if (snrData.isNotEmpty()) {
VerticalAxis.rememberEnd(
label = ChartStyling.rememberAxisLabel(color = snrColor),
valueFormatter = { _, value, _ -> "%.1f dB".format(value) },
valueFormatter = { _, value, _ -> formatString("%.1f dB", value) },
)
} else {
null
@@ -296,14 +297,14 @@ private fun SignalMetricsCard(meshPacket: MeshPacket, isSelected: Boolean, onCli
MetricIndicator(SignalMetric.RSSI.color)
Spacer(Modifier.width(4.dp))
Text(
text = "%.0f dBm".format(meshPacket.rx_rssi.toFloat()),
text = formatString("%.0f dBm", meshPacket.rx_rssi.toFloat()),
style = MaterialTheme.typography.labelLarge,
)
Spacer(Modifier.width(12.dp))
MetricIndicator(SignalMetric.SNR.color)
Spacer(Modifier.width(4.dp))
Text(
text = "%.1f dB".format(meshPacket.rx_snr),
text = formatString("%.1f dB", meshPacket.rx_snr),
style = MaterialTheme.typography.labelLarge,
)
}

View File

@@ -45,6 +45,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.model.getTracerouteResponse
import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC
@@ -163,7 +164,8 @@ fun TracerouteLogScreen(
statusYellow = statusYellow,
statusOrange = statusOrange,
)
val durationText = stringResource(Res.string.traceroute_duration, "%.1f".format(seconds))
val durationText =
stringResource(Res.string.traceroute_duration, formatString("%.1f", seconds))
buildAnnotatedString {
append(annotatedBase)
append("\n\n$durationText")

View File

@@ -16,7 +16,6 @@
*/
package org.meshtastic.feature.node.navigation
import androidx.activity.compose.BackHandler
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.layout.AnimatedPane
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
@@ -30,12 +29,16 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalFocusManager
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import androidx.navigationevent.NavigationEventInfo
import androidx.navigationevent.compose.NavigationBackHandler
import androidx.navigationevent.compose.rememberNavigationEventState
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.navigation.ChannelsRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.nodes
import org.meshtastic.core.ui.component.EmptyDetailPlaceholder
@@ -55,6 +58,7 @@ fun AdaptiveNodeListScreen(
backStack: NavBackStack<NavKey>,
scrollToTopEvents: Flow<ScrollToTopEvent>,
initialNodeId: Int? = null,
onNavigate: (Route) -> Unit = {},
onNavigateToMessages: (String) -> Unit = {},
) {
val nodeListViewModel: NodeListViewModel = koinViewModel()
@@ -78,7 +82,13 @@ fun AdaptiveNodeListScreen(
}
}
BackHandler(enabled = navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail) { handleBack() }
val navState = rememberNavigationEventState(NavigationEventInfo.None)
NavigationBackHandler(
state = navState,
isBackEnabled = navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail,
onBackCancelled = {},
onBackCompleted = { handleBack() },
)
LaunchedEffect(initialNodeId) {
if (initialNodeId != null) {
@@ -134,7 +144,7 @@ fun AdaptiveNodeListScreen(
viewModel = nodeDetailViewModel,
compassViewModel = compassViewModel,
navigateToMessages = onNavigateToMessages,
onNavigate = { route -> backStack.add(route) },
onNavigate = onNavigate,
onNavigateUp = handleBack,
)
}

View File

@@ -32,6 +32,7 @@ import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import org.jetbrains.compose.resources.StringResource
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@@ -60,18 +61,19 @@ import org.meshtastic.feature.node.metrics.PositionLogScreen
import org.meshtastic.feature.node.metrics.PowerMetricsScreen
import org.meshtastic.feature.node.metrics.SignalMetricsScreen
import org.meshtastic.feature.node.metrics.TracerouteLogScreen
import org.meshtastic.feature.node.metrics.TracerouteMapScreen
import kotlin.reflect.KClass
@Suppress("LongMethod")
fun EntryProviderScope<NavKey>.nodesGraph(
backStack: NavBackStack<NavKey>,
scrollToTopEvents: Flow<ScrollToTopEvent>,
nodeMapScreen: @Composable (destNum: Int, onNavigateUp: () -> Unit) -> Unit,
scrollToTopEvents: Flow<ScrollToTopEvent> = MutableSharedFlow(),
nodeMapScreen: @Composable (destNum: Int, onNavigateUp: () -> Unit) -> Unit = { _, _ -> },
) {
entry<NodesRoutes.NodesGraph> {
AdaptiveNodeListScreen(
backStack = backStack,
scrollToTopEvents = scrollToTopEvents,
onNavigate = { backStack.add(it) },
onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
)
}
@@ -80,6 +82,7 @@ fun EntryProviderScope<NavKey>.nodesGraph(
AdaptiveNodeListScreen(
backStack = backStack,
scrollToTopEvents = scrollToTopEvents,
onNavigate = { backStack.add(it) },
onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
)
}
@@ -98,6 +101,7 @@ fun EntryProviderScope<NavKey>.nodeDetailGraph(
backStack = backStack,
scrollToTopEvents = scrollToTopEvents,
initialNodeId = args.destNum,
onNavigate = { backStack.add(it) },
onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
)
}
@@ -107,6 +111,7 @@ fun EntryProviderScope<NavKey>.nodeDetailGraph(
backStack = backStack,
scrollToTopEvents = scrollToTopEvents,
initialNodeId = args.destNum,
onNavigate = { backStack.add(it) },
onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
)
}
@@ -134,12 +139,8 @@ fun EntryProviderScope<NavKey>.nodeDetailGraph(
}
entry<NodeDetailRoutes.TracerouteMap> { args ->
val metricsViewModel =
koinViewModel<MetricsViewModel>(key = "metrics-${args.destNum}") { parametersOf(args.destNum) }
metricsViewModel.setNodeId(args.destNum)
TracerouteMapScreen(
metricsViewModel = metricsViewModel,
destNum = args.destNum,
requestId = args.requestId,
logUuid = args.logUuid,
onNavigateUp = { backStack.removeLastOrNull() },
@@ -185,6 +186,9 @@ private inline fun <reified R : Route> EntryProviderScope<NavKey>.addNodeDetailS
}
}
/** Expect declaration for the platform-specific traceroute map screen. */
@Composable expect fun TracerouteMapScreen(destNum: Int, requestId: Int, logUuid: String?, onNavigateUp: () -> Unit)
enum class NodeDetailRoute(
val title: StringResource,
val routeClass: KClass<out Route>,

View File

@@ -0,0 +1,35 @@
/*
* Copyright (c) 2025-2026 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 androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import org.meshtastic.core.navigation.Route
import org.meshtastic.feature.node.compass.CompassViewModel
@Composable
actual fun NodeDetailScreen(
nodeId: Int,
modifier: Modifier,
viewModel: NodeDetailViewModel,
navigateToMessages: (String) -> Unit,
onNavigate: (Route) -> Unit,
onNavigateUp: () -> Unit,
compassViewModel: CompassViewModel?,
) {
// TODO: Implement iOS node detail screen
}

View File

@@ -0,0 +1,24 @@
/*
* Copyright (c) 2025-2026 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.metrics
import androidx.compose.runtime.Composable
@Composable
actual fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
// TODO: Implement iOS position log screen
}

View File

@@ -0,0 +1,24 @@
/*
* Copyright (c) 2025-2026 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.navigation
import androidx.compose.runtime.Composable
@Composable
actual fun TracerouteMapScreen(destNum: Int, requestId: Int, logUuid: String?, onNavigateUp: () -> Unit) {
// TODO: Implement iOS traceroute map screen
}

View File

@@ -0,0 +1,70 @@
/*
* Copyright (c) 2025-2026 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 androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.meshtastic.core.navigation.Route
import org.meshtastic.feature.node.compass.CompassViewModel
import org.meshtastic.feature.node.component.NodeMenuAction
import org.meshtastic.feature.node.model.NodeDetailAction
@Composable
actual fun NodeDetailScreen(
nodeId: Int,
modifier: Modifier,
viewModel: NodeDetailViewModel,
navigateToMessages: (String) -> Unit,
onNavigate: (Route) -> Unit,
onNavigateUp: () -> Unit,
compassViewModel: CompassViewModel?,
) {
LaunchedEffect(nodeId) { viewModel.start(nodeId) }
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// Desktop just renders the NodeDetailContent directly. Overlays like Compass are no-ops.
NodeDetailContent(
uiState = uiState,
modifier = modifier,
onAction = { action ->
when (action) {
is NodeDetailAction.Navigate -> onNavigate(action.route)
is NodeDetailAction.TriggerServiceAction -> viewModel.onServiceAction(action.action)
is NodeDetailAction.HandleNodeMenuAction -> {
when (val menuAction = action.action) {
is NodeMenuAction.DirectMessage -> {
val route = viewModel.getDirectMessageRoute(menuAction.node, uiState.ourNode)
navigateToMessages(route)
}
is NodeMenuAction.Remove -> {
viewModel.handleNodeMenuAction(menuAction)
onNavigateUp()
}
else -> viewModel.handleNodeMenuAction(menuAction)
}
}
else -> {}
}
},
onFirmwareSelect = { /* No-op on desktop for now */ },
onSaveNotes = { num, notes -> viewModel.setNodeNotes(num, notes) },
)
}

View File

@@ -0,0 +1,36 @@
/*
* Copyright (c) 2025-2026 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.metrics
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
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
@Composable
actual fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = "Position Log",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright (c) 2025-2026 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.navigation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
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
@Composable
actual fun TracerouteMapScreen(destNum: Int, requestId: Int, logUuid: String?, onNavigateUp: () -> Unit) {
// Desktop placeholder for now
PlaceholderScreen(name = "Traceroute Map")
}
@Composable
internal fun PlaceholderScreen(name: String) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = name,
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}