mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: Traceroute map visualisation (#4002)
This commit is contained in:
parent
24f40b2005
commit
3dbc5108c2
18 changed files with 917 additions and 60 deletions
|
|
@ -35,6 +35,7 @@ dependencies {
|
|||
implementation(projects.core.strings)
|
||||
implementation(projects.core.ui)
|
||||
implementation(projects.core.navigation)
|
||||
implementation(projects.feature.map)
|
||||
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.compose.material.iconsExtended)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.meshtastic.feature.node.metrics
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
internal object TracerouteMapOverlayInsets {
|
||||
val overlayAlignment: Alignment = Alignment.BottomEnd
|
||||
val overlayPadding: PaddingValues = PaddingValues(end = 16.dp, bottom = 16.dp)
|
||||
val contentHorizontalAlignment: Alignment.Horizontal = Alignment.End
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.meshtastic.feature.node.metrics
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
internal object TracerouteMapOverlayInsets {
|
||||
val overlayAlignment: Alignment = Alignment.BottomCenter
|
||||
val overlayPadding: PaddingValues = PaddingValues(bottom = 16.dp)
|
||||
val contentHorizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally
|
||||
}
|
||||
|
|
@ -47,12 +47,15 @@ import org.meshtastic.core.database.entity.MeshLog
|
|||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.TracerouteMapAvailability
|
||||
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
|
||||
import org.meshtastic.core.navigation.NodesRoutes
|
||||
import org.meshtastic.core.service.ServiceAction
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.fallback_node_name
|
||||
import org.meshtastic.core.ui.util.toPosition
|
||||
import org.meshtastic.feature.map.model.TracerouteOverlay
|
||||
import org.meshtastic.feature.node.model.MetricsState
|
||||
import org.meshtastic.feature.node.model.TimeFrame
|
||||
import org.meshtastic.proto.MeshProtos
|
||||
|
|
@ -92,6 +95,8 @@ constructor(
|
|||
|
||||
private var jobs: Job? = null
|
||||
|
||||
private val tracerouteOverlayCache = MutableStateFlow<Map<Int, TracerouteOverlay>>(emptyMap())
|
||||
|
||||
private fun MeshLog.hasValidTraceroute(): Boolean =
|
||||
with(fromRadio.packet) { hasDecoded() && decoded.wantResponse && from == 0 && to == destNum }
|
||||
|
||||
|
|
@ -118,6 +123,60 @@ constructor(
|
|||
|
||||
fun deleteLog(uuid: String) = viewModelScope.launch(dispatchers.io) { meshLogRepository.deleteLog(uuid) }
|
||||
|
||||
fun getTracerouteOverlay(requestId: Int): TracerouteOverlay? {
|
||||
val cached = tracerouteOverlayCache.value[requestId]
|
||||
if (cached != null) return cached
|
||||
|
||||
val overlay =
|
||||
serviceRepository.tracerouteResponse.value
|
||||
?.takeIf { it.requestId == requestId }
|
||||
?.let { response ->
|
||||
TracerouteOverlay(
|
||||
requestId = response.requestId,
|
||||
forwardRoute = response.forwardRoute,
|
||||
returnRoute = response.returnRoute,
|
||||
)
|
||||
}
|
||||
?.takeIf { it.hasRoutes }
|
||||
|
||||
if (overlay != null) {
|
||||
tracerouteOverlayCache.update { it + (requestId to overlay) }
|
||||
}
|
||||
|
||||
return overlay
|
||||
}
|
||||
|
||||
fun clearTracerouteResponse() = serviceRepository.clearTracerouteResponse()
|
||||
|
||||
fun tracerouteMapAvailability(forwardRoute: List<Int>, returnRoute: List<Int>): TracerouteMapAvailability =
|
||||
evaluateTracerouteMapAvailability(
|
||||
forwardRoute = forwardRoute,
|
||||
returnRoute = returnRoute,
|
||||
positionedNodeNums = positionedNodeNums(),
|
||||
)
|
||||
|
||||
fun tracerouteMapAvailability(overlay: TracerouteOverlay): TracerouteMapAvailability =
|
||||
tracerouteMapAvailability(overlay.forwardRoute, overlay.returnRoute)
|
||||
|
||||
fun positionedNodeNums(): Set<Int> =
|
||||
nodeRepository.nodeDBbyNum.value.values.filter { it.validPosition != null }.map { it.num }.toSet()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
serviceRepository.tracerouteResponse.filterNotNull().collect { response ->
|
||||
val overlay =
|
||||
TracerouteOverlay(
|
||||
requestId = response.requestId,
|
||||
forwardRoute = response.forwardRoute,
|
||||
returnRoute = response.returnRoute,
|
||||
)
|
||||
if (overlay.hasRoutes) {
|
||||
tracerouteOverlayCache.update { it + (response.requestId to overlay) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearPosition() = viewModelScope.launch(dispatchers.io) {
|
||||
destNum?.let { meshLogRepository.deleteLogs(it, PortNum.POSITION_APP_VALUE) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ package org.meshtastic.feature.node.metrics
|
|||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
|
|
@ -51,7 +52,6 @@ import androidx.compose.runtime.setValue
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
|
|
@ -61,17 +61,21 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.pluralStringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.model.fullRouteDiscovery
|
||||
import org.meshtastic.core.model.getTracerouteResponse
|
||||
import org.meshtastic.core.model.toMessageRes
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.close
|
||||
import org.meshtastic.core.strings.delete
|
||||
import org.meshtastic.core.strings.routing_error_no_response
|
||||
import org.meshtastic.core.strings.traceroute
|
||||
import org.meshtastic.core.strings.traceroute_diff
|
||||
import org.meshtastic.core.strings.traceroute_direct
|
||||
import org.meshtastic.core.strings.traceroute_hops
|
||||
import org.meshtastic.core.strings.view_on_map
|
||||
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
|
||||
|
|
@ -80,10 +84,13 @@ import org.meshtastic.core.ui.theme.AppTheme
|
|||
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
|
||||
import org.meshtastic.feature.map.model.TracerouteOverlay
|
||||
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
|
||||
import org.meshtastic.proto.MeshProtos
|
||||
import java.text.DateFormat
|
||||
|
||||
private data class TracerouteDialog(val message: AnnotatedString, val requestId: Int, val overlay: TracerouteOverlay?)
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
|
|
@ -91,22 +98,25 @@ fun TracerouteLogScreen(
|
|||
modifier: Modifier = Modifier,
|
||||
viewModel: MetricsViewModel = hiltViewModel(),
|
||||
onNavigateUp: () -> Unit,
|
||||
onViewOnMap: (requestId: Int) -> Unit = {},
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val dateFormat = remember { DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM) }
|
||||
|
||||
fun getUsername(nodeNum: Int): String = with(viewModel.getUser(nodeNum)) { "$longName ($shortName)" }
|
||||
|
||||
var showDialog by remember { mutableStateOf<AnnotatedString?>(null) }
|
||||
var showDialog by remember { mutableStateOf<TracerouteDialog?>(null) }
|
||||
var errorMessageRes by remember { mutableStateOf<StringResource?>(null) }
|
||||
|
||||
if (showDialog != null) {
|
||||
val message = showDialog ?: AnnotatedString("") // Should not be null if dialog is shown
|
||||
SimpleAlertDialog(
|
||||
title = Res.string.traceroute,
|
||||
text = { SelectionContainer { Text(text = message) } },
|
||||
onDismiss = { showDialog = null },
|
||||
)
|
||||
}
|
||||
TracerouteLogDialogs(
|
||||
dialog = showDialog,
|
||||
errorMessageRes = errorMessageRes,
|
||||
viewModel = viewModel,
|
||||
onViewOnMap = onViewOnMap,
|
||||
onShowErrorMessageRes = { errorMessageRes = it },
|
||||
onDismissDialog = { showDialog = null },
|
||||
onDismissError = { errorMessageRes = null },
|
||||
)
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
|
|
@ -154,6 +164,14 @@ fun TracerouteLogScreen(
|
|||
res.fromRadio.packet.getTracerouteResponse(::getUsername)?.let { AnnotatedString(it) }
|
||||
}
|
||||
}
|
||||
val overlay =
|
||||
route?.let {
|
||||
TracerouteOverlay(
|
||||
requestId = log.fromRadio.packet.id,
|
||||
forwardRoute = it.routeList,
|
||||
returnRoute = it.routeBackList,
|
||||
)
|
||||
}
|
||||
|
||||
Box {
|
||||
TracerouteItem(
|
||||
|
|
@ -161,14 +179,18 @@ fun TracerouteLogScreen(
|
|||
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 dialogMessage =
|
||||
tracerouteDetailsAnnotated
|
||||
?: result?.fromRadio?.packet?.getTracerouteResponse(::getUsername)?.let {
|
||||
AnnotatedString(it)
|
||||
}
|
||||
dialogMessage?.let {
|
||||
showDialog =
|
||||
TracerouteDialog(
|
||||
message = it,
|
||||
requestId = log.fromRadio.packet.id,
|
||||
overlay = overlay,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
@ -184,6 +206,44 @@ fun TracerouteLogScreen(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TracerouteLogDialogs(
|
||||
dialog: TracerouteDialog?,
|
||||
errorMessageRes: StringResource?,
|
||||
viewModel: MetricsViewModel,
|
||||
onViewOnMap: (requestId: Int) -> Unit,
|
||||
onShowErrorMessageRes: (StringResource) -> Unit,
|
||||
onDismissDialog: () -> Unit,
|
||||
onDismissError: () -> Unit,
|
||||
) {
|
||||
dialog?.let { dialogState ->
|
||||
SimpleAlertDialog(
|
||||
title = Res.string.traceroute,
|
||||
text = { SelectionContainer { Text(text = dialogState.message) } },
|
||||
confirmText = stringResource(Res.string.view_on_map),
|
||||
onConfirm = {
|
||||
val availability =
|
||||
viewModel.tracerouteMapAvailability(
|
||||
forwardRoute = dialogState.overlay?.forwardRoute.orEmpty(),
|
||||
returnRoute = dialogState.overlay?.returnRoute.orEmpty(),
|
||||
)
|
||||
availability.toMessageRes()?.let(onShowErrorMessageRes) ?: onViewOnMap(dialogState.requestId)
|
||||
onDismissDialog()
|
||||
},
|
||||
onDismiss = onDismissDialog,
|
||||
)
|
||||
}
|
||||
|
||||
errorMessageRes?.let { res ->
|
||||
SimpleAlertDialog(
|
||||
title = Res.string.traceroute,
|
||||
text = { Text(text = stringResource(res)) },
|
||||
dismissText = stringResource(Res.string.close),
|
||||
onDismiss = onDismissError,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeleteItem(onClick: () -> Unit) {
|
||||
DropdownMenuItem(
|
||||
|
|
@ -205,13 +265,12 @@ private fun DeleteItem(onClick: () -> Unit) {
|
|||
@Composable
|
||||
private fun TracerouteItem(icon: ImageVector, text: String, modifier: Modifier = Modifier) {
|
||||
Card(modifier = modifier.fillMaxWidth().heightIn(min = 56.dp).padding(vertical = 2.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(imageVector = icon, contentDescription = stringResource(Res.string.traceroute))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = text, style = MaterialTheme.typography.bodyLarge)
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(imageVector = icon, contentDescription = stringResource(Res.string.traceroute))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = text, style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,158 @@
|
|||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.meshtastic.feature.node.metrics
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Route
|
||||
import androidx.compose.material3.Card
|
||||
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.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.model.fullRouteDiscovery
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.traceroute
|
||||
import org.meshtastic.core.strings.traceroute_outgoing_route
|
||||
import org.meshtastic.core.strings.traceroute_return_route
|
||||
import org.meshtastic.core.strings.traceroute_showing_nodes
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.theme.TracerouteColors
|
||||
import org.meshtastic.feature.map.MapView
|
||||
import org.meshtastic.feature.map.model.TracerouteOverlay
|
||||
|
||||
@Composable
|
||||
fun TracerouteMapScreen(
|
||||
metricsViewModel: MetricsViewModel = hiltViewModel(),
|
||||
requestId: Int,
|
||||
onNavigateUp: () -> Unit,
|
||||
) {
|
||||
val state by metricsViewModel.state.collectAsStateWithLifecycle()
|
||||
val nodeTitle = state.node?.user?.longName ?: stringResource(Res.string.traceroute)
|
||||
val routeDiscovery =
|
||||
state.tracerouteResults
|
||||
.find { it.fromRadio.packet.decoded.requestId == requestId }
|
||||
?.fromRadio
|
||||
?.packet
|
||||
?.fullRouteDiscovery
|
||||
val overlayFromLogs =
|
||||
remember(routeDiscovery) {
|
||||
routeDiscovery?.let {
|
||||
TracerouteOverlay(requestId = requestId, forwardRoute = it.routeList, returnRoute = it.routeBackList)
|
||||
}
|
||||
}
|
||||
val overlayFromService = remember(requestId) { metricsViewModel.getTracerouteOverlay(requestId) }
|
||||
val overlay = overlayFromLogs ?: overlayFromService
|
||||
var tracerouteNodesShown by remember { mutableStateOf(0) }
|
||||
var tracerouteNodesTotal by remember { mutableStateOf(0) }
|
||||
LaunchedEffect(Unit) { metricsViewModel.clearTracerouteResponse() }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
MainAppBar(
|
||||
title = nodeTitle,
|
||||
ourNode = null,
|
||||
showNodeChip = false,
|
||||
canNavigateUp = true,
|
||||
onNavigateUp = onNavigateUp,
|
||||
actions = {},
|
||||
onClickChip = {},
|
||||
)
|
||||
},
|
||||
) { paddingValues ->
|
||||
Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
|
||||
MapView(
|
||||
navigateToNodeDetails = {},
|
||||
tracerouteOverlay = overlay,
|
||||
onTracerouteMappableCountChanged = { shown, total ->
|
||||
tracerouteNodesShown = shown
|
||||
tracerouteNodesTotal = total
|
||||
},
|
||||
)
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.align(TracerouteMapOverlayInsets.overlayAlignment)
|
||||
.padding(TracerouteMapOverlayInsets.overlayPadding),
|
||||
horizontalAlignment = TracerouteMapOverlayInsets.contentHorizontalAlignment,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
TracerouteNodeCount(shown = tracerouteNodesShown, total = tracerouteNodesTotal)
|
||||
TracerouteLegend()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TracerouteLegend(modifier: Modifier = Modifier) {
|
||||
Card(modifier = modifier) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
LegendRow(
|
||||
color = TracerouteColors.OutgoingRoute,
|
||||
label = stringResource(Res.string.traceroute_outgoing_route),
|
||||
)
|
||||
LegendRow(color = TracerouteColors.ReturnRoute, label = stringResource(Res.string.traceroute_return_route))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TracerouteNodeCount(modifier: Modifier = Modifier, shown: Int, total: Int) {
|
||||
Card(modifier = modifier) {
|
||||
Text(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
text = stringResource(Res.string.traceroute_showing_nodes, shown, total),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LegendRow(color: Color, label: String) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Route,
|
||||
contentDescription = null,
|
||||
tint = color,
|
||||
modifier = Modifier.padding(end = 8.dp).size(18.dp),
|
||||
)
|
||||
Text(text = label, style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue