diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 392407038..d146f2023 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/com/geeksville/mesh/model/EnvironmentMetricsState.kt b/app/src/main/java/com/geeksville/mesh/model/EnvironmentMetricsState.kt index 27be749c7..f1478e944 100644 --- a/app/src/main/java/com/geeksville/mesh/model/EnvironmentMetricsState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/EnvironmentMetricsState.kt @@ -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 = emptyList(), - val signalMetrics: List = emptyList(), - val powerMetrics: List = emptyList(), - val hostMetrics: List = emptyList(), - val tracerouteRequests: List = emptyList(), - val tracerouteResults: List = emptyList(), - val positionLogs: List = emptyList(), - val deviceHardware: DeviceHardware? = null, - val isLocalDevice: Boolean = false, - val firmwareEdition: MeshProtos.FirmwareEdition? = null, - val latestStableFirmware: FirmwareRelease = FirmwareRelease(), - val latestAlphaFirmware: FirmwareRelease = FirmwareRelease(), - val paxMetrics: List = 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 { - val oldestTime = timeFrame.calculateOldestTime() - return deviceMetrics.filter { it.time >= oldestTime } - } - - fun signalMetricsFiltered(timeFrame: TimeFrame): List { - val oldestTime = timeFrame.calculateOldestTime() - return signalMetrics.filter { it.rxTime >= oldestTime } - } - - fun powerMetricsFiltered(timeFrame: TimeFrame): List { - 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) diff --git a/app/src/main/java/com/geeksville/mesh/ui/metrics/DeviceMetrics.kt b/app/src/main/java/com/geeksville/mesh/ui/metrics/DeviceMetrics.kt index 3bd7467a3..75e97dee1 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/metrics/DeviceMetrics.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/metrics/DeviceMetrics.kt @@ -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 diff --git a/app/src/main/java/com/geeksville/mesh/ui/metrics/EnvironmentCharts.kt b/app/src/main/java/com/geeksville/mesh/ui/metrics/EnvironmentCharts.kt index d56c915b5..6d619c0fa 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/metrics/EnvironmentCharts.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/metrics/EnvironmentCharts.kt @@ -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 diff --git a/app/src/main/java/com/geeksville/mesh/ui/metrics/EnvironmentMetrics.kt b/app/src/main/java/com/geeksville/mesh/ui/metrics/EnvironmentMetrics.kt index 152a2acdf..cfe599695 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/metrics/EnvironmentMetrics.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/metrics/EnvironmentMetrics.kt @@ -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) { diff --git a/app/src/main/java/com/geeksville/mesh/ui/metrics/PaxMetrics.kt b/app/src/main/java/com/geeksville/mesh/ui/metrics/PaxMetrics.kt index c3f13d430..c8a65de5a 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/metrics/PaxMetrics.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/metrics/PaxMetrics.kt @@ -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 diff --git a/app/src/main/java/com/geeksville/mesh/ui/metrics/PowerMetrics.kt b/app/src/main/java/com/geeksville/mesh/ui/metrics/PowerMetrics.kt index e1d71578d..4757ce8ca 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/metrics/PowerMetrics.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/metrics/PowerMetrics.kt @@ -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 diff --git a/app/src/main/java/com/geeksville/mesh/ui/metrics/SignalMetrics.kt b/app/src/main/java/com/geeksville/mesh/ui/metrics/SignalMetrics.kt index 46d4cb1c5..57320df6e 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/metrics/SignalMetrics.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/metrics/SignalMetrics.kt @@ -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) { diff --git a/app/src/main/java/com/geeksville/mesh/ui/node/NodeDetailList.kt b/app/src/main/java/com/geeksville/mesh/ui/node/NodeDetailList.kt new file mode 100644 index 000000000..eb75360a8 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/node/NodeDetailList.kt @@ -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 . + */ + +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, + 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, + onSaveNotes: (Int, String) -> Unit, + modifier: Modifier = Modifier, +) { + var showFirmwareSheet by remember { mutableStateOf(false) } + var selectedFirmware by remember { mutableStateOf(null) } + + if (showFirmwareSheet) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) + ModalBottomSheet(onDismissRequest = { showFirmwareSheet = false }, sheetState = sheetState) { + selectedFirmware?.let { FirmwareReleaseSheetContent(firmwareRelease = it) } + } + } + + Column( + modifier = modifier.fillMaxSize().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 = { _, _ -> }, + ) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/node/NodeDetailScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/node/NodeDetailScreen.kt index 27f87acf4..8225f4c71 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/node/NodeDetailScreen.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/node/NodeDetailScreen.kt @@ -17,174 +17,28 @@ package com.geeksville.mesh.ui.node -import android.content.ActivityNotFoundException -import android.content.Intent -import android.widget.Toast -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -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.automirrored.twotone.Message -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.ChargingStation -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.Download -import androidx.compose.material.icons.filled.ForkLeft -import androidx.compose.material.icons.filled.Height -import androidx.compose.material.icons.filled.History -import androidx.compose.material.icons.filled.Icecream -import androidx.compose.material.icons.filled.KeyOff -import androidx.compose.material.icons.filled.LightMode -import androidx.compose.material.icons.filled.Link -import androidx.compose.material.icons.filled.LocationOn -import androidx.compose.material.icons.filled.Map -import androidx.compose.material.icons.filled.Memory -import androidx.compose.material.icons.filled.Numbers -import androidx.compose.material.icons.filled.People -import androidx.compose.material.icons.filled.Person -import androidx.compose.material.icons.filled.Power -import androidx.compose.material.icons.filled.Route -import androidx.compose.material.icons.filled.Router -import androidx.compose.material.icons.filled.Save -import androidx.compose.material.icons.filled.Scale -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.SignalCellularAlt -import androidx.compose.material.icons.filled.SocialDistance -import androidx.compose.material.icons.filled.Speed -import androidx.compose.material.icons.filled.Star -import androidx.compose.material.icons.filled.StarBorder -import androidx.compose.material.icons.filled.Thermostat -import androidx.compose.material.icons.filled.WaterDrop -import androidx.compose.material.icons.filled.Work -import androidx.compose.material.icons.outlined.Navigation -import androidx.compose.material.icons.outlined.NoCell -import androidx.compose.material.icons.outlined.Person -import androidx.compose.material.icons.rounded.Delete -import androidx.compose.material.icons.rounded.QrCode2 -import androidx.compose.material.icons.twotone.Person -import androidx.compose.material.icons.twotone.Verified -import androidx.compose.material3.Button -import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.MaterialTheme.colorScheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf 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.draw.clip -import androidx.compose.ui.draw.rotate -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.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.unit.dp -import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil3.compose.AsyncImage -import coil3.request.ImageRequest -import com.geeksville.mesh.ConfigProtos -import com.geeksville.mesh.MeshProtos -import com.geeksville.mesh.model.MetricsState import com.geeksville.mesh.model.MetricsViewModel -import com.geeksville.mesh.ui.sharing.SharedContactDialog -import com.geeksville.mesh.util.thenIf -import com.mikepenz.markdown.m3.Markdown -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.database.model.isUnmessageableRole import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.DeviceHardware -import org.meshtastic.core.model.DeviceVersion -import org.meshtastic.core.model.util.UnitConversions -import org.meshtastic.core.model.util.UnitConversions.toTempString -import org.meshtastic.core.model.util.formatAgo -import org.meshtastic.core.model.util.formatUptime -import org.meshtastic.core.model.util.toDistanceString -import org.meshtastic.core.model.util.toSmallDistanceString -import org.meshtastic.core.model.util.toSpeedString -import org.meshtastic.core.navigation.NodeDetailRoutes import org.meshtastic.core.navigation.Route -import org.meshtastic.core.navigation.SettingsRoutes -import org.meshtastic.core.service.ServiceAction -import org.meshtastic.core.strings.R import org.meshtastic.core.ui.component.MainAppBar -import org.meshtastic.core.ui.component.SettingsItem -import org.meshtastic.core.ui.component.SettingsItemDetail -import org.meshtastic.core.ui.component.SettingsItemSwitch -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.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.component.NodeActionDialogs import org.meshtastic.feature.node.component.NodeMenuAction -import org.meshtastic.feature.node.component.TracerouteButton import org.meshtastic.feature.node.detail.NodeDetailViewModel -import timber.log.Timber - -private data class VectorMetricInfo( - @StringRes val label: Int, - val value: String, - val icon: ImageVector, - val rotateIcon: Float = 0f, -) - -private data class DrawableMetricInfo( - @StringRes val label: Int, - val value: String, - @DrawableRes val icon: Int, - val rotateIcon: Float = 0f, -) +import org.meshtastic.feature.node.model.LogsType +import org.meshtastic.feature.node.model.NodeDetailAction @Suppress("LongMethod") @Composable @@ -302,912 +156,3 @@ private fun handleNodeAction( } } } - -sealed interface NodeDetailAction { - data class Navigate(val route: Route) : NodeDetailAction - - data class TriggerServiceAction(val action: ServiceAction) : NodeDetailAction - - data class HandleNodeMenuAction(val action: NodeMenuAction) : NodeDetailAction - - data object ShareContact : NodeDetailAction -} - -val Node.isEffectivelyUnmessageable: Boolean - get() = - if (user.hasIsUnmessagable()) { - user.isUnmessagable - } else { - user.role?.isUnmessageableRole() == true - } - -private enum class LogsType(@StringRes val titleRes: Int, val icon: ImageVector, val route: Route) { - DEVICE(R.string.device_metrics_log, Icons.Default.ChargingStation, NodeDetailRoutes.DeviceMetrics), - NODE_MAP(R.string.node_map, Icons.Default.Map, NodeDetailRoutes.NodeMap), - POSITIONS(R.string.position_log, Icons.Default.LocationOn, NodeDetailRoutes.PositionLog), - ENVIRONMENT(R.string.env_metrics_log, Icons.Default.Thermostat, NodeDetailRoutes.EnvironmentMetrics), - SIGNAL(R.string.sig_metrics_log, Icons.Default.SignalCellularAlt, NodeDetailRoutes.SignalMetrics), - POWER(R.string.power_metrics_log, Icons.Default.Power, NodeDetailRoutes.PowerMetrics), - TRACEROUTE(R.string.traceroute_log, Icons.Default.Route, NodeDetailRoutes.TracerouteLog), - HOST(R.string.host_metrics_log, Icons.Default.Memory, NodeDetailRoutes.HostMetricsLog), - PAX(R.string.pax_metrics_log, Icons.Default.People, NodeDetailRoutes.PaxMetrics), -} - -@Composable -private fun NodeDetailContent( - node: Node, - ourNode: Node?, - metricsState: MetricsState, - lastTracerouteTime: Long?, - availableLogs: Set, - onAction: (NodeDetailAction) -> Unit, - modifier: Modifier = Modifier, - onSaveNotes: (nodeNum: Int, notes: String) -> Unit, -) { - 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, - ) -} - -@Composable -private fun notesSection(node: Node, onSaveNotes: (Int, String) -> Unit) { - if (node.isFavorite) { - TitledCard(title = stringResource(R.string.notes)) { - 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() - }, - ), - ) - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun NodeDetailList( - modifier: Modifier = Modifier, - node: Node, - lastTracerouteTime: Long?, - ourNode: Node?, - metricsState: MetricsState, - onAction: (NodeDetailAction) -> Unit, - availableLogs: Set, - onSaveNotes: (Int, String) -> Unit, -) { - var showFirmwareSheet by remember { mutableStateOf(false) } - var selectedFirmware by remember { mutableStateOf(null) } - - if (showFirmwareSheet) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) - ModalBottomSheet(onDismissRequest = { showFirmwareSheet = false }, sheetState = sheetState) { - selectedFirmware?.let { FirmwareReleaseSheetContent(firmwareRelease = it) } - } - } - - Column( - modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - if (metricsState.deviceHardware != null) { - TitledCard(title = stringResource(R.string.device)) { - Spacer(modifier = Modifier.height(16.dp)) - DeviceDetailsContent(metricsState) - } - } - - TitledCard(title = stringResource(R.string.details)) { - NodeDetailsContent(node, ourNode, metricsState.displayUnits) - } - notesSection(node = node, onSaveNotes = onSaveNotes) - - DeviceActions( - isLocal = metricsState.isLocal, - lastTracerouteTime = lastTracerouteTime, - node = node, - onAction = onAction, - ) - MetricsSection(node, metricsState, availableLogs, onAction) - - if (!metricsState.isManaged) { - AdministrationSection( - node = node, - metricsState = metricsState, - onAction = onAction, - onFirmwareSelected = { firmware -> - selectedFirmware = firmware - showFirmwareSheet = true - }, - ) - } - } -} - -@Composable -private fun MetricsSection( - node: Node, - metricsState: MetricsState, - availableLogs: Set, - onAction: (NodeDetailAction) -> Unit, -) { - if (node.hasEnvironmentMetrics) { - TitledCard(stringResource(R.string.environment)) {} - EnvironmentMetrics(node, metricsState.isFahrenheit, metricsState.displayUnits) - Spacer(modifier = Modifier.height(8.dp)) - } - - if (node.hasPowerMetrics) { - TitledCard(stringResource(R.string.power)) {} - PowerMetrics(node) - Spacer(modifier = Modifier.height(8.dp)) - } - - if (availableLogs.isNotEmpty()) { - TitledCard(title = stringResource(id = R.string.logs)) { - LogsType.entries.forEach { type -> - if (availableLogs.contains(type)) { - SettingsItem(text = stringResource(type.titleRes), leadingIcon = type.icon) { - onAction(NodeDetailAction.Navigate(type.route)) - } - } - } - } - } -} - -@Suppress("LongMethod") -@Composable -private fun AdministrationSection( - node: Node, - metricsState: MetricsState, - onAction: (NodeDetailAction) -> Unit, - onFirmwareSelected: (FirmwareRelease) -> Unit, -) { - TitledCard(stringResource(id = R.string.administration)) { - 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))) - } - } - - TitledCard(stringResource(R.string.firmware)) { - if (metricsState.isLocal) { - val firmwareEdition = metricsState.firmwareEdition - 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, - ) - } - } - node.metadata?.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 = colorScheme.StatusGreen, - onClick = { onFirmwareSelected(latestStable) }, - ) - SettingsItemDetail( - text = stringResource(R.string.latest_alpha_firmware), - icon = Icons.Default.Memory, - supportingText = latestAlpha.id.substringBeforeLast(".").replace("v", ""), - iconTint = colorScheme.StatusYellow, - onClick = { onFirmwareSelected(latestAlpha) }, - ) - } - } -} - -@Composable -private fun DeviceVersion.determineFirmwareStatusColor( - latestStable: FirmwareRelease, - latestAlpha: FirmwareRelease, -): Color { - val stableVersion = latestStable.asDeviceVersion() - val alphaVersion = latestAlpha.asDeviceVersion() - return when { - this < stableVersion -> colorScheme.StatusRed - this == stableVersion -> colorScheme.StatusGreen - this in stableVersion..alphaVersion -> colorScheme.StatusYellow - this > alphaVersion -> colorScheme.StatusOrange - else -> colorScheme.onSurface - } -} - -@Composable -private fun FirmwareReleaseSheetContent(firmwareRelease: FirmwareRelease) { - val context = LocalContext.current - Column( - modifier = Modifier.verticalScroll(rememberScrollState()).padding(16.dp).fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text(text = firmwareRelease.title, style = MaterialTheme.typography.titleLarge) - Text(text = "Version: ${firmwareRelease.id}", style = MaterialTheme.typography.bodyMedium) - Markdown(modifier = Modifier.padding(8.dp), content = firmwareRelease.releaseNotes) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button( - onClick = { - 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)) - } - } - } -} - -@Composable -private fun DeviceActions( - isLocal: Boolean = false, - node: Node, - lastTracerouteTime: Long?, - onAction: (NodeDetailAction) -> Unit, -) { - 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)) { - 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 }, - ) - } -} - -@Composable -private 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_position), - leadingIcon = Icons.Default.LocationOn, - trailingContent = {}, - onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestPosition(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))) }, - ) -} - -@Composable -private fun ColumnScope.DeviceDetailsContent(state: MetricsState) { - val node = state.node ?: return - val deviceHardware = state.deviceHardware ?: return - val hwModelName = deviceHardware.displayName - val isSupported = deviceHardware.activelySupported - 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(com.geeksville.mesh.R.drawable.unverified) - }, - supportingText = null, - iconTint = if (isSupported) colorScheme.StatusGreen else colorScheme.StatusRed, - ) -} - -@Composable -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(com.geeksville.mesh.R.drawable.hw_unknown), - error = painterResource(com.geeksville.mesh.R.drawable.hw_unknown), - fallback = painterResource(com.geeksville.mesh.R.drawable.hw_unknown), - modifier = modifier.padding(16.dp), - ) -} - -@Composable -private fun NodeDetailsContent( - node: Node, - ourNode: Node?, - displayUnits: ConfigProtos.Config.DisplayConfig.DisplayUnits, -) { - if (node.mismatchKey) { - EncryptionErrorContent() - } - MainNodeDetails(node, ourNode, displayUnits) -} - -@Composable -private fun EncryptionErrorContent() { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Default.KeyOff, - contentDescription = stringResource(id = R.string.encryption_error), - tint = Color.Red, - ) - Spacer(Modifier.width(12.dp)) - Text( - text = stringResource(id = R.string.encryption_error), - 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)) -} - -@Composable -private fun MainNodeDetails(node: Node, ourNode: Node?, displayUnits: ConfigProtos.Config.DisplayConfig.DisplayUnits) { - 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), - ) - val distance = ourNode?.distance(node)?.takeIf { it > 0 }?.toDistanceString(displayUnits) - if (distance != null && distance.isNotEmpty()) { - SettingsItemDetail( - text = stringResource(R.string.node_sort_distance), - icon = Icons.Default.SocialDistance, - supportingText = distance, - ) - } - - SettingsItemDetail( - text = stringResource(R.string.last_position_update), - icon = Icons.Default.LocationOn, - supportingText = formatAgo(node.position.time), - ) -} - -@Composable -private fun InfoCard(icon: ImageVector, text: String, value: String, rotateIcon: Float = 0f) { - Card(modifier = Modifier.padding(4.dp).width(100.dp).height(100.dp)) { - 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 -private fun DrawableInfoCard(@DrawableRes iconRes: Int, text: String, value: String, rotateIcon: Float = 0f) { - Card(modifier = Modifier.padding(4.dp).width(100.dp).height(100.dp)) { - Box(modifier = Modifier.padding(4.dp).width(100.dp).height(100.dp), contentAlignment = Alignment.Center) { - Column(verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) { - Icon( - painter = painterResource(id = iconRes), - contentDescription = text, - modifier = Modifier.size(24.dp).thenIf(rotateIcon != 0f) { rotate(rotateIcon) }, - ) - Text( - textAlign = TextAlign.Center, - text = text, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.labelSmall, - ) - Text( - text = value, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.titleMedium, - ) - } - } - } -} - -@Suppress("CyclomaticComplexMethod", "LongMethod") -@Composable -private fun EnvironmentMetrics( - node: Node, - isFahrenheit: Boolean = false, - displayUnits: ConfigProtos.Config.DisplayConfig.DisplayUnits, -) { - val vectorMetrics = - remember(node.environmentMetrics, isFahrenheit, displayUnits) { - buildList { - with(node.environmentMetrics) { - if (hasTemperature()) { - add( - VectorMetricInfo( - R.string.temperature, - temperature.toTempString(isFahrenheit), - Icons.Default.Thermostat, - ), - ) - } - if (hasRelativeHumidity()) { - add( - VectorMetricInfo( - R.string.humidity, - "%.0f%%".format(relativeHumidity), - Icons.Default.WaterDrop, - ), - ) - } - if (hasBarometricPressure()) { - add( - VectorMetricInfo( - R.string.pressure, - "%.0f hPa".format(barometricPressure), - Icons.Default.Speed, - ), - ) - } - if (hasGasResistance()) { - add( - VectorMetricInfo( - R.string.gas_resistance, - "%.0f MΩ".format(gasResistance), - Icons.Default.BlurOn, - ), - ) - } - if (hasVoltage()) { - add(VectorMetricInfo(R.string.voltage, "%.2fV".format(voltage), Icons.Default.Bolt)) - } - if (hasCurrent()) { - add(VectorMetricInfo(R.string.current, "%.1fmA".format(current), Icons.Default.Power)) - } - if (hasIaq()) add(VectorMetricInfo(R.string.iaq, iaq.toString(), Icons.Default.Air)) - if (hasDistance()) { - add( - VectorMetricInfo( - R.string.distance, - distance.toSmallDistanceString(displayUnits), - Icons.Default.Height, - ), - ) - } - if (hasLux()) add(VectorMetricInfo(R.string.lux, "%.0f lx".format(lux), Icons.Default.LightMode)) - if (hasUvLux()) { - add(VectorMetricInfo(R.string.uv_lux, "%.0f lx".format(uvLux), Icons.Default.LightMode)) - } - if (hasWindSpeed()) { - @Suppress("MagicNumber") - val normalizedBearing = (windDirection + 180) % 360 - add( - VectorMetricInfo( - R.string.wind, - windSpeed.toSpeedString(displayUnits), - Icons.Outlined.Navigation, - normalizedBearing.toFloat(), - ), - ) - } - if (hasWeight()) { - add(VectorMetricInfo(R.string.weight, "%.2f kg".format(weight), Icons.Default.Scale)) - } - } - } - } - val drawableMetrics = - remember(node.environmentMetrics, isFahrenheit) { - buildList { - with(node.environmentMetrics) { - if (hasTemperature() && hasRelativeHumidity()) { - val dewPoint = UnitConversions.calculateDewPoint(temperature, relativeHumidity) - add( - DrawableMetricInfo( - R.string.dew_point, - dewPoint.toTempString(isFahrenheit), - com.geeksville.mesh.R.drawable.ic_outlined_dew_point_24, - ), - ) - } - if (hasSoilTemperature()) { - add( - DrawableMetricInfo( - R.string.soil_temperature, - soilTemperature.toTempString(isFahrenheit), - com.geeksville.mesh.R.drawable.soil_temperature, - ), - ) - } - if (hasSoilMoisture()) { - add( - DrawableMetricInfo( - R.string.soil_moisture, - "%d%%".format(soilMoisture), - com.geeksville.mesh.R.drawable.soil_moisture, - ), - ) - } - if (hasRadiation()) { - add( - DrawableMetricInfo( - R.string.radiation, - "%.1f µR/h".format(radiation), - com.geeksville.mesh.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, - ) - } - } -} - -/** - * 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 -private fun PowerMetrics(node: Node) { - val metrics = - remember(node.powerMetrics) { - buildList { - with(node.powerMetrics) { - if (ch1Voltage != 0f) { - add(VectorMetricInfo(R.string.channel_1, "%.2fV".format(ch1Voltage), Icons.Default.Bolt)) - add(VectorMetricInfo(R.string.channel_1, "%.1fmA".format(ch1Current), Icons.Default.Power)) - } - if (ch2Voltage != 0f) { - add(VectorMetricInfo(R.string.channel_2, "%.2fV".format(ch2Voltage), Icons.Default.Bolt)) - add(VectorMetricInfo(R.string.channel_2, "%.1fmA".format(ch2Current), Icons.Default.Power)) - } - if (ch3Voltage != 0f) { - add(VectorMetricInfo(R.string.channel_3, "%.2fV".format(ch3Voltage), Icons.Default.Bolt)) - add(VectorMetricInfo(R.string.channel_3, "%.1fmA".format(ch3Current), Icons.Default.Power)) - } - } - } - } - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalArrangement = Arrangement.SpaceEvenly, - ) { - metrics.chunked(2).forEach { rowMetrics -> - Column { - rowMetrics.forEach { metric -> - InfoCard(icon = metric.icon, text = stringResource(metric.label), value = metric.value) - } - } - } - } -} - -@Preview(showBackground = true) -@Composable -private fun NodeDetailsPreview(@PreviewParameter(NodePreviewParameterProvider::class) node: Node) { - AppTheme { - NodeDetailList( - node = node, - ourNode = node, - lastTracerouteTime = null, - metricsState = MetricsState.Empty, - availableLogs = emptySet(), - onAction = {}, - onSaveNotes = { _, _ -> }, - ) - } -} - -@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) -} diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/SettingsItem.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/SettingsItem.kt index b5a003954..7460b2cc3 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/SettingsItem.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/SettingsItem.kt @@ -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. diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/CustomMapLayersSheet.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/CustomMapLayersSheet.kt index ef71a6fb6..da9f2683e 100644 --- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/CustomMapLayersSheet.kt +++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/CustomMapLayersSheet.kt @@ -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)) } } diff --git a/feature/node/build.gradle.kts b/feature/node/build.gradle.kts index 26290bda7..bb5d5a22d 100644 --- a/feature/node/build.gradle.kts +++ b/feature/node/build.gradle.kts @@ -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) } diff --git a/feature/node/src/fdroid/kotlin/org/meshtastic/feature/node/component/InlineMap.kt b/feature/node/src/fdroid/kotlin/org/meshtastic/feature/node/component/InlineMap.kt new file mode 100644 index 000000000..27416ceb1 --- /dev/null +++ b/feature/node/src/fdroid/kotlin/org/meshtastic/feature/node/component/InlineMap.kt @@ -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 . + */ + +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 +} diff --git a/feature/node/src/google/kotlin/org/meshtastic/feature/node/component/InlineMap.kt b/feature/node/src/google/kotlin/org/meshtastic/feature/node/component/InlineMap.kt new file mode 100644 index 000000000..b4a56914d --- /dev/null +++ b/feature/node/src/google/kotlin/org/meshtastic/feature/node/component/InlineMap.kt @@ -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 . + */ + +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) } + } +} diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt new file mode 100644 index 000000000..e9449beaf --- /dev/null +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt @@ -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 . + */ + +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 + } +} diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt new file mode 100644 index 000000000..de4a76204 --- /dev/null +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package 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 }, + ) + } +} diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceDetailsSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceDetailsSection.kt new file mode 100644 index 000000000..8bb4d7fc6 --- /dev/null +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceDetailsSection.kt @@ -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 . + */ + +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), + ) +} diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt new file mode 100644 index 000000000..a384f331d --- /dev/null +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt @@ -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 . + */ + +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, + ) + } + } +} diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/FirmwareReleaseSheetContent.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/FirmwareReleaseSheetContent.kt new file mode 100644 index 000000000..700eaeea2 --- /dev/null +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/FirmwareReleaseSheetContent.kt @@ -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 . + */ + +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)) + } + } + } +} diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/InfoCard.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/InfoCard.kt new file mode 100644 index 000000000..4bc18b409 --- /dev/null +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/InfoCard.kt @@ -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 . + */ + +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 diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/InfoCardPreview.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/InfoCardPreview.kt new file mode 100644 index 000000000..f7d46a939 --- /dev/null +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/InfoCardPreview.kt @@ -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 . + */ + +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) +} diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/LinkedCoordinates.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/LinkedCoordinates.kt index 7b03af764..2cc69f50f 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/LinkedCoordinates.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/LinkedCoordinates.kt @@ -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, ) } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/MetricsSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/MetricsSection.kt new file mode 100644 index 000000000..1d31c1744 --- /dev/null +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/MetricsSection.kt @@ -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 . + */ + +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, + 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)) + } +} diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt new file mode 100644 index 000000000..e17e6f9f7 --- /dev/null +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt @@ -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 . + */ + +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), + ) +} diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NotesSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NotesSection.kt new file mode 100644 index 000000000..17d0e623a --- /dev/null +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NotesSection.kt @@ -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 . + */ + +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() + }, + ), + ) + } + } +} diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt new file mode 100644 index 000000000..58591076b --- /dev/null +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt @@ -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 . + */ + +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, + 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)) + } + } + } +} diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PowerMetrics.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PowerMetrics.kt new file mode 100644 index 000000000..3d78e898e --- /dev/null +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PowerMetrics.kt @@ -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 . + */ + +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) + } + } + } + } +} diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/RemoteDeviceActions.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/RemoteDeviceActions.kt new file mode 100644 index 000000000..e1c00f6d6 --- /dev/null +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/RemoteDeviceActions.kt @@ -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 . + */ + +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))) }, + ) +} diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/IsEffectivelyUnmessageable.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/IsEffectivelyUnmessageable.kt new file mode 100644 index 000000000..589983537 --- /dev/null +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/IsEffectivelyUnmessageable.kt @@ -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 . + */ + +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 + } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/LogsType.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/LogsType.kt new file mode 100644 index 000000000..1d4d77748 --- /dev/null +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/LogsType.kt @@ -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 . + */ + +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), +} diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/MetricInfo.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/MetricInfo.kt new file mode 100644 index 000000000..69a8332ba --- /dev/null +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/MetricInfo.kt @@ -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 . + */ + +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, +) diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/MetricsState.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/MetricsState.kt new file mode 100644 index 000000000..7b0251976 --- /dev/null +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/MetricsState.kt @@ -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 . + */ + +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 = emptyList(), + val signalMetrics: List = emptyList(), + val powerMetrics: List = emptyList(), + val hostMetrics: List = emptyList(), + val tracerouteRequests: List = emptyList(), + val tracerouteResults: List = emptyList(), + val positionLogs: List = emptyList(), + val deviceHardware: DeviceHardware? = null, + val isLocalDevice: Boolean = false, + val firmwareEdition: MeshProtos.FirmwareEdition? = null, + val latestStableFirmware: FirmwareRelease = FirmwareRelease(), + val latestAlphaFirmware: FirmwareRelease = FirmwareRelease(), + val paxMetrics: List = 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 { + val oldestTime = timeFrame.calculateOldestTime() + return deviceMetrics.filter { it.time >= oldestTime } + } + + fun signalMetricsFiltered(timeFrame: TimeFrame): List { + val oldestTime = timeFrame.calculateOldestTime() + return signalMetrics.filter { it.rxTime >= oldestTime } + } + + fun powerMetricsFiltered(timeFrame: TimeFrame): List { + 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 + } +} diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/NodeDetailAction.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/NodeDetailAction.kt new file mode 100644 index 000000000..09829beb8 --- /dev/null +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/NodeDetailAction.kt @@ -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 . + */ + +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 +} diff --git a/app/src/main/res/drawable/hw_unknown.xml b/feature/node/src/main/res/drawable/hw_unknown.xml similarity index 100% rename from app/src/main/res/drawable/hw_unknown.xml rename to feature/node/src/main/res/drawable/hw_unknown.xml diff --git a/app/src/main/res/drawable/ic_filled_radioactive_24.xml b/feature/node/src/main/res/drawable/ic_filled_radioactive_24.xml similarity index 100% rename from app/src/main/res/drawable/ic_filled_radioactive_24.xml rename to feature/node/src/main/res/drawable/ic_filled_radioactive_24.xml diff --git a/app/src/main/res/drawable/ic_outlined_dew_point_24.xml b/feature/node/src/main/res/drawable/ic_outlined_dew_point_24.xml similarity index 100% rename from app/src/main/res/drawable/ic_outlined_dew_point_24.xml rename to feature/node/src/main/res/drawable/ic_outlined_dew_point_24.xml diff --git a/app/src/main/res/drawable/soil_moisture.xml b/feature/node/src/main/res/drawable/soil_moisture.xml similarity index 100% rename from app/src/main/res/drawable/soil_moisture.xml rename to feature/node/src/main/res/drawable/soil_moisture.xml diff --git a/app/src/main/res/drawable/soil_temperature.xml b/feature/node/src/main/res/drawable/soil_temperature.xml similarity index 100% rename from app/src/main/res/drawable/soil_temperature.xml rename to feature/node/src/main/res/drawable/soil_temperature.xml diff --git a/app/src/main/res/drawable/unverified.xml b/feature/node/src/main/res/drawable/unverified.xml similarity index 100% rename from app/src/main/res/drawable/unverified.xml rename to feature/node/src/main/res/drawable/unverified.xml diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreference.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreference.kt index 2007e848f..8b3d65615 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreference.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreference.kt @@ -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,