From 2583b3fcf1e896ad5abeb4408a302510a3fba8f6 Mon Sep 17 00:00:00 2001 From: Phil Oliver <3497406+poliver@users.noreply.github.com> Date: Thu, 4 Sep 2025 20:34:28 -0400 Subject: [PATCH] Componentize traceroute button (#2965) --- .../com/geeksville/mesh/ui/node/NodeDetail.kt | 61 +---------- .../ui/node/components/TracerouteButton.kt | 100 ++++++++++++++++++ .../ui/settings/components/SettingsItem.kt | 28 +++-- 3 files changed, 123 insertions(+), 66 deletions(-) create mode 100644 app/src/main/java/com/geeksville/mesh/ui/node/components/TracerouteButton.kt 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 a3871c453..ef37619f5 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 @@ -20,8 +20,6 @@ package com.geeksville.mesh.ui.node import android.content.Intent import androidx.annotation.DrawableRes import androidx.annotation.StringRes -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -93,11 +91,9 @@ import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.ProgressIndicatorDefaults 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.mutableStateOf @@ -150,6 +146,7 @@ import com.geeksville.mesh.ui.common.theme.StatusColors.StatusRed import com.geeksville.mesh.ui.common.theme.StatusColors.StatusYellow import com.geeksville.mesh.ui.node.components.NodeActionDialogs import com.geeksville.mesh.ui.node.components.NodeMenuAction +import com.geeksville.mesh.ui.node.components.TracerouteButton import com.geeksville.mesh.ui.settings.components.SettingsItem import com.geeksville.mesh.ui.settings.components.SettingsItemDetail import com.geeksville.mesh.ui.settings.components.SettingsItemSwitch @@ -631,8 +628,7 @@ private fun RemoteDeviceActions(node: Node, lastTracerouteTime: Long?, onAction: trailingIcon = null, onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestUserInfo(node))) }, ) - TracerouteActionButton( - title = stringResource(id = R.string.traceroute), + TracerouteButton( lastTracerouteTime = lastTracerouteTime, onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.TraceRoute(node))) }, ) @@ -1035,59 +1031,6 @@ private fun PowerMetrics(node: Node) { } } -private const val COOL_DOWN_TIME_MS = 30000L - -@Composable -fun TracerouteActionButton(title: String, lastTracerouteTime: Long?, onClick: () -> Unit) { - val progress = remember { Animatable(0f) } - var isCoolingDown by remember { mutableStateOf(false) } - - LaunchedEffect(lastTracerouteTime) { - val timeSinceLast = System.currentTimeMillis() - (lastTracerouteTime ?: 0) - isCoolingDown = timeSinceLast < COOL_DOWN_TIME_MS - - if (isCoolingDown) { - val remainingTime = COOL_DOWN_TIME_MS - timeSinceLast - progress.snapTo(remainingTime / COOL_DOWN_TIME_MS.toFloat()) - progress.animateTo( - targetValue = 0f, - animationSpec = tween(durationMillis = remainingTime.toInt(), easing = { it }), - ) - isCoolingDown = false - } - } - - Button( - onClick = { - if (!isCoolingDown) { - onClick() - } - }, - enabled = !isCoolingDown, - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).height(48.dp), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - if (isCoolingDown) { - CircularProgressIndicator( - progress = { progress.value }, - modifier = Modifier.size(24.dp), - strokeWidth = 2.dp, - trackColor = ProgressIndicatorDefaults.circularDeterminateTrackColor, - strokeCap = ProgressIndicatorDefaults.CircularDeterminateStrokeCap, - ) - } else { - Icon( - imageVector = Icons.Default.Route, - contentDescription = stringResource(R.string.traceroute), - modifier = Modifier.size(24.dp), - ) - } - Spacer(modifier = Modifier.width(8.dp)) - Text(text = title, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) - } - } -} - @Composable fun NodeActionButton( modifier: Modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).height(48.dp), diff --git a/app/src/main/java/com/geeksville/mesh/ui/node/components/TracerouteButton.kt b/app/src/main/java/com/geeksville/mesh/ui/node/components/TracerouteButton.kt new file mode 100644 index 000000000..ac1f34805 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/node/components/TracerouteButton.kt @@ -0,0 +1,100 @@ +/* + * 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.node.components + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Route +import androidx.compose.material3.CircularWavyProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.geeksville.mesh.R +import com.geeksville.mesh.ui.common.theme.AppTheme +import com.geeksville.mesh.ui.settings.components.SettingsItem + +private const val COOL_DOWN_TIME_MS = 30000L + +@Composable +fun TracerouteButton( + text: String = stringResource(id = R.string.traceroute), + lastTracerouteTime: Long?, + onClick: () -> Unit, +) { + val progress = remember { Animatable(0f) } + + LaunchedEffect(lastTracerouteTime) { + val timeSinceLast = System.currentTimeMillis() - (lastTracerouteTime ?: 0) + if (timeSinceLast < COOL_DOWN_TIME_MS) { + val remainingTime = COOL_DOWN_TIME_MS - timeSinceLast + progress.snapTo(remainingTime / COOL_DOWN_TIME_MS.toFloat()) + progress.animateTo( + targetValue = 0f, + animationSpec = tween(durationMillis = remainingTime.toInt(), easing = { it }), + ) + } + } + + TracerouteButton(text = text, progress = progress.value, onClick = onClick) +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun TracerouteButton(text: String, progress: Float, onClick: () -> Unit) { + val isCoolingDown = progress > 0f + + val stroke = Stroke(width = with(LocalDensity.current) { 2.dp.toPx() }, cap = StrokeCap.Round) + + SettingsItem( + text = text, + enabled = !isCoolingDown, + leadingIcon = Icons.Default.Route, + trailingContent = { + if (isCoolingDown) { + CircularWavyProgressIndicator( + progress = { progress }, + modifier = Modifier.size(24.dp), + stroke = stroke, + trackStroke = stroke, + wavelength = 8.dp, + ) + } + }, + onClick = { + if (!isCoolingDown) { + onClick() + } + }, + ) +} + +@Preview(showBackground = true) +@Composable +private fun TracerouteButtonPreview() { + AppTheme { TracerouteButton(text = "Traceroute", progress = .6f, onClick = {}) } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/settings/components/SettingsItem.kt b/app/src/main/java/com/geeksville/mesh/ui/settings/components/SettingsItem.kt index 5617cfb7a..ea0369072 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/settings/components/SettingsItem.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/settings/components/SettingsItem.kt @@ -26,7 +26,6 @@ import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight import androidx.compose.material.icons.rounded.Android import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults @@ -51,18 +50,33 @@ fun SettingsItem( trailingIcon: ImageVector? = Icons.AutoMirrored.Rounded.KeyboardArrowRight, trailingIconTint: Color = LocalContentColor.current, onClick: () -> Unit, +) { + SettingsItem( + text = text, + enabled = enabled, + leadingIcon = leadingIcon, + leadingIconTint = leadingIconTint, + trailingContent = { trailingIcon.Icon(trailingIconTint) }, + onClick = onClick, + ) +} + +/** A clickable settings button item. */ +@Composable +fun SettingsItem( + text: String, + enabled: Boolean = true, + leadingIcon: ImageVector? = null, + leadingIconTint: Color = LocalContentColor.current, + trailingContent: @Composable (() -> Unit), + onClick: () -> Unit, ) { ClickableWrapper(enabled = enabled, onClick = onClick) { - Content( - leading = { leadingIcon.Icon(leadingIconTint) }, - text = text, - trailing = { trailingIcon.Icon(trailingIconTint) }, - ) + Content(leading = { leadingIcon.Icon(leadingIconTint) }, text = text, trailing = trailingContent) } } /** A toggleable settings switch item. */ -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun SettingsItemSwitch( checked: Boolean,