Finish migration away from global app bar (#3297)

This commit is contained in:
Phil Oliver 2025-10-03 12:06:51 -04:00 committed by GitHub
parent ee74d4700a
commit 47f3961f3a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 563 additions and 447 deletions

View file

@ -7,6 +7,7 @@
<ID>CommentSpacing:Coroutines.kt$/// Wrap launch with an exception handler, FIXME, move into a utility lib</ID>
<ID>CommentWrapping:SignalMetrics.kt$Metric.SNR$/* Selected 12 as the max to get 4 equal vertical sections. */</ID>
<ID>ComposableNaming:NodeDetailScreen.kt$notesSection</ID>
<ID>ComposableParamOrder:Channel.kt$ChannelScreen</ID>
<ID>ComposableParamOrder:ChannelSettingsItemList.kt$ChannelSettingsItemList</ID>
<ID>ComposableParamOrder:Debug.kt$DecodedPayloadBlock</ID>
<ID>ComposableParamOrder:DebugSearch.kt$DebugSearchState</ID>
@ -34,6 +35,7 @@
<ID>ComposableParamOrder:PermissionScreenLayout.kt$PermissionScreenLayout</ID>
<ID>ComposableParamOrder:PowerMetrics.kt$PowerMetricsChart</ID>
<ID>ComposableParamOrder:QuickChat.kt$OutlinedTextFieldWithCounter</ID>
<ID>ComposableParamOrder:Share.kt$ShareScreen</ID>
<ID>ComposableParamOrder:SignalMetrics.kt$SignalMetricsChart</ID>
<ID>ComposableParamOrder:TopLevelNavIcon.kt$ConnectionsNavIcon</ID>
<ID>ComposableParamOrder:WarningDialog.kt$WarningDialog</ID>
@ -72,6 +74,8 @@
<ID>LambdaParameterEventTrailing:MessageList.kt$onReply</ID>
<ID>LambdaParameterEventTrailing:NodeDetailScreen.kt$onClick</ID>
<ID>LambdaParameterEventTrailing:NodeDetailScreen.kt$onSaveNotes</ID>
<ID>LambdaParameterEventTrailing:QuickChat.kt$onNavigateUp</ID>
<ID>LambdaParameterEventTrailing:TracerouteLog.kt$onNavigateUp</ID>
<ID>LambdaParameterInRestartableEffect:Channel.kt$onConfirm</ID>
<ID>LambdaParameterInRestartableEffect:MessageList.kt$onUnreadChanged</ID>
<ID>LargeClass:MeshService.kt$MeshService : Service</ID>
@ -81,6 +85,7 @@
<ID>LongMethod:DetectionSensorConfigItemList.kt$@Composable fun DetectionSensorConfigScreen(navController: NavController, viewModel: RadioConfigViewModel = hiltViewModel())</ID>
<ID>LongMethod:DeviceConfigItemList.kt$@Composable fun DeviceConfigScreen(navController: NavController, viewModel: RadioConfigViewModel = hiltViewModel())</ID>
<ID>LongMethod:DisplayConfigItemList.kt$@Composable fun DisplayConfigScreen(navController: NavController, viewModel: RadioConfigViewModel = hiltViewModel())</ID>
<ID>LongMethod:EnvironmentMetrics.kt$@Composable fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit)</ID>
<ID>LongMethod:ExternalNotificationConfigItemList.kt$@Composable fun ExternalNotificationConfigScreen(navController: NavController, viewModel: RadioConfigViewModel = hiltViewModel())</ID>
<ID>LongMethod:LoRaConfigItemList.kt$@Composable fun LoRaConfigScreen(navController: NavController, viewModel: RadioConfigViewModel = hiltViewModel())</ID>
<ID>LongMethod:MeshService.kt$MeshService$private fun handleReceivedData(packet: MeshPacket)</ID>
@ -193,9 +198,11 @@
<ID>ModifierNotUsedAtRoot:PowerMetrics.kt$modifier = modifier.weight(weight = Y_AXIS_WEIGHT)</ID>
<ID>ModifierNotUsedAtRoot:PowerMetrics.kt$modifier = modifier.width(dp)</ID>
<ID>ModifierNotUsedAtRoot:PowerMetrics.kt$modifier.width(dp)</ID>
<ID>ModifierNotUsedAtRoot:QuickChat.kt$modifier = modifier.fillMaxSize().padding(innerPadding)</ID>
<ID>ModifierNotUsedAtRoot:SignalMetrics.kt$modifier = modifier.weight(weight = Y_AXIS_WEIGHT)</ID>
<ID>ModifierNotUsedAtRoot:SignalMetrics.kt$modifier = modifier.width(dp)</ID>
<ID>ModifierNotUsedAtRoot:SignalMetrics.kt$modifier.width(dp)</ID>
<ID>ModifierNotUsedAtRoot:TracerouteLog.kt$modifier = modifier.fillMaxSize().padding(innerPadding)</ID>
<ID>ModifierReused:DeviceMetrics.kt$Canvas(modifier = modifier.width(dp)) { val height = size.height val width = size.width for (i in telemetries.indices) { val telemetry = telemetries[i] /* x-value time */ val xRatio = (telemetry.time - oldest.time).toFloat() / timeDiff val x = xRatio * width /* Channel Utilization */ plotPoint( drawContext = drawContext, color = Device.CH_UTIL.color, x = x, value = telemetry.deviceMetrics.channelUtilization, divisor = MAX_PERCENT_VALUE, ) /* Air Utilization Transmit */ plotPoint( drawContext = drawContext, color = Device.AIR_UTIL.color, x = x, value = telemetry.deviceMetrics.airUtilTx, divisor = MAX_PERCENT_VALUE, ) } /* Battery Line */ var index = 0 while (index &lt; telemetries.size) { val path = Path() index = createPath( telemetries = telemetries, index = index, path = path, oldestTime = oldest.time, timeRange = timeDiff, width = width, timeThreshold = selectedTime.timeThreshold(), ) { i -> val telemetry = telemetries.getOrNull(i) ?: telemetries.last() val ratio = telemetry.deviceMetrics.batteryLevel / MAX_PERCENT_VALUE val y = height - (ratio * height) return@createPath y } drawPath( path = path, color = Device.BATTERY.color, style = Stroke(width = GraphUtil.RADIUS, cap = StrokeCap.Round), ) } }</ID>
<ID>ModifierReused:DeviceMetrics.kt$HorizontalLinesOverlay( modifier.width(dp), lineColors = listOf(graphColor, Color.Yellow, Color.Red, graphColor, graphColor), )</ID>
<ID>ModifierReused:DeviceMetrics.kt$TimeAxisOverlay(modifier.width(dp), oldest = oldest.time, newest = newest.time, selectedTime.lineInterval())</ID>

View file

@ -26,7 +26,6 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.geeksville.mesh.model.MetricsViewModel
import com.geeksville.mesh.ui.map.NodeMapViewModel
import com.geeksville.mesh.ui.map.rememberMapViewWithLifecycle
@ -41,9 +40,9 @@ private const val DEG_D = 1e-7
@Composable
fun NodeMapScreen(
navController: NavHostController,
metricsViewModel: MetricsViewModel = hiltViewModel(),
nodeMapViewModel: NodeMapViewModel = hiltViewModel(),
onNavigateUp: () -> Unit,
) {
val density = LocalDensity.current
val state by metricsViewModel.state.collectAsStateWithLifecycle()

View file

@ -26,7 +26,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.geeksville.mesh.model.MetricsViewModel
import com.geeksville.mesh.ui.map.MapView
import com.geeksville.mesh.ui.map.NodeMapViewModel
@ -36,9 +35,9 @@ const val DEG_D = 1e-7
@Composable
fun NodeMapScreen(
navController: NavHostController,
metricsViewModel: MetricsViewModel = hiltViewModel(),
nodeMapViewModel: NodeMapViewModel = hiltViewModel(),
onNavigateUp: () -> Unit,
) {
val state by metricsViewModel.state.collectAsState()
val positions = state.positionLogs
@ -52,7 +51,7 @@ fun NodeMapScreen(
ourNode = ourNodeInfo,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = navController::navigateUp,
onNavigateUp = onNavigateUp,
actions = {},
onClickChip = {},
)

View file

@ -40,6 +40,7 @@ fun NavGraphBuilder.channelsGraph(navController: NavHostController) {
ChannelScreen(
radioConfigViewModel = hiltViewModel(parentEntry),
onNavigate = { route -> navController.navigate(route) },
onNavigateUp = { navController.navigateUp() },
)
}
configRoutes(navController)

View file

@ -79,15 +79,18 @@ fun NavGraphBuilder.contactsGraph(navController: NavHostController) {
),
) { backStackEntry ->
val message = backStackEntry.toRoute<ContactsRoutes.Share>().message
ShareScreen {
navController.navigate(ContactsRoutes.Messages(it, message)) {
popUpTo<ContactsRoutes.Share> { inclusive = true }
}
}
ShareScreen(
onConfirm = {
navController.navigate(ContactsRoutes.Messages(it, message)) {
popUpTo<ContactsRoutes.Share> { inclusive = true }
}
},
onNavigateUp = navController::navigateUp,
)
}
composable<ContactsRoutes.QuickChat>(
deepLinks = listOf(navDeepLink<ContactsRoutes.QuickChat>(basePath = "$DEEP_LINK_BASE_URI/quick_chat")),
) {
QuickChatScreen()
QuickChatScreen(onNavigateUp = navController::navigateUp)
}
}

View file

@ -171,8 +171,7 @@ fun NavDestination.isNodeDetailRoute(): Boolean = NodeDetailRoute.entries.any {
private inline fun <reified R : Route> NavGraphBuilder.addNodeDetailScreenComposable(
navController: NavHostController,
routeInfo: NodeDetailRoute,
crossinline screenContent:
@Composable (navController: NavHostController, metricsViewModel: MetricsViewModel) -> Unit,
crossinline screenContent: @Composable (metricsViewModel: MetricsViewModel, onNavigateUp: () -> Unit) -> Unit,
) {
composable<R>(
deepLinks =
@ -184,7 +183,7 @@ private inline fun <reified R : Route> NavGraphBuilder.addNodeDetailScreenCompos
val parentGraphBackStackEntry =
remember(backStackEntry) { navController.getBackStackEntry(NodesRoutes.NodeDetailGraph::class) }
val metricsViewModel = hiltViewModel<MetricsViewModel>(parentGraphBackStackEntry)
screenContent(navController, metricsViewModel)
screenContent(metricsViewModel, navController::navigateUp)
}
}
@ -192,60 +191,60 @@ enum class NodeDetailRoute(
@StringRes val title: Int,
val route: Route,
val icon: ImageVector?,
val screenComposable: @Composable (navController: NavHostController, metricsViewModel: MetricsViewModel) -> Unit,
val screenComposable: @Composable (metricsViewModel: MetricsViewModel, onNavigateUp: () -> Unit) -> Unit,
) {
DEVICE(
R.string.device,
NodeDetailRoutes.DeviceMetrics,
Icons.Default.Router,
{ _, metricsVM -> DeviceMetricsScreen(metricsVM) },
{ metricsVM, onNavigateUp -> DeviceMetricsScreen(metricsVM, onNavigateUp) },
),
NODE_MAP(
R.string.node_map,
NodeDetailRoutes.NodeMap,
Icons.Default.LocationOn,
{ navController, metricsVM -> NodeMapScreen(navController, metricsVM) },
{ metricsVM, onNavigateUp -> NodeMapScreen(metricsVM, onNavigateUp = onNavigateUp) },
),
POSITION_LOG(
R.string.position_log,
NodeDetailRoutes.PositionLog,
Icons.Default.LocationOn,
{ _, metricsVM -> PositionLogScreen(metricsVM) },
{ metricsVM, onNavigateUp -> PositionLogScreen(metricsVM, onNavigateUp) },
),
ENVIRONMENT(
R.string.environment,
NodeDetailRoutes.EnvironmentMetrics,
Icons.Default.LightMode,
{ _, metricsVM -> EnvironmentMetricsScreen(metricsVM) },
{ metricsVM, onNavigateUp -> EnvironmentMetricsScreen(metricsVM, onNavigateUp) },
),
SIGNAL(
R.string.signal,
NodeDetailRoutes.SignalMetrics,
Icons.Default.CellTower,
{ _, metricsVM -> SignalMetricsScreen(metricsVM) },
{ metricsVM, onNavigateUp -> SignalMetricsScreen(metricsVM, onNavigateUp) },
),
TRACEROUTE(
R.string.traceroute,
NodeDetailRoutes.TracerouteLog,
Icons.Default.PermScanWifi,
{ _, metricsVM -> TracerouteLogScreen(viewModel = metricsVM) },
{ metricsVM, onNavigateUp -> TracerouteLogScreen(viewModel = metricsVM, onNavigateUp = onNavigateUp) },
),
POWER(
R.string.power,
NodeDetailRoutes.PowerMetrics,
Icons.Default.Power,
{ _, metricsVM -> PowerMetricsScreen(metricsVM) },
{ metricsVM, onNavigateUp -> PowerMetricsScreen(metricsVM, onNavigateUp) },
),
HOST(
R.string.host,
NodeDetailRoutes.HostMetricsLog,
Icons.Default.Memory,
{ _, metricsVM -> HostMetricsLogScreen(metricsVM) },
{ metricsVM, onNavigateUp -> HostMetricsLogScreen(metricsVM, onNavigateUp) },
),
PAX(
R.string.pax,
NodeDetailRoutes.PaxMetrics,
Icons.Default.People,
{ _, metricsVM -> PaxMetricsScreen(metricsVM) },
{ metricsVM, onNavigateUp -> PaxMetricsScreen(metricsVM, onNavigateUp) },
),
}

View file

@ -22,7 +22,6 @@ package com.geeksville.mesh.ui
import android.Manifest
import android.os.Build
import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
@ -101,13 +100,11 @@ import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.navigation.ConnectionsRoutes
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.MapRoutes
import org.meshtastic.core.navigation.NodeDetailRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MultipleChoiceAlertDialog
import org.meshtastic.core.ui.component.SimpleAlertDialog
import org.meshtastic.core.ui.icon.Conversations
@ -338,60 +335,6 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode
) {
Scaffold(snackbarHost = { SnackbarHost(uIViewModel.snackBarHostState) }) { _ ->
Column(modifier = Modifier.fillMaxSize()) {
fun NavDestination.hasGlobalAppBar(): Boolean =
// List of screens to exclude from having the global app bar
listOf(
ConnectionsRoutes.Connections::class,
ContactsRoutes.Contacts::class,
MapRoutes.Map::class,
NodeDetailRoutes.NodeMap::class,
NodesRoutes.Nodes::class,
NodesRoutes.NodeDetail::class,
SettingsRoutes.Settings::class,
SettingsRoutes.AmbientLighting::class,
SettingsRoutes.LoRa::class,
SettingsRoutes.Security::class,
SettingsRoutes.Audio::class,
SettingsRoutes.Bluetooth::class,
SettingsRoutes.ChannelConfig::class,
SettingsRoutes.DetectionSensor::class,
SettingsRoutes.Display::class,
SettingsRoutes.Telemetry::class,
SettingsRoutes.Network::class,
SettingsRoutes.Paxcounter::class,
SettingsRoutes.Power::class,
SettingsRoutes.Position::class,
SettingsRoutes.User::class,
SettingsRoutes.StoreForward::class,
SettingsRoutes.MQTT::class,
SettingsRoutes.Serial::class,
SettingsRoutes.ExtNotification::class,
SettingsRoutes.CleanNodeDb::class,
SettingsRoutes.DebugPanel::class,
SettingsRoutes.RangeTest::class,
SettingsRoutes.CannedMessage::class,
SettingsRoutes.RemoteHardware::class,
SettingsRoutes.NeighborInfo::class,
)
.none { this.hasRoute(it) }
val ourNodeInfo by uIViewModel.ourNodeInfo.collectAsStateWithLifecycle()
AnimatedVisibility(visible = currentDestination?.hasGlobalAppBar() ?: false) {
MainAppBar(
navController = navController,
ourNode = ourNodeInfo,
onClickChip = {
navController.navigate(
NodesRoutes.NodeDetailGraph(it.num),
{
launchSingleTop = true
restoreState = true
},
)
},
)
}
NavHost(
navController = navController,
startDestination = NodesRoutes.NodesGraph,

View file

@ -48,6 +48,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@ -73,13 +74,18 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.model.UIViewModel
import org.meshtastic.core.database.entity.QuickChatAction
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.dragContainer
import org.meshtastic.core.ui.component.dragDropItemsIndexed
import org.meshtastic.core.ui.component.rememberDragDropState
import org.meshtastic.core.ui.theme.AppTheme
@Composable
internal fun QuickChatScreen(modifier: Modifier = Modifier, viewModel: UIViewModel = hiltViewModel()) {
internal fun QuickChatScreen(
modifier: Modifier = Modifier,
viewModel: UIViewModel = hiltViewModel(),
onNavigateUp: () -> Unit,
) {
val actions by viewModel.quickChatActions.collectAsStateWithLifecycle()
var showActionDialog by remember { mutableStateOf<QuickChatAction?>(null) }
@ -90,38 +96,51 @@ internal fun QuickChatScreen(modifier: Modifier = Modifier, viewModel: UIViewMod
viewModel.updateActionPositions(list)
}
Box(modifier = modifier.fillMaxSize()) {
if (showActionDialog != null) {
val action = showActionDialog ?: return
EditQuickChatDialog(
action = action,
onSave = viewModel::addQuickChatAction,
onDelete = viewModel::deleteQuickChatAction,
Scaffold(
topBar = {
MainAppBar(
title = stringResource(id = R.string.quick_chat),
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {},
onClickChip = {},
)
},
) { innerPadding ->
Box(modifier = modifier.fillMaxSize().padding(innerPadding)) {
showActionDialog?.let {
EditQuickChatDialog(
action = it,
onSave = viewModel::addQuickChatAction,
onDelete = viewModel::deleteQuickChatAction,
) {
showActionDialog = null
}
}
LazyColumn(
modifier = Modifier.dragContainer(dragDropState = dragDropState, haptics = LocalHapticFeedback.current),
state = listState,
contentPadding = PaddingValues(16.dp),
) {
showActionDialog = null
dragDropItemsIndexed(items = actions, dragDropState = dragDropState, key = { _, item -> item.uuid }) {
_,
action,
isDragging,
->
QuickChatItem(action = action, onEdit = { showActionDialog = it })
}
}
}
LazyColumn(
modifier = Modifier.dragContainer(dragDropState = dragDropState, haptics = LocalHapticFeedback.current),
state = listState,
contentPadding = PaddingValues(16.dp),
) {
dragDropItemsIndexed(items = actions, dragDropState = dragDropState, key = { _, item -> item.uuid }) {
_,
action,
isDragging,
->
QuickChatItem(action = action, onEdit = { showActionDialog = it })
FloatingActionButton(
onClick = { showActionDialog = QuickChatAction(position = actions.size) },
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
) {
Icon(imageVector = Icons.Default.Add, contentDescription = stringResource(id = R.string.add))
}
}
FloatingActionButton(
onClick = { showActionDialog = QuickChatAction(position = actions.size) },
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
) {
Icon(imageVector = Icons.Default.Add, contentDescription = stringResource(id = R.string.add))
}
}
}

View file

@ -36,6 +36,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -70,6 +71,7 @@ import com.geeksville.mesh.util.GraphUtil
import com.geeksville.mesh.util.GraphUtil.createPath
import com.geeksville.mesh.util.GraphUtil.plotPoint
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MaterialBatteryInfo
import org.meshtastic.core.ui.component.OptionLabel
import org.meshtastic.core.ui.component.SlidingSelector
@ -114,41 +116,55 @@ private val LEGEND_DATA =
)
@Composable
fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel()) {
fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
var displayInfoDialog by remember { mutableStateOf(false) }
val selectedTimeFrame by viewModel.timeFrame.collectAsState()
val data = state.deviceMetricsFiltered(selectedTimeFrame)
Column {
if (displayInfoDialog) {
LegendInfoDialog(
pairedRes =
listOf(
Pair(R.string.channel_utilization, R.string.ch_util_definition),
Pair(R.string.air_utilization, R.string.air_util_definition),
),
onDismiss = { displayInfoDialog = false },
Scaffold(
topBar = {
MainAppBar(
title = state.node?.user?.longName ?: "",
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {},
onClickChip = {},
)
},
) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
if (displayInfoDialog) {
LegendInfoDialog(
pairedRes =
listOf(
Pair(R.string.channel_utilization, R.string.ch_util_definition),
Pair(R.string.air_utilization, R.string.air_util_definition),
),
onDismiss = { displayInfoDialog = false },
)
}
DeviceMetricsChart(
modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
telemetries = data.reversed(),
selectedTimeFrame,
promptInfoDialog = { displayInfoDialog = true },
)
SlidingSelector(
TimeFrame.entries.toList(),
selectedTimeFrame,
onOptionSelected = { viewModel.setTimeFrame(it) },
) {
OptionLabel(stringResource(it.strRes))
}
/* Device Metric Cards */
LazyColumn(modifier = Modifier.fillMaxSize()) { items(data) { telemetry -> DeviceMetricsCard(telemetry) } }
}
DeviceMetricsChart(
modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
telemetries = data.reversed(),
selectedTimeFrame,
promptInfoDialog = { displayInfoDialog = true },
)
SlidingSelector(
TimeFrame.entries.toList(),
selectedTimeFrame,
onOptionSelected = { viewModel.setTimeFrame(it) },
) {
OptionLabel(stringResource(it.strRes))
}
/* Device Metric Cards */
LazyColumn(modifier = Modifier.fillMaxSize()) { items(data) { telemetry -> DeviceMetricsCard(telemetry) } }
}
}

View file

@ -31,6 +31,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -59,11 +60,12 @@ import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.IaqDisplayMode
import org.meshtastic.core.ui.component.IndoorAirQuality
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.OptionLabel
import org.meshtastic.core.ui.component.SlidingSelector
@Composable
fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel()) {
fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
val environmentState by viewModel.environmentState.collectAsStateWithLifecycle()
val selectedTimeFrame by viewModel.timeFrame.collectAsState()
@ -88,32 +90,47 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel()) {
}
var displayInfoDialog by remember { mutableStateOf(false) }
Column {
if (displayInfoDialog) {
LegendInfoDialog(
pairedRes = listOf(Pair(R.string.iaq, R.string.iaq_definition)),
onDismiss = { displayInfoDialog = false },
Scaffold(
topBar = {
MainAppBar(
title = state.node?.user?.longName ?: "",
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {},
onClickChip = {},
)
}
},
) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
if (displayInfoDialog) {
LegendInfoDialog(
pairedRes = listOf(Pair(R.string.iaq, R.string.iaq_definition)),
onDismiss = { displayInfoDialog = false },
)
}
EnvironmentMetricsChart(
modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
telemetries = processedTelemetries.reversed(),
graphData = graphData,
selectedTime = selectedTimeFrame,
promptInfoDialog = { displayInfoDialog = true },
)
EnvironmentMetricsChart(
modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
telemetries = processedTelemetries.reversed(),
graphData = graphData,
selectedTime = selectedTimeFrame,
promptInfoDialog = { displayInfoDialog = true },
)
SlidingSelector(
TimeFrame.entries.toList(),
selectedTimeFrame,
onOptionSelected = { viewModel.setTimeFrame(it) },
) {
OptionLabel(stringResource(it.strRes))
}
SlidingSelector(
TimeFrame.entries.toList(),
selectedTimeFrame,
onOptionSelected = { viewModel.setTimeFrame(it) },
) {
OptionLabel(stringResource(it.strRes))
}
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(processedTelemetries) { telemetry -> EnvironmentMetricsCard(telemetry, state.isFahrenheit) }
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(processedTelemetries) { telemetry -> EnvironmentMetricsCard(telemetry, state.isFahrenheit) }
}
}
}
}

View file

@ -39,6 +39,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -58,18 +59,36 @@ import com.geeksville.mesh.model.MetricsViewModel
import com.geeksville.mesh.ui.metrics.CommonCharts.DATE_TIME_FORMAT
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.theme.AppTheme
import java.text.DecimalFormat
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun HostMetricsLogScreen(metricsViewModel: MetricsViewModel = hiltViewModel()) {
fun HostMetricsLogScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
val state by metricsViewModel.state.collectAsStateWithLifecycle()
val hostMetrics = state.hostMetrics
LazyColumn(modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(horizontal = 16.dp)) {
items(hostMetrics) { telemetry -> HostMetricsItem(telemetry = telemetry) }
Scaffold(
topBar = {
MainAppBar(
title = state.node?.user?.longName ?: "",
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {},
onClickChip = {},
)
},
) { innerPadding ->
LazyColumn(
modifier = Modifier.fillMaxSize().padding(innerPadding),
contentPadding = PaddingValues(horizontal = 16.dp),
) {
items(hostMetrics) { telemetry -> HostMetricsItem(telemetry = telemetry) }
}
}
}

View file

@ -35,6 +35,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -60,6 +61,7 @@ import com.geeksville.mesh.model.TimeFrame
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.OptionLabel
import org.meshtastic.core.ui.component.SlidingSelector
import java.text.DateFormat
@ -153,7 +155,7 @@ private fun PaxMetricsChart(
@Composable
@Suppress("MagicNumber", "LongMethod")
fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel()) {
fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
val state by metricsViewModel.state.collectAsStateWithLifecycle()
val dateFormat = DateFormat.getDateTimeInstance()
var timeFrame by remember { mutableStateOf(TimeFrame.TWENTY_FOUR_HOURS) }
@ -189,38 +191,52 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel()) {
LegendData(PaxSeries.WIFI.legendRes, PaxSeries.WIFI.color, environmentMetric = null),
)
Column(modifier = Modifier.fillMaxSize()) {
// Time frame selector
SlidingSelector(
options = TimeFrame.entries.toList(),
selectedOption = timeFrame,
onOptionSelected = { timeFrame = it },
) { tf: TimeFrame ->
OptionLabel(stringResource(tf.strRes))
}
// Graph
if (graphData.isNotEmpty()) {
ChartHeader(graphData.size)
Legend(legendData = legendData)
PaxMetricsChart(
totalSeries = totalSeries,
bleSeries = bleSeries,
wifiSeries = wifiSeries,
minValue = minValue,
maxValue = maxValue,
timeFrame = timeFrame,
Scaffold(
topBar = {
MainAppBar(
title = state.node?.user?.longName ?: "",
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {},
onClickChip = {},
)
}
// List
if (paxMetrics.isEmpty()) {
Text(
text = stringResource(R.string.no_pax_metrics_logs),
modifier = Modifier.fillMaxSize().padding(16.dp),
textAlign = TextAlign.Center,
)
} else {
LazyColumn(modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(horizontal = 16.dp)) {
items(paxMetrics) { (log, pax) -> PaxMetricsItem(log, pax, dateFormat) }
},
) { innerPadding ->
Column(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
// Time frame selector
SlidingSelector(
options = TimeFrame.entries.toList(),
selectedOption = timeFrame,
onOptionSelected = { timeFrame = it },
) { tf: TimeFrame ->
OptionLabel(stringResource(tf.strRes))
}
// Graph
if (graphData.isNotEmpty()) {
ChartHeader(graphData.size)
Legend(legendData = legendData)
PaxMetricsChart(
totalSeries = totalSeries,
bleSeries = bleSeries,
wifiSeries = wifiSeries,
minValue = minValue,
maxValue = maxValue,
timeFrame = timeFrame,
)
}
// List
if (paxMetrics.isEmpty()) {
Text(
text = stringResource(R.string.no_pax_metrics_logs),
modifier = Modifier.fillMaxSize().padding(16.dp),
textAlign = TextAlign.Center,
)
} else {
LazyColumn(modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(horizontal = 16.dp)) {
items(paxMetrics) { (log, pax) -> PaxMetricsItem(log, pax, dateFormat) }
}
}
}
}

View file

@ -43,6 +43,7 @@ import androidx.compose.material3.Icon
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
@ -67,6 +68,7 @@ import com.geeksville.mesh.model.MetricsViewModel
import org.meshtastic.core.model.util.metersIn
import org.meshtastic.core.model.util.toString
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.theme.AppTheme
import java.text.DateFormat
import kotlin.time.Duration.Companion.days
@ -171,7 +173,7 @@ private fun ActionButtons(
}
@Composable
fun PositionLogScreen(viewModel: MetricsViewModel = hiltViewModel()) {
fun PositionLogScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
val exportPositionLauncher =
@ -183,37 +185,51 @@ fun PositionLogScreen(viewModel: MetricsViewModel = hiltViewModel()) {
var clearButtonEnabled by rememberSaveable(state.positionLogs) { mutableStateOf(state.positionLogs.isNotEmpty()) }
BoxWithConstraints {
val compactWidth = maxWidth < 600.dp
Column {
val textStyle =
if (compactWidth) {
MaterialTheme.typography.bodySmall
} else {
LocalTextStyle.current
}
CompositionLocalProvider(LocalTextStyle provides textStyle) {
HeaderItem(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)
},
Scaffold(
topBar = {
MainAppBar(
title = state.node?.user?.longName ?: "",
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {},
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) {
HeaderItem(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)
},
)
}
}
}
}

View file

@ -37,6 +37,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -67,6 +68,7 @@ import com.geeksville.mesh.ui.metrics.CommonCharts.MS_PER_SEC
import com.geeksville.mesh.util.GraphUtil
import com.geeksville.mesh.util.GraphUtil.createPath
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.OptionLabel
import org.meshtastic.core.ui.component.SlidingSelector
import org.meshtastic.core.ui.theme.GraphColors.InfantryBlue
@ -117,33 +119,50 @@ private val LEGEND_DATA =
)
@Composable
fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel()) {
fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
val selectedTimeFrame by viewModel.timeFrame.collectAsState()
var selectedChannel by remember { mutableStateOf(PowerChannel.ONE) }
val data = state.powerMetricsFiltered(selectedTimeFrame)
Scaffold(
topBar = {
MainAppBar(
title = state.node?.user?.longName ?: "",
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {},
onClickChip = {},
)
},
) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
PowerMetricsChart(
modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
telemetries = data.reversed(),
selectedTimeFrame,
selectedChannel,
)
Column {
PowerMetricsChart(
modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
telemetries = data.reversed(),
selectedTimeFrame,
selectedChannel,
)
SlidingSelector(
PowerChannel.entries.toList(),
selectedChannel,
onOptionSelected = { selectedChannel = it },
) {
OptionLabel(stringResource(it.strRes))
}
Spacer(modifier = Modifier.height(2.dp))
SlidingSelector(
TimeFrame.entries.toList(),
selectedTimeFrame,
onOptionSelected = { viewModel.setTimeFrame(it) },
) {
OptionLabel(stringResource(it.strRes))
}
SlidingSelector(PowerChannel.entries.toList(), selectedChannel, onOptionSelected = { selectedChannel = it }) {
OptionLabel(stringResource(it.strRes))
LazyColumn(modifier = Modifier.fillMaxSize()) { items(data) { telemetry -> PowerMetricsCard(telemetry) } }
}
Spacer(modifier = Modifier.height(2.dp))
SlidingSelector(
TimeFrame.entries.toList(),
selectedTimeFrame,
onOptionSelected = { viewModel.setTimeFrame(it) },
) {
OptionLabel(stringResource(it.strRes))
}
LazyColumn(modifier = Modifier.fillMaxSize()) { items(data) { telemetry -> PowerMetricsCard(telemetry) } }
}
}

View file

@ -37,6 +37,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -64,6 +65,7 @@ import com.geeksville.mesh.ui.metrics.CommonCharts.MS_PER_SEC
import com.geeksville.mesh.util.GraphUtil.plotPoint
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.LoraSignalIndicator
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.OptionLabel
import org.meshtastic.core.ui.component.SlidingSelector
import org.meshtastic.core.ui.component.SnrAndRssi
@ -89,37 +91,56 @@ private val LEGEND_DATA =
)
@Composable
fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel()) {
fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
var displayInfoDialog by remember { mutableStateOf(false) }
val selectedTimeFrame by viewModel.timeFrame.collectAsState()
val data = state.signalMetricsFiltered(selectedTimeFrame)
Column {
if (displayInfoDialog) {
LegendInfoDialog(
pairedRes =
listOf(Pair(R.string.snr, R.string.snr_definition), Pair(R.string.rssi, R.string.rssi_definition)),
onDismiss = { displayInfoDialog = false },
Scaffold(
topBar = {
MainAppBar(
title = state.node?.user?.longName ?: "",
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {},
onClickChip = {},
)
},
) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
if (displayInfoDialog) {
LegendInfoDialog(
pairedRes =
listOf(
Pair(R.string.snr, R.string.snr_definition),
Pair(R.string.rssi, R.string.rssi_definition),
),
onDismiss = { displayInfoDialog = false },
)
}
SignalMetricsChart(
modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
meshPackets = data.reversed(),
selectedTimeFrame,
promptInfoDialog = { displayInfoDialog = true },
)
SlidingSelector(
TimeFrame.entries.toList(),
selectedTimeFrame,
onOptionSelected = { viewModel.setTimeFrame(it) },
) {
OptionLabel(stringResource(it.strRes))
}
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(data) { meshPacket -> SignalMetricsCard(meshPacket) }
}
}
SignalMetricsChart(
modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
meshPackets = data.reversed(),
selectedTimeFrame,
promptInfoDialog = { displayInfoDialog = true },
)
SlidingSelector(
TimeFrame.entries.toList(),
selectedTimeFrame,
onOptionSelected = { viewModel.setTimeFrame(it) },
) {
OptionLabel(stringResource(it.strRes))
}
LazyColumn(modifier = Modifier.fillMaxSize()) { items(data) { meshPacket -> SignalMetricsCard(meshPacket) } }
}
}

View file

@ -41,6 +41,7 @@ import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -67,6 +68,7 @@ import com.geeksville.mesh.ui.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.model.getTracerouteResponse
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.SNR_FAIR_THRESHOLD
import org.meshtastic.core.ui.component.SNR_GOOD_THRESHOLD
import org.meshtastic.core.ui.component.SimpleAlertDialog
@ -79,7 +81,11 @@ import java.text.DateFormat
@OptIn(ExperimentalFoundationApi::class)
@Suppress("LongMethod")
@Composable
fun TracerouteLogScreen(modifier: Modifier = Modifier, viewModel: MetricsViewModel = hiltViewModel()) {
fun TracerouteLogScreen(
modifier: Modifier = Modifier,
viewModel: MetricsViewModel = hiltViewModel(),
onNavigateUp: () -> Unit,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val dateFormat = remember { DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM) }
@ -96,55 +102,75 @@ fun TracerouteLogScreen(modifier: Modifier = Modifier, viewModel: MetricsViewMod
)
}
LazyColumn(modifier = modifier.fillMaxSize(), contentPadding = PaddingValues(horizontal = 16.dp)) {
items(state.tracerouteRequests, key = { it.uuid }) { log ->
val result =
remember(state.tracerouteRequests, log.fromRadio.packet.id) {
state.tracerouteResults.find { it.fromRadio.packet.decoded.requestId == log.fromRadio.packet.id }
}
val route = remember(result) { result?.fromRadio?.packet?.fullRouteDiscovery }
val time = dateFormat.format(log.received_date)
val (text, icon) = route.getTextAndIcon()
var expanded by remember { mutableStateOf(false) }
val tracerouteDetailsAnnotated: AnnotatedString? =
result?.let { res ->
if (route != null && route.routeList.isNotEmpty() && route.routeBackList.isNotEmpty()) {
val seconds = (res.received_date - log.received_date).coerceAtLeast(0).toDouble() / MS_PER_SEC
val annotatedBase =
annotateTraceroute(res.fromRadio.packet.getTracerouteResponse(::getUsername))
buildAnnotatedString {
append(annotatedBase)
append("\n\nDuration: ${"%.1f".format(seconds)} s")
Scaffold(
topBar = {
MainAppBar(
title = state.node?.user?.longName ?: "",
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {},
onClickChip = {},
)
},
) { innerPadding ->
LazyColumn(
modifier = modifier.fillMaxSize().padding(innerPadding),
contentPadding = PaddingValues(horizontal = 16.dp),
) {
items(state.tracerouteRequests, key = { it.uuid }) { log ->
val result =
remember(state.tracerouteRequests, log.fromRadio.packet.id) {
state.tracerouteResults.find {
it.fromRadio.packet.decoded.requestId == log.fromRadio.packet.id
}
} else {
// For cases where there's a result but no full route, display plain text
res.fromRadio.packet.getTracerouteResponse(::getUsername)?.let { AnnotatedString(it) }
}
}
val route = remember(result) { result?.fromRadio?.packet?.fullRouteDiscovery }
Box {
TracerouteItem(
icon = icon,
text = "$time - $text",
modifier =
Modifier.combinedClickable(onLongClick = { expanded = true }) {
if (tracerouteDetailsAnnotated != null) {
showDialog = tracerouteDetailsAnnotated
} else if (result != null) {
// Fallback for results that couldn't be fully annotated but have basic info
val basicInfo = result.fromRadio.packet.getTracerouteResponse(::getUsername)
if (basicInfo != null) {
showDialog = AnnotatedString(basicInfo)
val time = dateFormat.format(log.received_date)
val (text, icon) = route.getTextAndIcon()
var expanded by remember { mutableStateOf(false) }
val tracerouteDetailsAnnotated: AnnotatedString? =
result?.let { res ->
if (route != null && route.routeList.isNotEmpty() && route.routeBackList.isNotEmpty()) {
val seconds =
(res.received_date - log.received_date).coerceAtLeast(0).toDouble() / MS_PER_SEC
val annotatedBase =
annotateTraceroute(res.fromRadio.packet.getTracerouteResponse(::getUsername))
buildAnnotatedString {
append(annotatedBase)
append("\n\nDuration: ${"%.1f".format(seconds)} s")
}
} else {
// For cases where there's a result but no full route, display plain text
res.fromRadio.packet.getTracerouteResponse(::getUsername)?.let { AnnotatedString(it) }
}
}
Box {
TracerouteItem(
icon = icon,
text = "$time - $text",
modifier =
Modifier.combinedClickable(onLongClick = { expanded = true }) {
if (tracerouteDetailsAnnotated != null) {
showDialog = tracerouteDetailsAnnotated
} else if (result != null) {
// Fallback for results that couldn't be fully annotated but have basic info
val basicInfo = result.fromRadio.packet.getTracerouteResponse(::getUsername)
if (basicInfo != null) {
showDialog = AnnotatedString(basicInfo)
}
}
},
)
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DeleteItem {
viewModel.deleteLog(log.uuid)
expanded = false
}
},
)
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DeleteItem {
viewModel.deleteLog(log.uuid)
expanded = false
}
}
}

View file

@ -51,6 +51,7 @@ import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
@ -115,6 +116,7 @@ import org.meshtastic.core.navigation.Route
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.AdaptiveTwoPane
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.PreferenceFooter
import timber.log.Timber
@ -129,6 +131,7 @@ fun ChannelScreen(
viewModel: ChannelViewModel = hiltViewModel(),
radioConfigViewModel: RadioConfigViewModel = hiltViewModel(),
onNavigate: (Route) -> Unit,
onNavigateUp: () -> Unit,
) {
val focusManager = LocalFocusManager.current
@ -274,73 +277,91 @@ fun ChannelScreen(
requestChannelSet?.let { ScannedQrCodeDialog(it, onDismiss = { viewModel.clearRequestChannelUrl() }) }
val listState = rememberLazyListState()
LazyColumn(state = listState, contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp)) {
item {
ChannelListView(
enabled = enabled,
channelSet = channelSet,
modemPresetName = modemPresetName,
channelSelections = channelSelections,
shouldAddChannel = shouldAddChannelsState,
onClick = {
isWaiting = true
radioConfigViewModel.setResponseStateLoading(ConfigRoute.CHANNELS)
},
Scaffold(
topBar = {
MainAppBar(
title = "",
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {},
onClickChip = {},
)
EditChannelUrl(
enabled = enabled,
channelUrl = selectedChannelSet.getChannelUrl(shouldAdd = shouldAddChannelsState),
onConfirm = {
viewModel.requestChannelUrl(it) {
Toast.makeText(context, R.string.channel_invalid, Toast.LENGTH_SHORT).show()
}
},
)
}
item {
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
SegmentedButton(
label = { Text(text = stringResource(R.string.replace)) },
onClick = { shouldAddChannelsState = false },
selected = !shouldAddChannelsState,
shape = SegmentedButtonDefaults.itemShape(0, 2),
},
) { innerPadding ->
val listState = rememberLazyListState()
LazyColumn(
state = listState,
modifier = Modifier.padding(innerPadding),
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp),
) {
item {
ChannelListView(
enabled = enabled,
channelSet = channelSet,
modemPresetName = modemPresetName,
channelSelections = channelSelections,
shouldAddChannel = shouldAddChannelsState,
onClick = {
isWaiting = true
radioConfigViewModel.setResponseStateLoading(ConfigRoute.CHANNELS)
},
)
SegmentedButton(
label = { Text(text = stringResource(R.string.add)) },
onClick = { shouldAddChannelsState = true },
selected = shouldAddChannelsState,
shape = SegmentedButtonDefaults.itemShape(1, 2),
EditChannelUrl(
enabled = enabled,
channelUrl = selectedChannelSet.getChannelUrl(shouldAdd = shouldAddChannelsState),
onConfirm = {
viewModel.requestChannelUrl(it) {
Toast.makeText(context, R.string.channel_invalid, Toast.LENGTH_SHORT).show()
}
},
)
}
item {
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
SegmentedButton(
label = { Text(text = stringResource(R.string.replace)) },
onClick = { shouldAddChannelsState = false },
selected = !shouldAddChannelsState,
shape = SegmentedButtonDefaults.itemShape(0, 2),
)
SegmentedButton(
label = { Text(text = stringResource(R.string.add)) },
onClick = { shouldAddChannelsState = true },
selected = shouldAddChannelsState,
shape = SegmentedButtonDefaults.itemShape(1, 2),
)
}
}
item {
ModemPresetInfo(
modemPresetName = modemPresetName,
onClick = {
isWaiting = true
radioConfigViewModel.setResponseStateLoading(ConfigRoute.LORA)
},
)
}
item {
PreferenceFooter(
enabled = enabled,
negativeText = R.string.reset,
onNegativeClicked = {
focusManager.clearFocus()
showResetDialog = true
},
positiveText = R.string.scan,
onPositiveClicked = {
focusManager.clearFocus()
if (cameraPermissionState.status.isGranted) {
zxingScan()
} else {
cameraPermissionState.launchPermissionRequest()
}
},
)
}
}
item {
ModemPresetInfo(
modemPresetName = modemPresetName,
onClick = {
isWaiting = true
radioConfigViewModel.setResponseStateLoading(ConfigRoute.LORA)
},
)
}
item {
PreferenceFooter(
enabled = enabled,
negativeText = R.string.reset,
onNegativeClicked = {
focusManager.clearFocus()
showResetDialog = true
},
positiveText = R.string.scan,
onPositiveClicked = {
focusManager.clearFocus()
if (cameraPermissionState.status.isGranted) {
zxingScan()
} else {
cameraPermissionState.launchPermissionRequest()
}
},
)
}
}
}

View file

@ -27,6 +27,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -43,40 +44,59 @@ import com.geeksville.mesh.model.Contact
import com.geeksville.mesh.ui.contact.ContactItem
import com.geeksville.mesh.ui.contact.ContactsViewModel
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.theme.AppTheme
@Composable
fun ShareScreen(viewModel: ContactsViewModel = hiltViewModel(), onConfirm: (String) -> Unit) {
fun ShareScreen(viewModel: ContactsViewModel = hiltViewModel(), onConfirm: (String) -> Unit, onNavigateUp: () -> Unit) {
val contactList by viewModel.contactList.collectAsStateWithLifecycle()
ShareScreen(contacts = contactList, onConfirm = onConfirm)
ShareScreen(contacts = contactList, onConfirm = onConfirm, onNavigateUp = onNavigateUp)
}
@Composable
fun ShareScreen(contacts: List<Contact>, onConfirm: (String) -> Unit) {
fun ShareScreen(contacts: List<Contact>, onConfirm: (String) -> Unit, onNavigateUp: () -> Unit) {
var selectedContact by remember { mutableStateOf("") }
Column {
LazyColumn(
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(6.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
items(contacts, key = { it.contactKey }) { contact ->
val selected = contact.contactKey == selectedContact
ContactItem(contact = contact, selected = selected, onClick = { selectedContact = contact.contactKey })
}
}
Button(
onClick = { onConfirm(selectedContact) },
modifier = Modifier.fillMaxWidth().padding(24.dp),
enabled = selectedContact.isNotEmpty(),
) {
Icon(
imageVector = Icons.AutoMirrored.Default.Send,
contentDescription = stringResource(id = R.string.share),
Scaffold(
topBar = {
MainAppBar(
title = stringResource(id = R.string.share_to),
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {},
onClickChip = {},
)
},
) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
LazyColumn(
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(6.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
items(contacts, key = { it.contactKey }) { contact ->
val selected = contact.contactKey == selectedContact
ContactItem(
contact = contact,
selected = selected,
onClick = { selectedContact = contact.contactKey },
)
}
}
Button(
onClick = { onConfirm(selectedContact) },
modifier = Modifier.fillMaxWidth().padding(24.dp),
enabled = selectedContact.isNotEmpty(),
) {
Icon(
imageVector = Icons.AutoMirrored.Default.Send,
contentDescription = stringResource(id = R.string.share),
)
}
}
}
}
@ -101,6 +121,7 @@ private fun ShareScreenPreview() {
),
),
onConfirm = {},
onNavigateUp = {},
)
}
}

View file

@ -31,7 +31,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
@ -40,57 +39,12 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.preview.BooleanProvider
import org.meshtastic.core.ui.component.preview.previewNode
import org.meshtastic.core.ui.theme.AppTheme
@Suppress("CyclomaticComplexMethod")
@Composable
fun MainAppBar(
modifier: Modifier = Modifier,
navController: NavHostController,
ourNode: Node?,
onClickChip: (Node) -> Unit,
) {
val backStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = backStackEntry?.destination
if (currentDestination?.hasRoute<ContactsRoutes.Messages>() == true) {
return
}
val title: String =
when {
currentDestination == null -> ""
currentDestination.hasRoute<SettingsRoutes.DebugPanel>() -> stringResource(id = R.string.debug_panel)
currentDestination.hasRoute<ContactsRoutes.QuickChat>() -> stringResource(id = R.string.quick_chat)
currentDestination.hasRoute<ContactsRoutes.Share>() -> stringResource(id = R.string.share_to)
else -> ""
}
MainAppBar(
modifier = modifier,
title = title,
subtitle = null,
canNavigateUp = navController.previousBackStackEntry != null,
ourNode = ourNode,
showNodeChip = false,
onNavigateUp = navController::navigateUp,
actions = {},
onClickChip = onClickChip,
)
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
fun MainAppBar(