feat(ui): Refactor node position details into separate section (#3382)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich
2025-10-07 19:50:53 -05:00
committed by GitHub
parent b2ff4483c8
commit 8baf8714d0
42 changed files with 1967 additions and 1193 deletions

View File

@@ -221,9 +221,6 @@ dependencies {
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.markdown.renderer)
implementation(libs.markdown.renderer.android)
implementation(libs.markdown.renderer.m3)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.coil)
implementation(libs.coil.network.okhttp)

View File

@@ -29,6 +29,7 @@ import org.meshtastic.core.ui.theme.GraphColors.Pink
import org.meshtastic.core.ui.theme.GraphColors.Purple
import org.meshtastic.core.ui.theme.GraphColors.Red
import org.meshtastic.core.ui.theme.GraphColors.Yellow
import org.meshtastic.feature.node.model.TimeFrame
@Suppress("MagicNumber")
enum class Environment(val color: Color) {
@@ -83,7 +84,7 @@ data class EnvironmentMetricsState(val environmentMetrics: List<TelemetryProtos.
fun hasEnvironmentMetrics() = environmentMetrics.isNotEmpty()
/**
* Filters [environmentMetrics] based on a [TimeFrame].
* Filters [environmentMetrics] based on a [org.meshtastic.feature.node.model.TimeFrame].
*
* @param timeFrame used to filter
* @return [EnvironmentGraphingData]

View File

@@ -19,21 +19,15 @@ package com.geeksville.mesh.model
import android.app.Application
import android.net.Uri
import androidx.annotation.StringRes
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig.DisplayUnits
import com.geeksville.mesh.CoroutineDispatchers
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.MeshProtos.MeshPacket
import com.geeksville.mesh.MeshProtos.Position
import com.geeksville.mesh.Portnums
import com.geeksville.mesh.Portnums.PortNum
import com.geeksville.mesh.TelemetryProtos.Telemetry
import com.geeksville.mesh.util.safeNumber
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
@@ -55,11 +49,9 @@ import org.meshtastic.core.data.repository.FirmwareReleaseRepository
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.prefs.map.MapPrefs
import org.meshtastic.core.proto.toPosition
@@ -67,127 +59,18 @@ import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.R
import org.meshtastic.feature.map.model.CustomTileSource
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.TimeFrame
import timber.log.Timber
import java.io.BufferedWriter
import java.io.FileNotFoundException
import java.io.FileWriter
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.concurrent.TimeUnit
import javax.inject.Inject
private const val DEFAULT_ID_SUFFIX_LENGTH = 4
data class MetricsState(
val isLocal: Boolean = false,
val isManaged: Boolean = true,
val isFahrenheit: Boolean = false,
val displayUnits: DisplayUnits = DisplayUnits.METRIC,
val node: Node? = null,
val deviceMetrics: List<Telemetry> = emptyList(),
val signalMetrics: List<MeshPacket> = emptyList(),
val powerMetrics: List<Telemetry> = emptyList(),
val hostMetrics: List<Telemetry> = emptyList(),
val tracerouteRequests: List<MeshLog> = emptyList(),
val tracerouteResults: List<MeshLog> = emptyList(),
val positionLogs: List<Position> = emptyList(),
val deviceHardware: DeviceHardware? = null,
val isLocalDevice: Boolean = false,
val firmwareEdition: MeshProtos.FirmwareEdition? = null,
val latestStableFirmware: FirmwareRelease = FirmwareRelease(),
val latestAlphaFirmware: FirmwareRelease = FirmwareRelease(),
val paxMetrics: List<MeshLog> = emptyList(),
) {
fun hasDeviceMetrics() = deviceMetrics.isNotEmpty()
fun hasSignalMetrics() = signalMetrics.isNotEmpty()
fun hasPowerMetrics() = powerMetrics.isNotEmpty()
fun hasTracerouteLogs() = tracerouteRequests.isNotEmpty()
fun hasPositionLogs() = positionLogs.isNotEmpty()
fun hasHostMetrics() = hostMetrics.isNotEmpty()
fun hasPaxMetrics() = paxMetrics.isNotEmpty()
fun deviceMetricsFiltered(timeFrame: TimeFrame): List<Telemetry> {
val oldestTime = timeFrame.calculateOldestTime()
return deviceMetrics.filter { it.time >= oldestTime }
}
fun signalMetricsFiltered(timeFrame: TimeFrame): List<MeshPacket> {
val oldestTime = timeFrame.calculateOldestTime()
return signalMetrics.filter { it.rxTime >= oldestTime }
}
fun powerMetricsFiltered(timeFrame: TimeFrame): List<Telemetry> {
val oldestTime = timeFrame.calculateOldestTime()
return powerMetrics.filter { it.time >= oldestTime }
}
companion object {
val Empty = MetricsState()
}
}
/** Supported time frames used to display data. */
@Suppress("MagicNumber")
enum class TimeFrame(val seconds: Long, @StringRes val strRes: Int) {
TWENTY_FOUR_HOURS(TimeUnit.DAYS.toSeconds(1), R.string.twenty_four_hours),
FORTY_EIGHT_HOURS(TimeUnit.DAYS.toSeconds(2), R.string.forty_eight_hours),
ONE_WEEK(TimeUnit.DAYS.toSeconds(7), R.string.one_week),
TWO_WEEKS(TimeUnit.DAYS.toSeconds(14), R.string.two_weeks),
FOUR_WEEKS(TimeUnit.DAYS.toSeconds(28), R.string.four_weeks),
MAX(0L, R.string.max),
;
fun calculateOldestTime(): Long = if (this == MAX) {
MAX.seconds
} else {
System.currentTimeMillis() / 1000 - this.seconds
}
/**
* The time interval to draw the vertical lines representing time on the x-axis.
*
* @return seconds epoch seconds
*/
fun lineInterval(): Long = when (this.ordinal) {
TWENTY_FOUR_HOURS.ordinal -> TimeUnit.HOURS.toSeconds(6)
FORTY_EIGHT_HOURS.ordinal -> TimeUnit.HOURS.toSeconds(12)
ONE_WEEK.ordinal,
TWO_WEEKS.ordinal,
-> TimeUnit.DAYS.toSeconds(1)
else -> TimeUnit.DAYS.toSeconds(7)
}
/** Used to detect a significant time separation between [Telemetry]s. */
fun timeThreshold(): Long = when (this.ordinal) {
TWENTY_FOUR_HOURS.ordinal -> TimeUnit.HOURS.toSeconds(6)
FORTY_EIGHT_HOURS.ordinal -> TimeUnit.HOURS.toSeconds(12)
else -> TimeUnit.DAYS.toSeconds(1)
}
/**
* Calculates the needed [Dp] depending on the amount of time being plotted.
*
* @param time in seconds
*/
fun dp(screenWidth: Int, time: Long): Dp {
val timePerScreen = this.lineInterval()
val multiplier = time / timePerScreen
val dp = (screenWidth * multiplier).toInt().dp
return dp.takeIf { it != 0.dp } ?: screenWidth.dp
}
}
private fun MeshPacket.hasValidSignal(): Boolean =
rxTime > 0 && (rxSnr != 0f && rxRssi != 0) && (hopStart > 0 && hopStart - hopLimit == 0)

View File

@@ -63,7 +63,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.TelemetryProtos
import com.geeksville.mesh.TelemetryProtos.Telemetry
import com.geeksville.mesh.model.MetricsViewModel
import com.geeksville.mesh.model.TimeFrame
import com.geeksville.mesh.ui.metrics.CommonCharts.DATE_TIME_FORMAT
import com.geeksville.mesh.ui.metrics.CommonCharts.MAX_PERCENT_VALUE
import com.geeksville.mesh.ui.metrics.CommonCharts.MS_PER_SEC
@@ -79,6 +78,7 @@ import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.GraphColors.Cyan
import org.meshtastic.core.ui.theme.GraphColors.Green
import org.meshtastic.core.ui.theme.GraphColors.Magenta
import org.meshtastic.feature.node.model.TimeFrame
private const val CHART_WEIGHT = 1f
private const val Y_AXIS_WEIGHT = 0.1f

View File

@@ -43,10 +43,10 @@ import androidx.compose.ui.unit.dp
import com.geeksville.mesh.TelemetryProtos.Telemetry
import com.geeksville.mesh.model.Environment
import com.geeksville.mesh.model.EnvironmentGraphingData
import com.geeksville.mesh.model.TimeFrame
import com.geeksville.mesh.util.GraphUtil.createPath
import com.geeksville.mesh.util.GraphUtil.drawPathWithGradient
import org.meshtastic.core.strings.R
import org.meshtastic.feature.node.model.TimeFrame
private const val CHART_WEIGHT = 1f
private const val Y_AXIS_WEIGHT = 0.1f

View File

@@ -53,7 +53,6 @@ import com.geeksville.mesh.TelemetryProtos
import com.geeksville.mesh.TelemetryProtos.Telemetry
import com.geeksville.mesh.copy
import com.geeksville.mesh.model.MetricsViewModel
import com.geeksville.mesh.model.TimeFrame
import com.geeksville.mesh.ui.metrics.CommonCharts.DATE_TIME_FORMAT
import com.geeksville.mesh.ui.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit
@@ -63,6 +62,7 @@ import org.meshtastic.core.ui.component.IndoorAirQuality
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.OptionLabel
import org.meshtastic.core.ui.component.SlidingSelector
import org.meshtastic.feature.node.model.TimeFrame
@Composable
fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {

View File

@@ -57,13 +57,13 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.PaxcountProtos
import com.geeksville.mesh.Portnums.PortNum
import com.geeksville.mesh.model.MetricsViewModel
import com.geeksville.mesh.model.TimeFrame
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.OptionLabel
import org.meshtastic.core.ui.component.SlidingSelector
import org.meshtastic.feature.node.model.TimeFrame
import java.text.DateFormat
import java.util.Date

View File

@@ -62,7 +62,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.TelemetryProtos.Telemetry
import com.geeksville.mesh.model.MetricsViewModel
import com.geeksville.mesh.model.TimeFrame
import com.geeksville.mesh.ui.metrics.CommonCharts.DATE_TIME_FORMAT
import com.geeksville.mesh.ui.metrics.CommonCharts.MS_PER_SEC
import com.geeksville.mesh.util.GraphUtil
@@ -73,6 +72,7 @@ import org.meshtastic.core.ui.component.OptionLabel
import org.meshtastic.core.ui.component.SlidingSelector
import org.meshtastic.core.ui.theme.GraphColors.InfantryBlue
import org.meshtastic.core.ui.theme.GraphColors.Red
import org.meshtastic.feature.node.model.TimeFrame
import kotlin.math.ceil
import kotlin.math.floor

View File

@@ -59,7 +59,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.MeshProtos.MeshPacket
import com.geeksville.mesh.model.MetricsViewModel
import com.geeksville.mesh.model.TimeFrame
import com.geeksville.mesh.ui.metrics.CommonCharts.DATE_TIME_FORMAT
import com.geeksville.mesh.ui.metrics.CommonCharts.MS_PER_SEC
import com.geeksville.mesh.util.GraphUtil.plotPoint
@@ -69,6 +68,7 @@ import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.OptionLabel
import org.meshtastic.core.ui.component.SlidingSelector
import org.meshtastic.core.ui.component.SnrAndRssi
import org.meshtastic.feature.node.model.TimeFrame
@Suppress("MagicNumber")
private enum class Metric(val color: Color, val min: Float, val max: Float) {

View File

@@ -0,0 +1,170 @@
/*
* 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 com.geeksville.mesh.ui.node
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.ui.sharing.SharedContactDialog
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.feature.node.component.AdministrationSection
import org.meshtastic.feature.node.component.DeviceActions
import org.meshtastic.feature.node.component.DeviceDetailsSection
import org.meshtastic.feature.node.component.FirmwareReleaseSheetContent
import org.meshtastic.feature.node.component.MetricsSection
import org.meshtastic.feature.node.component.NodeDetailsSection
import org.meshtastic.feature.node.component.NotesSection
import org.meshtastic.feature.node.component.PositionSection
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.NodeDetailAction
@Composable
fun NodeDetailContent(
node: Node,
ourNode: Node?,
metricsState: MetricsState,
lastTracerouteTime: Long?,
availableLogs: Set<LogsType>,
onAction: (NodeDetailAction) -> Unit,
onSaveNotes: (nodeNum: Int, notes: String) -> Unit,
modifier: Modifier = Modifier,
) {
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,
onSaveNotes = onSaveNotes,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NodeDetailList(
node: Node,
lastTracerouteTime: Long?,
ourNode: Node?,
metricsState: MetricsState,
onAction: (NodeDetailAction) -> Unit,
availableLogs: Set<LogsType>,
onSaveNotes: (Int, String) -> Unit,
modifier: Modifier = Modifier,
) {
var showFirmwareSheet by remember { mutableStateOf(false) }
var selectedFirmware by remember { mutableStateOf<FirmwareRelease?>(null) }
if (showFirmwareSheet) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
ModalBottomSheet(onDismissRequest = { showFirmwareSheet = false }, sheetState = sheetState) {
selectedFirmware?.let { FirmwareReleaseSheetContent(firmwareRelease = it) }
}
}
Column(
modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
if (metricsState.deviceHardware != null) {
TitledCard(title = stringResource(R.string.device)) { DeviceDetailsSection(metricsState) }
}
NodeDetailsSection(node)
NotesSection(node = node, onSaveNotes = onSaveNotes)
DeviceActions(
isLocal = metricsState.isLocal,
lastTracerouteTime = lastTracerouteTime,
node = node,
onAction = onAction,
)
PositionSection(
node = node,
ourNode = ourNode,
metricsState = metricsState,
availableLogs = availableLogs,
onAction = onAction,
)
MetricsSection(node, metricsState, availableLogs, onAction)
if (!metricsState.isManaged) {
AdministrationSection(
node = node,
metricsState = metricsState,
onAction = onAction,
onFirmwareSelect = { firmware ->
selectedFirmware = firmware
showFirmwareSheet = true
},
)
}
}
}
@Preview(showBackground = true)
@Composable
private fun NodeDetailsPreview(@PreviewParameter(NodePreviewParameterProvider::class) node: Node) {
AppTheme {
NodeDetailList(
node = node,
ourNode = node,
lastTracerouteTime = null,
metricsState = MetricsState.Companion.Empty,
availableLogs = emptySet(),
onAction = {},
onSaveNotes = { _, _ -> },
)
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -122,6 +122,28 @@ fun SettingsItemDetail(
)
}
/** A settings detail item with composable content for the detail. */
@Composable
fun SettingsItemDetail(
text: String,
textColor: Color = LocalContentColor.current,
icon: ImageVector? = null,
iconTint: Color = LocalContentColor.current,
enabled: Boolean = true,
onClick: (() -> Unit)? = null,
supportingContent: @Composable () -> Unit,
) {
SettingsListItem(
text = text,
textColor = textColor,
enabled = enabled,
onClick = onClick,
leadingContent = { icon.Icon(iconTint) },
supportingContent = supportingContent,
trailingContent = {},
)
}
/**
* Base composable for all settings screen list items. It handles the Material3 [ListItem] structure and the conditional
* click wrapper.

View File

@@ -54,7 +54,7 @@ fun CustomMapLayersSheet(
LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) {
item {
Text(
modifier = Modifier.Companion.padding(16.dp),
modifier = Modifier.padding(16.dp),
text = stringResource(R.string.manage_map_layers),
style = MaterialTheme.typography.headlineSmall,
)
@@ -71,7 +71,7 @@ fun CustomMapLayersSheet(
if (mapLayers.isEmpty()) {
item {
Text(
modifier = Modifier.Companion.padding(16.dp),
modifier = Modifier.padding(16.dp),
text = stringResource(R.string.no_map_layers_loaded),
style = MaterialTheme.typography.bodyMedium,
)
@@ -113,7 +113,7 @@ fun CustomMapLayersSheet(
}
}
item {
Button(modifier = Modifier.Companion.fillMaxWidth().padding(16.dp), onClick = onAddLayerClicked) {
Button(modifier = Modifier.fillMaxWidth().padding(16.dp), onClick = onAddLayerClicked) {
Text(stringResource(R.string.add_layer))
}
}

View File

@@ -33,6 +33,8 @@ dependencies {
implementation(projects.core.service)
implementation(projects.core.strings)
implementation(projects.core.ui)
implementation(projects.core.navigation)
implementation(projects.core.common)
implementation(libs.androidx.compose.material.iconsExtended)
implementation(libs.androidx.compose.material3)
@@ -42,4 +44,12 @@ dependencies {
implementation(libs.androidx.constraintlayout)
implementation(libs.material)
implementation(libs.timber)
implementation(libs.coil)
implementation(libs.markdown.renderer.android)
implementation(libs.markdown.renderer.m3)
implementation(libs.markdown.renderer)
googleImplementation(libs.location.services)
googleImplementation(libs.maps.compose)
googleImplementation(libs.maps.compose.utils)
}

View File

@@ -0,0 +1,27 @@
/*
* 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.component
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import org.meshtastic.core.database.model.Node
@Composable
internal fun InlineMap(node: Node, modifier: Modifier = Modifier) {
// No-op for F-Droid builds
}

View File

@@ -0,0 +1,80 @@
/*
* 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.component
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.google.android.gms.maps.model.CameraPosition
import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.compose.Circle
import com.google.maps.android.compose.ComposeMapColorScheme
import com.google.maps.android.compose.GoogleMap
import com.google.maps.android.compose.MapUiSettings
import com.google.maps.android.compose.MapsComposeExperimentalApi
import com.google.maps.android.compose.MarkerComposable
import com.google.maps.android.compose.rememberCameraPositionState
import com.google.maps.android.compose.rememberUpdatedMarkerState
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.ui.component.NodeChip
import org.meshtastic.core.ui.component.precisionBitsToMeters
@OptIn(MapsComposeExperimentalApi::class)
@Composable
internal fun InlineMap(node: Node, modifier: Modifier = Modifier) {
val dark = isSystemInDarkTheme()
val mapColorScheme =
when (dark) {
true -> ComposeMapColorScheme.DARK
else -> ComposeMapColorScheme.LIGHT
}
val location = LatLng(node.latitude, node.longitude)
val cameraState = rememberCameraPositionState { position = CameraPosition.fromLatLngZoom(location, 15f) }
GoogleMap(
mapColorScheme = mapColorScheme,
modifier = modifier,
uiSettings =
MapUiSettings(
zoomControlsEnabled = true,
mapToolbarEnabled = false,
compassEnabled = false,
myLocationButtonEnabled = false,
rotationGesturesEnabled = false,
scrollGesturesEnabled = false,
tiltGesturesEnabled = false,
zoomGesturesEnabled = false,
),
cameraPositionState = cameraState,
) {
val precisionMeters = precisionBitsToMeters(node.position.precisionBits)
val latLng = LatLng(node.latitude, node.longitude)
if (precisionMeters > 0) {
Circle(
center = latLng,
radius = precisionMeters,
fillColor = Color(node.colors.second).copy(alpha = 0.2f),
strokeColor = Color(node.colors.second),
strokeWidth = 2f,
)
}
MarkerComposable(state = rememberUpdatedMarkerState(position = latLng)) { NodeChip(node = node) }
}
}

View File

@@ -0,0 +1,137 @@
/*
* 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.component
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ForkLeft
import androidx.compose.material.icons.filled.Icecream
import androidx.compose.material.icons.filled.Memory
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import com.geeksville.mesh.MeshProtos
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.entity.asDeviceVersion
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.SettingsItem
import org.meshtastic.core.ui.component.SettingsItemDetail
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.NodeDetailAction
@Suppress("LongMethod")
@Composable
fun AdministrationSection(
node: Node,
metricsState: MetricsState,
onAction: (NodeDetailAction) -> Unit,
onFirmwareSelect: (FirmwareRelease) -> Unit,
modifier: Modifier = Modifier,
) {
TitledCard(stringResource(id = R.string.administration), modifier = modifier) {
SettingsItem(
text = stringResource(id = R.string.request_metadata),
leadingIcon = Icons.Default.Memory,
trailingContent = {},
onClick = { onAction(NodeDetailAction.TriggerServiceAction(ServiceAction.GetDeviceMetadata(node.num))) },
)
SettingsItem(
text = stringResource(id = R.string.remote_admin),
leadingIcon = Icons.Default.Settings,
enabled = metricsState.isLocal || node.metadata != null,
) {
onAction(NodeDetailAction.Navigate(SettingsRoutes.Settings(node.num)))
}
}
val firmwareVersion = node.metadata?.firmwareVersion
val firmwareEdition = metricsState.firmwareEdition
if (firmwareVersion != null || (firmwareEdition != null && metricsState.isLocal)) {
TitledCard(stringResource(R.string.firmware)) {
firmwareEdition?.let {
val icon =
when (it) {
MeshProtos.FirmwareEdition.VANILLA -> Icons.Default.Icecream
else -> Icons.Default.ForkLeft
}
SettingsItemDetail(
text = stringResource(R.string.firmware_edition),
icon = icon,
supportingText = it.name,
)
}
firmwareVersion?.let { firmwareVersion ->
val latestStable = metricsState.latestStableFirmware
val latestAlpha = metricsState.latestAlphaFirmware
val deviceVersion = DeviceVersion(firmwareVersion.substringBeforeLast("."))
val statusColor = deviceVersion.determineFirmwareStatusColor(latestStable, latestAlpha)
SettingsItemDetail(
text = stringResource(R.string.installed_firmware_version),
icon = Icons.Default.Memory,
supportingText = firmwareVersion.substringBeforeLast("."),
iconTint = statusColor,
)
HorizontalDivider()
SettingsItemDetail(
text = stringResource(R.string.latest_stable_firmware),
icon = Icons.Default.Memory,
supportingText = latestStable.id.substringBeforeLast(".").replace("v", ""),
iconTint = MaterialTheme.colorScheme.StatusGreen,
onClick = { onFirmwareSelect(latestStable) },
)
SettingsItemDetail(
text = stringResource(R.string.latest_alpha_firmware),
icon = Icons.Default.Memory,
supportingText = latestAlpha.id.substringBeforeLast(".").replace("v", ""),
iconTint = MaterialTheme.colorScheme.StatusYellow,
onClick = { onFirmwareSelect(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.StatusRed
this == stableVersion -> MaterialTheme.colorScheme.StatusGreen
this in stableVersion..alphaVersion -> MaterialTheme.colorScheme.StatusYellow
this > alphaVersion -> MaterialTheme.colorScheme.StatusOrange
else -> MaterialTheme.colorScheme.onSurface
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.component
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.automirrored.outlined.VolumeMute
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.StarBorder
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.QrCode2
import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.SettingsItem
import org.meshtastic.core.ui.component.SettingsItemSwitch
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.node.model.NodeDetailAction
@Composable
fun DeviceActions(
node: Node,
lastTracerouteTime: Long?,
onAction: (NodeDetailAction) -> Unit,
modifier: Modifier = Modifier,
isLocal: Boolean = false,
) {
var displayFavoriteDialog by remember { mutableStateOf(false) }
var displayIgnoreDialog by remember { mutableStateOf(false) }
var displayRemoveDialog by remember { mutableStateOf(false) }
NodeActionDialogs(
node = node,
displayFavoriteDialog = displayFavoriteDialog,
displayIgnoreDialog = displayIgnoreDialog,
displayRemoveDialog = displayRemoveDialog,
onDismissMenuRequest = {
displayFavoriteDialog = false
displayIgnoreDialog = false
displayRemoveDialog = false
},
onConfirmFavorite = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Favorite(it))) },
onConfirmIgnore = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Ignore(it))) },
onConfirmRemove = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Remove(it))) },
)
TitledCard(title = stringResource(R.string.actions), modifier = modifier) {
SettingsItem(
text = stringResource(id = R.string.share_contact),
leadingIcon = Icons.Rounded.QrCode2,
trailingContent = {},
onClick = { onAction(NodeDetailAction.ShareContact) },
)
if (!isLocal) {
RemoteDeviceActions(node = node, lastTracerouteTime = lastTracerouteTime, onAction = onAction)
}
SettingsItemSwitch(
text = stringResource(R.string.favorite),
leadingIcon = if (node.isFavorite) Icons.Default.Star else Icons.Default.StarBorder,
leadingIconTint = if (node.isFavorite) Color.Yellow else LocalContentColor.current,
checked = node.isFavorite,
onClick = { displayFavoriteDialog = true },
)
SettingsItemSwitch(
text = stringResource(R.string.ignore),
leadingIcon =
if (node.isIgnored) Icons.AutoMirrored.Outlined.VolumeMute else Icons.AutoMirrored.Default.VolumeUp,
checked = node.isIgnored,
onClick = { displayIgnoreDialog = true },
)
SettingsItem(
text = stringResource(id = R.string.remove),
leadingIcon = Icons.Rounded.Delete,
trailingContent = {},
onClick = { displayRemoveDialog = true },
)
}
}

View File

@@ -0,0 +1,111 @@
/*
* 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.component
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Router
import androidx.compose.material.icons.twotone.Verified
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.SettingsItemDetail
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.feature.node.model.MetricsState
@Composable
fun DeviceDetailsSection(state: MetricsState, modifier: Modifier = Modifier) {
val node = state.node ?: return
val deviceHardware = state.deviceHardware ?: return
val hwModelName = deviceHardware.displayName
val isSupported = deviceHardware.activelySupported
TitledCard(stringResource(R.string.device), modifier = modifier) {
Box(
modifier =
Modifier.align(Alignment.CenterHorizontally)
.size(100.dp)
.clip(CircleShape)
.background(color = Color(node.colors.second).copy(alpha = .5f), shape = CircleShape),
contentAlignment = Alignment.Center,
) {
DeviceHardwareImage(deviceHardware, Modifier.fillMaxSize())
}
Spacer(modifier = Modifier.height(16.dp))
SettingsItemDetail(
text = stringResource(R.string.hardware),
icon = Icons.Default.Router,
supportingText = hwModelName,
)
SettingsItemDetail(
text =
if (isSupported) {
stringResource(R.string.supported)
} else {
stringResource(R.string.supported_by_community)
},
icon =
if (isSupported) {
Icons.TwoTone.Verified
} else {
ImageVector.vectorResource(org.meshtastic.feature.node.R.drawable.unverified)
},
supportingText = null,
iconTint = if (isSupported) colorScheme.StatusGreen else colorScheme.StatusRed,
)
}
}
@Composable
private 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"
AsyncImage(
model = ImageRequest.Builder(LocalContext.current).data(imageUrl).build(),
contentScale = ContentScale.Inside,
contentDescription = deviceHardware.displayName,
placeholder = painterResource(org.meshtastic.feature.node.R.drawable.hw_unknown),
error = painterResource(org.meshtastic.feature.node.R.drawable.hw_unknown),
fallback = painterResource(org.meshtastic.feature.node.R.drawable.hw_unknown),
modifier = modifier.padding(16.dp),
)
}

View File

@@ -0,0 +1,200 @@
/*
* 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.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Air
import androidx.compose.material.icons.filled.BlurOn
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Height
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.Power
import androidx.compose.material.icons.filled.Scale
import androidx.compose.material.icons.filled.Speed
import androidx.compose.material.icons.filled.Thermostat
import androidx.compose.material.icons.filled.WaterDrop
import androidx.compose.material.icons.outlined.Navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.geeksville.mesh.ConfigProtos
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.util.UnitConversions
import org.meshtastic.core.model.util.UnitConversions.toTempString
import org.meshtastic.core.model.util.toSmallDistanceString
import org.meshtastic.core.model.util.toSpeedString
import org.meshtastic.core.strings.R
import org.meshtastic.feature.node.model.DrawableMetricInfo
import org.meshtastic.feature.node.model.VectorMetricInfo
@Suppress("CyclomaticComplexMethod", "LongMethod")
@Composable
internal fun EnvironmentMetrics(
node: Node,
displayUnits: ConfigProtos.Config.DisplayConfig.DisplayUnits,
isFahrenheit: Boolean = false,
) {
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),
org.meshtastic.feature.node.R.drawable.ic_outlined_dew_point_24,
),
)
}
if (hasSoilTemperature()) {
add(
DrawableMetricInfo(
R.string.soil_temperature,
soilTemperature.toTempString(isFahrenheit),
org.meshtastic.feature.node.R.drawable.soil_temperature,
),
)
}
if (hasSoilMoisture()) {
add(
DrawableMetricInfo(
R.string.soil_moisture,
"%d%%".format(soilMoisture),
org.meshtastic.feature.node.R.drawable.soil_moisture,
),
)
}
if (hasRadiation()) {
add(
DrawableMetricInfo(
R.string.radiation,
"%.1f µR/h".format(radiation),
org.meshtastic.feature.node.R.drawable.ic_filled_radioactive_24,
),
)
}
}
}
}
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalArrangement = Arrangement.SpaceEvenly,
) {
vectorMetrics.forEach { metric ->
InfoCard(
icon = metric.icon,
text = stringResource(metric.label),
value = metric.value,
rotateIcon = metric.rotateIcon,
)
}
drawableMetrics.forEach { metric ->
DrawableInfoCard(
iconRes = metric.icon,
text = stringResource(metric.label),
value = metric.value,
rotateIcon = metric.rotateIcon,
)
}
}
}

View File

@@ -0,0 +1,95 @@
/*
* 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.component
import android.content.ActivityNotFoundException
import android.content.Intent
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Link
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import com.mikepenz.markdown.m3.Markdown
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.strings.R
import timber.log.Timber
@Composable
fun FirmwareReleaseSheetContent(firmwareRelease: FirmwareRelease, modifier: Modifier = Modifier) {
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 = {
try {
val intent = Intent(Intent.ACTION_VIEW, firmwareRelease.pageUrl.toUri())
context.startActivity(intent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(context, R.string.error_no_app_to_handle_link, Toast.LENGTH_LONG).show()
Timber.e(e)
}
},
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 = {
try {
val intent = Intent(Intent.ACTION_VIEW, firmwareRelease.zipUrl.toUri())
context.startActivity(intent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(context, R.string.error_no_app_to_handle_link, Toast.LENGTH_LONG).show()
Timber.e(e)
}
},
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))
}
}
}
}

View File

@@ -0,0 +1,99 @@
/*
* 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.component
import androidx.annotation.DrawableRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@Composable
fun InfoCard(icon: ImageVector, text: String, value: String, modifier: Modifier = Modifier, 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(
imageVector = icon,
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,
)
}
}
}
}
@Composable
internal 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,
)
}
}
}
}
inline fun Modifier.thenIf(precondition: Boolean, action: Modifier.() -> Modifier): Modifier =
if (precondition) action() else this

View File

@@ -0,0 +1,93 @@
/*
* 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.component
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Navigation
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
@Preview(name = "Wind Dir -359°")
@Suppress("detekt:MagicNumber")
@Composable
private fun PreviewWindDirectionn359() {
PreviewWindDirectionItem(-359f)
}
@Preview(name = "Wind Dir 0°")
@Suppress("detekt:MagicNumber")
@Composable
private fun PreviewWindDirection0() {
PreviewWindDirectionItem(0f)
}
@Preview(name = "Wind Dir 45°")
@Suppress("detekt:MagicNumber")
@Composable
private fun PreviewWindDirection45() {
PreviewWindDirectionItem(45f)
}
@Preview(name = "Wind Dir 90°")
@Suppress("detekt:MagicNumber")
@Composable
private fun PreviewWindDirection90() {
PreviewWindDirectionItem(90f)
}
@Preview(name = "Wind Dir 180°")
@Suppress("detekt:MagicNumber")
@Composable
private fun PreviewWindDirection180() {
PreviewWindDirectionItem(180f)
}
@Preview(name = "Wind Dir 225°")
@Suppress("detekt:MagicNumber")
@Composable
private fun PreviewWindDirection225() {
PreviewWindDirectionItem(225f)
}
@Preview(name = "Wind Dir 270°")
@Suppress("detekt:MagicNumber")
@Composable
private fun PreviewWindDirection270() {
PreviewWindDirectionItem(270f)
}
@Preview(name = "Wind Dir 315°")
@Suppress("detekt:MagicNumber")
@Composable
private fun PreviewWindDirection315() {
PreviewWindDirectionItem(315f)
}
@Preview(name = "Wind Dir -45")
@Suppress("detekt:MagicNumber")
@Composable
private fun PreviewWindDirectionN45() {
PreviewWindDirectionItem(-45f)
}
@Suppress("detekt:MagicNumber")
@Composable
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)
}

View File

@@ -56,7 +56,7 @@ fun LinkedCoordinates(modifier: Modifier = Modifier, latitude: Double, longitude
val style =
SpanStyle(
color = HyperlinkBlue,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
fontStyle = MaterialTheme.typography.titleLarge.fontStyle,
textDecoration = TextDecoration.Underline,
)
@@ -74,6 +74,7 @@ fun LinkedCoordinates(modifier: Modifier = Modifier, latitude: Double, longitude
},
),
text = annotatedString,
style = MaterialTheme.typography.titleLarge,
)
}

View File

@@ -0,0 +1,67 @@
/*
* 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.component
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.SettingsItem
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.NodeDetailAction
@Composable
@Suppress("MultipleEmitters")
fun MetricsSection(
node: Node,
metricsState: MetricsState,
availableLogs: Set<LogsType>,
onAction: (NodeDetailAction) -> Unit,
modifier: Modifier = Modifier,
) {
if (node.hasEnvironmentMetrics) {
TitledCard(stringResource(R.string.environment), modifier = modifier) {}
EnvironmentMetrics(node, isFahrenheit = metricsState.isFahrenheit, displayUnits = metricsState.displayUnits)
Spacer(modifier = Modifier.height(8.dp))
}
if (node.hasPowerMetrics) {
TitledCard(stringResource(R.string.power), modifier = modifier) {}
PowerMetrics(node)
Spacer(modifier = Modifier.height(8.dp))
}
val nonPositionLogs = availableLogs.filter { it != LogsType.NODE_MAP && it != LogsType.POSITIONS }
if (nonPositionLogs.isNotEmpty()) {
TitledCard(title = stringResource(id = R.string.logs), modifier = modifier) {
nonPositionLogs.forEach { type ->
SettingsItem(text = stringResource(type.titleRes), leadingIcon = type.icon) {
onAction(NodeDetailAction.Navigate(type.route))
}
}
}
Spacer(modifier = Modifier.height(8.dp))
}
}

View File

@@ -0,0 +1,127 @@
/*
* 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.component
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.KeyOff
import androidx.compose.material.icons.filled.Numbers
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Work
import androidx.compose.material.icons.outlined.NoCell
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.twotone.Person
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.util.formatAgo
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.SettingsItemDetail
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
@Composable
fun NodeDetailsSection(node: Node, modifier: Modifier = Modifier) {
TitledCard(title = stringResource(R.string.details), modifier = modifier) {
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))
Text(
text = stringResource(id = R.string.encryption_error_text),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(16.dp))
}
MainNodeDetails(node)
}
}
@Composable
private fun MainNodeDetails(node: Node) {
SettingsItemDetail(
text = stringResource(R.string.long_name),
icon = Icons.TwoTone.Person,
supportingText = node.user.longName.ifEmpty { "???" },
)
SettingsItemDetail(
text = stringResource(R.string.short_name),
icon = Icons.Outlined.Person,
supportingText = node.user.shortName.ifEmpty { "???" },
)
SettingsItemDetail(
text = stringResource(R.string.node_number),
icon = Icons.Default.Numbers,
supportingText = node.num.toUInt().toString(),
)
SettingsItemDetail(
text = stringResource(R.string.user_id),
icon = Icons.Default.Person,
supportingText = node.user.id,
)
SettingsItemDetail(
text = stringResource(R.string.role),
icon = Icons.Default.Work,
supportingText = node.user.role.name,
)
if (node.isEffectivelyUnmessageable) {
SettingsItemDetail(
text = stringResource(R.string.unmonitored_or_infrastructure),
icon = Icons.Outlined.NoCell,
supportingText = null,
)
}
if (node.deviceMetrics.uptimeSeconds > 0) {
SettingsItemDetail(
text = stringResource(R.string.uptime),
icon = Icons.Default.CheckCircle,
supportingText = formatUptime(node.deviceMetrics.uptimeSeconds),
)
}
SettingsItemDetail(
text = stringResource(R.string.node_sort_last_heard),
icon = Icons.Default.History,
supportingText = formatAgo(node.lastHeard),
)
}

View File

@@ -0,0 +1,80 @@
/*
* 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.component
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.TitledCard
@Composable
fun NotesSection(node: Node, onSaveNotes: (Int, String) -> Unit, modifier: Modifier = Modifier) {
if (node.isFavorite) {
TitledCard(title = stringResource(R.string.notes), modifier = modifier) {
val originalNotes = node.notes
var notes by remember(node.notes) { mutableStateOf(node.notes) }
val edited = notes.trim() != originalNotes.trim()
val keyboardController = LocalSoftwareKeyboardController.current
OutlinedTextField(
value = notes,
onValueChange = { notes = it },
modifier = Modifier.fillMaxWidth().padding(8.dp),
placeholder = { Text(stringResource(id = R.string.add_a_note)) },
trailingIcon = {
IconButton(
onClick = {
onSaveNotes(node.num, notes.trim())
keyboardController?.hide()
},
enabled = edited,
) {
Icon(imageVector = Icons.Default.Save, contentDescription = stringResource(id = R.string.save))
}
},
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions =
KeyboardActions(
onDone = {
onSaveNotes(node.num, notes.trim())
keyboardController?.hide()
},
),
)
}
}
}

View File

@@ -0,0 +1,117 @@
/*
* 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.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.SocialDistance
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.util.formatAgo
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.SettingsItem
import org.meshtastic.core.ui.component.SettingsItemDetail
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.NodeDetailAction
import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
/**
* Displays node position details, last update time, distance, and related actions like requesting position and
* accessing map/position logs.
*/
@Composable
fun PositionSection(
node: Node,
ourNode: Node?,
metricsState: MetricsState,
availableLogs: Set<LogsType>,
onAction: (NodeDetailAction) -> Unit,
modifier: Modifier = Modifier,
) {
val distance = ourNode?.distance(node)?.takeIf { it > 0 }?.toDistanceString(metricsState.displayUnits)
val hasValidPosition = node.latitude != 0.0 || node.longitude != 0.0
TitledCard(title = stringResource(R.string.position), modifier = modifier) {
// Current position coordinates (linked)
if (hasValidPosition) {
InlineMap(node = node, Modifier.fillMaxWidth().height(200.dp))
SettingsItemDetail(
text = stringResource(R.string.last_position_update),
icon = Icons.Default.LocationOn,
supportingContent = {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(formatAgo(node.position.time), style = MaterialTheme.typography.titleLarge)
LinkedCoordinates(
latitude = node.latitude,
longitude = node.longitude,
nodeName = node.user.longName,
)
}
},
)
}
// Distance (if available)
if (distance != null && distance.isNotEmpty()) {
SettingsItemDetail(
text = stringResource(R.string.node_sort_distance),
icon = Icons.Default.SocialDistance,
supportingText = distance,
)
}
// Exchange position action
if (!node.isEffectivelyUnmessageable) {
SettingsItem(
text = stringResource(id = R.string.exchange_position),
leadingIcon = Icons.Default.LocationOn,
trailingContent = {},
onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestPosition(node))) },
)
}
// Node Map log
if (availableLogs.contains(LogsType.NODE_MAP)) {
SettingsItem(text = stringResource(LogsType.NODE_MAP.titleRes), leadingIcon = LogsType.NODE_MAP.icon) {
onAction(NodeDetailAction.Navigate(LogsType.NODE_MAP.route))
}
}
// Positions Log
if (availableLogs.contains(LogsType.POSITIONS)) {
SettingsItem(text = stringResource(LogsType.POSITIONS.titleRes), leadingIcon = LogsType.POSITIONS.icon) {
onAction(NodeDetailAction.Navigate(LogsType.POSITIONS.route))
}
}
}
}

View File

@@ -0,0 +1,76 @@
/*
* 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.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Power
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.strings.R
import org.meshtastic.feature.node.model.VectorMetricInfo
/**
* Displays environmental metrics for a node, including temperature, humidity, pressure, and other sensor data.
*
* WARNING: All metrics must be added in pairs (e.g., voltage and current for each channel) due to the display logic,
* which arranges metrics in columns of two. If an odd number of metrics is provided, the UI may not display as
* intended.
*/
@Composable
internal 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,
) {
metrics.chunked(2).forEach { rowMetrics ->
Column {
rowMetrics.forEach { metric ->
InfoCard(icon = metric.icon, text = stringResource(metric.label), value = metric.value)
}
}
}
}
}

View File

@@ -0,0 +1,51 @@
/*
* 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.component
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.Message
import androidx.compose.material.icons.filled.Person
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.SettingsItem
import org.meshtastic.feature.node.model.NodeDetailAction
import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
@Composable
internal fun RemoteDeviceActions(node: Node, lastTracerouteTime: Long?, onAction: (NodeDetailAction) -> Unit) {
if (!node.isEffectivelyUnmessageable) {
SettingsItem(
text = stringResource(id = R.string.direct_message),
leadingIcon = Icons.AutoMirrored.TwoTone.Message,
trailingContent = {},
onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.DirectMessage(node))) },
)
}
SettingsItem(
text = stringResource(id = R.string.exchange_userinfo),
leadingIcon = Icons.Default.Person,
trailingContent = {},
onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestUserInfo(node))) },
)
TracerouteButton(
lastTracerouteTime = lastTracerouteTime,
onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.TraceRoute(node))) },
)
}

View File

@@ -0,0 +1,29 @@
/*
* 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.model
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.database.model.isUnmessageableRole
val Node.isEffectivelyUnmessageable: Boolean
get() =
if (user.hasIsUnmessagable()) {
user.isUnmessagable
} else {
user.role?.isUnmessageableRole() == true
}

View File

@@ -0,0 +1,46 @@
/*
* 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.model
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChargingStation
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.Map
import androidx.compose.material.icons.filled.Memory
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.Power
import androidx.compose.material.icons.filled.Route
import androidx.compose.material.icons.filled.SignalCellularAlt
import androidx.compose.material.icons.filled.Thermostat
import androidx.compose.ui.graphics.vector.ImageVector
import org.meshtastic.core.navigation.NodeDetailRoutes
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.strings.R
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),
}

View File

@@ -0,0 +1,36 @@
/*
* 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.model
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.ui.graphics.vector.ImageVector
internal data class VectorMetricInfo(
@StringRes val label: Int,
val value: String,
val icon: ImageVector,
val rotateIcon: Float = 0f,
)
internal data class DrawableMetricInfo(
@StringRes val label: Int,
val value: String,
@DrawableRes val icon: Int,
val rotateIcon: Float = 0f,
)

View File

@@ -0,0 +1,142 @@
/*
* 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.model
import androidx.annotation.StringRes
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.ConfigProtos
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.TelemetryProtos
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.strings.R
import java.util.concurrent.TimeUnit
data class MetricsState(
val isLocal: Boolean = false,
val isManaged: Boolean = true,
val isFahrenheit: Boolean = false,
val displayUnits: ConfigProtos.Config.DisplayConfig.DisplayUnits =
ConfigProtos.Config.DisplayConfig.DisplayUnits.METRIC,
val node: Node? = null,
val deviceMetrics: List<TelemetryProtos.Telemetry> = emptyList(),
val signalMetrics: List<MeshProtos.MeshPacket> = emptyList(),
val powerMetrics: List<TelemetryProtos.Telemetry> = emptyList(),
val hostMetrics: List<TelemetryProtos.Telemetry> = emptyList(),
val tracerouteRequests: List<MeshLog> = emptyList(),
val tracerouteResults: List<MeshLog> = emptyList(),
val positionLogs: List<MeshProtos.Position> = emptyList(),
val deviceHardware: DeviceHardware? = null,
val isLocalDevice: Boolean = false,
val firmwareEdition: MeshProtos.FirmwareEdition? = null,
val latestStableFirmware: FirmwareRelease = FirmwareRelease(),
val latestAlphaFirmware: FirmwareRelease = FirmwareRelease(),
val paxMetrics: List<MeshLog> = emptyList(),
) {
fun hasDeviceMetrics() = deviceMetrics.isNotEmpty()
fun hasSignalMetrics() = signalMetrics.isNotEmpty()
fun hasPowerMetrics() = powerMetrics.isNotEmpty()
fun hasTracerouteLogs() = tracerouteRequests.isNotEmpty()
fun hasPositionLogs() = positionLogs.isNotEmpty()
fun hasHostMetrics() = hostMetrics.isNotEmpty()
fun hasPaxMetrics() = paxMetrics.isNotEmpty()
fun deviceMetricsFiltered(timeFrame: TimeFrame): List<TelemetryProtos.Telemetry> {
val oldestTime = timeFrame.calculateOldestTime()
return deviceMetrics.filter { it.time >= oldestTime }
}
fun signalMetricsFiltered(timeFrame: TimeFrame): List<MeshProtos.MeshPacket> {
val oldestTime = timeFrame.calculateOldestTime()
return signalMetrics.filter { it.rxTime >= oldestTime }
}
fun powerMetricsFiltered(timeFrame: TimeFrame): List<TelemetryProtos.Telemetry> {
val oldestTime = timeFrame.calculateOldestTime()
return powerMetrics.filter { it.time >= oldestTime }
}
companion object {
val Empty = MetricsState()
}
}
/** Supported time frames used to display data. */
@Suppress("MagicNumber")
enum class TimeFrame(val seconds: Long, @StringRes val strRes: Int) {
TWENTY_FOUR_HOURS(TimeUnit.DAYS.toSeconds(1), R.string.twenty_four_hours),
FORTY_EIGHT_HOURS(TimeUnit.DAYS.toSeconds(2), R.string.forty_eight_hours),
ONE_WEEK(TimeUnit.DAYS.toSeconds(7), R.string.one_week),
TWO_WEEKS(TimeUnit.DAYS.toSeconds(14), R.string.two_weeks),
FOUR_WEEKS(TimeUnit.DAYS.toSeconds(28), R.string.four_weeks),
MAX(0L, R.string.max),
;
fun calculateOldestTime(): Long = if (this == MAX) {
MAX.seconds
} else {
System.currentTimeMillis() / 1000 - this.seconds
}
/**
* The time interval to draw the vertical lines representing time on the x-axis.
*
* @return seconds epoch seconds
*/
fun lineInterval(): Long = when (this.ordinal) {
TWENTY_FOUR_HOURS.ordinal -> TimeUnit.HOURS.toSeconds(6)
FORTY_EIGHT_HOURS.ordinal -> TimeUnit.HOURS.toSeconds(12)
ONE_WEEK.ordinal,
TWO_WEEKS.ordinal,
-> TimeUnit.DAYS.toSeconds(1)
else -> TimeUnit.DAYS.toSeconds(7)
}
/** Used to detect a significant time separation between [TelemetryProtos.Telemetry]s. */
fun timeThreshold(): Long = when (this.ordinal) {
TWENTY_FOUR_HOURS.ordinal -> TimeUnit.HOURS.toSeconds(6)
FORTY_EIGHT_HOURS.ordinal -> TimeUnit.HOURS.toSeconds(12)
else -> TimeUnit.DAYS.toSeconds(1)
}
/**
* Calculates the needed [androidx.compose.ui.unit.Dp] depending on the amount of time being plotted.
*
* @param time in seconds
*/
fun dp(screenWidth: Int, time: Long): Dp {
val timePerScreen = this.lineInterval()
val multiplier = time / timePerScreen
val dp = (screenWidth * multiplier).toInt().dp
return dp.takeIf { it != 0.dp } ?: screenWidth.dp
}
}

View File

@@ -0,0 +1,32 @@
/*
* 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.model
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.feature.node.component.NodeMenuAction
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
}

View File

@@ -105,7 +105,7 @@ fun MapReportingPreference(
)
if (shouldReportLocation && mapReportingEnabled) {
Slider(
modifier = Modifier.Companion.padding(horizontal = 16.dp),
modifier = Modifier.padding(horizontal = 16.dp),
value = positionPrecision.toFloat(),
onValueChange = { onPositionPrecisionChanged(it.roundToInt()) },
enabled = enabled,
@@ -116,13 +116,13 @@ fun MapReportingPreference(
val unit = DistanceUnit.Companion.getFromLocale()
Text(
text = precisionMeters.toDistanceString(unit),
modifier = Modifier.Companion.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
fontSize = MaterialTheme.typography.bodyLarge.fontSize,
overflow = TextOverflow.Companion.Ellipsis,
maxLines = 1,
)
EditTextPreference(
modifier = Modifier.Companion.padding(bottom = 16.dp),
modifier = Modifier.padding(bottom = 16.dp),
title = stringResource(R.string.map_reporting_interval_seconds),
value = publishIntervalSecs,
isError = publishIntervalSecs < 3600,