mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-03-27 18:21:58 -04:00
feat: Implement iOS support and unify Compose Multiplatform infrastructure (#4876)
This commit is contained in:
@@ -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) }
|
||||
|
||||
|
||||
@@ -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() }
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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>,
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user