Refactor map layer management and navigation infrastructure (#4921)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-25 19:29:24 -05:00 committed by GitHub
parent b608a04ca4
commit a005231d94
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
142 changed files with 5408 additions and 3090 deletions

View file

@ -1,239 +0,0 @@
/*
* 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 android.Manifest
import android.content.Intent
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.Node
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.details
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.SharedContactDialog
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.feature.node.compass.CompassUiState
import org.meshtastic.feature.node.compass.CompassViewModel
import org.meshtastic.feature.node.component.CompassSheetContent
import org.meshtastic.feature.node.component.FirmwareReleaseSheetContent
import org.meshtastic.feature.node.component.NodeMenuAction
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.NodeDetailAction
private sealed interface NodeDetailOverlay {
data object SharedContact : NodeDetailOverlay
data class FirmwareReleaseInfo(val release: FirmwareRelease) : NodeDetailOverlay
data object Compass : NodeDetailOverlay
}
@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()
NodeDetailScaffold(
modifier = modifier,
uiState = uiState,
viewModel = viewModel,
navigateToMessages = navigateToMessages,
onNavigate = onNavigate,
onNavigateUp = onNavigateUp,
compassViewModel = compassViewModel,
)
}
@Composable
@Suppress("LongParameterList")
private fun NodeDetailScaffold(
modifier: Modifier,
uiState: NodeDetailUiState,
viewModel: NodeDetailViewModel,
navigateToMessages: (String) -> Unit,
onNavigate: (Route) -> Unit,
onNavigateUp: () -> Unit,
compassViewModel: CompassViewModel? = null,
) {
var activeOverlay by remember { mutableStateOf<NodeDetailOverlay?>(null) }
val inspectionMode = LocalInspectionMode.current
val actualCompassViewModel = compassViewModel ?: if (inspectionMode) null else koinViewModel()
val compassUiState by
actualCompassViewModel?.uiState?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(CompassUiState()) }
val node = uiState.node
val listState = rememberLazyListState()
Scaffold(
modifier = modifier,
topBar = {
MainAppBar(
title = stringResource(Res.string.details),
subtitle = uiState.nodeName.asString(),
ourNode = uiState.ourNode,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {},
onClickChip = {},
)
},
) { paddingValues ->
NodeDetailContent(
uiState = uiState,
listState = listState,
onAction = { action ->
when (action) {
is NodeDetailAction.ShareContact -> activeOverlay = NodeDetailOverlay.SharedContact
is NodeDetailAction.OpenCompass -> {
actualCompassViewModel?.start(action.node, action.displayUnits)
activeOverlay = NodeDetailOverlay.Compass
}
else ->
handleNodeAction(
action = action,
uiState = uiState,
navigateToMessages = navigateToMessages,
onNavigateUp = onNavigateUp,
onNavigate = onNavigate,
viewModel = viewModel,
)
}
},
onFirmwareSelect = { activeOverlay = NodeDetailOverlay.FirmwareReleaseInfo(it) },
onSaveNotes = { num, notes -> viewModel.setNodeNotes(num, notes) },
modifier = Modifier.padding(paddingValues),
)
}
NodeDetailOverlays(activeOverlay, node, compassUiState, actualCompassViewModel, { activeOverlay = null }) {
viewModel.handleNodeMenuAction(NodeMenuAction.RequestPosition(it))
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun NodeDetailOverlays(
overlay: NodeDetailOverlay?,
node: Node?,
compassUiState: CompassUiState,
compassViewModel: CompassViewModel?,
onDismiss: () -> Unit,
onRequestPosition: (Node) -> Unit,
) {
val permissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { _ -> }
val locationSettingsLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { _ -> }
when (overlay) {
is NodeDetailOverlay.SharedContact -> node?.let { SharedContactDialog(it, onDismiss) }
is NodeDetailOverlay.FirmwareReleaseInfo ->
NodeDetailBottomSheet(onDismiss) { FirmwareReleaseSheetContent(firmwareRelease = overlay.release) }
is NodeDetailOverlay.Compass -> {
DisposableEffect(Unit) { onDispose { compassViewModel?.stop() } }
NodeDetailBottomSheet(
onDismiss = {
compassViewModel?.stop()
onDismiss()
},
) {
CompassSheetContent(
uiState = compassUiState,
onRequestLocationPermission = {
val perms =
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
)
permissionLauncher.launch(perms)
},
onOpenLocationSettings = {
locationSettingsLauncher.launch(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS))
},
onRequestPosition = { node?.let { onRequestPosition(it) } },
modifier = Modifier.padding(bottom = 24.dp),
)
}
}
null -> {}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun NodeDetailBottomSheet(onDismiss: () -> Unit, content: @Composable () -> Unit) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { content() }
}
@Preview(showBackground = true)
@Composable
private fun NodeDetailListPreview(@PreviewParameter(NodePreviewParameterProvider::class) node: Node) {
AppTheme {
val uiState =
NodeDetailUiState(
node = node,
ourNode = node,
metricsState = MetricsState(node = node, isLocal = true, isManaged = false),
availableLogs = emptySet(),
)
NodeDetailList(
node = node,
ourNode = node,
uiState = uiState,
listState = rememberLazyListState(),
onAction = {},
onFirmwareSelect = {},
onSaveNotes = { _, _ -> },
)
}
}

View file

@ -1,193 +0,0 @@
/*
* 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 android.app.Activity
import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.common.util.toMeshtasticUri
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.clear
import org.meshtastic.core.resources.save
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.icon.Delete
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.core.ui.icon.Save
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.proto.Config
import org.meshtastic.proto.Position
@Composable
private fun ActionButtons(
clearButtonEnabled: Boolean,
onClear: () -> Unit,
saveButtonEnabled: Boolean,
onSave: () -> Unit,
modifier: Modifier = Modifier,
) {
FlowRow(
modifier = modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedButton(
modifier = Modifier.weight(1f),
onClick = onClear,
enabled = clearButtonEnabled,
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Icon(imageVector = MeshtasticIcons.Delete, contentDescription = stringResource(Res.string.clear))
Spacer(Modifier.width(8.dp))
Text(text = stringResource(Res.string.clear))
}
OutlinedButton(modifier = Modifier.weight(1f), onClick = onSave, enabled = saveButtonEnabled) {
Icon(imageVector = MeshtasticIcons.Save, contentDescription = stringResource(Res.string.save))
Spacer(Modifier.width(8.dp))
Text(text = stringResource(Res.string.save))
}
}
}
@Suppress("LongMethod")
@Composable
actual fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
val exportPositionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) {
it.data?.data?.let { uri -> viewModel.savePositionCSV(uri.toMeshtasticUri()) }
}
}
var clearButtonEnabled by rememberSaveable(state.positionLogs) { mutableStateOf(state.positionLogs.isNotEmpty()) }
Scaffold(
topBar = {
MainAppBar(
title = state.node?.user?.long_name ?: "",
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {
if (!state.isLocal) {
IconButton(onClick = { viewModel.requestPosition() }) {
Icon(imageVector = MeshtasticIcons.Refresh, contentDescription = null)
}
}
},
onClickChip = {},
)
},
) { innerPadding ->
BoxWithConstraints(modifier = Modifier.padding(innerPadding)) {
val compactWidth = maxWidth < 600.dp
Column {
val textStyle =
if (compactWidth) {
MaterialTheme.typography.bodySmall
} else {
LocalTextStyle.current
}
CompositionLocalProvider(LocalTextStyle provides textStyle) {
PositionLogHeader(compactWidth)
PositionList(compactWidth, state.positionLogs, state.displayUnits)
}
ActionButtons(
clearButtonEnabled = clearButtonEnabled,
onClear = {
clearButtonEnabled = false
viewModel.clearPosition()
},
saveButtonEnabled = state.hasPositionLogs(),
onSave = {
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/*"
putExtra(Intent.EXTRA_TITLE, "position.csv")
}
exportPositionLauncher.launch(intent)
},
)
}
}
}
}
@Suppress("MagicNumber")
private val testPosition =
Position(
latitude_i = 297604270,
longitude_i = -953698040,
altitude = 1230,
sats_in_view = 7,
time = nowSeconds.toInt(),
)
@Preview(showBackground = true)
@Composable
private fun PositionItemPreview() {
AppTheme {
PositionItem(compactWidth = false, position = testPosition, system = Config.DisplayConfig.DisplayUnits.METRIC)
}
}
@PreviewScreenSizes
@Composable
private fun ActionButtonsPreview() {
AppTheme {
Column(Modifier.fillMaxSize(), Arrangement.Bottom) {
ActionButtons(clearButtonEnabled = true, onClear = {}, saveButtonEnabled = true, onSave = {})
}
}
}

View file

@ -1,36 +0,0 @@
/*
* 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

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
* Copyright (c) 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
@ -16,13 +16,47 @@
*/
package org.meshtastic.feature.node.detail
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.Node
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.details
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.SharedContactDialog
import org.meshtastic.feature.node.compass.CompassUiState
import org.meshtastic.feature.node.compass.CompassViewModel
import org.meshtastic.feature.node.component.CompassSheetContent
import org.meshtastic.feature.node.component.FirmwareReleaseSheetContent
import org.meshtastic.feature.node.component.NodeMenuAction
import org.meshtastic.feature.node.model.NodeDetailAction
private sealed interface NodeDetailOverlay {
data object SharedContact : NodeDetailOverlay
data class FirmwareReleaseInfo(val release: FirmwareRelease) : NodeDetailOverlay
data object Compass : NodeDetailOverlay
}
@Composable
expect fun NodeDetailScreen(
fun NodeDetailScreen(
nodeId: Int,
modifier: Modifier = Modifier,
viewModel: NodeDetailViewModel,
@ -30,4 +64,131 @@ expect fun NodeDetailScreen(
onNavigate: (Route) -> Unit = {},
onNavigateUp: () -> Unit = {},
compassViewModel: CompassViewModel? = null,
)
) {
LaunchedEffect(nodeId) { viewModel.start(nodeId) }
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
NodeDetailScaffold(
modifier = modifier,
uiState = uiState,
viewModel = viewModel,
navigateToMessages = navigateToMessages,
onNavigate = onNavigate,
onNavigateUp = onNavigateUp,
compassViewModel = compassViewModel,
)
}
@Composable
@Suppress("LongParameterList")
private fun NodeDetailScaffold(
modifier: Modifier,
uiState: NodeDetailUiState,
viewModel: NodeDetailViewModel,
navigateToMessages: (String) -> Unit,
onNavigate: (Route) -> Unit,
onNavigateUp: () -> Unit,
compassViewModel: CompassViewModel? = null,
) {
var activeOverlay by remember { mutableStateOf<NodeDetailOverlay?>(null) }
val actualCompassViewModel = compassViewModel
val compassUiState by
actualCompassViewModel?.uiState?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(CompassUiState()) }
val node = uiState.node
val listState = rememberLazyListState()
Scaffold(
modifier = modifier,
topBar = {
MainAppBar(
title = stringResource(Res.string.details),
subtitle = uiState.nodeName.asString(),
ourNode = uiState.ourNode,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {},
onClickChip = {},
)
},
) { paddingValues ->
NodeDetailContent(
uiState = uiState,
listState = listState,
onAction = { action ->
when (action) {
is NodeDetailAction.ShareContact -> activeOverlay = NodeDetailOverlay.SharedContact
is NodeDetailAction.OpenCompass -> {
actualCompassViewModel?.start(action.node, action.displayUnits)
activeOverlay = NodeDetailOverlay.Compass
}
else ->
handleNodeAction(
action = action,
uiState = uiState,
navigateToMessages = navigateToMessages,
onNavigateUp = onNavigateUp,
onNavigate = onNavigate,
viewModel = viewModel,
)
}
},
onFirmwareSelect = { activeOverlay = NodeDetailOverlay.FirmwareReleaseInfo(it) },
onSaveNotes = { num, notes -> viewModel.setNodeNotes(num, notes) },
modifier = Modifier.padding(paddingValues),
)
}
NodeDetailOverlays(activeOverlay, node, compassUiState, actualCompassViewModel, { activeOverlay = null }) {
viewModel.handleNodeMenuAction(NodeMenuAction.RequestPosition(it))
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun NodeDetailOverlays(
overlay: NodeDetailOverlay?,
node: Node?,
compassUiState: CompassUiState,
compassViewModel: CompassViewModel?,
onDismiss: () -> Unit,
onRequestPosition: (Node) -> Unit,
) {
val requestLocationPermission =
org.meshtastic.core.ui.util.rememberRequestLocationPermission(
onGranted = { node?.let { onRequestPosition(it) } },
onDenied = {},
)
val openLocationSettings = org.meshtastic.core.ui.util.rememberOpenLocationSettings()
when (overlay) {
is NodeDetailOverlay.SharedContact -> node?.let { SharedContactDialog(it, onDismiss) }
is NodeDetailOverlay.FirmwareReleaseInfo ->
NodeDetailBottomSheet(onDismiss) { FirmwareReleaseSheetContent(firmwareRelease = overlay.release) }
is NodeDetailOverlay.Compass -> {
DisposableEffect(Unit) { onDispose { compassViewModel?.stop() } }
NodeDetailBottomSheet(
onDismiss = {
compassViewModel?.stop()
onDismiss()
},
) {
CompassSheetContent(
uiState = compassUiState,
onRequestLocationPermission = { requestLocationPermission() },
onOpenLocationSettings = { openLocationSettings() },
onRequestPosition = { node?.let { onRequestPosition(it) } },
modifier = Modifier.padding(bottom = 24.dp),
)
}
}
null -> {}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun NodeDetailBottomSheet(onDismiss: () -> Unit, content: @Composable () -> Unit) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { content() }
}

View file

@ -24,7 +24,6 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import org.koin.core.annotation.Single
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.MyNodeInfo
@ -32,6 +31,7 @@ import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.hasValidEnvironmentMetrics
import org.meshtastic.core.model.util.isDirectSignal
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.FirmwareReleaseRepository
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioConfigRepository

View file

@ -16,6 +16,126 @@
*/
package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.clear
import org.meshtastic.core.resources.save
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.icon.Delete
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.core.ui.icon.Save
@Composable expect fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit)
@Composable
private fun ActionButtons(
clearButtonEnabled: Boolean,
onClear: () -> Unit,
saveButtonEnabled: Boolean,
onSave: () -> Unit,
modifier: Modifier = Modifier,
) {
FlowRow(
modifier = modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedButton(
modifier = Modifier.weight(1f),
onClick = onClear,
enabled = clearButtonEnabled,
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Icon(imageVector = MeshtasticIcons.Delete, contentDescription = stringResource(Res.string.clear))
Spacer(Modifier.width(8.dp))
Text(text = stringResource(Res.string.clear))
}
OutlinedButton(modifier = Modifier.weight(1f), onClick = onSave, enabled = saveButtonEnabled) {
Icon(imageVector = MeshtasticIcons.Save, contentDescription = stringResource(Res.string.save))
Spacer(Modifier.width(8.dp))
Text(text = stringResource(Res.string.save))
}
}
}
@Suppress("LongMethod")
@Composable
fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
val exportPositionLauncher =
org.meshtastic.core.ui.util.rememberSaveFileLauncher { uri -> viewModel.savePositionCSV(uri) }
var clearButtonEnabled by rememberSaveable(state.positionLogs) { mutableStateOf(state.positionLogs.isNotEmpty()) }
Scaffold(
topBar = {
MainAppBar(
title = state.node?.user?.long_name ?: "",
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {
if (!state.isLocal) {
IconButton(onClick = { viewModel.requestPosition() }) {
Icon(imageVector = MeshtasticIcons.Refresh, contentDescription = null)
}
}
},
onClickChip = {},
)
},
) { innerPadding ->
BoxWithConstraints(modifier = Modifier.padding(innerPadding)) {
val compactWidth = maxWidth < 600.dp
Column {
val textStyle =
if (compactWidth) {
MaterialTheme.typography.bodySmall
} else {
LocalTextStyle.current
}
CompositionLocalProvider(LocalTextStyle provides textStyle) {
PositionLogHeader(compactWidth)
PositionList(compactWidth, state.positionLogs, state.displayUnits)
}
ActionButtons(
clearButtonEnabled = clearButtonEnabled,
onClear = {
clearButtonEnabled = false
viewModel.clearPosition()
},
saveButtonEnabled = state.hasPositionLogs(),
onSave = { exportPositionLauncher("position.csv", "text/csv") },
)
}
}
}
}

View file

@ -68,7 +68,6 @@ fun EntryProviderScope<NavKey>.nodesGraph(
backStack: NavBackStack<NavKey>,
scrollToTopEvents: Flow<ScrollToTopEvent> = MutableSharedFlow(),
onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
nodeMapScreen: @Composable (destNum: Int, onNavigateUp: () -> Unit) -> Unit = { _, _ -> },
) {
entry<NodesRoutes.NodesGraph> {
AdaptiveNodeListScreen(
@ -90,7 +89,7 @@ fun EntryProviderScope<NavKey>.nodesGraph(
)
}
nodeDetailGraph(backStack, scrollToTopEvents, onHandleDeepLink, nodeMapScreen)
nodeDetailGraph(backStack, scrollToTopEvents, onHandleDeepLink)
}
@Suppress("LongMethod")
@ -98,7 +97,6 @@ fun EntryProviderScope<NavKey>.nodeDetailGraph(
backStack: NavBackStack<NavKey>,
scrollToTopEvents: Flow<ScrollToTopEvent>,
onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
nodeMapScreen: @Composable (destNum: Int, onNavigateUp: () -> Unit) -> Unit,
) {
entry<NodesRoutes.NodeDetailGraph> { args ->
AdaptiveNodeListScreen(
@ -122,7 +120,10 @@ fun EntryProviderScope<NavKey>.nodeDetailGraph(
)
}
entry<NodeDetailRoutes.NodeMap> { args -> nodeMapScreen(args.destNum) { backStack.removeLastOrNull() } }
entry<NodeDetailRoutes.NodeMap> { args ->
val mapScreen = org.meshtastic.core.ui.util.LocalNodeMapScreenProvider.current
mapScreen(args.destNum) { backStack.removeLastOrNull() }
}
entry<NodeDetailRoutes.TracerouteLog> { args ->
val metricsViewModel =
@ -145,12 +146,8 @@ fun EntryProviderScope<NavKey>.nodeDetailGraph(
}
entry<NodeDetailRoutes.TracerouteMap> { args ->
TracerouteMapScreen(
destNum = args.destNum,
requestId = args.requestId,
logUuid = args.logUuid,
onNavigateUp = { backStack.removeLastOrNull() },
)
val tracerouteMapScreen = org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider.current
tracerouteMapScreen(args.destNum, args.requestId, args.logUuid) { backStack.removeLastOrNull() }
}
NodeDetailRoute.entries.forEach { routeInfo ->
@ -193,8 +190,6 @@ 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

@ -1,35 +0,0 @@
/*
* 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

@ -1,24 +0,0 @@
/*
* 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

@ -1,24 +0,0 @@
/*
* 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

@ -1,58 +0,0 @@
/*
* 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
@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 ->
handleNodeAction(
action = action,
uiState = uiState,
navigateToMessages = navigateToMessages,
onNavigateUp = onNavigateUp,
onNavigate = onNavigate,
viewModel = viewModel,
)
},
onFirmwareSelect = { /* No-op on desktop for now */ },
onSaveNotes = { num, notes -> viewModel.setNodeNotes(num, notes) },
)
}

View file

@ -1,25 +0,0 @@
/*
* 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
import org.meshtastic.core.ui.component.PlaceholderScreen
@Composable
actual fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
PlaceholderScreen(name = "Position Log")
}

View file

@ -1,26 +0,0 @@
/*
* 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.meshtastic.core.ui.component.PlaceholderScreen
@Composable
actual fun TracerouteMapScreen(destNum: Int, requestId: Int, logUuid: String?, onNavigateUp: () -> Unit) {
// Desktop placeholder for now
PlaceholderScreen(name = "Traceroute Map")
}