mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: introduce Desktop target and expand Kotlin Multiplatform (KMP) architecture (#4761)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
f4364cff9a
commit
ac6bb5479b
386 changed files with 17089 additions and 4590 deletions
|
|
@ -17,6 +17,7 @@
|
|||
package org.meshtastic.feature.node.compass
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.location.Location
|
||||
import android.location.LocationManager
|
||||
|
|
@ -36,6 +37,7 @@ import org.meshtastic.core.di.CoroutineDispatchers
|
|||
class AndroidPhoneLocationProvider(private val context: Context, private val dispatchers: CoroutineDispatchers) :
|
||||
PhoneLocationProvider {
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun locationUpdates(): Flow<PhoneLocationState> = callbackFlow {
|
||||
val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager
|
||||
if (locationManager == null) {
|
||||
|
|
@ -91,7 +93,7 @@ class AndroidPhoneLocationProvider(private val context: Context, private val dis
|
|||
sendUpdate()
|
||||
|
||||
providers.forEach { provider ->
|
||||
if (locationManager.getProvider(provider) != null) {
|
||||
if (provider in locationManager.allProviders) {
|
||||
LocationManagerCompat.requestLocationUpdates(
|
||||
locationManager,
|
||||
provider,
|
||||
|
|
|
|||
|
|
@ -21,16 +21,8 @@ import android.content.Intent
|
|||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Scaffold
|
||||
|
|
@ -44,11 +36,8 @@ 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.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -60,22 +49,15 @@ import org.meshtastic.core.model.Node
|
|||
import org.meshtastic.core.navigation.Route
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.details
|
||||
import org.meshtastic.core.resources.loading
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.component.SharedContactDialog
|
||||
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import org.meshtastic.feature.node.compass.CompassUiState
|
||||
import org.meshtastic.feature.node.compass.CompassViewModel
|
||||
import org.meshtastic.feature.node.component.AdministrationSection
|
||||
import org.meshtastic.feature.node.component.CompassSheetContent
|
||||
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.NodeDetailsSection
|
||||
import org.meshtastic.feature.node.component.NodeMenuAction
|
||||
import org.meshtastic.feature.node.component.NotesSection
|
||||
import org.meshtastic.feature.node.component.PositionSection
|
||||
import org.meshtastic.feature.node.model.MetricsState
|
||||
import org.meshtastic.feature.node.model.NodeDetailAction
|
||||
|
||||
|
|
@ -161,7 +143,6 @@ private fun NodeDetailScaffold(
|
|||
) { paddingValues ->
|
||||
NodeDetailContent(
|
||||
uiState = uiState,
|
||||
viewModel = viewModel,
|
||||
listState = listState,
|
||||
onAction = { action ->
|
||||
when (action) {
|
||||
|
|
@ -182,6 +163,7 @@ private fun NodeDetailScaffold(
|
|||
}
|
||||
},
|
||||
onFirmwareSelect = { activeOverlay = NodeDetailOverlay.FirmwareReleaseInfo(it) },
|
||||
onSaveNotes = { num, notes -> viewModel.setNodeNotes(num, notes) },
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
)
|
||||
}
|
||||
|
|
@ -191,35 +173,6 @@ private fun NodeDetailScaffold(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NodeDetailContent(
|
||||
uiState: NodeDetailUiState,
|
||||
viewModel: NodeDetailViewModel,
|
||||
listState: LazyListState,
|
||||
onAction: (NodeDetailAction) -> Unit,
|
||||
onFirmwareSelect: (FirmwareRelease) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Crossfade(targetState = uiState.node != null, label = "NodeDetailContent", modifier = modifier) { isNodePresent ->
|
||||
if (isNodePresent && uiState.node != null) {
|
||||
NodeDetailList(
|
||||
node = uiState.node,
|
||||
ourNode = uiState.ourNode,
|
||||
uiState = uiState,
|
||||
listState = listState,
|
||||
onAction = onAction,
|
||||
onFirmwareSelect = onFirmwareSelect,
|
||||
onSaveNotes = { num, notes -> viewModel.setNodeNotes(num, notes) },
|
||||
)
|
||||
} else {
|
||||
val loadingDescription = stringResource(Res.string.loading)
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(modifier = Modifier.semantics { contentDescription = loadingDescription })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun NodeDetailOverlays(
|
||||
|
|
@ -276,46 +229,6 @@ private fun NodeDetailBottomSheet(onDismiss: () -> Unit, content: @Composable ()
|
|||
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { content() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NodeDetailList(
|
||||
node: Node,
|
||||
ourNode: Node?,
|
||||
uiState: NodeDetailUiState,
|
||||
listState: LazyListState,
|
||||
onAction: (NodeDetailAction) -> Unit,
|
||||
onFirmwareSelect: (FirmwareRelease) -> Unit,
|
||||
onSaveNotes: (Int, String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
state = listState,
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
item { NodeDetailsSection(node) }
|
||||
item {
|
||||
DeviceActions(
|
||||
node = node,
|
||||
lastTracerouteTime = uiState.lastTracerouteTime,
|
||||
lastRequestNeighborsTime = uiState.lastRequestNeighborsTime,
|
||||
availableLogs = uiState.availableLogs,
|
||||
onAction = onAction,
|
||||
metricsState = uiState.metricsState,
|
||||
isLocal = uiState.metricsState.isLocal,
|
||||
)
|
||||
}
|
||||
item { PositionSection(node, ourNode, uiState.metricsState, uiState.availableLogs, onAction) }
|
||||
if (uiState.metricsState.deviceHardware != null) {
|
||||
item { DeviceDetailsSection(uiState.metricsState) }
|
||||
}
|
||||
item { NotesSection(node = node, onSaveNotes = onSaveNotes) }
|
||||
if (!uiState.metricsState.isManaged) {
|
||||
item { AdministrationSection(node, uiState.metricsState, onAction, onFirmwareSelect) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleNodeAction(
|
||||
action: NodeDetailAction,
|
||||
uiState: NodeDetailUiState,
|
||||
|
|
|
|||
|
|
@ -30,22 +30,9 @@ import androidx.compose.foundation.layout.padding
|
|||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeOff
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||
import androidx.compose.material.icons.filled.DoDisturbOn
|
||||
import androidx.compose.material.icons.outlined.DoDisturbOn
|
||||
import androidx.compose.material.icons.rounded.DeleteOutline
|
||||
import androidx.compose.material.icons.rounded.Star
|
||||
import androidx.compose.material.icons.rounded.StarBorder
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.animateFloatingActionButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
|
|
@ -57,7 +44,6 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -67,25 +53,17 @@ import kotlinx.coroutines.flow.collectLatest
|
|||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.add_favorite
|
||||
import org.meshtastic.core.resources.channel_invalid
|
||||
import org.meshtastic.core.resources.ignore
|
||||
import org.meshtastic.core.resources.mute_always
|
||||
import org.meshtastic.core.resources.node_count_template
|
||||
import org.meshtastic.core.resources.nodes
|
||||
import org.meshtastic.core.resources.remove
|
||||
import org.meshtastic.core.resources.remove_favorite
|
||||
import org.meshtastic.core.resources.remove_ignored
|
||||
import org.meshtastic.core.resources.unmute
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.component.MeshtasticImportFAB
|
||||
import org.meshtastic.core.ui.component.ScrollToTopEvent
|
||||
import org.meshtastic.core.ui.component.smartScrollToTop
|
||||
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
|
||||
import org.meshtastic.core.ui.util.showToast
|
||||
import org.meshtastic.feature.node.component.NodeContextMenu
|
||||
import org.meshtastic.feature.node.component.NodeFilterTextField
|
||||
import org.meshtastic.feature.node.component.NodeItem
|
||||
import org.meshtastic.proto.SharedContact
|
||||
|
|
@ -221,7 +199,7 @@ fun NodeListScreen(
|
|||
)
|
||||
val isThisNode = remember(node) { ourNode?.num == node.num }
|
||||
if (!isThisNode) {
|
||||
ContextMenu(
|
||||
NodeContextMenu(
|
||||
expanded = expanded,
|
||||
node = node,
|
||||
onFavorite = { viewModel.favoriteNode(node) },
|
||||
|
|
@ -238,108 +216,3 @@ fun NodeListScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContextMenu(
|
||||
expanded: Boolean,
|
||||
node: Node,
|
||||
onFavorite: () -> Unit,
|
||||
onIgnore: () -> Unit,
|
||||
onMute: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
|
||||
FavoriteMenuItem(node, onFavorite, onDismiss)
|
||||
IgnoreMenuItem(node, onIgnore, onDismiss)
|
||||
if (node.capabilities.canMuteNode) {
|
||||
MuteMenuItem(node, onMute, onDismiss)
|
||||
}
|
||||
RemoveMenuItem(node, onRemove, onDismiss)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FavoriteMenuItem(node: Node, onFavorite: () -> Unit, onDismiss: () -> Unit) {
|
||||
val isFavorite = node.isFavorite
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onFavorite()
|
||||
onDismiss()
|
||||
},
|
||||
enabled = !node.isIgnored,
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = if (isFavorite) Icons.Rounded.Star else Icons.Rounded.StarBorder,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
text = { Text(stringResource(if (isFavorite) Res.string.remove_favorite else Res.string.add_favorite)) },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IgnoreMenuItem(node: Node, onIgnore: () -> Unit, onDismiss: () -> Unit) {
|
||||
val isIgnored = node.isIgnored
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onIgnore()
|
||||
onDismiss()
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = if (isIgnored) Icons.Filled.DoDisturbOn else Icons.Outlined.DoDisturbOn,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.StatusRed,
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(if (isIgnored) Res.string.remove_ignored else Res.string.ignore),
|
||||
color = MaterialTheme.colorScheme.StatusRed,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MuteMenuItem(node: Node, onMute: () -> Unit, onDismiss: () -> Unit) {
|
||||
val isMuted = node.isMuted
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onMute()
|
||||
onDismiss()
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = if (isMuted) Icons.AutoMirrored.Filled.VolumeOff else Icons.AutoMirrored.Filled.VolumeUp,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
text = { Text(text = stringResource(if (isMuted) Res.string.unmute else Res.string.mute_always)) },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RemoveMenuItem(node: Node, onRemove: () -> Unit, onDismiss: () -> Unit) {
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onRemove()
|
||||
onDismiss()
|
||||
},
|
||||
enabled = !node.isIgnored,
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.DeleteOutline,
|
||||
contentDescription = null,
|
||||
tint = if (node.isIgnored) LocalContentColor.current else MaterialTheme.colorScheme.StatusRed,
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(Res.string.remove),
|
||||
color = if (node.isIgnored) Color.Unspecified else MaterialTheme.colorScheme.StatusRed,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,17 +23,12 @@ import androidx.activity.result.contract.ActivityResultContracts
|
|||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
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.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
|
|
@ -52,91 +47,26 @@ import androidx.compose.runtime.mutableStateOf
|
|||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.PreviewScreenSizes
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.common.util.nowSeconds
|
||||
import org.meshtastic.core.model.util.metersIn
|
||||
import org.meshtastic.core.model.util.toString
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.alt
|
||||
import org.meshtastic.core.resources.clear
|
||||
import org.meshtastic.core.resources.heading
|
||||
import org.meshtastic.core.resources.latitude
|
||||
import org.meshtastic.core.resources.longitude
|
||||
import org.meshtastic.core.resources.sats
|
||||
import org.meshtastic.core.resources.save
|
||||
import org.meshtastic.core.resources.speed
|
||||
import org.meshtastic.core.resources.timestamp
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.icon.Delete
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.Refresh
|
||||
import org.meshtastic.core.ui.icon.Save
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import org.meshtastic.core.ui.util.formatPositionTime
|
||||
import org.meshtastic.feature.node.detail.NodeRequestEffect
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.Position
|
||||
|
||||
@Composable
|
||||
private fun RowScope.PositionText(text: String, weight: Float) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = Modifier.weight(weight),
|
||||
textAlign = TextAlign.Center,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
|
||||
private const val WEIGHT_10 = .10f
|
||||
private const val WEIGHT_15 = .15f
|
||||
private const val WEIGHT_20 = .20f
|
||||
private const val WEIGHT_40 = .40f
|
||||
|
||||
@Composable
|
||||
private fun HeaderItem(compactWidth: Boolean) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
PositionText(stringResource(Res.string.latitude), WEIGHT_20)
|
||||
PositionText(stringResource(Res.string.longitude), WEIGHT_20)
|
||||
PositionText(stringResource(Res.string.sats), WEIGHT_10)
|
||||
PositionText(stringResource(Res.string.alt), WEIGHT_15)
|
||||
if (!compactWidth) {
|
||||
PositionText(stringResource(Res.string.speed), WEIGHT_15)
|
||||
PositionText(stringResource(Res.string.heading), WEIGHT_15)
|
||||
}
|
||||
PositionText(stringResource(Res.string.timestamp), WEIGHT_40)
|
||||
}
|
||||
}
|
||||
|
||||
const val DEG_D = 1e-7
|
||||
const val HEADING_DEG = 1e-5
|
||||
|
||||
@Composable
|
||||
fun PositionItem(compactWidth: Boolean, position: Position, system: Config.DisplayConfig.DisplayUnits) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
PositionText("%.5f".format((position.latitude_i ?: 0) * DEG_D), WEIGHT_20)
|
||||
PositionText("%.5f".format((position.longitude_i ?: 0) * DEG_D), WEIGHT_20)
|
||||
PositionText(position.sats_in_view.toString(), WEIGHT_10)
|
||||
PositionText((position.altitude ?: 0).metersIn(system).toString(system), WEIGHT_15)
|
||||
if (!compactWidth) {
|
||||
PositionText("${position.ground_speed ?: 0} Km/h", WEIGHT_15)
|
||||
PositionText("%.0f°".format((position.ground_track ?: 0) * HEADING_DEG), WEIGHT_15)
|
||||
}
|
||||
PositionText(position.formatPositionTime(), WEIGHT_40)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionButtons(
|
||||
clearButtonEnabled: Boolean,
|
||||
|
|
@ -225,7 +155,7 @@ fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
|
|||
LocalTextStyle.current
|
||||
}
|
||||
CompositionLocalProvider(LocalTextStyle provides textStyle) {
|
||||
HeaderItem(compactWidth)
|
||||
PositionLogHeader(compactWidth)
|
||||
PositionList(compactWidth, state.positionLogs, state.displayUnits)
|
||||
}
|
||||
|
||||
|
|
@ -251,17 +181,6 @@ fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.PositionList(
|
||||
compactWidth: Boolean,
|
||||
positions: List<Position>,
|
||||
displayUnits: Config.DisplayConfig.DisplayUnits,
|
||||
) {
|
||||
LazyColumn(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
items(positions) { position -> PositionItem(compactWidth, position, displayUnits) }
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private val testPosition =
|
||||
Position(
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import kotlinx.coroutines.flow.combine
|
|||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.annotation.KoinViewModel
|
||||
import org.meshtastic.core.common.util.bearing
|
||||
import org.meshtastic.core.common.util.latLongToMeter
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
|
|
@ -52,7 +53,8 @@ private const val HUNDRED = 100f
|
|||
private const val MILLIMETERS_PER_METER = 1000f
|
||||
|
||||
@Suppress("TooManyFunctions")
|
||||
open class CompassViewModel(
|
||||
@KoinViewModel
|
||||
class CompassViewModel(
|
||||
private val headingProvider: CompassHeadingProvider,
|
||||
private val phoneLocationProvider: PhoneLocationProvider,
|
||||
private val magneticFieldProvider: MagneticFieldProvider,
|
||||
|
|
@ -72,10 +74,9 @@ open class CompassViewModel(
|
|||
targetPosition = targetPos
|
||||
targetPositionProto = node.position
|
||||
val targetColor = Color(node.colors.second)
|
||||
val targetName =
|
||||
(node.user.long_name ?: "").ifBlank { (node.user.short_name ?: "").ifBlank { node.num.toString() } }
|
||||
val targetName = node.user.long_name.ifBlank { node.user.short_name.ifBlank { node.num.toString() } }
|
||||
targetPositionTimeSec =
|
||||
node.position.timestamp?.takeIf { it > 0 }?.toLong() ?: node.position.time?.takeIf { it > 0 }?.toLong()
|
||||
node.position.timestamp.takeIf { it > 0 }?.toLong() ?: node.position.time.takeIf { it > 0 }?.toLong()
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
|
|
@ -207,10 +208,10 @@ open class CompassViewModel(
|
|||
val positionTime = targetPositionTimeSec
|
||||
if (positionTime == null || positionTime <= 0) return null
|
||||
|
||||
val gpsAccuracyMm = (position.gps_accuracy ?: 0).toFloat()
|
||||
val pdop = position.PDOP ?: 0
|
||||
val hdop = position.HDOP ?: 0
|
||||
val vdop = position.VDOP ?: 0
|
||||
val gpsAccuracyMm = position.gps_accuracy.toFloat()
|
||||
val pdop = position.PDOP
|
||||
val hdop = position.HDOP
|
||||
val vdop = position.VDOP
|
||||
val dop: Float? =
|
||||
when {
|
||||
pdop > 0 -> pdop / HUNDRED
|
||||
|
|
@ -225,7 +226,7 @@ open class CompassViewModel(
|
|||
}
|
||||
|
||||
// Fallback: infer radius from precision bits if provided
|
||||
val precisionBits = position.precision_bits ?: 0
|
||||
val precisionBits = position.precision_bits
|
||||
if (precisionBits > 0) {
|
||||
return precisionBitsToMeters(precisionBits).toFloat()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ internal fun EnvironmentMetrics(
|
|||
add(
|
||||
VectorMetricInfo(
|
||||
label = Res.string.wind,
|
||||
value = ws.toFloat().toSpeedString(displayUnits),
|
||||
value = ws.toSpeedString(displayUnits),
|
||||
icon = Icons.Outlined.Navigation,
|
||||
rotateIcon = normalizedBearing.toFloat(),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ import androidx.compose.foundation.layout.padding
|
|||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
|
|
@ -52,7 +51,7 @@ import org.meshtastic.core.resources.copy
|
|||
import org.meshtastic.core.ui.util.createClipEntry
|
||||
import org.meshtastic.core.ui.util.thenIf
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalFoundationApi::class)
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun InfoCard(
|
||||
text: String,
|
||||
|
|
@ -106,11 +105,7 @@ fun InfoCard(
|
|||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
value,
|
||||
style = MaterialTheme.typography.labelLargeEmphasized,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(value, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ fun LinkedCoordinatesItem(
|
|||
leadingIcon = Icons.Rounded.LocationOn,
|
||||
supportingText = "$ago • $coordinates$elevationText",
|
||||
trailingContent = Icons.AutoMirrored.Rounded.KeyboardArrowRight.icon(),
|
||||
onClick = { openMap(node.latitude, node.longitude, node.user.long_name ?: "") },
|
||||
onClick = { openMap(node.latitude, node.longitude, node.user.long_name) },
|
||||
onLongClick = { coroutineScope.launch { clipboard.setClipEntry(createClipEntry(coordinates, copyLabel)) } },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,155 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.node.component
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeOff
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||
import androidx.compose.material.icons.filled.DoDisturbOn
|
||||
import androidx.compose.material.icons.outlined.DoDisturbOn
|
||||
import androidx.compose.material.icons.rounded.DeleteOutline
|
||||
import androidx.compose.material.icons.rounded.Star
|
||||
import androidx.compose.material.icons.rounded.StarBorder
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.add_favorite
|
||||
import org.meshtastic.core.resources.ignore
|
||||
import org.meshtastic.core.resources.mute_always
|
||||
import org.meshtastic.core.resources.remove
|
||||
import org.meshtastic.core.resources.remove_favorite
|
||||
import org.meshtastic.core.resources.remove_ignored
|
||||
import org.meshtastic.core.resources.unmute
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
|
||||
|
||||
/**
|
||||
* Shared context menu for node actions (favorite, ignore, mute, remove).
|
||||
*
|
||||
* Used by both Android and Desktop adaptive node list screens.
|
||||
*/
|
||||
@Composable
|
||||
fun NodeContextMenu(
|
||||
expanded: Boolean,
|
||||
node: Node,
|
||||
onFavorite: () -> Unit,
|
||||
onIgnore: () -> Unit,
|
||||
onMute: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
|
||||
FavoriteMenuItem(node, onFavorite, onDismiss)
|
||||
IgnoreMenuItem(node, onIgnore, onDismiss)
|
||||
if (node.capabilities.canMuteNode) {
|
||||
MuteMenuItem(node, onMute, onDismiss)
|
||||
}
|
||||
RemoveMenuItem(node, onRemove, onDismiss)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FavoriteMenuItem(node: Node, onFavorite: () -> Unit, onDismiss: () -> Unit) {
|
||||
val isFavorite = node.isFavorite
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onFavorite()
|
||||
onDismiss()
|
||||
},
|
||||
enabled = !node.isIgnored,
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = if (isFavorite) Icons.Rounded.Star else Icons.Rounded.StarBorder,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
text = { Text(stringResource(if (isFavorite) Res.string.remove_favorite else Res.string.add_favorite)) },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IgnoreMenuItem(node: Node, onIgnore: () -> Unit, onDismiss: () -> Unit) {
|
||||
val isIgnored = node.isIgnored
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onIgnore()
|
||||
onDismiss()
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = if (isIgnored) Icons.Filled.DoDisturbOn else Icons.Outlined.DoDisturbOn,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.StatusRed,
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(if (isIgnored) Res.string.remove_ignored else Res.string.ignore),
|
||||
color = MaterialTheme.colorScheme.StatusRed,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MuteMenuItem(node: Node, onMute: () -> Unit, onDismiss: () -> Unit) {
|
||||
val isMuted = node.isMuted
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onMute()
|
||||
onDismiss()
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = if (isMuted) Icons.AutoMirrored.Filled.VolumeOff else Icons.AutoMirrored.Filled.VolumeUp,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
text = { Text(text = stringResource(if (isMuted) Res.string.unmute else Res.string.mute_always)) },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RemoveMenuItem(node: Node, onRemove: () -> Unit, onDismiss: () -> Unit) {
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onRemove()
|
||||
onDismiss()
|
||||
},
|
||||
enabled = !node.isIgnored,
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.DeleteOutline,
|
||||
contentDescription = null,
|
||||
tint = if (node.isIgnored) LocalContentColor.current else MaterialTheme.colorScheme.StatusRed,
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(Res.string.remove),
|
||||
color = if (node.isIgnored) Color.Unspecified else MaterialTheme.colorScheme.StatusRed,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -157,7 +157,7 @@ private fun MainNodeDetails(node: Node) {
|
|||
MqttAndVerificationRow(node)
|
||||
}
|
||||
val publicKey = node.publicKey ?: node.user.public_key
|
||||
if (publicKey != null && publicKey.size > 0) {
|
||||
if (publicKey.size > 0) {
|
||||
SectionDivider()
|
||||
PublicKeyItem(publicKey.toByteArray())
|
||||
}
|
||||
|
|
@ -169,13 +169,13 @@ private fun NameAndRoleRow(node: Node) {
|
|||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
InfoItem(
|
||||
label = stringResource(Res.string.short_name),
|
||||
value = (node.user.short_name ?: "").ifEmpty { "???" },
|
||||
value = node.user.short_name.ifEmpty { "???" },
|
||||
icon = MeshtasticIcons.Person,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
InfoItem(
|
||||
label = stringResource(Res.string.role),
|
||||
value = node.user.role?.name ?: "",
|
||||
value = node.user.role.name,
|
||||
icon = MeshtasticIcons.role(node.user.role),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
|
|
@ -235,16 +235,17 @@ private fun HearsAndHopsRow(node: Node) {
|
|||
@Composable
|
||||
private fun UserAndUptimeRow(node: Node) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
val uptimeSeconds = node.deviceMetrics.uptime_seconds
|
||||
InfoItem(
|
||||
label = stringResource(Res.string.user_id),
|
||||
value = node.user.id ?: "",
|
||||
value = node.user.id,
|
||||
icon = MeshtasticIcons.Person,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if ((node.deviceMetrics.uptime_seconds ?: 0) > 0) {
|
||||
if (uptimeSeconds != null && uptimeSeconds > 0) {
|
||||
InfoItem(
|
||||
label = stringResource(Res.string.uptime),
|
||||
value = formatUptime(node.deviceMetrics.uptime_seconds!!),
|
||||
value = formatUptime(uptimeSeconds),
|
||||
icon = MeshtasticIcons.ArrowCircleUp,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ import androidx.compose.material.icons.Icons
|
|||
import androidx.compose.material.icons.automirrored.rounded.Notes
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
|
|
@ -95,7 +94,6 @@ private const val ACTIVE_ALPHA = 0.5f
|
|||
private const val INACTIVE_ALPHA = 0.2f
|
||||
private const val GRID_COLUMNS = 3
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
@Suppress("LongMethod")
|
||||
fun NodeItem(
|
||||
|
|
@ -109,10 +107,10 @@ fun NodeItem(
|
|||
connectionState: ConnectionState,
|
||||
isActive: Boolean = false,
|
||||
) {
|
||||
val isFavorite = remember(thatNode) { thatNode.isFavorite }
|
||||
val originalLongName = thatNode.user.long_name.ifEmpty { stringResource(Res.string.unknown_username) }
|
||||
val isMuted = remember(thatNode) { thatNode.isMuted }
|
||||
val isIgnored = thatNode.isIgnored
|
||||
val originalLongName = (thatNode.user.long_name ?: "").ifEmpty { stringResource(Res.string.unknown_username) }
|
||||
val isFavorite = thatNode.isFavorite
|
||||
|
||||
val isThisNode = remember(thatNode) { thisNode?.num == thatNode.num }
|
||||
val system =
|
||||
|
|
@ -313,9 +311,10 @@ private fun gatherSensors(node: Node, tempInFahrenheit: Boolean, contentColor: C
|
|||
val env = node.environmentMetrics
|
||||
val pax = node.paxcounter
|
||||
|
||||
if ((pax.ble ?: 0) != 0 || (pax.wifi ?: 0) != 0) {
|
||||
items.add { PaxcountInfo(pax = "B:${pax.ble ?: 0} W:${pax.wifi ?: 0}", contentColor = contentColor) }
|
||||
if (pax.ble != 0 || pax.wifi != 0) {
|
||||
items.add { PaxcountInfo(pax = "B:${pax.ble} W:${pax.wifi}", contentColor = contentColor) }
|
||||
}
|
||||
|
||||
if ((env.temperature ?: 0f) != 0f) {
|
||||
val temp =
|
||||
if (tempInFahrenheit) {
|
||||
|
|
@ -387,7 +386,6 @@ private fun MetricsGrid(items: List<@Composable () -> Unit>) {
|
|||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun NodeItemHeader(
|
||||
thatNode: Node,
|
||||
|
|
@ -415,15 +413,19 @@ private fun NodeItemHeader(
|
|||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = longName,
|
||||
style = MaterialTheme.typography.titleMediumEmphasized.copy(fontStyle = style),
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontStyle = style),
|
||||
textDecoration = TextDecoration.LineThrough.takeIf { isIgnored },
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TransportIcon(
|
||||
transport = thatNode.lastTransport,
|
||||
|
|
|
|||
|
|
@ -181,7 +181,7 @@ private fun StatusBadge(
|
|||
tint: Color = LocalContentColor.current,
|
||||
) {
|
||||
TooltipBox(
|
||||
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(),
|
||||
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
|
||||
tooltip = { PlainTooltip { Text(stringResource(tooltipText)) } },
|
||||
state = rememberTooltipState(),
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ import androidx.compose.foundation.layout.padding
|
|||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
|
|
@ -56,7 +55,6 @@ import org.meshtastic.core.resources.request_telemetry
|
|||
import org.meshtastic.core.resources.telemetry
|
||||
import org.meshtastic.core.resources.userinfo
|
||||
import org.meshtastic.core.ui.icon.AirQuality
|
||||
import org.meshtastic.core.ui.icon.LineAxis
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.Person
|
||||
import org.meshtastic.core.ui.icon.Refresh
|
||||
|
|
@ -190,7 +188,7 @@ private fun rememberTelemetricFeatures(
|
|||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun FeatureRow(node: Node, feature: TelemetricFeature, hasLogs: Boolean, onAction: (NodeDetailAction) -> Unit) {
|
||||
|
|
@ -223,7 +221,6 @@ private fun FeatureRow(node: Node, feature: TelemetricFeature, hasLogs: Boolean,
|
|||
state = rememberTooltipState(),
|
||||
) {
|
||||
FilledTonalIconButton(
|
||||
shapes = IconButtonDefaults.shapes(),
|
||||
colors = IconButtonDefaults.filledTonalIconButtonColors(),
|
||||
onClick = {
|
||||
feature.logsType?.let {
|
||||
|
|
@ -232,9 +229,9 @@ private fun FeatureRow(node: Node, feature: TelemetricFeature, hasLogs: Boolean,
|
|||
},
|
||||
) {
|
||||
Icon(
|
||||
MeshtasticIcons.LineAxis,
|
||||
imageVector = feature.logsType?.icon ?: feature.icon,
|
||||
modifier = Modifier.size(24.dp),
|
||||
contentDescription = logsDescription,
|
||||
modifier = Modifier.size(IconButtonDefaults.mediumIconSize),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
|
|
@ -271,7 +268,7 @@ private fun FeatureRow(node: Node, feature: TelemetricFeature, hasLogs: Boolean,
|
|||
|
||||
if (showContent) {
|
||||
Column(modifier = Modifier.padding(start = 56.dp, end = 20.dp, bottom = 12.dp)) {
|
||||
feature.content?.invoke(node)
|
||||
feature.content.invoke(node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,20 +35,15 @@ constructor(
|
|||
is NodeMenuAction.Mute -> nodeManagementActions.muteNode(scope, action.node)
|
||||
is NodeMenuAction.Favorite -> nodeManagementActions.favoriteNode(scope, action.node)
|
||||
is NodeMenuAction.RequestUserInfo ->
|
||||
nodeRequestActions.requestUserInfo(scope, action.node.num, action.node.user.long_name ?: "")
|
||||
nodeRequestActions.requestUserInfo(scope, action.node.num, action.node.user.long_name)
|
||||
is NodeMenuAction.RequestNeighborInfo ->
|
||||
nodeRequestActions.requestNeighborInfo(scope, action.node.num, action.node.user.long_name ?: "")
|
||||
nodeRequestActions.requestNeighborInfo(scope, action.node.num, action.node.user.long_name)
|
||||
is NodeMenuAction.RequestPosition ->
|
||||
nodeRequestActions.requestPosition(scope, action.node.num, action.node.user.long_name ?: "")
|
||||
nodeRequestActions.requestPosition(scope, action.node.num, action.node.user.long_name)
|
||||
is NodeMenuAction.RequestTelemetry ->
|
||||
nodeRequestActions.requestTelemetry(
|
||||
scope,
|
||||
action.node.num,
|
||||
action.node.user.long_name ?: "",
|
||||
action.type,
|
||||
)
|
||||
nodeRequestActions.requestTelemetry(scope, action.node.num, action.node.user.long_name, action.type)
|
||||
is NodeMenuAction.TraceRoute ->
|
||||
nodeRequestActions.requestTraceroute(scope, action.node.num, action.node.user.long_name ?: "")
|
||||
nodeRequestActions.requestTraceroute(scope, action.node.num, action.node.user.long_name)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.node.detail
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.database.entity.FirmwareRelease
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.loading
|
||||
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.NodeDetailsSection
|
||||
import org.meshtastic.feature.node.component.NotesSection
|
||||
import org.meshtastic.feature.node.component.PositionSection
|
||||
import org.meshtastic.feature.node.model.NodeDetailAction
|
||||
|
||||
/**
|
||||
* Shared content composable for node details, usable from both Android and Desktop.
|
||||
*
|
||||
* Renders a [Crossfade] between a loading spinner and the full [NodeDetailList] when the node is present. This
|
||||
* composable contains no Android-specific APIs — overlays (compass, bottom sheets, permission launchers) are handled by
|
||||
* the platform-specific screen wrapper.
|
||||
*/
|
||||
@Composable
|
||||
fun NodeDetailContent(
|
||||
uiState: NodeDetailUiState,
|
||||
onAction: (NodeDetailAction) -> Unit,
|
||||
onFirmwareSelect: (FirmwareRelease) -> Unit,
|
||||
onSaveNotes: (Int, String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
listState: LazyListState = rememberLazyListState(),
|
||||
) {
|
||||
Crossfade(targetState = uiState.node != null, label = "NodeDetailContent", modifier = modifier) { isNodePresent ->
|
||||
if (isNodePresent && uiState.node != null) {
|
||||
NodeDetailList(
|
||||
node = uiState.node,
|
||||
ourNode = uiState.ourNode,
|
||||
uiState = uiState,
|
||||
listState = listState,
|
||||
onAction = onAction,
|
||||
onFirmwareSelect = onFirmwareSelect,
|
||||
onSaveNotes = onSaveNotes,
|
||||
)
|
||||
} else {
|
||||
val loadingDescription = stringResource(Res.string.loading)
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(modifier = Modifier.semantics { contentDescription = loadingDescription })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrollable list of node detail sections: identity, device actions, position, hardware details, notes, and
|
||||
* administration.
|
||||
*/
|
||||
@Composable
|
||||
fun NodeDetailList(
|
||||
node: Node,
|
||||
ourNode: Node?,
|
||||
uiState: NodeDetailUiState,
|
||||
listState: LazyListState,
|
||||
onAction: (NodeDetailAction) -> Unit,
|
||||
onFirmwareSelect: (FirmwareRelease) -> Unit,
|
||||
onSaveNotes: (Int, String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
state = listState,
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
item { NodeDetailsSection(node) }
|
||||
item {
|
||||
DeviceActions(
|
||||
node = node,
|
||||
lastTracerouteTime = uiState.lastTracerouteTime,
|
||||
lastRequestNeighborsTime = uiState.lastRequestNeighborsTime,
|
||||
availableLogs = uiState.availableLogs,
|
||||
onAction = onAction,
|
||||
metricsState = uiState.metricsState,
|
||||
isLocal = uiState.metricsState.isLocal,
|
||||
)
|
||||
}
|
||||
item { PositionSection(node, ourNode, uiState.metricsState, uiState.availableLogs, onAction) }
|
||||
if (uiState.metricsState.deviceHardware != null) {
|
||||
item { DeviceDetailsSection(uiState.metricsState) }
|
||||
}
|
||||
item { NotesSection(node = node, onSaveNotes = onSaveNotes) }
|
||||
if (!uiState.metricsState.isManaged) {
|
||||
item { AdministrationSection(node, uiState.metricsState, onAction, onFirmwareSelect) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ import kotlinx.coroutines.flow.flatMapLatest
|
|||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.annotation.KoinViewModel
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.service.ServiceAction
|
||||
|
|
@ -58,7 +59,8 @@ data class NodeDetailUiState(
|
|||
* ViewModel for the Node Details screen, coordinating data from the node database, mesh logs, and radio configuration.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
open class NodeDetailViewModel(
|
||||
@KoinViewModel
|
||||
class NodeDetailViewModel(
|
||||
private val savedStateHandle: SavedStateHandle,
|
||||
private val nodeManagementActions: NodeManagementActions,
|
||||
private val nodeRequestActions: NodeRequestActions,
|
||||
|
|
@ -98,24 +100,20 @@ open class NodeDetailViewModel(
|
|||
is NodeMenuAction.Mute -> nodeManagementActions.requestMuteNode(viewModelScope, action.node)
|
||||
is NodeMenuAction.Favorite -> nodeManagementActions.requestFavoriteNode(viewModelScope, action.node)
|
||||
is NodeMenuAction.RequestUserInfo ->
|
||||
nodeRequestActions.requestUserInfo(viewModelScope, action.node.num, action.node.user.long_name ?: "")
|
||||
nodeRequestActions.requestUserInfo(viewModelScope, action.node.num, action.node.user.long_name)
|
||||
is NodeMenuAction.RequestNeighborInfo ->
|
||||
nodeRequestActions.requestNeighborInfo(
|
||||
viewModelScope,
|
||||
action.node.num,
|
||||
action.node.user.long_name ?: "",
|
||||
)
|
||||
nodeRequestActions.requestNeighborInfo(viewModelScope, action.node.num, action.node.user.long_name)
|
||||
is NodeMenuAction.RequestPosition ->
|
||||
nodeRequestActions.requestPosition(viewModelScope, action.node.num, action.node.user.long_name ?: "")
|
||||
nodeRequestActions.requestPosition(viewModelScope, action.node.num, action.node.user.long_name)
|
||||
is NodeMenuAction.RequestTelemetry ->
|
||||
nodeRequestActions.requestTelemetry(
|
||||
viewModelScope,
|
||||
action.node.num,
|
||||
action.node.user.long_name ?: "",
|
||||
action.node.user.long_name,
|
||||
action.type,
|
||||
)
|
||||
is NodeMenuAction.TraceRoute ->
|
||||
nodeRequestActions.requestTraceroute(viewModelScope, action.node.num, action.node.user.long_name ?: "")
|
||||
nodeRequestActions.requestTraceroute(viewModelScope, action.node.num, action.node.user.long_name)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,10 +70,7 @@ constructor(
|
|||
fun requestIgnoreNode(scope: CoroutineScope, node: Node) {
|
||||
scope.launch {
|
||||
val message =
|
||||
getString(
|
||||
if (node.isIgnored) Res.string.ignore_remove else Res.string.ignore_add,
|
||||
node.user.long_name ?: "",
|
||||
)
|
||||
getString(if (node.isIgnored) Res.string.ignore_remove else Res.string.ignore_add, node.user.long_name)
|
||||
alertManager.showAlert(
|
||||
titleRes = Res.string.ignore,
|
||||
message = message,
|
||||
|
|
@ -89,7 +86,7 @@ constructor(
|
|||
fun requestMuteNode(scope: CoroutineScope, node: Node) {
|
||||
scope.launch {
|
||||
val message =
|
||||
getString(if (node.isMuted) Res.string.mute_remove else Res.string.mute_add, node.user.long_name ?: "")
|
||||
getString(if (node.isMuted) Res.string.mute_remove else Res.string.mute_add, node.user.long_name)
|
||||
alertManager.showAlert(
|
||||
titleRes = if (node.isMuted) Res.string.unmute else Res.string.mute_notifications,
|
||||
message = message,
|
||||
|
|
@ -107,7 +104,7 @@ constructor(
|
|||
val message =
|
||||
getString(
|
||||
if (node.isFavorite) Res.string.favorite_remove else Res.string.favorite_add,
|
||||
node.user.long_name ?: "",
|
||||
node.user.long_name,
|
||||
)
|
||||
alertManager.showAlert(
|
||||
titleRes = Res.string.favorite,
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import kotlinx.coroutines.flow.onStart
|
|||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
|
||||
import org.meshtastic.core.database.entity.FirmwareRelease
|
||||
import org.meshtastic.core.database.entity.MeshLog
|
||||
import org.meshtastic.core.model.MeshLog
|
||||
import org.meshtastic.core.model.MyNodeInfo
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.util.hasValidEnvironmentMetrics
|
||||
|
|
@ -200,7 +200,7 @@ constructor(
|
|||
|
||||
@Suppress("MagicNumber")
|
||||
val nodeName =
|
||||
node.user.long_name?.takeIf { it.isNotBlank() }?.let { UiText.DynamicString(it) }
|
||||
node.user.long_name.takeIf { it.isNotBlank() }?.let { UiText.DynamicString(it) }
|
||||
?: UiText.Resource(Res.string.fallback_node_name, node.user.id.takeLast(4))
|
||||
|
||||
NodeDetailUiState(
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.annotation.KoinViewModel
|
||||
import org.meshtastic.core.common.util.CommonUri
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.NodeSortOption
|
||||
|
|
@ -42,7 +43,8 @@ import org.meshtastic.proto.Config
|
|||
import org.meshtastic.proto.SharedContact
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
open class NodeListViewModel(
|
||||
@KoinViewModel
|
||||
class NodeListViewModel(
|
||||
private val savedStateHandle: SavedStateHandle,
|
||||
private val nodeRepository: NodeRepository,
|
||||
private val radioConfigRepository: RadioConfigRepository,
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ import androidx.compose.foundation.layout.FlowRow
|
|||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
|
|
@ -37,9 +36,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Info
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
|
|
@ -51,34 +47,23 @@ import androidx.compose.ui.draw.clip
|
|||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.patrykandpatrick.vico.compose.cartesian.CartesianDrawingContext
|
||||
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianValueFormatter
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.common.util.toDate
|
||||
import org.meshtastic.core.common.util.toInstant
|
||||
import org.meshtastic.core.common.util.DateFormatter
|
||||
import org.meshtastic.core.model.util.TimeConstants
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.close
|
||||
import org.meshtastic.core.resources.delete
|
||||
import org.meshtastic.core.resources.info
|
||||
import org.meshtastic.core.resources.rssi
|
||||
import org.meshtastic.core.resources.snr
|
||||
import org.meshtastic.core.ui.icon.Delete
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import java.text.DateFormat
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
object CommonCharts {
|
||||
val DATE_TIME_FORMAT: DateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM)
|
||||
val TIME_MINUTE_FORMAT: DateFormat = DateFormat.getTimeInstance(DateFormat.SHORT)
|
||||
val TIME_SECONDS_FORMAT: DateFormat = DateFormat.getTimeInstance(DateFormat.MEDIUM)
|
||||
val DATE_FORMAT: DateFormat = DateFormat.getDateInstance(DateFormat.SHORT)
|
||||
const val MS_PER_SEC = 1000L
|
||||
const val MAX_PERCENT_VALUE = 100f
|
||||
const val SCROLL_BIAS = 0.5f
|
||||
|
|
@ -101,23 +86,25 @@ object CommonCharts {
|
|||
|
||||
/** A dynamic [CartesianValueFormatter] that adjusts the time format based on the visible X range. */
|
||||
val dynamicTimeFormatter = CartesianValueFormatter { context, value, _ ->
|
||||
val date = (value * MS_PER_SEC.toDouble()).toLong().toInstant().toDate()
|
||||
val timestampMillis = (value * MS_PER_SEC.toDouble()).toLong()
|
||||
val xLength = context.ranges.xLength
|
||||
val zoom = if (context is CartesianDrawingContext) context.zoom else 1f
|
||||
val visibleSpan = xLength / zoom
|
||||
|
||||
when {
|
||||
visibleSpan <= TimeConstants.ONE_HOUR.inWholeSeconds -> TIME_SECONDS_FORMAT.format(date) // < 1 hour visible
|
||||
visibleSpan <= 2.days.inWholeSeconds -> TIME_MINUTE_FORMAT.format(date) // < 2 days visible
|
||||
visibleSpan <= TimeConstants.ONE_HOUR.inWholeSeconds -> DateFormatter.formatTimeWithSeconds(timestampMillis)
|
||||
visibleSpan <= 2.days.inWholeSeconds -> DateFormatter.formatTime(timestampMillis)
|
||||
visibleSpan <= 14.days.inWholeSeconds -> {
|
||||
// < 2 weeks visible: separate date and time with a newline
|
||||
val dateStr = DATE_FORMAT.format(date)
|
||||
val timeStr = TIME_MINUTE_FORMAT.format(date)
|
||||
val dateStr = DateFormatter.formatDate(timestampMillis)
|
||||
val timeStr = DateFormatter.formatTime(timestampMillis)
|
||||
"$dateStr\n$timeStr"
|
||||
}
|
||||
else -> DATE_FORMAT.format(date)
|
||||
else -> DateFormatter.formatDate(timestampMillis)
|
||||
}
|
||||
}
|
||||
|
||||
fun formatDateTime(timestampMillis: Long): String = DateFormatter.formatDateTime(timestampMillis)
|
||||
}
|
||||
|
||||
data class LegendData(
|
||||
|
|
@ -221,58 +208,7 @@ fun MetricIndicator(color: Color, modifier: Modifier = Modifier) {
|
|||
Box(modifier = modifier.size(8.dp).clip(CircleShape).background(color))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeleteItem(onClick: () -> Unit) {
|
||||
DropdownMenuItem(
|
||||
onClick = onClick,
|
||||
text = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = MeshtasticIcons.Delete,
|
||||
contentDescription = stringResource(Res.string.delete),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(text = stringResource(Res.string.delete), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MetricLogItem(icon: ImageVector, text: String, contentDescription: String, modifier: Modifier = Modifier) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth().heightIn(min = 64.dp).padding(vertical = 4.dp, horizontal = 8.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.size(40.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Suppress("UnusedPrivateMember") // Compose preview
|
||||
@Composable
|
||||
private fun LegendPreview() {
|
||||
val data =
|
||||
|
|
@ -49,7 +49,6 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
|
||||
|
|
@ -81,7 +80,6 @@ import org.meshtastic.core.ui.theme.GraphColors.Gold
|
|||
import org.meshtastic.core.ui.theme.GraphColors.Green
|
||||
import org.meshtastic.core.ui.theme.GraphColors.Purple
|
||||
import org.meshtastic.feature.node.detail.NodeRequestEffect
|
||||
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT
|
||||
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
|
||||
import org.meshtastic.proto.Telemetry
|
||||
|
||||
|
|
@ -126,7 +124,7 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
|
|||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val timeFrame by viewModel.timeFrame.collectAsStateWithLifecycle()
|
||||
val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle()
|
||||
val data = state.deviceMetrics.filter { (it.time ?: 0).toLong() >= timeFrame.timeThreshold() }
|
||||
val data = state.deviceMetrics.filter { it.time.toLong() >= timeFrame.timeThreshold() }
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
val hasBattery = remember(data) { data.any { it.device_metrics?.battery_level != null } }
|
||||
|
|
@ -188,7 +186,7 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
|
|||
titleRes = Res.string.device_metrics_log,
|
||||
nodeName = state.node?.user?.long_name ?: "",
|
||||
data = data,
|
||||
timeProvider = { (it.time ?: 0).toDouble() },
|
||||
timeProvider = { it.time.toDouble() },
|
||||
infoData = infoItems,
|
||||
snackbarHostState = snackbarHostState,
|
||||
onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.DEVICE) },
|
||||
|
|
@ -215,8 +213,8 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
|
|||
itemsIndexed(data) { _, telemetry ->
|
||||
DeviceMetricsCard(
|
||||
telemetry = telemetry,
|
||||
isSelected = (telemetry.time ?: 0).toDouble() == selectedX,
|
||||
onClick = { onCardClick((telemetry.time ?: 0).toDouble()) },
|
||||
isSelected = telemetry.time.toDouble() == selectedX,
|
||||
onClick = { onCardClick(telemetry.time.toDouble()) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -290,19 +288,19 @@ private fun DeviceMetricsChart(
|
|||
lineSeries {
|
||||
if (batteryData.isNotEmpty()) {
|
||||
series(
|
||||
x = batteryData.map { it.time ?: 0 },
|
||||
x = batteryData.map { it.time },
|
||||
y = batteryData.map { (it.device_metrics?.battery_level ?: 0).toFloat() },
|
||||
)
|
||||
}
|
||||
if (chUtilData.isNotEmpty()) {
|
||||
series(
|
||||
x = chUtilData.map { it.time ?: 0 },
|
||||
x = chUtilData.map { it.time },
|
||||
y = chUtilData.map { it.device_metrics?.channel_utilization ?: 0f },
|
||||
)
|
||||
}
|
||||
if (airUtilData.isNotEmpty()) {
|
||||
series(
|
||||
x = airUtilData.map { it.time ?: 0 },
|
||||
x = airUtilData.map { it.time },
|
||||
y = airUtilData.map { it.device_metrics?.air_util_tx ?: 0f },
|
||||
)
|
||||
}
|
||||
|
|
@ -312,7 +310,7 @@ private fun DeviceMetricsChart(
|
|||
if (voltageData.isNotEmpty()) {
|
||||
lineSeries {
|
||||
series(
|
||||
x = voltageData.map { it.time ?: 0 },
|
||||
x = voltageData.map { it.time },
|
||||
y = voltageData.map { it.device_metrics?.voltage ?: 0f },
|
||||
)
|
||||
}
|
||||
|
|
@ -389,8 +387,7 @@ private fun DeviceMetricsChart(
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("detekt:MagicNumber") // fake data
|
||||
@PreviewLightDark
|
||||
@Suppress("detekt:MagicNumber", "UnusedPrivateMember") // Compose preview with fake data
|
||||
@Composable
|
||||
private fun DeviceMetricsChartPreview() {
|
||||
val now = nowSeconds.toInt()
|
||||
|
|
@ -424,7 +421,7 @@ private fun DeviceMetricsChartPreview() {
|
|||
@Suppress("LongMethod")
|
||||
private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick: () -> Unit) {
|
||||
val deviceMetrics = telemetry.device_metrics
|
||||
val time = (telemetry.time ?: 0).toLong() * MS_PER_SEC
|
||||
val time = telemetry.time.toLong() * MS_PER_SEC
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() },
|
||||
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
|
||||
|
|
@ -444,7 +441,7 @@ private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick
|
|||
/* Time, Battery, and Voltage */
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
text = DATE_TIME_FORMAT.format(time),
|
||||
text = CommonCharts.formatDateTime(time),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
|
@ -505,8 +502,7 @@ private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("detekt:MagicNumber") // fake data
|
||||
@PreviewLightDark
|
||||
@Suppress("detekt:MagicNumber", "UnusedPrivateMember") // Compose preview with fake data
|
||||
@Composable
|
||||
private fun DeviceMetricsCardPreview() {
|
||||
val now = nowSeconds.toInt()
|
||||
|
|
@ -525,8 +521,7 @@ private fun DeviceMetricsCardPreview() {
|
|||
AppTheme { DeviceMetricsCard(telemetry = telemetry, isSelected = false, onClick = {}) }
|
||||
}
|
||||
|
||||
@Suppress("detekt:MagicNumber") // fake data
|
||||
@PreviewLightDark
|
||||
@Suppress("detekt:MagicNumber", "UnusedPrivateMember") // Compose preview with fake data
|
||||
@Composable
|
||||
private fun DeviceMetricsScreenPreview() {
|
||||
val now = nowSeconds.toInt()
|
||||
|
|
@ -14,6 +14,8 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@file:Suppress("TooManyFunctions")
|
||||
|
||||
package org.meshtastic.feature.node.metrics
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
|
|
@ -44,7 +46,6 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
|
@ -67,7 +68,6 @@ import org.meshtastic.core.resources.voltage
|
|||
import org.meshtastic.core.ui.component.IaqDisplayMode
|
||||
import org.meshtastic.core.ui.component.IndoorAirQuality
|
||||
import org.meshtastic.feature.node.detail.NodeRequestEffect
|
||||
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT
|
||||
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
|
||||
import org.meshtastic.proto.Telemetry
|
||||
|
||||
|
|
@ -97,7 +97,7 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Un
|
|||
titleRes = Res.string.env_metrics_log,
|
||||
nodeName = state.node?.user?.long_name ?: "",
|
||||
data = filteredTelemetries,
|
||||
timeProvider = { (it.time ?: 0).toDouble() },
|
||||
timeProvider = { it.time.toDouble() },
|
||||
infoData = listOf(InfoDialogData(Res.string.iaq, Res.string.iaq_definition, Environment.IAQ.color)),
|
||||
snackbarHostState = snackbarHostState,
|
||||
onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.ENVIRONMENT) },
|
||||
|
|
@ -125,8 +125,8 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Un
|
|||
EnvironmentMetricsCard(
|
||||
telemetry = telemetry,
|
||||
environmentDisplayFahrenheit = state.isFahrenheit,
|
||||
isSelected = (telemetry.time ?: 0).toDouble() == selectedX,
|
||||
onClick = { onCardClick((telemetry.time ?: 0).toDouble()) },
|
||||
isSelected = telemetry.time.toDouble() == selectedX,
|
||||
onClick = { onCardClick(telemetry.time.toDouble()) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -386,12 +386,12 @@ private fun EnvironmentMetricsCard(
|
|||
@Composable
|
||||
private fun EnvironmentMetricsContent(telemetry: Telemetry, environmentDisplayFahrenheit: Boolean) {
|
||||
val envMetrics = telemetry.environment_metrics ?: org.meshtastic.proto.EnvironmentMetrics()
|
||||
val time = (telemetry.time ?: 0).toLong() * MS_PER_SEC
|
||||
val time = telemetry.time.toLong() * MS_PER_SEC
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
|
||||
/* Time and Temperature */
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
text = DATE_TIME_FORMAT.format(time),
|
||||
text = CommonCharts.formatDateTime(time),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
|
@ -413,8 +413,7 @@ private fun EnvironmentMetricsContent(telemetry: Telemetry, environmentDisplayFa
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber") // preview data
|
||||
@Preview(showBackground = true)
|
||||
@Suppress("MagicNumber", "UnusedPrivateMember") // Compose preview with fake data
|
||||
@Composable
|
||||
private fun PreviewEnvironmentMetricsContent() {
|
||||
val fakeEnvMetrics =
|
||||
|
|
@ -51,12 +51,12 @@ import androidx.compose.ui.text.TextStyle
|
|||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.common.util.nowSeconds
|
||||
import org.meshtastic.core.common.util.DateFormatter
|
||||
import org.meshtastic.core.model.TelemetryType
|
||||
import org.meshtastic.core.model.util.TimeConstants
|
||||
import org.meshtastic.core.model.util.formatUptime
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.disk_free_indexed
|
||||
|
|
@ -68,12 +68,8 @@ import org.meshtastic.core.ui.component.MainAppBar
|
|||
import org.meshtastic.core.ui.icon.DataArray
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.Refresh
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import org.meshtastic.feature.node.detail.NodeRequestEffect
|
||||
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT
|
||||
import org.meshtastic.proto.HostMetrics
|
||||
import org.meshtastic.proto.Telemetry
|
||||
import java.text.DecimalFormat
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
|
|
@ -127,7 +123,7 @@ fun HostMetricsLogScreen(metricsViewModel: MetricsViewModel, onNavigateUp: () ->
|
|||
@Composable
|
||||
fun HostMetricsItem(modifier: Modifier = Modifier, telemetry: Telemetry) {
|
||||
val hostMetrics = telemetry.host_metrics
|
||||
val time = telemetry.time.toLong() * CommonCharts.MS_PER_SEC
|
||||
val time = telemetry.time.toLong() * TimeConstants.MS_PER_SEC
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp).combinedClickable(onClick = { /* Handle click */ }),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
|
|
@ -140,7 +136,7 @@ fun HostMetricsItem(modifier: Modifier = Modifier, telemetry: Telemetry) {
|
|||
Text(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.End,
|
||||
text = DATE_TIME_FORMAT.format(time),
|
||||
text = DateFormatter.formatDateTime(time),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
|
@ -247,39 +243,31 @@ const val BYTES_IN_KB = 1024.0
|
|||
const val BYTES_IN_MB = BYTES_IN_KB * 1024.0
|
||||
const val BYTES_IN_GB = BYTES_IN_MB * 1024.0
|
||||
|
||||
private const val DECIMAL_FACTOR_1 = 10.0
|
||||
private const val DECIMAL_FACTOR_2 = 100.0
|
||||
|
||||
fun formatBytes(bytes: Long, decimalPlaces: Int = 2): String {
|
||||
val formatter =
|
||||
DecimalFormat().apply {
|
||||
maximumFractionDigits = decimalPlaces
|
||||
minimumFractionDigits = 0
|
||||
isGroupingUsed = false
|
||||
fun formatValue(value: Double): String {
|
||||
// Simple decimal formatting without java.text.DecimalFormat
|
||||
val factor =
|
||||
when (decimalPlaces) {
|
||||
0 -> 1.0
|
||||
1 -> DECIMAL_FACTOR_1
|
||||
else -> DECIMAL_FACTOR_2
|
||||
}
|
||||
val rounded = kotlin.math.round(value * factor) / factor
|
||||
return if (rounded == rounded.toLong().toDouble()) {
|
||||
rounded.toLong().toString()
|
||||
} else {
|
||||
rounded.toString()
|
||||
}
|
||||
}
|
||||
return when {
|
||||
bytes < 0 -> "N/A" // Handle negative bytes gracefully
|
||||
bytes < 0 -> "N/A"
|
||||
bytes == 0L -> "0 B"
|
||||
bytes >= BYTES_IN_GB -> "${formatter.format(bytes / BYTES_IN_GB)} GB"
|
||||
bytes >= BYTES_IN_MB -> "${formatter.format(bytes / BYTES_IN_MB)} MB"
|
||||
bytes >= BYTES_IN_KB -> "${formatter.format(bytes / BYTES_IN_KB)} KB"
|
||||
bytes >= BYTES_IN_GB -> "${formatValue(bytes / BYTES_IN_GB)} GB"
|
||||
bytes >= BYTES_IN_MB -> "${formatValue(bytes / BYTES_IN_MB)} MB"
|
||||
bytes >= BYTES_IN_KB -> "${formatValue(bytes / BYTES_IN_KB)} KB"
|
||||
else -> "$bytes B"
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun HostMetricsItemPreview() {
|
||||
val hostMetrics =
|
||||
HostMetrics(
|
||||
uptime_seconds = 3600,
|
||||
freemem_bytes = 2048000,
|
||||
diskfree1_bytes = 104857600,
|
||||
diskfree2_bytes = 2097915200,
|
||||
diskfree3_bytes = 44444,
|
||||
load1 = 30,
|
||||
load5 = 75,
|
||||
load15 = 19,
|
||||
user_string = "test",
|
||||
)
|
||||
val logs = Telemetry(time = nowSeconds.toInt(), host_metrics = hostMetrics)
|
||||
AppTheme { HostMetricsItem(telemetry = logs) }
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.node.metrics
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
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.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.delete
|
||||
import org.meshtastic.core.ui.icon.Delete
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
|
||||
/** Shared metric log/list UI components used by TracerouteLog, NeighborInfoLog, HostMetricsLog, and PositionLog. */
|
||||
@Composable
|
||||
fun MetricLogItem(icon: ImageVector, text: String, contentDescription: String, modifier: Modifier = Modifier) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth().heightIn(min = 64.dp).padding(vertical = 4.dp, horizontal = 8.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.size(40.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeleteItem(onClick: () -> Unit) {
|
||||
DropdownMenuItem(
|
||||
onClick = onClick,
|
||||
text = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = MeshtasticIcons.Delete,
|
||||
contentDescription = stringResource(Res.string.delete),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(text = stringResource(Res.string.delete), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -36,10 +36,12 @@ import kotlinx.coroutines.flow.stateIn
|
|||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.koin.core.annotation.InjectedParam
|
||||
import org.koin.core.annotation.KoinViewModel
|
||||
import org.meshtastic.core.common.util.nowSeconds
|
||||
import org.meshtastic.core.data.repository.TracerouteSnapshotRepository
|
||||
import org.meshtastic.core.database.entity.MeshLog
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.model.MeshLog
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.TelemetryType
|
||||
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
|
||||
|
|
@ -67,9 +69,10 @@ import org.meshtastic.proto.Paxcount as ProtoPaxcount
|
|||
/**
|
||||
* ViewModel responsible for managing and graphing metrics (telemetry, signal strength, paxcount) for a specific node.
|
||||
*/
|
||||
@KoinViewModel
|
||||
@Suppress("LongParameterList", "TooManyFunctions")
|
||||
open class MetricsViewModel(
|
||||
val destNum: Int,
|
||||
@InjectedParam val destNum: Int,
|
||||
protected val dispatchers: CoroutineDispatchers,
|
||||
private val meshLogRepository: MeshLogRepository,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
|
|
|
|||
|
|
@ -75,8 +75,7 @@ fun NeighborInfoLogScreen(modifier: Modifier = Modifier, viewModel: MetricsViewM
|
|||
}
|
||||
}
|
||||
|
||||
fun getUsername(nodeNum: Int): String =
|
||||
with(viewModel.getUser(nodeNum)) { "${long_name ?: ""} (${short_name ?: ""})" }
|
||||
fun getUsername(nodeNum: Int): String = with(viewModel.getUser(nodeNum)) { "$long_name ($short_name)" }
|
||||
|
||||
val statusGreen = MaterialTheme.colorScheme.StatusGreen
|
||||
val statusYellow = MaterialTheme.colorScheme.StatusYellow
|
||||
|
|
@ -53,9 +53,8 @@ import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
|
|||
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.common.util.toDate
|
||||
import org.meshtastic.core.common.util.toInstant
|
||||
import org.meshtastic.core.database.entity.MeshLog
|
||||
import org.meshtastic.core.common.util.DateFormatter
|
||||
import org.meshtastic.core.model.MeshLog
|
||||
import org.meshtastic.core.model.TelemetryType
|
||||
import org.meshtastic.core.model.util.formatUptime
|
||||
import org.meshtastic.core.resources.Res
|
||||
|
|
@ -71,7 +70,6 @@ import org.meshtastic.core.ui.icon.Paxcount
|
|||
import org.meshtastic.core.ui.theme.GraphColors.Orange
|
||||
import org.meshtastic.core.ui.theme.GraphColors.Purple
|
||||
import org.meshtastic.feature.node.detail.NodeRequestEffect
|
||||
import java.text.DateFormat
|
||||
import org.meshtastic.proto.Paxcount as ProtoPaxcount
|
||||
|
||||
private enum class PaxSeries(val color: Color, val legendRes: StringResource) {
|
||||
|
|
@ -180,8 +178,6 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel, onNavigateUp: () -> Uni
|
|||
val availableTimeFrames by metricsViewModel.availableTimeFrames.collectAsStateWithLifecycle()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
val dateFormat = DateFormat.getDateTimeInstance()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
metricsViewModel.effects.collect { effect ->
|
||||
when (effect) {
|
||||
|
|
@ -199,7 +195,7 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel, onNavigateUp: () -> Uni
|
|||
paxMetrics
|
||||
.map {
|
||||
val t = (it.first.received_date / CommonCharts.MS_PER_SEC).toInt()
|
||||
Triple(t, it.second.ble ?: 0, it.second.wifi ?: 0)
|
||||
Triple(t, it.second.ble, it.second.wifi)
|
||||
}
|
||||
.sortedBy { it.first }
|
||||
}
|
||||
|
|
@ -254,7 +250,6 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel, onNavigateUp: () -> Uni
|
|||
PaxMetricsItem(
|
||||
log = log,
|
||||
pax = pax,
|
||||
dateFormat = dateFormat,
|
||||
isSelected = (log.received_date / CommonCharts.MS_PER_SEC).toDouble() == selectedX,
|
||||
onClick = { onCardClick((log.received_date / CommonCharts.MS_PER_SEC).toDouble()) },
|
||||
)
|
||||
|
|
@ -281,7 +276,7 @@ fun PaxcountInfo(
|
|||
}
|
||||
|
||||
@Composable
|
||||
fun PaxMetricsItem(log: MeshLog, pax: ProtoPaxcount, dateFormat: DateFormat, isSelected: Boolean, onClick: () -> Unit) {
|
||||
fun PaxMetricsItem(log: MeshLog, pax: ProtoPaxcount, isSelected: Boolean, onClick: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).clickable { onClick() },
|
||||
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
|
||||
|
|
@ -297,7 +292,7 @@ fun PaxMetricsItem(log: MeshLog, pax: ProtoPaxcount, dateFormat: DateFormat, isS
|
|||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
|
||||
Text(
|
||||
text = dateFormat.format(log.received_date.toInstant().toDate()),
|
||||
text = DateFormatter.formatDateTime(log.received_date),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.End,
|
||||
|
|
@ -310,19 +305,19 @@ fun PaxMetricsItem(log: MeshLog, pax: ProtoPaxcount, dateFormat: DateFormat, isS
|
|||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
|
||||
MetricIndicator(PaxSeries.PAX.color)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(text = "PAX: ${(pax.ble ?: 0) + (pax.wifi ?: 0)}", style = MaterialTheme.typography.bodyLarge)
|
||||
Text(text = "PAX: ${pax.ble + pax.wifi}", style = MaterialTheme.typography.bodyLarge)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
MetricIndicator(PaxSeries.BLE.color)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(text = "B:${pax.ble ?: 0}", style = MaterialTheme.typography.bodyLarge)
|
||||
Text(text = "B:${pax.ble}", style = MaterialTheme.typography.bodyLarge)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
MetricIndicator(PaxSeries.WIFI.color)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(text = "W:${pax.wifi ?: 0}", style = MaterialTheme.typography.bodyLarge)
|
||||
Text(text = "W:${pax.wifi}", style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(Res.string.uptime) + ": " + formatUptime(pax.uptime ?: 0),
|
||||
text = stringResource(Res.string.uptime) + ": " + formatUptime(pax.uptime),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.End,
|
||||
)
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.node.metrics
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.model.util.metersIn
|
||||
import org.meshtastic.core.model.util.toString
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.alt
|
||||
import org.meshtastic.core.resources.heading
|
||||
import org.meshtastic.core.resources.latitude
|
||||
import org.meshtastic.core.resources.longitude
|
||||
import org.meshtastic.core.resources.sats
|
||||
import org.meshtastic.core.resources.speed
|
||||
import org.meshtastic.core.resources.timestamp
|
||||
import org.meshtastic.core.ui.util.formatPositionTime
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.Position
|
||||
|
||||
@Composable
|
||||
private fun RowScope.PositionText(text: String, weight: Float) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = Modifier.weight(weight),
|
||||
textAlign = TextAlign.Center,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
|
||||
private const val WEIGHT_10 = .10f
|
||||
private const val WEIGHT_15 = .15f
|
||||
private const val WEIGHT_20 = .20f
|
||||
private const val WEIGHT_40 = .40f
|
||||
|
||||
@Composable
|
||||
fun PositionLogHeader(compactWidth: Boolean) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
PositionText(stringResource(Res.string.latitude), WEIGHT_20)
|
||||
PositionText(stringResource(Res.string.longitude), WEIGHT_20)
|
||||
PositionText(stringResource(Res.string.sats), WEIGHT_10)
|
||||
PositionText(stringResource(Res.string.alt), WEIGHT_15)
|
||||
if (!compactWidth) {
|
||||
PositionText(stringResource(Res.string.speed), WEIGHT_15)
|
||||
PositionText(stringResource(Res.string.heading), WEIGHT_15)
|
||||
}
|
||||
PositionText(stringResource(Res.string.timestamp), WEIGHT_40)
|
||||
}
|
||||
}
|
||||
|
||||
const val DEG_D = 1e-7
|
||||
const val HEADING_DEG = 1e-5
|
||||
|
||||
@Composable
|
||||
fun PositionItem(compactWidth: Boolean, position: Position, system: Config.DisplayConfig.DisplayUnits) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
PositionText("%.5f".format((position.latitude_i ?: 0) * DEG_D), WEIGHT_20)
|
||||
PositionText("%.5f".format((position.longitude_i ?: 0) * DEG_D), WEIGHT_20)
|
||||
PositionText(position.sats_in_view.toString(), WEIGHT_10)
|
||||
PositionText((position.altitude ?: 0).metersIn(system).toString(system), WEIGHT_15)
|
||||
if (!compactWidth) {
|
||||
PositionText("${position.ground_speed ?: 0} Km/h", WEIGHT_15)
|
||||
PositionText("%.0f°".format((position.ground_track ?: 0) * HEADING_DEG), WEIGHT_15)
|
||||
}
|
||||
PositionText(position.formatPositionTime(), WEIGHT_40)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ColumnScope.PositionList(
|
||||
compactWidth: Boolean,
|
||||
positions: List<Position>,
|
||||
displayUnits: Config.DisplayConfig.DisplayUnits,
|
||||
) {
|
||||
LazyColumn(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
items(positions) { position -> PositionItem(compactWidth, position, displayUnits) }
|
||||
}
|
||||
}
|
||||
|
|
@ -73,7 +73,6 @@ import org.meshtastic.core.resources.voltage
|
|||
import org.meshtastic.core.ui.theme.GraphColors.Gold
|
||||
import org.meshtastic.core.ui.theme.GraphColors.InfantryBlue
|
||||
import org.meshtastic.feature.node.detail.NodeRequestEffect
|
||||
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT
|
||||
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
|
||||
import org.meshtastic.proto.Telemetry
|
||||
|
||||
|
|
@ -110,7 +109,7 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
|
|||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val timeFrame by viewModel.timeFrame.collectAsStateWithLifecycle()
|
||||
val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle()
|
||||
val data = state.powerMetrics.filter { (it.time ?: 0).toLong() >= timeFrame.timeThreshold() }
|
||||
val data = state.powerMetrics.filter { it.time.toLong() >= timeFrame.timeThreshold() }
|
||||
var selectedChannel by remember { mutableStateOf(PowerChannel.ONE) }
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
|
|
@ -131,7 +130,7 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
|
|||
titleRes = Res.string.power_metrics_log,
|
||||
nodeName = state.node?.user?.long_name ?: "",
|
||||
data = data,
|
||||
timeProvider = { (it.time ?: 0).toDouble() },
|
||||
timeProvider = { it.time.toDouble() },
|
||||
snackbarHostState = snackbarHostState,
|
||||
onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.POWER) },
|
||||
controlPart = {
|
||||
|
|
@ -172,8 +171,8 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
|
|||
itemsIndexed(data) { _, telemetry ->
|
||||
PowerMetricsCard(
|
||||
telemetry = telemetry,
|
||||
isSelected = (telemetry.time ?: 0).toDouble() == selectedX,
|
||||
onClick = { onCardClick((telemetry.time ?: 0).toDouble()) },
|
||||
isSelected = telemetry.time.toDouble() == selectedX,
|
||||
onClick = { onCardClick(telemetry.time.toDouble()) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -223,7 +222,7 @@ private fun PowerMetricsChart(
|
|||
if (currentData.isNotEmpty()) {
|
||||
lineSeries {
|
||||
series(
|
||||
x = currentData.map { it.time ?: 0 },
|
||||
x = currentData.map { it.time },
|
||||
y = currentData.map { retrieveCurrent(selectedChannel, it) },
|
||||
)
|
||||
}
|
||||
|
|
@ -231,7 +230,7 @@ private fun PowerMetricsChart(
|
|||
if (voltageData.isNotEmpty()) {
|
||||
lineSeries {
|
||||
series(
|
||||
x = voltageData.map { it.time ?: 0 },
|
||||
x = voltageData.map { it.time },
|
||||
y = voltageData.map { retrieveVoltage(selectedChannel, it) },
|
||||
)
|
||||
}
|
||||
|
|
@ -311,7 +310,7 @@ private fun PowerMetricsChart(
|
|||
@Composable
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
private fun PowerMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick: () -> Unit) {
|
||||
val time = (telemetry.time ?: 0).toLong() * MS_PER_SEC
|
||||
val time = telemetry.time.toLong() * MS_PER_SEC
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() },
|
||||
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
|
||||
|
|
@ -332,7 +331,7 @@ private fun PowerMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick:
|
|||
/* Time */
|
||||
Row {
|
||||
Text(
|
||||
text = DATE_TIME_FORMAT.format(time),
|
||||
text = CommonCharts.formatDateTime(time),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
|
@ -67,7 +67,6 @@ import org.meshtastic.core.ui.component.LoraSignalIndicator
|
|||
import org.meshtastic.core.ui.theme.GraphColors.Blue
|
||||
import org.meshtastic.core.ui.theme.GraphColors.Green
|
||||
import org.meshtastic.feature.node.detail.NodeRequestEffect
|
||||
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT
|
||||
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
|
||||
|
|
@ -88,7 +87,7 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
|
|||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val timeFrame by viewModel.timeFrame.collectAsStateWithLifecycle()
|
||||
val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle()
|
||||
val data = state.signalMetrics.filter { (it.rx_time ?: 0).toLong() >= timeFrame.timeThreshold() }
|
||||
val data = state.signalMetrics.filter { it.rx_time.toLong() >= timeFrame.timeThreshold() }
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
|
|
@ -108,7 +107,7 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
|
|||
titleRes = Res.string.signal_quality,
|
||||
nodeName = state.node?.user?.long_name ?: "",
|
||||
data = data,
|
||||
timeProvider = { (it.rx_time ?: 0).toDouble() },
|
||||
timeProvider = { it.rx_time.toDouble() },
|
||||
snackbarHostState = snackbarHostState,
|
||||
onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.LOCAL_STATS) },
|
||||
infoData =
|
||||
|
|
@ -138,8 +137,8 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
|
|||
itemsIndexed(data) { _, meshPacket ->
|
||||
SignalMetricsCard(
|
||||
meshPacket = meshPacket,
|
||||
isSelected = (meshPacket.rx_time ?: 0).toDouble() == selectedX,
|
||||
onClick = { onCardClick((meshPacket.rx_time ?: 0).toDouble()) },
|
||||
isSelected = meshPacket.rx_time.toDouble() == selectedX,
|
||||
onClick = { onCardClick(meshPacket.rx_time.toDouble()) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -163,17 +162,17 @@ private fun SignalMetricsChart(
|
|||
val rssiColor = SignalMetric.RSSI.color
|
||||
val snrColor = SignalMetric.SNR.color
|
||||
|
||||
val rssiData = remember(meshPackets) { meshPackets.filter { (it.rx_rssi ?: 0) != 0 } }
|
||||
val snrData = remember(meshPackets) { meshPackets.filter { !((it.rx_snr ?: Float.NaN).isNaN()) } }
|
||||
val rssiData = remember(meshPackets) { meshPackets.filter { it.rx_rssi != 0 } }
|
||||
val snrData = remember(meshPackets) { meshPackets.filter { !it.rx_snr.isNaN() } }
|
||||
|
||||
LaunchedEffect(rssiData, snrData) {
|
||||
modelProducer.runTransaction {
|
||||
if (rssiData.isNotEmpty()) {
|
||||
/* Use separate lineSeries calls to associate them with different vertical axes */
|
||||
lineSeries { series(x = rssiData.map { it.rx_time ?: 0 }, y = rssiData.map { it.rx_rssi ?: 0 }) }
|
||||
lineSeries { series(x = rssiData.map { it.rx_time }, y = rssiData.map { it.rx_rssi }) }
|
||||
}
|
||||
if (snrData.isNotEmpty()) {
|
||||
lineSeries { series(x = snrData.map { it.rx_time ?: 0 }, y = snrData.map { it.rx_snr ?: 0f }) }
|
||||
lineSeries { series(x = snrData.map { it.rx_time }, y = snrData.map { it.rx_snr }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -261,7 +260,7 @@ private fun SignalMetricsChart(
|
|||
|
||||
@Composable
|
||||
private fun SignalMetricsCard(meshPacket: MeshPacket, isSelected: Boolean, onClick: () -> Unit) {
|
||||
val time = (meshPacket.rx_time ?: 0).toLong() * MS_PER_SEC
|
||||
val time = meshPacket.rx_time.toLong() * MS_PER_SEC
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() },
|
||||
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
|
||||
|
|
@ -284,7 +283,7 @@ private fun SignalMetricsCard(meshPacket: MeshPacket, isSelected: Boolean, onCli
|
|||
/* Time */
|
||||
Row(horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
text = DATE_TIME_FORMAT.format(time),
|
||||
text = CommonCharts.formatDateTime(time),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
|
@ -297,14 +296,14 @@ private fun SignalMetricsCard(meshPacket: MeshPacket, isSelected: Boolean, onCli
|
|||
MetricIndicator(SignalMetric.RSSI.color)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "%.0f dBm".format((meshPacket.rx_rssi ?: 0).toFloat()),
|
||||
text = "%.0f dBm".format(meshPacket.rx_rssi.toFloat()),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
MetricIndicator(SignalMetric.SNR.color)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "%.1f dB".format(meshPacket.rx_snr ?: 0f),
|
||||
text = "%.1f dB".format(meshPacket.rx_snr),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
|
|
@ -313,7 +312,7 @@ private fun SignalMetricsCard(meshPacket: MeshPacket, isSelected: Boolean, onCli
|
|||
|
||||
/* Signal Indicator */
|
||||
Box(modifier = Modifier.weight(weight = 3f).height(IntrinsicSize.Max)) {
|
||||
LoraSignalIndicator(meshPacket.rx_snr ?: 0f, meshPacket.rx_rssi ?: 0)
|
||||
LoraSignalIndicator(meshPacket.rx_snr, meshPacket.rx_rssi)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -40,15 +40,14 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.jetbrains.compose.resources.pluralStringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.common.util.DateFormatter
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.model.fullRouteDiscovery
|
||||
import org.meshtastic.core.model.getTracerouteResponse
|
||||
import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.routing_error_no_response
|
||||
import org.meshtastic.core.resources.traceroute
|
||||
|
|
@ -66,7 +65,6 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons
|
|||
import org.meshtastic.core.ui.icon.PersonOff
|
||||
import org.meshtastic.core.ui.icon.Refresh
|
||||
import org.meshtastic.core.ui.icon.Route
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
|
||||
|
|
@ -74,7 +72,6 @@ import org.meshtastic.core.ui.util.annotateTraceroute
|
|||
import org.meshtastic.feature.map.model.TracerouteOverlay
|
||||
import org.meshtastic.feature.node.component.CooldownIconButton
|
||||
import org.meshtastic.feature.node.detail.NodeRequestEffect
|
||||
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
|
||||
import org.meshtastic.proto.RouteDiscovery
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
|
|
@ -100,8 +97,7 @@ fun TracerouteLogScreen(
|
|||
}
|
||||
}
|
||||
|
||||
fun getUsername(nodeNum: Int): String =
|
||||
with(viewModel.getUser(nodeNum)) { "${long_name ?: ""} (${short_name ?: ""})" }
|
||||
fun getUsername(nodeNum: Int): String = with(viewModel.getUser(nodeNum)) { "$long_name ($short_name)" }
|
||||
|
||||
val statusGreen = MaterialTheme.colorScheme.StatusGreen
|
||||
val statusYellow = MaterialTheme.colorScheme.StatusYellow
|
||||
|
|
@ -265,16 +261,3 @@ private fun RouteDiscovery?.getTextAndIcon(): Pair<String, ImageVector> = when {
|
|||
stringResource(Res.string.traceroute_diff, towards, back) to MeshtasticIcons.Route
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun TracerouteItemPreview() {
|
||||
val time = DateFormatter.formatDateTime(nowMillis)
|
||||
AppTheme {
|
||||
MetricLogItem(
|
||||
icon = MeshtasticIcons.Group,
|
||||
text = "$time - Direct",
|
||||
contentDescription = stringResource(Res.string.traceroute),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -20,4 +20,4 @@ import org.meshtastic.core.model.Node
|
|||
import org.meshtastic.core.model.isUnmessageableRole
|
||||
|
||||
val Node.isEffectivelyUnmessageable: Boolean
|
||||
get() = user.is_unmessagable ?: (user.role?.isUnmessageableRole() == true)
|
||||
get() = user.is_unmessagable ?: user.role.isUnmessageableRole()
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@
|
|||
package org.meshtastic.feature.node.model
|
||||
|
||||
import org.meshtastic.core.database.entity.FirmwareRelease
|
||||
import org.meshtastic.core.database.entity.MeshLog
|
||||
import org.meshtastic.core.model.DeviceHardware
|
||||
import org.meshtastic.core.model.MeshLog
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.FirmwareEdition
|
||||
|
|
@ -68,13 +68,13 @@ data class MetricsState(
|
|||
/** Finds the oldest timestamp (in seconds) among all collected metric types. */
|
||||
@Suppress("MagicNumber")
|
||||
fun oldestTimestampSeconds(): Long? {
|
||||
val telemetryTimes = (deviceMetrics + powerMetrics + hostMetrics).mapNotNull { it.time?.toLong() }
|
||||
val signalTimes = signalMetrics.mapNotNull { it.rx_time?.toLong() }
|
||||
val telemetryTimes = (deviceMetrics + powerMetrics + hostMetrics).map { it.time.toLong() }
|
||||
val signalTimes = signalMetrics.map { it.rx_time.toLong() }
|
||||
val logTimes =
|
||||
(tracerouteRequests + tracerouteResults + neighborInfoRequests + neighborInfoResults + paxMetrics).map {
|
||||
it.received_date / 1000L
|
||||
}
|
||||
val positionTimes = positionLogs.mapNotNull { it.time?.toLong() }
|
||||
val positionTimes = positionLogs.map { it.time.toLong() }
|
||||
|
||||
val allTimes = telemetryTimes + signalTimes + logTimes + positionTimes
|
||||
return allTimes.minOrNull()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,168 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.node.list
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.meshtastic.core.testing.FakeNodeRepository
|
||||
import org.meshtastic.core.testing.FakeRadioController
|
||||
import org.meshtastic.core.testing.TestDataFactory
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Error handling tests for node feature.
|
||||
*
|
||||
* Tests edge cases, failure recovery, and boundary conditions.
|
||||
*/
|
||||
class NodeErrorHandlingTest {
|
||||
|
||||
private lateinit var nodeRepository: FakeNodeRepository
|
||||
private lateinit var radioController: FakeRadioController
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
nodeRepository = FakeNodeRepository()
|
||||
radioController = FakeRadioController()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetNonexistentNode() = runTest {
|
||||
val node = nodeRepository.getNode("!nonexistent")
|
||||
// FakeNodeRepository returns a fallback node (never null)
|
||||
assertEquals("!nonexistent", node.user.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDeleteNonexistentNode() = runTest {
|
||||
val beforeCount = nodeRepository.nodeDBbyNum.value.size
|
||||
|
||||
nodeRepository.deleteNode(999)
|
||||
|
||||
val afterCount = nodeRepository.nodeDBbyNum.value.size
|
||||
assertEquals(beforeCount, afterCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNodeDatabaseEmptyOnStart() = runTest {
|
||||
val nodes = nodeRepository.nodeDBbyNum.value
|
||||
assertEquals(0, nodes.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRepeatedClear() = runTest {
|
||||
nodeRepository.setNodes(TestDataFactory.createTestNodes(5))
|
||||
assertEquals(5, nodeRepository.nodeDBbyNum.value.size)
|
||||
|
||||
// Clear multiple times
|
||||
nodeRepository.clearNodeDB(preserveFavorites = false)
|
||||
nodeRepository.clearNodeDB(preserveFavorites = false)
|
||||
nodeRepository.clearNodeDB(preserveFavorites = false)
|
||||
|
||||
// Should still be empty
|
||||
assertEquals(0, nodeRepository.nodeDBbyNum.value.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSetEmptyNodeList() = runTest {
|
||||
nodeRepository.setNodes(TestDataFactory.createTestNodes(3))
|
||||
assertEquals(3, nodeRepository.nodeDBbyNum.value.size)
|
||||
|
||||
// Set to empty
|
||||
nodeRepository.setNodes(emptyList())
|
||||
assertEquals(0, nodeRepository.nodeDBbyNum.value.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDeleteAllNodes() = runTest {
|
||||
val nodes = TestDataFactory.createTestNodes(5)
|
||||
nodeRepository.setNodes(nodes)
|
||||
|
||||
// Delete each node
|
||||
nodes.forEach { node -> nodeRepository.deleteNode(node.num) }
|
||||
|
||||
assertEquals(0, nodeRepository.nodeDBbyNum.value.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNodeMetadataOnDeletedNode() = runTest {
|
||||
val node = TestDataFactory.createTestNode(num = 1, longName = "Test")
|
||||
nodeRepository.setNodes(listOf(node))
|
||||
|
||||
// Delete node
|
||||
nodeRepository.deleteNode(1)
|
||||
|
||||
// Try to get notes on deleted node
|
||||
// Should not crash
|
||||
assertTrue(true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNotesOnNonexistentNode() = runTest {
|
||||
// Set notes on node that never existed
|
||||
nodeRepository.setNodeNotes(999, "Notes")
|
||||
|
||||
// Should be no-op
|
||||
assertEquals(0, nodeRepository.nodeDBbyNum.value.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testConnectionStateChangesDuringNodeManagement() = runTest {
|
||||
radioController.setConnectionState(org.meshtastic.core.model.ConnectionState.Disconnected)
|
||||
|
||||
// Add nodes while disconnected (local operation)
|
||||
nodeRepository.setNodes(TestDataFactory.createTestNodes(3))
|
||||
assertEquals(3, nodeRepository.nodeDBbyNum.value.size)
|
||||
|
||||
// Switch to connected
|
||||
radioController.setConnectionState(org.meshtastic.core.model.ConnectionState.Connected)
|
||||
|
||||
// Nodes should still be there
|
||||
assertEquals(3, nodeRepository.nodeDBbyNum.value.size)
|
||||
|
||||
// Switch back to disconnected
|
||||
radioController.setConnectionState(org.meshtastic.core.model.ConnectionState.Disconnected)
|
||||
|
||||
// Nodes still there
|
||||
assertEquals(3, nodeRepository.nodeDBbyNum.value.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testLargeNodeDatabaseHandling() = runTest {
|
||||
// Create large dataset
|
||||
val largeNodeSet = TestDataFactory.createTestNodes(500)
|
||||
nodeRepository.setNodes(largeNodeSet)
|
||||
|
||||
assertEquals(500, nodeRepository.nodeDBbyNum.value.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRapidAddDelete() = runTest {
|
||||
// Rapidly add and delete nodes
|
||||
repeat(10) { iteration ->
|
||||
nodeRepository.setNodes(TestDataFactory.createTestNodes(5))
|
||||
assertEquals(5, nodeRepository.nodeDBbyNum.value.size)
|
||||
|
||||
nodeRepository.clearNodeDB(preserveFavorites = false)
|
||||
assertEquals(0, nodeRepository.nodeDBbyNum.value.size)
|
||||
}
|
||||
|
||||
// Final state should be clean
|
||||
assertEquals(0, nodeRepository.nodeDBbyNum.value.size)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.node.list
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.meshtastic.core.testing.FakeNodeRepository
|
||||
import org.meshtastic.core.testing.FakeRadioController
|
||||
import org.meshtastic.core.testing.TestDataFactory
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Integration tests for node feature.
|
||||
*
|
||||
* Tests node filtering, sorting, and state management with multiple nodes.
|
||||
*/
|
||||
class NodeIntegrationTest {
|
||||
|
||||
private lateinit var nodeRepository: FakeNodeRepository
|
||||
private lateinit var radioController: FakeRadioController
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
nodeRepository = FakeNodeRepository()
|
||||
radioController = FakeRadioController()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPopulatingMeshWithMultipleNodes() = runTest {
|
||||
// Create diverse node set
|
||||
val nodes =
|
||||
listOf(
|
||||
TestDataFactory.createTestNode(num = 1, longName = "Alice", shortName = "A"),
|
||||
TestDataFactory.createTestNode(num = 2, longName = "Bob", shortName = "B"),
|
||||
TestDataFactory.createTestNode(num = 3, longName = "Charlie", shortName = "C"),
|
||||
TestDataFactory.createTestNode(num = 4, longName = "Diana", shortName = "D"),
|
||||
TestDataFactory.createTestNode(num = 5, longName = "Eve", shortName = "E"),
|
||||
)
|
||||
|
||||
// Add to repository
|
||||
nodeRepository.setNodes(nodes)
|
||||
|
||||
// Verify all nodes present
|
||||
assertEquals(5, nodeRepository.nodeDBbyNum.value.size)
|
||||
assertTrue(nodeRepository.nodeDBbyNum.value.containsKey(1))
|
||||
assertTrue(nodeRepository.nodeDBbyNum.value.containsKey(5))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRetrievingNodeByUserId() = runTest {
|
||||
val node = TestDataFactory.createTestNode(num = 42, userId = "!alice123", longName = "Alice")
|
||||
nodeRepository.setNodes(listOf(node))
|
||||
|
||||
// Retrieve by userId
|
||||
val retrieved = nodeRepository.getNode("!alice123")
|
||||
assertEquals("Alice", retrieved.user.long_name)
|
||||
assertEquals(42, retrieved.num)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNodeDeletionAndRemoval() = runTest {
|
||||
val nodes = TestDataFactory.createTestNodes(5)
|
||||
nodeRepository.setNodes(nodes)
|
||||
|
||||
assertEquals(5, nodeRepository.nodeDBbyNum.value.size)
|
||||
|
||||
// Delete one node
|
||||
nodeRepository.deleteNode(2)
|
||||
|
||||
// Verify deletion
|
||||
assertEquals(4, nodeRepository.nodeDBbyNum.value.size)
|
||||
assertTrue(!nodeRepository.nodeDBbyNum.value.containsKey(2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testBulkNodeDeletion() = runTest {
|
||||
val nodes = TestDataFactory.createTestNodes(10)
|
||||
nodeRepository.setNodes(nodes)
|
||||
|
||||
assertEquals(10, nodeRepository.nodeDBbyNum.value.size)
|
||||
|
||||
// Delete multiple nodes
|
||||
nodeRepository.deleteNodes(listOf(1, 3, 5, 7, 9))
|
||||
|
||||
// Verify deletions
|
||||
assertEquals(5, nodeRepository.nodeDBbyNum.value.size)
|
||||
assertTrue(!nodeRepository.nodeDBbyNum.value.containsKey(1))
|
||||
assertTrue(!nodeRepository.nodeDBbyNum.value.containsKey(3))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUpdatingNodeMetadata() = runTest {
|
||||
val originalNode = TestDataFactory.createTestNode(num = 1, longName = "Original Name")
|
||||
nodeRepository.setNodes(listOf(originalNode))
|
||||
|
||||
// Update node notes
|
||||
nodeRepository.setNodeNotes(1, "Test notes")
|
||||
|
||||
// Retrieve and verify
|
||||
val updated = nodeRepository.getUser(1)
|
||||
assertTrue(true, "Node updated successfully")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNodeConnectionStateTracking() = runTest {
|
||||
// Create nodes with different last heard times
|
||||
val onlineNode =
|
||||
TestDataFactory.createTestNode(num = 1, lastHeard = (System.currentTimeMillis() / 1000).toInt())
|
||||
val offlineNode =
|
||||
TestDataFactory.createTestNode(
|
||||
num = 2,
|
||||
lastHeard = ((System.currentTimeMillis() / 1000) - 86400).toInt(), // 24 hours ago
|
||||
)
|
||||
|
||||
nodeRepository.setNodes(listOf(onlineNode, offlineNode))
|
||||
|
||||
// Verify both nodes exist
|
||||
assertEquals(2, nodeRepository.nodeDBbyNum.value.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFilteringNodesBySearchTerm() = runTest {
|
||||
val nodes =
|
||||
listOf(
|
||||
TestDataFactory.createTestNode(num = 1, longName = "Alice Wonderland", shortName = "AW"),
|
||||
TestDataFactory.createTestNode(num = 2, longName = "Bob Builder", shortName = "BB"),
|
||||
TestDataFactory.createTestNode(num = 3, longName = "Charlie Chaplin", shortName = "CC"),
|
||||
)
|
||||
nodeRepository.setNodes(nodes)
|
||||
|
||||
// Manual filtering for test
|
||||
val allNodes = nodeRepository.nodeDBbyNum.value.values.toList()
|
||||
val filtered = allNodes.filter { it.user.long_name.contains("Alice", ignoreCase = true) }
|
||||
|
||||
assertEquals(1, filtered.size)
|
||||
assertEquals("Alice Wonderland", filtered.first().user.long_name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMaintainingFavoriteNodesList() = runTest {
|
||||
val node1 = TestDataFactory.createTestNode(num = 1, longName = "Favorite Node")
|
||||
val node2 = TestDataFactory.createTestNode(num = 2, longName = "Regular Node")
|
||||
|
||||
// Add nodes
|
||||
nodeRepository.setNodes(listOf(node1, node2))
|
||||
|
||||
// In real implementation, would have separate favorite tracking
|
||||
// For now, verify nodes are accessible
|
||||
assertEquals(2, nodeRepository.nodeDBbyNum.value.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testClearingAllNodesFromMesh() = runTest {
|
||||
nodeRepository.setNodes(TestDataFactory.createTestNodes(10))
|
||||
assertEquals(10, nodeRepository.nodeDBbyNum.value.size)
|
||||
|
||||
// Clear database
|
||||
nodeRepository.clearNodeDB(preserveFavorites = false)
|
||||
|
||||
// Verify cleared
|
||||
assertEquals(0, nodeRepository.nodeDBbyNum.value.size)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.node.list
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.core.testing.FakeNodeRepository
|
||||
import org.meshtastic.core.testing.FakeRadioController
|
||||
import org.meshtastic.core.testing.TestDataFactory
|
||||
import org.meshtastic.feature.node.detail.NodeManagementActions
|
||||
import org.meshtastic.feature.node.domain.usecase.GetFilteredNodesUseCase
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Bootstrap tests for NodeListViewModel.
|
||||
*
|
||||
* Demonstrates using FakeNodeRepository with a node list feature.
|
||||
*/
|
||||
class NodeListViewModelTest {
|
||||
|
||||
private lateinit var viewModel: NodeListViewModel
|
||||
private lateinit var nodeRepository: FakeNodeRepository
|
||||
private lateinit var radioController: FakeRadioController
|
||||
private lateinit var radioConfigRepository: RadioConfigRepository
|
||||
private lateinit var serviceRepository: ServiceRepository
|
||||
private lateinit var nodeFilterPreferences: NodeFilterPreferences
|
||||
private lateinit var nodeManagementActions: NodeManagementActions
|
||||
private lateinit var getFilteredNodesUseCase: GetFilteredNodesUseCase
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
// Use real fakes
|
||||
nodeRepository = FakeNodeRepository()
|
||||
radioController = FakeRadioController()
|
||||
|
||||
// Mock remaining dependencies with explicit types
|
||||
radioConfigRepository = mockk(relaxed = true)
|
||||
serviceRepository = mockk(relaxed = true)
|
||||
nodeFilterPreferences =
|
||||
mockk(relaxed = true) {
|
||||
every { nodeSortOption } returns MutableStateFlow(org.meshtastic.core.model.NodeSortOption.LAST_HEARD)
|
||||
every { includeUnknown } returns MutableStateFlow(true)
|
||||
every { excludeInfrastructure } returns MutableStateFlow(false)
|
||||
every { onlyOnline } returns MutableStateFlow(false)
|
||||
}
|
||||
nodeManagementActions = mockk(relaxed = true)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
getFilteredNodesUseCase = mockk<GetFilteredNodesUseCase>(relaxed = true)
|
||||
|
||||
viewModel =
|
||||
NodeListViewModel(
|
||||
savedStateHandle = SavedStateHandle(),
|
||||
nodeRepository = nodeRepository,
|
||||
radioConfigRepository = radioConfigRepository,
|
||||
serviceRepository = serviceRepository,
|
||||
radioController = radioController,
|
||||
nodeManagementActions = nodeManagementActions,
|
||||
getFilteredNodesUseCase = getFilteredNodesUseCase,
|
||||
nodeFilterPreferences = nodeFilterPreferences,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInitialization() = runTest {
|
||||
setUp()
|
||||
// ViewModel should initialize without errors
|
||||
assertTrue(true, "NodeListViewModel initialized successfully")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testOurNodeInfoFlow() = runTest {
|
||||
setUp()
|
||||
// Verify ourNodeInfo StateFlow is accessible
|
||||
val ourNode = viewModel.ourNodeInfo.value
|
||||
assertTrue(ourNode == null, "ourNodeInfo starts as null before connection")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNodeCounts() = runTest {
|
||||
setUp()
|
||||
// Add test nodes to repository
|
||||
val testNodes = TestDataFactory.createTestNodes(3)
|
||||
nodeRepository.setNodes(testNodes)
|
||||
|
||||
// Verify nodes are in repository
|
||||
assertEquals(3, nodeRepository.nodeDBbyNum.value.size, "Test nodes added to repository")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTotalAndOnlineNodeCounts() = runTest {
|
||||
setUp()
|
||||
// Verify count flows are accessible
|
||||
val totalCount = viewModel.totalNodeCount.value
|
||||
val onlineCount = viewModel.onlineNodeCount.value
|
||||
|
||||
// Both should be accessible without error
|
||||
assertTrue(true, "Node count flows are accessible")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue