diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 90155009e..f7b306d85 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -181,6 +181,7 @@ dependencies { // Bundles implementation(libs.bundles.androidx) implementation(libs.bundles.ui) + implementation(libs.bundles.markdown) debugImplementation(libs.bundles.ui.tooling) implementation(libs.bundles.adaptive) implementation(libs.bundles.lifecycle) diff --git a/app/src/main/java/com/geeksville/mesh/ui/node/NodeDetail.kt b/app/src/main/java/com/geeksville/mesh/ui/node/NodeDetail.kt index a159d6019..9c3f32008 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/node/NodeDetail.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/node/NodeDetail.kt @@ -17,7 +17,12 @@ package com.geeksville.mesh.ui.node +import android.content.Intent +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -45,10 +50,12 @@ import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.ChargingStation import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Height import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.KeyOff import androidx.compose.material.icons.filled.LightMode +import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.LocationOn import androidx.compose.material.icons.filled.Map import androidx.compose.material.icons.filled.Memory @@ -77,17 +84,20 @@ import androidx.compose.material.icons.twotone.Verified import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -108,16 +118,15 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp +import androidx.core.net.toUri import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage -import coil3.request.ErrorResult import coil3.request.ImageRequest -import coil3.request.SuccessResult -import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig.DisplayUnits +import com.geeksville.mesh.ConfigProtos import com.geeksville.mesh.DataPacket import com.geeksville.mesh.R -import com.geeksville.mesh.android.BuildUtils.debug +import com.geeksville.mesh.database.entity.FirmwareRelease import com.geeksville.mesh.database.entity.asDeviceVersion import com.geeksville.mesh.model.DeviceHardware import com.geeksville.mesh.model.DeviceVersion @@ -140,7 +149,7 @@ import com.geeksville.mesh.ui.node.components.NodeActionDialogs import com.geeksville.mesh.ui.node.components.NodeMenuAction import com.geeksville.mesh.ui.radioconfig.NavCard import com.geeksville.mesh.ui.sharing.SharedContactDialog -import com.geeksville.mesh.util.UnitConversions.calculateDewPoint +import com.geeksville.mesh.util.UnitConversions import com.geeksville.mesh.util.UnitConversions.toTempString import com.geeksville.mesh.util.formatAgo import com.geeksville.mesh.util.formatUptime @@ -148,38 +157,23 @@ import com.geeksville.mesh.util.thenIf import com.geeksville.mesh.util.toDistanceString import com.geeksville.mesh.util.toSmallDistanceString import com.geeksville.mesh.util.toSpeedString +import com.mikepenz.markdown.m3.Markdown import kotlinx.coroutines.delay -import kotlin.time.Duration.Companion.milliseconds -private enum class LogsType( - val titleRes: Int, +private data class VectorMetricInfo( + @StringRes val label: Int, + val value: String, val icon: ImageVector, - val route: Route -) { - DEVICE( - R.string.device_metrics_log, - Icons.Default.ChargingStation, - NodeDetailRoutes.DeviceMetrics - ), - NODE_MAP(R.string.node_map, Icons.Default.Map, NodeDetailRoutes.NodeMap), - POSITIONS(R.string.position_log, Icons.Default.LocationOn, NodeDetailRoutes.PositionLog), - ENVIRONMENT( - R.string.env_metrics_log, - Icons.Default.Thermostat, - NodeDetailRoutes.EnvironmentMetrics - ), - SIGNAL( - R.string.sig_metrics_log, - Icons.Default.SignalCellularAlt, - NodeDetailRoutes.SignalMetrics - ), - POWER(R.string.power_metrics_log, Icons.Default.Power, NodeDetailRoutes.PowerMetrics), - TRACEROUTE(R.string.traceroute_log, Icons.Default.Route, NodeDetailRoutes.TracerouteLog), - HOST(R.string.host_metrics_log, Icons.Default.Memory, NodeDetailRoutes.HostMetricsLog), - PAX(R.string.pax_metrics_log, Icons.Default.People, NodeDetailRoutes.PaxMetrics), -} + val rotateIcon: Float = 0f, +) + +private data class DrawableMetricInfo( + @StringRes val label: Int, + val value: String, + @DrawableRes val icon: Int, + val rotateIcon: Float = 0f, +) -@Suppress("LongMethod") @Composable fun NodeDetailScreen( modifier: Modifier = Modifier, @@ -192,93 +186,172 @@ fun NodeDetailScreen( val state by viewModel.state.collectAsStateWithLifecycle() val environmentState by viewModel.environmentState.collectAsStateWithLifecycle() val lastTracerouteTime by uiViewModel.lastTraceRouteTime.collectAsStateWithLifecycle() - - /* The order is with respect to the enum above: LogsType */ - val availabilities = remember(key1 = state, key2 = environmentState) { - booleanArrayOf( - state.hasDeviceMetrics(), - state.hasPositionLogs(), - state.hasPositionLogs(), - environmentState.hasEnvironmentMetrics(), - state.hasSignalMetrics(), - state.hasPowerMetrics(), - state.hasTracerouteLogs(), - state.hasHostMetrics(), - state.hasPaxMetrics(), // Added for PAX log - ) - } val ourNode by uiViewModel.ourNodeInfo.collectAsStateWithLifecycle() - if (state.node != null) { - val node = state.node ?: return - uiViewModel.setTitle(node.user.longName) - var share by remember { mutableStateOf(false) } - if (share) { - SharedContactDialog(node) { - share = false + val availableLogs by remember(state, environmentState) { + derivedStateOf { + buildSet { + if (state.hasDeviceMetrics()) add(LogsType.DEVICE) + if (state.hasPositionLogs()) { + add(LogsType.NODE_MAP) + add(LogsType.POSITIONS) + } + if (environmentState.hasEnvironmentMetrics()) add(LogsType.ENVIRONMENT) + if (state.hasSignalMetrics()) add(LogsType.SIGNAL) + if (state.hasPowerMetrics()) add(LogsType.POWER) + if (state.hasTracerouteLogs()) add(LogsType.TRACEROUTE) + if (state.hasHostMetrics()) add(LogsType.HOST) + if (state.hasPaxMetrics()) add(LogsType.PAX) } } - NodeDetailList( + } + + val node = state.node + if (node != null) { + NodeDetailContent( node = node, - lastTracerouteTime = lastTracerouteTime, ourNode = ourNode, metricsState = state, + lastTracerouteTime = lastTracerouteTime, + availableLogs = availableLogs, + uiViewModel = uiViewModel, onAction = { action -> - when (action) { - is Route -> onNavigate(action) - is ServiceAction -> viewModel.onServiceAction(action) - - is NodeMenuAction -> { - if (action is NodeMenuAction.DirectMessage) { - val hasPKC = uiViewModel.ourNodeInfo.value?.hasPKC == true - val channel = - if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else node.channel - navigateToMessages("$channel${node.user.id}") - } else if (action is NodeMenuAction.Remove) { - uiViewModel.handleNodeMenuAction(action) - onNavigateUp() - } else { - uiViewModel.handleNodeMenuAction(action) - } - } - - else -> debug("Unhandled action: $action") - } + handleNodeAction( + action = action, + uiViewModel = uiViewModel, + node = node, + navigateToMessages = navigateToMessages, + onNavigateUp = onNavigateUp, + onNavigate = onNavigate, + viewModel = viewModel, + ) }, modifier = modifier, - metricsAvailability = availabilities, - onShared = { - share = true - } ) } else { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } } } -@Suppress("LongMethod") +private fun handleNodeAction( + action: NodeDetailAction, + uiViewModel: UIViewModel, + node: Node, + navigateToMessages: (String) -> Unit, + onNavigateUp: () -> Unit, + onNavigate: (Route) -> Unit, + viewModel: MetricsViewModel, +) { + when (action) { + is NodeDetailAction.Navigate -> onNavigate(action.route) + is NodeDetailAction.TriggerServiceAction -> viewModel.onServiceAction(action.action) + is NodeDetailAction.HandleNodeMenuAction -> { + when (val menuAction = action.action) { + is NodeMenuAction.DirectMessage -> { + val hasPKC = uiViewModel.ourNodeInfo.value?.hasPKC == true + val channel = if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else node.channel + navigateToMessages("$channel${node.user.id}") + } + is NodeMenuAction.Remove -> { + uiViewModel.handleNodeMenuAction(menuAction) + onNavigateUp() + } + else -> uiViewModel.handleNodeMenuAction(menuAction) + } + } + is NodeDetailAction.ShareContact -> { + /* Handled in NodeDetailContent */ + } + } +} + +sealed interface NodeDetailAction { + data class Navigate(val route: Route) : NodeDetailAction + data class TriggerServiceAction(val action: ServiceAction) : NodeDetailAction + data class HandleNodeMenuAction(val action: NodeMenuAction) : NodeDetailAction + data object ShareContact : NodeDetailAction +} + +val Node.isEffectivelyUnmessageable: Boolean + get() = if (user.hasIsUnmessagable()) { + user.isUnmessagable + } else { + user.role?.isUnmessageableRole() == true + } + +private enum class LogsType(@StringRes val titleRes: Int, val icon: ImageVector, val route: Route) { + DEVICE(R.string.device_metrics_log, Icons.Default.ChargingStation, NodeDetailRoutes.DeviceMetrics), + NODE_MAP(R.string.node_map, Icons.Default.Map, NodeDetailRoutes.NodeMap), + POSITIONS(R.string.position_log, Icons.Default.LocationOn, NodeDetailRoutes.PositionLog), + ENVIRONMENT(R.string.env_metrics_log, Icons.Default.Thermostat, NodeDetailRoutes.EnvironmentMetrics), + SIGNAL(R.string.sig_metrics_log, Icons.Default.SignalCellularAlt, NodeDetailRoutes.SignalMetrics), + POWER(R.string.power_metrics_log, Icons.Default.Power, NodeDetailRoutes.PowerMetrics), + TRACEROUTE(R.string.traceroute_log, Icons.Default.Route, NodeDetailRoutes.TracerouteLog), + HOST(R.string.host_metrics_log, Icons.Default.Memory, NodeDetailRoutes.HostMetricsLog), + PAX(R.string.pax_metrics_log, Icons.Default.People, NodeDetailRoutes.PaxMetrics), +} + +@Composable +private fun NodeDetailContent( + node: Node, + ourNode: Node?, + metricsState: MetricsState, + lastTracerouteTime: Long?, + availableLogs: Set, + uiViewModel: UIViewModel, + onAction: (NodeDetailAction) -> Unit, + modifier: Modifier = Modifier, +) { + uiViewModel.setTitle(node.user.longName) + var showShareDialog by remember { mutableStateOf(false) } + if (showShareDialog) { + SharedContactDialog(node) { showShareDialog = false } + } + + NodeDetailList( + node = node, + lastTracerouteTime = lastTracerouteTime, + ourNode = ourNode, + metricsState = metricsState, + onAction = { action -> + if (action is NodeDetailAction.ShareContact) { + showShareDialog = true + } else { + onAction(action) + } + }, + modifier = modifier, + availableLogs = availableLogs, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun NodeDetailList( modifier: Modifier = Modifier, node: Node, - lastTracerouteTime: Long? = null, + lastTracerouteTime: Long?, ourNode: Node?, metricsState: MetricsState, - onAction: (Any) -> Unit = {}, - metricsAvailability: BooleanArray, - onShared: () -> Unit = {} + onAction: (NodeDetailAction) -> Unit, + availableLogs: Set, ) { - Column( - modifier = modifier - .fillMaxSize() - .padding(16.dp) - .verticalScroll(rememberScrollState()), - ) { + var showFirmwareSheet by remember { mutableStateOf(false) } + var selectedFirmware by remember { mutableStateOf(null) } + + if (showFirmwareSheet) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) + ModalBottomSheet( + onDismissRequest = { showFirmwareSheet = false }, + sheetState = sheetState, + ) { + selectedFirmware?.let { FirmwareReleaseSheetContent(firmwareRelease = it) } + } + } + + Column(modifier = modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState())) { if (metricsState.deviceHardware != null) { PreferenceCategory(stringResource(R.string.device)) { DeviceDetailsContent(metricsState) @@ -292,92 +365,176 @@ private fun NodeDetailList( isLocal = metricsState.isLocal, lastTracerouteTime = lastTracerouteTime, node = node, - onShared = onShared, - onAction = onAction + onAction = onAction, ) - - if (node.hasEnvironmentMetrics) { - PreferenceCategory(stringResource(R.string.environment)) - EnvironmentMetrics(node, metricsState.isFahrenheit, metricsState.displayUnits) - Spacer(modifier = Modifier.height(8.dp)) - } - - if (node.hasPowerMetrics) { - PreferenceCategory(stringResource(R.string.power)) - PowerMetrics(node) - Spacer(modifier = Modifier.height(8.dp)) - } - - /* Metric Logs Navigation */ - PreferenceCategory(stringResource(id = R.string.logs)) { - for (type in LogsType.entries) { - NavCard( - title = stringResource(type.titleRes), - icon = type.icon, - enabled = metricsAvailability[type.ordinal] - ) { - onAction(type.route) - } - } - } + MetricsSection(node, metricsState, availableLogs, onAction) if (!metricsState.isManaged) { - PreferenceCategory(stringResource(id = R.string.administration)) { - NodeActionButton( - title = stringResource(id = R.string.request_metadata), - icon = Icons.Default.Memory, - enabled = true, - onClick = { onAction(ServiceAction.GetDeviceMetadata(node.num)) } - ) - NavCard( - title = stringResource(id = R.string.remote_admin), - icon = Icons.Default.Settings, - enabled = metricsState.isLocal || node.metadata != null - ) { - onAction(RadioConfigRoutes.RadioConfig(node.num)) + AdministrationSection( + node = node, + metricsState = metricsState, + onAction = onAction, + onFirmwareSelected = { firmware -> + selectedFirmware = firmware + showFirmwareSheet = true + }, + ) + } + } +} + +@Composable +private fun MetricsSection( + node: Node, + metricsState: MetricsState, + availableLogs: Set, + onAction: (NodeDetailAction) -> Unit, +) { + if (node.hasEnvironmentMetrics) { + PreferenceCategory(stringResource(R.string.environment)) + EnvironmentMetrics(node, metricsState.isFahrenheit, metricsState.displayUnits) + Spacer(modifier = Modifier.height(8.dp)) + } + + if (node.hasPowerMetrics) { + PreferenceCategory(stringResource(R.string.power)) + PowerMetrics(node) + Spacer(modifier = Modifier.height(8.dp)) + } + + if (availableLogs.isNotEmpty()) { + PreferenceCategory(stringResource(id = R.string.logs)) { + LogsType.entries.forEach { type -> + if (availableLogs.contains(type)) { + NavCard( + title = stringResource(type.titleRes), + icon = type.icon, + enabled = true, + ) { + onAction(NodeDetailAction.Navigate(type.route)) + } } } + } + } +} - node.metadata?.firmwareVersion?.let { firmwareVersion -> - val deviceVersion = DeviceVersion(firmwareVersion.substringBeforeLast(".")) - PreferenceCategory(stringResource(R.string.firmware)) { - val latestStableFirmware = metricsState.latestStableFirmware - val latestAlphaFirmware = metricsState.latestAlphaFirmware - NodeDetailRow( - label = "Installed", - icon = Icons.Default.Memory, - value = firmwareVersion.substringBeforeLast("."), - iconTint = if (deviceVersion < latestStableFirmware.asDeviceVersion()) { - MaterialTheme.colorScheme.error - } else if (deviceVersion == latestStableFirmware.asDeviceVersion()) { - Green - } else if (deviceVersion in - latestStableFirmware.asDeviceVersion()..latestAlphaFirmware.asDeviceVersion() - ) { - Yellow - } else if (deviceVersion >= latestAlphaFirmware.asDeviceVersion()) { - Orange - } else { - MaterialTheme.colorScheme.onSurface - } - ) +@Composable +private fun AdministrationSection( + node: Node, + metricsState: MetricsState, + onAction: (NodeDetailAction) -> Unit, + onFirmwareSelected: (FirmwareRelease) -> Unit, +) { + PreferenceCategory(stringResource(id = R.string.administration)) { + NodeActionButton( + title = stringResource(id = R.string.request_metadata), + icon = Icons.Default.Memory, + enabled = true, + onClick = { onAction(NodeDetailAction.TriggerServiceAction(ServiceAction.GetDeviceMetadata(node.num))) }, + ) + NavCard( + title = stringResource(id = R.string.remote_admin), + icon = Icons.Default.Settings, + enabled = metricsState.isLocal || node.metadata != null, + ) { + onAction(NodeDetailAction.Navigate(RadioConfigRoutes.RadioConfig(node.num))) + } + } - HorizontalDivider() + node.metadata?.firmwareVersion?.let { firmwareVersion -> + val latestStable = metricsState.latestStableFirmware + val latestAlpha = metricsState.latestAlphaFirmware - NodeDetailRow( - label = "Latest stable", - icon = Icons.Default.Memory, - value = latestStableFirmware.id.substringBeforeLast(".").replace("v", ""), - iconTint = Green - ) + PreferenceCategory(stringResource(R.string.firmware)) { + val deviceVersion = DeviceVersion(firmwareVersion.substringBeforeLast(".")) + val statusColor = deviceVersion.determineFirmwareStatusColor(latestStable, latestAlpha) - NodeDetailRow( - label = "Latest alpha", - icon = Icons.Default.Memory, - value = latestAlphaFirmware.id.substringBeforeLast(".").replace("v", ""), - iconTint = Yellow - ) - } + NodeDetailRow( + label = stringResource(R.string.installed_firmware_version), + icon = Icons.Default.Memory, + value = firmwareVersion.substringBeforeLast("."), + iconTint = statusColor, + ) + HorizontalDivider() + NodeDetailRow( + label = stringResource(R.string.latest_stable_firmware), + icon = Icons.Default.Memory, + value = latestStable.id.substringBeforeLast(".").replace("v", ""), + iconTint = Green, + onClick = { onFirmwareSelected(latestStable) }, + ) + NodeDetailRow( + label = stringResource(R.string.latest_alpha_firmware), + icon = Icons.Default.Memory, + value = latestAlpha.id.substringBeforeLast(".").replace("v", ""), + iconTint = Yellow, + onClick = { onFirmwareSelected(latestAlpha) }, + ) + } + } +} + +@Composable +private fun DeviceVersion.determineFirmwareStatusColor( + latestStable: FirmwareRelease, + latestAlpha: FirmwareRelease, +): Color { + val stableVersion = latestStable.asDeviceVersion() + val alphaVersion = latestAlpha.asDeviceVersion() + return when { + this < stableVersion -> MaterialTheme.colorScheme.error + this == stableVersion -> Green + this in stableVersion..alphaVersion -> Yellow + this > alphaVersion -> Orange + else -> MaterialTheme.colorScheme.onSurface + } +} + +@Composable +private fun FirmwareReleaseSheetContent(firmwareRelease: FirmwareRelease) { + val context = LocalContext.current + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(16.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(text = firmwareRelease.title, style = MaterialTheme.typography.titleLarge) + Text(text = "Version: ${firmwareRelease.id}", style = MaterialTheme.typography.bodyMedium) + Markdown(modifier = Modifier.padding(8.dp), content = firmwareRelease.releaseNotes) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = { + val intent = Intent(Intent.ACTION_VIEW, firmwareRelease.pageUrl.toUri()) + context.startActivity(intent) + }, + modifier = Modifier.weight(1f), + ) { + Icon( + imageVector = Icons.Default.Link, + contentDescription = stringResource(id = R.string.view_release), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = stringResource(id = R.string.view_release)) + } + Button( + onClick = { + val intent = Intent(Intent.ACTION_VIEW, firmwareRelease.zipUrl.toUri()) + context.startActivity(intent) + }, + modifier = Modifier.weight(1f), + ) { + Icon( + imageVector = Icons.Default.Download, + contentDescription = stringResource(id = R.string.download), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = stringResource(id = R.string.download)) } } } @@ -389,45 +546,37 @@ private fun NodeDetailRow( label: String, icon: ImageVector, value: String, - iconTint: Color = MaterialTheme.colorScheme.onSurface + iconTint: Color = MaterialTheme.colorScheme.onSurface, + onClick: (() -> Unit)? = null, ) { Row( modifier = modifier .fillMaxWidth() + .thenIf(onClick != null) { + clickable(onClick = onClick!!) + } .padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Icon( - imageVector = icon, - contentDescription = label, - modifier = Modifier.size(24.dp), - tint = iconTint - ) + Icon(imageVector = icon, contentDescription = label, modifier = Modifier.size(24.dp), tint = iconTint) Text(label) Spacer(modifier = Modifier.weight(1f)) Text(textAlign = TextAlign.End, text = value) } } -@Suppress("LongMethod") @Composable private fun DeviceActions( isLocal: Boolean = false, node: Node, - lastTracerouteTime: Long? = null, - onShared: () -> Unit, - onAction: (Any) -> Unit, + lastTracerouteTime: Long?, + onAction: (NodeDetailAction) -> Unit, ) { var displayFavoriteDialog by remember { mutableStateOf(false) } var displayIgnoreDialog by remember { mutableStateOf(false) } var displayRemoveDialog by remember { mutableStateOf(false) } - val isUnmessageable = if (node.user.hasIsUnmessagable()) { - node.user.isUnmessagable - } else { - // for older firmwares - node.user.role?.isUnmessageableRole() == true - } + NodeActionDialogs( node = node, displayFavoriteDialog = displayFavoriteDialog, @@ -438,87 +587,77 @@ private fun DeviceActions( displayIgnoreDialog = false displayRemoveDialog = false }, - onAction = onAction, + onAction = { onAction(NodeDetailAction.HandleNodeMenuAction(it)) }, ) PreferenceCategory(text = stringResource(R.string.actions)) { NodeActionButton( title = stringResource(id = R.string.share_contact), icon = Icons.Default.Share, enabled = true, - onClick = onShared + onClick = { onAction(NodeDetailAction.ShareContact) }, ) - if (!isLocal) { - if (!isUnmessageable) { - NodeActionButton( - title = stringResource(id = R.string.direct_message), - icon = Icons.AutoMirrored.TwoTone.Message, - enabled = true, - onClick = { - onAction(NodeMenuAction.DirectMessage(node)) - } - ) - } - NodeActionButton( - title = stringResource(id = R.string.exchange_position), - icon = Icons.Default.LocationOn, - enabled = true, - onClick = { onAction(NodeMenuAction.RequestPosition(node)) } - ) - NodeActionButton( - title = stringResource(id = R.string.exchange_userinfo), - icon = Icons.Default.Person, - enabled = true, - onClick = { onAction(NodeMenuAction.RequestUserInfo(node)) } - ) - TracerouteActionButton( - title = stringResource(id = R.string.traceroute), - lastTracerouteTime = lastTracerouteTime, - onClick = { - onAction(NodeMenuAction.TraceRoute(node)) - } - ) - NodeActionSwitch( - title = stringResource(R.string.favorite), - icon = if (node.isFavorite) { - Icons.Default.Star - } else { - Icons.Default.StarBorder - }, - iconTint = if (node.isFavorite) { - Color.Yellow - } else { - LocalContentColor.current - }, - enabled = true, - checked = node.isFavorite, - onClick = { displayFavoriteDialog = true } - ) - NodeActionSwitch( - title = stringResource(R.string.ignore), - icon = if (node.isIgnored) { - Icons.AutoMirrored.Outlined.VolumeMute - } else { - Icons.AutoMirrored.Default.VolumeUp - }, - enabled = true, - checked = node.isIgnored, - onClick = { displayIgnoreDialog = true } - ) - NodeActionButton( - title = stringResource(id = R.string.remove), - icon = Icons.Default.Delete, - enabled = true, - onClick = { displayRemoveDialog = true } - ) + RemoteDeviceActions(node = node, lastTracerouteTime = lastTracerouteTime, onAction = onAction) } + NodeActionSwitch( + title = stringResource(R.string.favorite), + icon = if (node.isFavorite) Icons.Default.Star else Icons.Default.StarBorder, + iconTint = if (node.isFavorite) Color.Yellow else LocalContentColor.current, + enabled = true, + checked = node.isFavorite, + onClick = { displayFavoriteDialog = true }, + ) + NodeActionSwitch( + title = stringResource(R.string.ignore), + icon = if (node.isIgnored) Icons.AutoMirrored.Outlined.VolumeMute else Icons.AutoMirrored.Default.VolumeUp, + enabled = true, + checked = node.isIgnored, + onClick = { displayIgnoreDialog = true }, + ) + NodeActionButton( + title = stringResource(id = R.string.remove), + icon = Icons.Default.Delete, + enabled = true, + onClick = { displayRemoveDialog = true }, + ) } } @Composable -private fun DeviceDetailsContent( - state: MetricsState +private fun RemoteDeviceActions( + node: Node, + lastTracerouteTime: Long?, + onAction: (NodeDetailAction) -> Unit, ) { + if (!node.isEffectivelyUnmessageable) { + NodeActionButton( + title = stringResource(id = R.string.direct_message), + icon = Icons.AutoMirrored.TwoTone.Message, + enabled = true, + onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.DirectMessage(node))) }, + ) + } + NodeActionButton( + title = stringResource(id = R.string.exchange_position), + icon = Icons.Default.LocationOn, + enabled = true, + onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestPosition(node))) }, + ) + NodeActionButton( + title = stringResource(id = R.string.exchange_userinfo), + icon = Icons.Default.Person, + enabled = true, + onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestUserInfo(node))) }, + ) + TracerouteActionButton( + title = stringResource(id = R.string.traceroute), + lastTracerouteTime = lastTracerouteTime, + onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.TraceRoute(node))) }, + ) +} + +@Composable +private fun DeviceDetailsContent(state: MetricsState) { val node = state.node ?: return val deviceHardware = state.deviceHardware ?: return val hwModelName = deviceHardware.displayName @@ -528,179 +667,149 @@ private fun DeviceDetailsContent( .size(100.dp) .padding(4.dp) .clip(CircleShape) - .background( - color = Color(node.colors.second).copy(alpha = .5f), - shape = CircleShape - ), - contentAlignment = Alignment.Center + .background(color = Color(node.colors.second).copy(alpha = .5f), shape = CircleShape), + contentAlignment = Alignment.Center, ) { DeviceHardwareImage(deviceHardware, Modifier.fillMaxSize()) } + NodeDetailRow(label = stringResource(R.string.hardware), icon = Icons.Default.Router, value = hwModelName) NodeDetailRow( - label = stringResource(R.string.hardware), - icon = Icons.Default.Router, - value = hwModelName - ) - NodeDetailRow( - label = if (isSupported) stringResource(R.string.supported) else "Supported by Community", + label = if (isSupported) { + stringResource(R.string.supported) + } else { + stringResource(R.string.supported_by_community) + }, icon = if (isSupported) Icons.TwoTone.Verified else ImageVector.vectorResource(R.drawable.unverified), value = "", - iconTint = if (isSupported) Color.Green else Color.Red + iconTint = if (isSupported) Color.Green else Color.Red, ) } @Composable -fun DeviceHardwareImage( - deviceHardware: DeviceHardware, - modifier: Modifier = Modifier, -) { - val hwImg = - deviceHardware.images?.getOrNull(1) ?: deviceHardware.images?.getOrNull(0) - ?: "unknown.svg" +fun DeviceHardwareImage(deviceHardware: DeviceHardware, modifier: Modifier = Modifier) { + val hwImg = deviceHardware.images?.getOrNull(1) ?: deviceHardware.images?.getOrNull(0) ?: "unknown.svg" val imageUrl = "https://flasher.meshtastic.org/img/devices/$hwImg" - val listener = object : ImageRequest.Listener { - override fun onStart(request: ImageRequest) { - super.onStart(request) - debug("Image request started") - } - - override fun onError(request: ImageRequest, result: ErrorResult) { - super.onError(request, result) - debug("Image request failed: ${result.throwable.message}") - } - - override fun onSuccess(request: ImageRequest, result: SuccessResult) { - super.onSuccess(request, result) - debug("Image request succeeded: ${result.dataSource.name}") - } - } AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .listener(listener) - .data(imageUrl) - .build(), + model = ImageRequest.Builder(LocalContext.current).data(imageUrl).build(), contentScale = ContentScale.Inside, contentDescription = deviceHardware.displayName, placeholder = painterResource(R.drawable.hw_unknown), error = painterResource(R.drawable.hw_unknown), fallback = painterResource(R.drawable.hw_unknown), - modifier = modifier - .padding(16.dp) + modifier = modifier.padding(16.dp), ) } -@Suppress("LongMethod") @Composable private fun NodeDetailsContent( node: Node, ourNode: Node?, - displayUnits: DisplayUnits, + displayUnits: ConfigProtos.Config.DisplayConfig.DisplayUnits, ) { if (node.mismatchKey) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Default.KeyOff, - contentDescription = stringResource(id = R.string.encryption_error), - tint = Color.Red, - ) - Spacer(Modifier.width(12.dp)) - Text( - text = stringResource(id = R.string.encryption_error), - style = MaterialTheme.typography.titleLarge.copy(color = Color.Red), - textAlign = TextAlign.Center, - ) - } - Spacer(Modifier.height(16.dp)) + EncryptionErrorContent() + } + MainNodeDetails(node, ourNode, displayUnits) +} + +@Composable +private fun EncryptionErrorContent() { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.KeyOff, + contentDescription = stringResource(id = R.string.encryption_error), + tint = Color.Red, + ) + Spacer(Modifier.width(12.dp)) Text( - text = stringResource(id = R.string.encryption_error_text), - style = MaterialTheme.typography.bodyMedium, + text = stringResource(id = R.string.encryption_error), + style = MaterialTheme.typography.titleLarge.copy(color = Color.Red), textAlign = TextAlign.Center, ) - Spacer(Modifier.height(16.dp)) } + Spacer(Modifier.height(16.dp)) + Text( + text = stringResource(id = R.string.encryption_error_text), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(16.dp)) +} + +@Composable +private fun MainNodeDetails( + node: Node, + ourNode: Node?, + displayUnits: ConfigProtos.Config.DisplayConfig.DisplayUnits, +) { NodeDetailRow( label = stringResource(R.string.long_name), icon = Icons.TwoTone.Person, - value = node.user.longName.ifEmpty { "???" } + value = node.user.longName.ifEmpty { "???" }, ) NodeDetailRow( label = stringResource(R.string.short_name), icon = Icons.Outlined.Person, - value = node.user.shortName.ifEmpty { "???" } + value = node.user.shortName.ifEmpty { "???" }, ) NodeDetailRow( label = stringResource(R.string.node_number), icon = Icons.Default.Numbers, - value = node.num.toUInt().toString() + value = node.num.toUInt().toString(), ) NodeDetailRow( label = stringResource(R.string.user_id), icon = Icons.Default.Person, - value = node.user.id + value = node.user.id, ) NodeDetailRow( label = stringResource(R.string.role), icon = Icons.Default.Work, - value = node.user.role.name + value = node.user.role.name, ) - val unmessageable = if (node.user.hasIsUnmessagable()) { - node.user.isUnmessagable - } else { - node.user.role?.isUnmessageableRole() == true - } - if (unmessageable) { + if (node.isEffectivelyUnmessageable) { NodeDetailRow( label = stringResource(R.string.unmonitored_or_infrastructure), icon = Icons.Outlined.NoCell, - value = "" + value = "", ) } if (node.deviceMetrics.uptimeSeconds > 0) { NodeDetailRow( label = stringResource(R.string.uptime), icon = Icons.Default.CheckCircle, - value = formatUptime(node.deviceMetrics.uptimeSeconds) + value = formatUptime(node.deviceMetrics.uptimeSeconds), ) } NodeDetailRow( label = stringResource(R.string.node_sort_last_heard), icon = Icons.Default.History, - value = formatAgo(node.lastHeard) + value = formatAgo(node.lastHeard), ) val distance = ourNode?.distance(node)?.toDistanceString(displayUnits) if (node != ourNode && distance != null) { NodeDetailRow( label = stringResource(R.string.node_sort_distance), icon = Icons.Default.SocialDistance, - value = distance + value = distance, ) NodeDetailRow( label = stringResource(R.string.last_position_update), icon = Icons.Default.LocationOn, - value = formatAgo(node.position.time) + value = formatAgo(node.position.time), ) } } @Composable -private fun InfoCard( - icon: ImageVector, - text: String, - value: String, - rotateIcon: Float = 0f, -) { - Card( - modifier = Modifier - .padding(4.dp) - .width(100.dp) - .height(100.dp), - ) { +private fun InfoCard(icon: ImageVector, text: String, value: String, rotateIcon: Float = 0f) { + Card(modifier = Modifier.padding(4.dp).width(100.dp).height(100.dp)) { Box( modifier = Modifier .padding(4.dp) .width(100.dp) .height(100.dp), - contentAlignment = Alignment.Center + contentAlignment = Alignment.Center, ) { Column( verticalArrangement = Arrangement.Center, @@ -711,14 +820,16 @@ private fun InfoCard( contentDescription = text, modifier = Modifier .size(24.dp) - .thenIf(rotateIcon != 0f) { rotate(rotateIcon) }, + .thenIf(rotateIcon != 0f) { + rotate(rotateIcon) + }, ) Text( textAlign = TextAlign.Center, text = text, maxLines = 2, overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.labelSmall + style = MaterialTheme.typography.labelSmall, ) Text( text = value, @@ -731,228 +842,263 @@ private fun InfoCard( } } -@Suppress("LongMethod", "CyclomaticComplexMethod") +@Composable +private fun DrawableInfoCard( + @DrawableRes iconRes: Int, + text: String, + value: String, + rotateIcon: Float = 0f, +) { + Card(modifier = Modifier.padding(4.dp).width(100.dp).height(100.dp)) { + Box( + modifier = Modifier + .padding(4.dp) + .width(100.dp) + .height(100.dp), + contentAlignment = Alignment.Center, + ) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(id = iconRes), + contentDescription = text, + modifier = Modifier + .size(24.dp) + .thenIf(rotateIcon != 0f) { + rotate(rotateIcon) + }, + ) + Text( + textAlign = TextAlign.Center, + text = text, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelSmall, + ) + Text( + text = value, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleMedium, + ) + } + } + } +} + +@Suppress("CyclomaticComplexMethod", "LongMethod") @Composable private fun EnvironmentMetrics( node: Node, isFahrenheit: Boolean = false, - displayUnits: DisplayUnits, -) = with(node.environmentMetrics) { + displayUnits: ConfigProtos.Config.DisplayConfig.DisplayUnits, +) { + val vectorMetrics = remember(node.environmentMetrics, isFahrenheit, displayUnits) { + buildList { + with(node.environmentMetrics) { + if (hasTemperature()) { + add( + VectorMetricInfo( + R.string.temperature, + temperature.toTempString(isFahrenheit), + Icons.Default.Thermostat, + ), + ) + } + if (hasRelativeHumidity()) { + add( + VectorMetricInfo( + R.string.humidity, + "%.0f%%".format(relativeHumidity), + Icons.Default.WaterDrop, + ), + ) + } + if (hasBarometricPressure()) { + add( + VectorMetricInfo( + R.string.pressure, + "%.0f hPa".format(barometricPressure), + Icons.Default.Speed, + ), + ) + } + if (hasGasResistance()) { + add( + VectorMetricInfo( + R.string.gas_resistance, + "%.0f MΩ".format(gasResistance), + Icons.Default.BlurOn, + ), + ) + } + if (hasVoltage()) add(VectorMetricInfo(R.string.voltage, "%.2fV".format(voltage), Icons.Default.Bolt)) + if (hasCurrent()) add(VectorMetricInfo(R.string.current, "%.1fmA".format(current), Icons.Default.Power)) + if (hasIaq()) add(VectorMetricInfo(R.string.iaq, iaq.toString(), Icons.Default.Air)) + if (hasDistance()) { + add( + VectorMetricInfo( + R.string.distance, + distance.toSmallDistanceString(displayUnits), + Icons.Default.Height, + ), + ) + } + if (hasLux()) add(VectorMetricInfo(R.string.lux, "%.0f lx".format(lux), Icons.Default.LightMode)) + if (hasUvLux()) add(VectorMetricInfo(R.string.uv_lux, "%.0f lx".format(uvLux), Icons.Default.LightMode)) + if (hasWindSpeed()) { + @Suppress("MagicNumber") + val normalizedBearing = (windDirection + 180) % 360 + add( + VectorMetricInfo( + R.string.wind, + windSpeed.toSpeedString(displayUnits), + Icons.Outlined.Navigation, + normalizedBearing.toFloat(), + ), + ) + } + if (hasWeight()) add(VectorMetricInfo(R.string.weight, "%.2f kg".format(weight), Icons.Default.Scale)) + } + } + } + val drawableMetrics = remember(node.environmentMetrics, isFahrenheit) { + buildList { + with(node.environmentMetrics) { + if (hasTemperature() && hasRelativeHumidity()) { + val dewPoint = UnitConversions.calculateDewPoint(temperature, relativeHumidity) + add( + DrawableMetricInfo( + R.string.dew_point, + dewPoint.toTempString(isFahrenheit), + R.drawable.ic_outlined_dew_point_24, + ), + ) + } + if (hasSoilTemperature()) { + add( + DrawableMetricInfo( + R.string.soil_temperature, + soilTemperature.toTempString(isFahrenheit), + R.drawable.soil_temperature, + ), + ) + } + if (hasSoilMoisture()) { + add( + DrawableMetricInfo( + R.string.soil_moisture, + "%d%%".format(soilMoisture), + R.drawable.soil_moisture, + ), + ) + } + if (hasRadiation()) { + add( + DrawableMetricInfo( + R.string.radiation, + "%.1f µR/h".format(radiation), + R.drawable.ic_filled_radioactive_24, + ), + ) + } + } + } + } FlowRow( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly, verticalArrangement = Arrangement.SpaceEvenly, ) { - if (hasTemperature()) { + vectorMetrics.forEach { metric -> InfoCard( - icon = Icons.Default.Thermostat, - text = stringResource(R.string.temperature), - value = temperature.toTempString(isFahrenheit) + icon = metric.icon, + text = stringResource(metric.label), + value = metric.value, + rotateIcon = metric.rotateIcon, ) } - if (hasRelativeHumidity()) { - InfoCard( - icon = Icons.Default.WaterDrop, - text = stringResource(R.string.humidity), - value = "%.0f%%".format(relativeHumidity) - ) - } - if (hasTemperature() && hasRelativeHumidity()) { - val dewPoint = calculateDewPoint(temperature, relativeHumidity) - InfoCard( - icon = ImageVector.vectorResource(R.drawable.ic_outlined_dew_point_24), - text = stringResource(R.string.dew_point), - value = dewPoint.toTempString(isFahrenheit) - ) - } - if (hasSoilTemperature()) { - InfoCard( - icon = ImageVector.vectorResource(R.drawable.soil_temperature), - text = stringResource(R.string.soil_temperature), - value = soilTemperature.toTempString(isFahrenheit) - ) - } - if (hasSoilMoisture()) { - InfoCard( - icon = ImageVector.vectorResource(R.drawable.soil_moisture), - text = stringResource(R.string.soil_moisture), - value = "%d%%".format(soilMoisture) - ) - } - if (hasBarometricPressure()) { - InfoCard( - icon = Icons.Default.Speed, - text = stringResource(R.string.pressure), - value = "%.0f hPa".format(barometricPressure) - ) - } - if (hasGasResistance()) { - InfoCard( - icon = Icons.Default.BlurOn, - text = stringResource(R.string.gas_resistance), - value = "%.0f MΩ".format(gasResistance) - ) - } - if (hasVoltage()) { - InfoCard( - icon = Icons.Default.Bolt, - text = stringResource(R.string.voltage), - value = "%.2fV".format(voltage) - ) - } - if (hasCurrent()) { - InfoCard( - icon = Icons.Default.Power, - text = stringResource(R.string.current), - value = "%.1fmA".format(current) - ) - } - if (hasIaq()) { - InfoCard( - icon = Icons.Default.Air, - text = stringResource(R.string.iaq), - value = iaq.toString() - ) - } - if (hasDistance()) { - InfoCard( - icon = Icons.Default.Height, - text = stringResource(R.string.distance), - value = distance.toSmallDistanceString(displayUnits) - ) - } - if (hasLux()) { - InfoCard( - icon = Icons.Default.LightMode, - text = stringResource(R.string.lux), - value = "%.0f lx".format(lux) - ) - } - if (hasUvLux()) { - InfoCard( - icon = Icons.Default.LightMode, // You may want to use a different icon for UV - text = stringResource(R.string.uv_lux), - value = "%.0f lx".format(uvLux) - ) - } - if (hasWindSpeed()) { - @Suppress("MagicNumber") - val normalizedBearing = (windDirection + 180) % 360 - InfoCard( - icon = Icons.Outlined.Navigation, - text = stringResource(R.string.wind), - value = windSpeed.toSpeedString(displayUnits), - rotateIcon = normalizedBearing.toFloat(), - ) - } - if (hasWeight()) { - InfoCard( - icon = Icons.Default.Scale, - text = stringResource(R.string.weight), - value = "%.2f kg".format(weight) - ) - } - if (hasRadiation()) { - InfoCard( - icon = ImageVector.vectorResource(R.drawable.ic_filled_radioactive_24), - text = stringResource(R.string.radiation), - value = "%.1f µR/h".format(radiation) + drawableMetrics.forEach { metric -> + DrawableInfoCard( + iconRes = metric.icon, + text = stringResource(metric.label), + value = metric.value, + rotateIcon = metric.rotateIcon, ) } } } @Composable -private fun PowerMetrics(node: Node) = with(node.powerMetrics) { +private fun PowerMetrics(node: Node) { + val metrics = remember(node.powerMetrics) { + buildList { + with(node.powerMetrics) { + if (ch1Voltage != 0f) { + add(VectorMetricInfo(R.string.channel_1, "%.2fV".format(ch1Voltage), Icons.Default.Bolt)) + add(VectorMetricInfo(R.string.channel_1, "%.1fmA".format(ch1Current), Icons.Default.Power)) + } + if (ch2Voltage != 0f) { + add(VectorMetricInfo(R.string.channel_2, "%.2fV".format(ch2Voltage), Icons.Default.Bolt)) + add(VectorMetricInfo(R.string.channel_2, "%.1fmA".format(ch2Current), Icons.Default.Power)) + } + if (ch3Voltage != 0f) { + add(VectorMetricInfo(R.string.channel_3, "%.2fV".format(ch3Voltage), Icons.Default.Bolt)) + add(VectorMetricInfo(R.string.channel_3, "%.1fmA".format(ch3Current), Icons.Default.Power)) + } + } + } + } FlowRow( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalArrangement = Arrangement.SpaceEvenly, ) { - if (ch1Voltage != 0f) { - Column { - InfoCard( - icon = Icons.Default.Bolt, - text = stringResource(R.string.channel_1), - value = "%.2fV".format(ch1Voltage) - ) - InfoCard( - icon = Icons.Default.Power, - text = stringResource(R.string.channel_1), - value = "%.1fmA".format(ch1Current) - ) - } - } - if (ch2Voltage != 0f) { - Column { - InfoCard( - icon = Icons.Default.Bolt, - text = stringResource(R.string.channel_2), - value = "%.2fV".format(ch2Voltage) - ) - InfoCard( - icon = Icons.Default.Power, - text = stringResource(R.string.channel_2), - value = "%.1fmA".format(ch2Current) - ) - } - } - if (ch3Voltage != 0f) { - Column { - InfoCard( - icon = Icons.Default.Bolt, - text = stringResource(R.string.channel_3), - value = "%.2fV".format(ch3Voltage) - ) - InfoCard( - icon = Icons.Default.Power, - text = stringResource(R.string.channel_3), - value = "%.1fmA".format(ch3Current) - ) - } + metrics.forEach { metric -> + InfoCard(icon = metric.icon, text = stringResource(metric.label), value = metric.value) } } } -private const val CoolDownTime = 30000f +private const val COOL_DOWN_TIME_MS = 30000L @Composable -fun TracerouteActionButton( - title: String, - lastTracerouteTime: Long?, - onClick: () -> Unit -) { - var coolDownProgress by remember { mutableFloatStateOf(0f) } +fun TracerouteActionButton(title: String, lastTracerouteTime: Long?, onClick: () -> Unit) { + var isCoolingDown by remember(lastTracerouteTime) { + val timeSinceLast = System.currentTimeMillis() - (lastTracerouteTime ?: 0) + mutableStateOf(timeSinceLast < COOL_DOWN_TIME_MS) + } + LaunchedEffect(lastTracerouteTime) { - while (true) { - val timeSinceLast = ( - System.currentTimeMillis() - - (lastTracerouteTime ?: 0) - ) - val progress = 1f - (timeSinceLast / CoolDownTime) - coolDownProgress = progress.coerceIn(0f, 1f) - if (progress <= 0f) { - break - } - delay(10.milliseconds) + val timeSinceLast = System.currentTimeMillis() - (lastTracerouteTime ?: 0) + if (timeSinceLast < COOL_DOWN_TIME_MS) { + delay(COOL_DOWN_TIME_MS - timeSinceLast) + isCoolingDown = false } } + + val progress by animateFloatAsState( + targetValue = if (isCoolingDown) 1f else 0f, + label = "TracerouteCooldown", + ) + Button( onClick = { + isCoolingDown = true onClick() }, - enabled = coolDownProgress <= 0f, + enabled = !isCoolingDown, modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp) .height(48.dp), ) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - if (coolDownProgress > 0f) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (progress > 0f) { CircularProgressIndicator( - progress = { coolDownProgress }, + progress = { progress }, modifier = Modifier.size(24.dp), strokeWidth = 2.dp, trackColor = ProgressIndicatorDefaults.circularDeterminateTrackColor, @@ -966,16 +1112,11 @@ fun TracerouteActionButton( ) } Spacer(modifier = Modifier.width(8.dp)) - Text( - text = title, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.weight(1f) - ) + Text(text = title, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) } } } -@Suppress("LongMethod") @Composable fun NodeActionButton( modifier: Modifier = Modifier @@ -986,19 +1127,10 @@ fun NodeActionButton( enabled: Boolean, icon: ImageVector? = null, iconTint: Color? = null, - onClick: () -> Unit + onClick: () -> Unit, ) { - - Button( - onClick = { - onClick() - }, - enabled = enabled, - modifier = modifier - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { + Button(onClick = { onClick() }, enabled = enabled, modifier = modifier) { + Row(verticalAlignment = Alignment.CenterVertically) { if (icon != null) { Icon( imageVector = icon, @@ -1008,11 +1140,7 @@ fun NodeActionButton( ) Spacer(modifier = Modifier.width(8.dp)) } - Text( - text = title, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.weight(1f) - ) + Text(text = title, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) } } } @@ -1032,12 +1160,7 @@ fun NodeActionSwitch( .fillMaxWidth() .padding(vertical = 4.dp) .height(48.dp) - .toggleable( - value = checked, - enabled = enabled, - role = Role.Switch, - onValueChange = { onClick() } - ), + .toggleable(value = checked, enabled = enabled, role = Role.Switch, onValueChange = { onClick() }), shape = MaterialTheme.shapes.large, interactionSource = interactionSource, onClick = onClick, @@ -1046,7 +1169,7 @@ fun NodeActionSwitch( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .padding(vertical = 12.dp, horizontal = 16.dp) + .padding(vertical = 12.dp, horizontal = 16.dp), ) { if (icon != null) { Icon( @@ -1057,35 +1180,27 @@ fun NodeActionSwitch( ) Spacer(modifier = Modifier.width(8.dp)) } - Text( - text = title, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.weight(1f) - ) - Switch( - checked = checked, - onCheckedChange = null, - ) + Text(text = title, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + Switch(checked = checked, onCheckedChange = null) } } } @Preview(showBackground = true) @Composable -private fun NodeDetailsPreview( - @PreviewParameter(NodePreviewParameterProvider::class) - node: Node, -) { +private fun NodeDetailsPreview(@PreviewParameter(NodePreviewParameterProvider::class) node: Node) { AppTheme { NodeDetailList( node = node, ourNode = node, lastTracerouteTime = null, metricsState = MetricsState.Empty, - metricsAvailability = BooleanArray(LogsType.entries.size) { false }, + availableLogs = emptySet(), + onAction = {}, ) } } + @Preview(name = "Wind Dir -359°") @Suppress("detekt:MagicNumber") @Composable @@ -1151,15 +1266,12 @@ private fun PreviewWindDirectionN45() { @Suppress("detekt:MagicNumber") @Composable -private fun PreviewWindDirectionItem( - windDirection: Float, - windSpeed: String = "5 m/s" -) { +private fun PreviewWindDirectionItem(windDirection: Float, windSpeed: String = "5 m/s") { val normalizedBearing = (windDirection + 180) % 360 InfoCard( icon = Icons.Outlined.Navigation, text = "Wind", value = windSpeed, - rotateIcon = normalizedBearing + rotateIcon = normalizedBearing, ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5d1ca72ba..70b9a3df1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -797,4 +797,10 @@ BLE Devices Rate Limit Exceeded. Please try again later. + View Release + Download + Currently Installed + Latest stable + Latest alpha + Supported by Meshtastic Community diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5919e9df0..13dfa4e89 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,6 +29,7 @@ kotlinx-collections-immutable = "0.4.0" kotlinx-coroutines-android = "1.10.2" kotlinx-serialization-json = "1.9.0" lifecycle = "2.9.2" +markdownRenderer = "0.35.0" material = "1.12.0" material3 = "1.4.0-alpha18" mgrs = "2.1.3" @@ -108,6 +109,9 @@ lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-ru lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" } +markdown-renderer = { group = "com.mikepenz", name = "multiplatform-markdown-renderer", version.ref = "markdownRenderer" } +markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-m3", version.ref = "markdownRenderer" } +markdown-renderer-android = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-android", version.ref = "markdownRenderer" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } mgrs = { group = "mil.nga", name = "mgrs", version.ref = "mgrs" } navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" } @@ -141,6 +145,7 @@ androidx = ["core-ktx", "appcompat", "appcompat-resources", "fragment-ktx", "act ui = ["material", "constraintlayout", "compose-material3", "compose-material-icons-extended", "compose-ui-tooling-preview", "compose-runtime-livedata"] adaptive = ["adaptive", "adaptive-layout", "adaptive-navigation", "adaptive-navigation-android", "adaptive-navigation-suite"] ui-tooling = ["compose-ui-tooling"] #Separate for debugImplementation +markdown = ["markdown-renderer", "markdown-renderer-m3", "markdown-renderer-android"] # Lifecycle lifecycle = ["lifecycle-runtime-ktx", "lifecycle-livedata-ktx", "lifecycle-viewmodel-ktx", "lifecycle-common-java8", "lifecycle-process", "lifecycle-viewmodel-compose", "lifecycle-runtime-compose"]