diff --git a/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt b/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt index 58c7444b6..174d99d9e 100644 --- a/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt @@ -75,6 +75,7 @@ data class MetricsState( val deviceMetrics: List = emptyList(), val signalMetrics: List = emptyList(), val powerMetrics: List = emptyList(), + val hostMetrics: List = emptyList(), val tracerouteRequests: List = emptyList(), val tracerouteResults: List = emptyList(), val positionLogs: List = emptyList(), @@ -88,6 +89,7 @@ data class MetricsState( fun hasPowerMetrics() = powerMetrics.isNotEmpty() fun hasTracerouteLogs() = tracerouteRequests.isNotEmpty() fun hasPositionLogs() = positionLogs.isNotEmpty() + fun hasHostMetrics() = hostMetrics.isNotEmpty() fun deviceMetricsFiltered(timeFrame: TimeFrame): List { val oldestTime = timeFrame.calculateOldestTime() @@ -267,7 +269,8 @@ class MetricsViewModel @Inject constructor( _state.update { state -> state.copy( deviceMetrics = telemetry.filter { it.hasDeviceMetrics() }, - powerMetrics = telemetry.filter { it.hasPowerMetrics() } + powerMetrics = telemetry.filter { it.hasPowerMetrics() }, + hostMetrics = telemetry.filter { it.hasHostMetrics() }, ) } _envState.update { state -> diff --git a/app/src/main/java/com/geeksville/mesh/navigation/NavGraph.kt b/app/src/main/java/com/geeksville/mesh/navigation/NavGraph.kt index 94e258ef1..1be05827a 100644 --- a/app/src/main/java/com/geeksville/mesh/navigation/NavGraph.kt +++ b/app/src/main/java/com/geeksville/mesh/navigation/NavGraph.kt @@ -35,8 +35,6 @@ package com.geeksville.mesh.navigation import androidx.annotation.StringRes -import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.animation.SharedTransitionLayout import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel @@ -204,6 +202,9 @@ sealed interface Route { @Serializable data object TracerouteLog : Route + + @Serializable + data object HostMetricsLog : Route } fun NavDestination.isConfigRoute(): Boolean { @@ -225,7 +226,6 @@ fun NavDestination.showLongNameTitle(): Boolean { ) } -@OptIn(ExperimentalSharedTransitionApi::class) @Suppress("LongMethod") @Composable fun NavGraph( @@ -233,98 +233,95 @@ fun NavGraph( uIViewModel: UIViewModel = hiltViewModel(), navController: NavHostController = rememberNavController(), ) { - SharedTransitionLayout { - NavHost( - navController = navController, - startDestination = if (uIViewModel.bondedAddress.isNullOrBlank()) { - Route.Settings - } else { - Route.Contacts - }, - modifier = modifier, - ) { - composable { - ContactsScreen( - uIViewModel, - onNavigate = { navController.navigate(Route.Messages(it)) } - ) - } - composable { - NodeScreen( - model = uIViewModel, - navigateToMessages = { navController.navigate(Route.Messages(it)) }, - navigateToNodeDetails = { navController.navigate(Route.NodeDetail(it)) }, - ) - } - composable { - MapView(uIViewModel) - } - composable { - ChannelScreen(uIViewModel) - } - composable( - deepLinks = listOf( - navDeepLink { - uriPattern = "$DEEP_LINK_BASE_URI/settings" - action = "android.intent.action.VIEW" - } - ) - ) { backStackEntry -> - SettingsScreen( - uIViewModel, - onNavigateToRadioConfig = { - navController.navigate(Route.RadioConfig()) { - popUpTo(Route.Settings) { - inclusive = false - } - } - }, - onNavigateToNodeDetails = { navController.navigate(Route.NodeDetail(it)) } - ) - } - composable { - DebugScreen() - } - composable( - deepLinks = listOf( - navDeepLink { - uriPattern = "$DEEP_LINK_BASE_URI/messages/{contactKey}?message={message}" - action = "android.intent.action.VIEW" - }, - ) - ) { backStackEntry -> - val args = backStackEntry.toRoute() - MessageScreen( - contactKey = args.contactKey, - message = args.message, - viewModel = uIViewModel, - navigateToMessages = { navController.navigate(Route.Messages(it)) }, - navigateToNodeDetails = { navController.navigate(Route.NodeDetail(it)) }, - onNavigateBack = navController::navigateUp, - ) - } - composable { - QuickChatScreen() - } - nodeDetailGraph( - navController, + NavHost( + navController = navController, + startDestination = if (uIViewModel.bondedAddress.isNullOrBlank()) { + Route.Settings + } else { + Route.Contacts + }, + modifier = modifier, + ) { + composable { + ContactsScreen( uIViewModel, - sharedTransitionScope = this@SharedTransitionLayout + onNavigate = { navController.navigate(Route.Messages(it)) } ) - radioConfigGraph(navController, uIViewModel) - composable( - deepLinks = listOf( - navDeepLink { - uriPattern = "$DEEP_LINK_BASE_URI/share?message={message}" - action = "android.intent.action.VIEW" - } - ) - ) { backStackEntry -> - val message = backStackEntry.toRoute().message - ShareScreen(uIViewModel) { - navController.navigate(Route.Messages(it, message)) { - popUpTo { inclusive = true } + } + composable { + NodeScreen( + model = uIViewModel, + navigateToMessages = { navController.navigate(Route.Messages(it)) }, + navigateToNodeDetails = { navController.navigate(Route.NodeDetail(it)) }, + ) + } + composable { + MapView(uIViewModel) + } + composable { + ChannelScreen(uIViewModel) + } + composable( + deepLinks = listOf( + navDeepLink { + uriPattern = "$DEEP_LINK_BASE_URI/settings" + action = "android.intent.action.VIEW" + } + ) + ) { backStackEntry -> + SettingsScreen( + uIViewModel, + onNavigateToRadioConfig = { + navController.navigate(Route.RadioConfig()) { + popUpTo(Route.Settings) { + inclusive = false + } } + }, + onNavigateToNodeDetails = { navController.navigate(Route.NodeDetail(it)) } + ) + } + composable { + DebugScreen() + } + composable( + deepLinks = listOf( + navDeepLink { + uriPattern = "$DEEP_LINK_BASE_URI/messages/{contactKey}?message={message}" + action = "android.intent.action.VIEW" + }, + ) + ) { backStackEntry -> + val args = backStackEntry.toRoute() + MessageScreen( + contactKey = args.contactKey, + message = args.message, + viewModel = uIViewModel, + navigateToMessages = { navController.navigate(Route.Messages(it)) }, + navigateToNodeDetails = { navController.navigate(Route.NodeDetail(it)) }, + onNavigateBack = navController::navigateUp, + ) + } + composable { + QuickChatScreen() + } + nodeDetailGraph( + navController, + uIViewModel, + ) + radioConfigGraph(navController, uIViewModel) + composable( + deepLinks = listOf( + navDeepLink { + uriPattern = "$DEEP_LINK_BASE_URI/share?message={message}" + action = "android.intent.action.VIEW" + } + ) + ) { backStackEntry -> + val message = backStackEntry.toRoute().message + ShareScreen(uIViewModel) { + navController.navigate(Route.Messages(it, message)) { + popUpTo { inclusive = true } } } } diff --git a/app/src/main/java/com/geeksville/mesh/navigation/NodeDetailGraph.kt b/app/src/main/java/com/geeksville/mesh/navigation/NodeDetailGraph.kt index 088dda55a..f93a323ab 100644 --- a/app/src/main/java/com/geeksville/mesh/navigation/NodeDetailGraph.kt +++ b/app/src/main/java/com/geeksville/mesh/navigation/NodeDetailGraph.kt @@ -18,12 +18,11 @@ package com.geeksville.mesh.navigation import androidx.annotation.StringRes -import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.animation.SharedTransitionScope import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CellTower import androidx.compose.material.icons.filled.LightMode import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Memory import androidx.compose.material.icons.filled.PermScanWifi import androidx.compose.material.icons.filled.Power import androidx.compose.material.icons.filled.Router @@ -39,17 +38,16 @@ import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.ui.NodeDetailScreen import com.geeksville.mesh.ui.components.DeviceMetricsScreen import com.geeksville.mesh.ui.components.EnvironmentMetricsScreen +import com.geeksville.mesh.ui.components.HostMetricsLogScreen import com.geeksville.mesh.ui.components.NodeMapScreen import com.geeksville.mesh.ui.components.PositionLogScreen import com.geeksville.mesh.ui.components.PowerMetricsScreen import com.geeksville.mesh.ui.components.SignalMetricsScreen import com.geeksville.mesh.ui.components.TracerouteLogScreen -@OptIn(ExperimentalSharedTransitionApi::class) fun NavGraphBuilder.nodeDetailGraph( navController: NavHostController, uiViewModel: UIViewModel, - sharedTransitionScope: SharedTransitionScope, ) { navigation( startDestination = Route.NodeDetail(), @@ -61,8 +59,6 @@ fun NavGraphBuilder.nodeDetailGraph( NodeDetailScreen( uiViewModel = uiViewModel, viewModel = hiltViewModel(parentEntry), - sharedTransitionScope = sharedTransitionScope, - animatedContentScope = this@composable, ) { navController.navigate(it) { popUpTo(Route.NodeDetail()) { @@ -89,6 +85,7 @@ fun NavGraphBuilder.nodeDetailGraph( NodeDetailRoute.SIGNAL -> SignalMetricsScreen(hiltViewModel(parentEntry)) NodeDetailRoute.TRACEROUTE -> TracerouteLogScreen(hiltViewModel(parentEntry)) NodeDetailRoute.POWER -> PowerMetricsScreen(hiltViewModel(parentEntry)) + NodeDetailRoute.HOST -> HostMetricsLogScreen(hiltViewModel(parentEntry)) } } } @@ -107,4 +104,5 @@ enum class NodeDetailRoute( SIGNAL(R.string.signal, Route.SignalMetrics, Icons.Default.CellTower), TRACEROUTE(R.string.traceroute, Route.TracerouteLog, Icons.Default.PermScanWifi), POWER(R.string.power, Route.PowerMetrics, Icons.Default.Power), + HOST(R.string.host, Route.HostMetricsLog, Icons.Default.Memory), } diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt index b6060d808..bc86c5111 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt @@ -15,13 +15,8 @@ * along with this program. If not, see . */ -@file:OptIn(ExperimentalSharedTransitionApi::class) - package com.geeksville.mesh.ui -import androidx.compose.animation.AnimatedContentScope -import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.animation.SharedTransitionScope import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement @@ -125,9 +120,9 @@ import com.geeksville.mesh.service.ServiceAction import com.geeksville.mesh.ui.components.NodeActionDialogs import com.geeksville.mesh.ui.components.NodeMenuAction import com.geeksville.mesh.ui.components.PreferenceCategory -import com.geeksville.mesh.ui.components.SharedTransitionPreview import com.geeksville.mesh.ui.preview.NodePreviewParameterProvider import com.geeksville.mesh.ui.radioconfig.NavCard +import com.geeksville.mesh.ui.theme.AppTheme import com.geeksville.mesh.util.UnitConversions.calculateDewPoint import com.geeksville.mesh.util.UnitConversions.toTempString import com.geeksville.mesh.util.formatAgo @@ -146,17 +141,15 @@ private enum class LogsType( ENVIRONMENT(R.string.env_metrics_log, Icons.Default.Thermostat, Route.EnvironmentMetrics), SIGNAL(R.string.sig_metrics_log, Icons.Default.SignalCellularAlt, Route.SignalMetrics), POWER(R.string.power_metrics_log, Icons.Default.Power, Route.PowerMetrics), - TRACEROUTE(R.string.traceroute_log, Icons.Default.Route, Route.TracerouteLog) + TRACEROUTE(R.string.traceroute_log, Icons.Default.Route, Route.TracerouteLog), + HOST(R.string.host_metrics_log, Icons.Default.Memory, Route.HostMetricsLog), } -@OptIn(ExperimentalSharedTransitionApi::class) @Composable fun NodeDetailScreen( modifier: Modifier = Modifier, viewModel: MetricsViewModel = hiltViewModel(), uiViewModel: UIViewModel = hiltViewModel(), - sharedTransitionScope: SharedTransitionScope, - animatedContentScope: AnimatedContentScope, onNavigate: (Route) -> Unit = {}, ) { val state by viewModel.state.collectAsStateWithLifecycle() @@ -171,7 +164,8 @@ fun NodeDetailScreen( environmentState.hasEnvironmentMetrics(), state.hasSignalMetrics(), state.hasPowerMetrics(), - state.hasTracerouteLogs() + state.hasTracerouteLogs(), + state.hasHostMetrics(), ) } @@ -200,8 +194,6 @@ fun NodeDetailScreen( }, modifier = modifier, metricsAvailability = availabilities, - sharedTransitionScope = sharedTransitionScope, - animatedContentScope = animatedContentScope, onShared = { share = true } @@ -224,8 +216,6 @@ private fun NodeDetailList( metricsState: MetricsState, onAction: (Any) -> Unit = {}, metricsAvailability: BooleanArray, - sharedTransitionScope: SharedTransitionScope, - animatedContentScope: AnimatedContentScope, onShared: () -> Unit = {} ) { LazyColumn( @@ -235,13 +225,13 @@ private fun NodeDetailList( if (metricsState.deviceHardware != null) { item { PreferenceCategory(stringResource(R.string.device)) { - DeviceDetailsContent(metricsState, sharedTransitionScope, animatedContentScope) + DeviceDetailsContent(metricsState) } } } item { PreferenceCategory(stringResource(R.string.details)) { - NodeDetailsContent(node, sharedTransitionScope, animatedContentScope) + NodeDetailsContent(node) } } node.metadata?.firmwareVersion?.let { firmwareVersion -> @@ -447,32 +437,24 @@ private fun DeviceActions( @Composable private fun DeviceDetailsContent( - state: MetricsState, - sharedTransitionScope: SharedTransitionScope, - animatedContentScope: AnimatedContentScope, + state: MetricsState ) { val node = state.node ?: return val deviceHardware = state.deviceHardware ?: return val hwModelName = deviceHardware.displayName val isSupported = deviceHardware.activelySupported - with(sharedTransitionScope) { - Box( - modifier = Modifier - .size(100.dp) - .padding(4.dp) - .clip(CircleShape) - .background( - color = Color(node.colors.second).copy(alpha = .5f), - shape = CircleShape - ) - .sharedElement( - rememberSharedContentState("node_chip_${node.num}"), - animatedContentScope - ), - contentAlignment = Alignment.Center - ) { - DeviceHardwareImage(deviceHardware, Modifier.fillMaxSize()) - } + Box( + modifier = Modifier + .size(100.dp) + .padding(4.dp) + .clip(CircleShape) + .background( + color = Color(node.colors.second).copy(alpha = .5f), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + DeviceHardwareImage(deviceHardware, Modifier.fillMaxSize()) } NodeDetailRow( label = stringResource(R.string.hardware), @@ -531,8 +513,6 @@ fun DeviceHardwareImage( @Composable private fun NodeDetailsContent( node: Node, - sharedTransitionScope: SharedTransitionScope, - animatedContentScope: AnimatedContentScope, ) { if (node.mismatchKey) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -561,17 +541,11 @@ private fun NodeDetailsContent( icon = Icons.TwoTone.Person, value = node.user.longName.ifEmpty { "???" } ) - with(sharedTransitionScope) { - NodeDetailRow( - modifier = Modifier.sharedElement( - rememberSharedContentState("node_shortname_${node.num}"), - animatedContentScope - ), - label = stringResource(R.string.short_name), - icon = Icons.Outlined.Person, - value = node.user.shortName.ifEmpty { "???" } - ) - } + NodeDetailRow( + label = stringResource(R.string.short_name), + icon = Icons.Outlined.Person, + value = node.user.shortName.ifEmpty { "???" } + ) NodeDetailRow( label = stringResource(R.string.node_number), icon = Icons.Default.Numbers, @@ -919,13 +893,11 @@ private fun NodeDetailsPreview( @PreviewParameter(NodePreviewParameterProvider::class) node: Node ) { - SharedTransitionPreview { sharedTransitionScope, animatedContentScope -> + AppTheme { NodeDetailList( node = node, metricsState = MetricsState.Empty, metricsAvailability = BooleanArray(LogsType.entries.size) { false }, - sharedTransitionScope = sharedTransitionScope, - animatedContentScope = animatedContentScope, ) } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeItem.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeItem.kt index 575de1ef8..1d35f7a48 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NodeItem.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeItem.kt @@ -18,7 +18,6 @@ package com.geeksville.mesh.ui import android.content.res.Configuration -import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -56,7 +55,6 @@ import com.geeksville.mesh.model.isUnmessageableRole import com.geeksville.mesh.ui.components.NodeKeyStatusIcon import com.geeksville.mesh.ui.components.NodeMenuAction import com.geeksville.mesh.ui.components.NodeStatusIcons -import com.geeksville.mesh.ui.components.SharedTransitionPreview import com.geeksville.mesh.ui.components.SignalInfo import com.geeksville.mesh.ui.compose.ElevationInfo import com.geeksville.mesh.ui.compose.SatelliteCountInfo @@ -267,11 +265,10 @@ fun NodeItem( } } -@OptIn(ExperimentalSharedTransitionApi::class) @Composable @Preview(showBackground = false) fun NodeInfoSimplePreview() { - SharedTransitionPreview { sharedTransitionScope, animatedContentScope -> + AppTheme { val thisNode = NodePreviewParameterProvider().values.first() val thatNode = NodePreviewParameterProvider().values.last() NodeItem( @@ -285,7 +282,6 @@ fun NodeInfoSimplePreview() { } } -@OptIn(ExperimentalSharedTransitionApi::class) @Composable @Preview( showBackground = true, diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/HostMetricsLog.kt b/app/src/main/java/com/geeksville/mesh/ui/components/HostMetricsLog.kt new file mode 100644 index 000000000..d744f9cf4 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/HostMetricsLog.kt @@ -0,0 +1,257 @@ +/* + * Copyright (c) 2025 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.geeksville.mesh.ui.components + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DataArray +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProgressIndicatorDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.R +import com.geeksville.mesh.TelemetryProtos +import com.geeksville.mesh.model.MetricsViewModel +import com.geeksville.mesh.ui.components.CommonCharts.DATE_TIME_FORMAT +import com.geeksville.mesh.ui.theme.AppTheme +import com.geeksville.mesh.util.formatUptime +import java.text.DecimalFormat + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun HostMetricsLogScreen( + metricsViewModel: MetricsViewModel = hiltViewModel(), +) { + 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, + ) + } + } +} + +@Suppress("LongMethod") +@Composable +fun HostMetricsItem( + modifier: Modifier = Modifier, + telemetry: TelemetryProtos.Telemetry +) { + val hostMetrics = telemetry.hostMetrics + val time = telemetry.time * CommonCharts.MS_PER_SEC + Card( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .combinedClickable(onClick = { /* Handle click */ }), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Row( + modifier = Modifier.padding(16.dp) + ) { + Icon( + imageVector = Icons.Default.DataArray, + contentDescription = null, + modifier = Modifier.width(24.dp), + ) + Spacer(modifier = Modifier.width(16.dp)) + SelectionContainer { + + Column( + modifier = Modifier + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.End, + text = DATE_TIME_FORMAT.format(time), + style = TextStyle(fontWeight = FontWeight.Bold), + fontSize = MaterialTheme.typography.labelLarge.fontSize + ) + LogLine( + label = stringResource(R.string.uptime), + value = formatUptime(hostMetrics.uptimeSeconds), + modifier = Modifier.fillMaxWidth(), + ) + LogLine( + label = stringResource(R.string.free_memory), + value = formatBytes(hostMetrics.freememBytes), + modifier = Modifier.fillMaxWidth(), + ) + LogLine( + label = stringResource(R.string.disk_free) + " 1", + value = formatBytes(hostMetrics.diskfree1Bytes), + modifier = Modifier.fillMaxWidth(), + ) + LogLine( + label = stringResource(R.string.disk_free) + " 2", + value = formatBytes(hostMetrics.diskfree2Bytes), + modifier = Modifier.fillMaxWidth(), + ) + LogLine( + label = stringResource(R.string.disk_free) + " 3", + value = formatBytes(hostMetrics.diskfree3Bytes), + modifier = Modifier.fillMaxWidth(), + ) + LogLine( + label = stringResource(R.string.load) + " 1", + value = (hostMetrics.load1 / 100.0).toString(), + modifier = Modifier.fillMaxWidth(), + ) + LinearProgressIndicator( + progress = { hostMetrics.load1 / 100.0f }, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 4.dp), + color = ProgressIndicatorDefaults.linearColor, + trackColor = ProgressIndicatorDefaults.linearTrackColor, + strokeCap = ProgressIndicatorDefaults.LinearStrokeCap, + ) + LogLine( + label = stringResource(R.string.load) + " 5", + value = (hostMetrics.load5 / 100.0).toString(), + modifier = Modifier.fillMaxWidth(), + ) + LinearProgressIndicator( + progress = { hostMetrics.load5 / 100.0f }, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 4.dp), + color = ProgressIndicatorDefaults.linearColor, + trackColor = ProgressIndicatorDefaults.linearTrackColor, + strokeCap = ProgressIndicatorDefaults.LinearStrokeCap, + ) + LogLine( + label = stringResource(R.string.load) + " 15", + value = (hostMetrics.load15 / 100.0).toString(), + modifier = Modifier.fillMaxWidth(), + ) + LinearProgressIndicator( + progress = { hostMetrics.load15 / 100.0f }, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 4.dp), + color = ProgressIndicatorDefaults.linearColor, + trackColor = ProgressIndicatorDefaults.linearTrackColor, + strokeCap = ProgressIndicatorDefaults.LinearStrokeCap, + ) + } + } + } + } +} + +@Composable +fun LogLine( + modifier: Modifier = Modifier, + label: String, + value: String, +) { + Row( + modifier = modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = label, + ) + Text( + text = value, + ) + } +} + +const val BYTES_IN_KB = 1024.0 +const val BYTES_IN_MB = BYTES_IN_KB * 1024.0 +const val BYTES_IN_GB = BYTES_IN_MB * 1024.0 + +fun formatBytes(bytes: Long, decimalPlaces: Int = 2): String { + val formatter = DecimalFormat().apply { + maximumFractionDigits = decimalPlaces + minimumFractionDigits = 0 + isGroupingUsed = false + } + return when { + bytes < 0 -> "N/A" // Handle negative bytes gracefully + bytes == 0L -> "0 B" + bytes >= BYTES_IN_GB -> "${formatter.format(bytes / BYTES_IN_GB)} GB" + bytes >= BYTES_IN_MB -> "${formatter.format(bytes / BYTES_IN_MB)} MB" + bytes >= BYTES_IN_KB -> "${formatter.format(bytes / BYTES_IN_KB)} KB" + else -> "$bytes B" + } +} + +@Suppress("MagicNumber") +@PreviewLightDark +@Composable +private fun HostMetricsItemPreview() { + val hostMetrics = TelemetryProtos.HostMetrics.newBuilder() + .setUptimeSeconds(3600) + .setFreememBytes(2048000) + .setDiskfree1Bytes(104857600) + .setDiskfree2Bytes(209715200) + .setDiskfree3Bytes(44444) + .setLoad1(30) + .setLoad5(75) + .setLoad15(19) + .build() + val logs = TelemetryProtos.Telemetry.newBuilder() + .setTime((System.currentTimeMillis() / 1000L).toInt()) + .setHostMetrics(hostMetrics) + .build() + AppTheme { + HostMetricsItem(telemetry = logs) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/SharedTransitionPreview.kt b/app/src/main/java/com/geeksville/mesh/ui/components/SharedTransitionPreview.kt deleted file mode 100644 index 63d8546a0..000000000 --- a/app/src/main/java/com/geeksville/mesh/ui/components/SharedTransitionPreview.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright (c) 2025 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package com.geeksville.mesh.ui.components - -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedContentScope -import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.animation.SharedTransitionLayout -import androidx.compose.animation.SharedTransitionScope -import androidx.compose.runtime.Composable -import com.geeksville.mesh.ui.theme.AppTheme - -@OptIn(ExperimentalSharedTransitionApi::class) -@Composable -fun SharedTransitionPreview( - content: @Composable (SharedTransitionScope, AnimatedContentScope) -> Unit -) { - AppTheme { - SharedTransitionLayout { - val sharedTransitionScope: SharedTransitionScope = this - AnimatedContent( - targetState = true, - label = "SharedTransitionPreview", - ) { - if (it) { - val animatedContentScope: AnimatedContentScope = this - content(sharedTransitionScope, animatedContentScope) - } - } - } - } -} diff --git a/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageList.kt b/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageList.kt index 3b1080d33..8689c1e1f 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageList.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageList.kt @@ -18,7 +18,6 @@ package com.geeksville.mesh.ui.message.components import androidx.annotation.StringRes -import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -106,7 +105,6 @@ fun DeliveryInfo( containerColor = MaterialTheme.colorScheme.surface ) -@OptIn(ExperimentalSharedTransitionApi::class) @Suppress("LongMethod") @Composable internal fun MessageList( diff --git a/app/src/main/proto b/app/src/main/proto index 24c7a3d28..022ea79ba 160000 --- a/app/src/main/proto +++ b/app/src/main/proto @@ -1 +1 @@ -Subproject commit 24c7a3d287a4bd269ce191827e5dabd8ce8f57a7 +Subproject commit 022ea79bad79b70d0bee286cd9184916ab47c1b1 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0042fe977..6aa8b0c7f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -631,4 +631,10 @@ Firmware Use 12h clock format When enabled, the device will display the time in 12-hour format on screen. + Host Metrics Log + Host + Host Metrics + Free Memory + Disk Free + Load