refactor: Centralize snackbar management and decouple UI feedback from feature modules

- Introduce `SnackbarManager` and `MeshtasticSnackbarHost` to provide a unified, global mechanism for displaying transient messages across Android and Desktop platforms.
- Integrate `SnackbarManager` into `UIViewModel` and inject it into `CommonNodeRequestActions` to allow business logic to trigger feedback without direct UI dependencies.
- Remove localized `SnackbarHostState` and `LaunchedEffect` collectors from various screens in the `feature:node` module, including `PositionLog`, `PaxMetrics`, `DeviceMetrics`, and `TracerouteLog`.
- Delete `NodeRequestEffect` and refactor `NodeRequestActions` to utilize the global snackbar manager for request feedback.
- Update the top-level UI entry points in `Main.kt` (Android) and `DesktopMainScreen.kt` to host the shared `MeshtasticSnackbarHost`.
- Add comprehensive unit tests for `SnackbarManager` in `core:ui` to verify event buffering, action callbacks, and duration logic.
- Clean up related ViewModel tests to reflect the removal of the `effects` flow.
This commit is contained in:
James Rich
2026-03-24 12:43:38 -05:00
parent 05f450f23e
commit 8a52212bca
23 changed files with 333 additions and 269 deletions

View File

@@ -24,6 +24,7 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
@@ -55,6 +56,7 @@ import androidx.compose.runtime.mutableIntStateOf
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.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -91,6 +93,7 @@ import org.meshtastic.core.resources.traceroute
import org.meshtastic.core.resources.view_on_map
import org.meshtastic.core.service.MeshService
import org.meshtastic.core.ui.component.AlertHost
import org.meshtastic.core.ui.component.MeshtasticSnackbarHost
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.component.SharedDialogs
import org.meshtastic.core.ui.navigation.icon
@@ -316,30 +319,36 @@ fun MainScreen(uIViewModel: UIViewModel = koinViewModel(), scanModel: ScannerVie
}
},
) {
val provider =
entryProvider<NavKey> {
contactsGraph(backStack, uIViewModel.scrollToTopEventFlow)
nodesGraph(
backStack = backStack,
scrollToTopEvents = uIViewModel.scrollToTopEventFlow,
nodeMapScreen = { destNum, onNavigateUp ->
val vm =
org.koin.compose.viewmodel.koinViewModel<org.meshtastic.feature.map.node.NodeMapViewModel>()
vm.setDestNum(destNum)
org.meshtastic.app.map.node.NodeMapScreen(vm, onNavigateUp = onNavigateUp)
},
)
mapGraph(backStack)
channelsGraph(backStack)
connectionsGraph(backStack)
settingsGraph(backStack)
firmwareGraph(backStack)
}
NavDisplay(
backStack = backStack,
entryProvider = provider,
modifier = Modifier.fillMaxSize().recalculateWindowInsets().safeDrawingPadding(),
)
Box(modifier = Modifier.fillMaxSize()) {
val provider =
entryProvider<NavKey> {
contactsGraph(backStack, uIViewModel.scrollToTopEventFlow)
nodesGraph(
backStack = backStack,
scrollToTopEvents = uIViewModel.scrollToTopEventFlow,
nodeMapScreen = { destNum, onNavigateUp ->
val vm =
org.koin.compose.viewmodel.koinViewModel<org.meshtastic.feature.map.node.NodeMapViewModel>()
vm.setDestNum(destNum)
org.meshtastic.app.map.node.NodeMapScreen(vm, onNavigateUp = onNavigateUp)
},
)
mapGraph(backStack)
channelsGraph(backStack)
connectionsGraph(backStack)
settingsGraph(backStack)
firmwareGraph(backStack)
}
NavDisplay(
backStack = backStack,
entryProvider = provider,
modifier = Modifier.fillMaxSize().recalculateWindowInsets().safeDrawingPadding(),
)
MeshtasticSnackbarHost(
snackbarManager = uIViewModel.snackbarManager,
modifier = Modifier.align(Alignment.BottomCenter),
)
}
}
}

View File

@@ -0,0 +1,57 @@
/*
* 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.core.ui.component
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import org.meshtastic.core.ui.util.SnackbarManager
/**
* Shared composable that observes [SnackbarManager.events] and renders a Material 3 [SnackbarHost].
*
* Each event is consumed exactly once. If the snackbar had an action label and the user tapped it,
* [SnackbarManager.SnackbarEvent.onAction] is invoked.
*
* Usage: Place `MeshtasticSnackbarHost(snackbarManager)` once in the top-level composable of each platform host,
* typically inside a `Box` with `Alignment.BottomCenter`.
*/
@Composable
fun MeshtasticSnackbarHost(snackbarManager: SnackbarManager, modifier: Modifier = Modifier) {
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(snackbarManager) {
snackbarManager.events.collect { event ->
val result = snackbarHostState.showSnackbar(
message = event.message,
actionLabel = event.actionLabel,
withDismissAction = event.withDismissAction,
duration = event.duration,
)
if (result == SnackbarResult.ActionPerformed) {
event.onAction?.invoke()
}
}
}
SnackbarHost(hostState = snackbarHostState, modifier = modifier)
}

View File

@@ -0,0 +1,65 @@
/*
* 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.core.ui.util
import androidx.compose.material3.SnackbarDuration
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.receiveAsFlow
import org.koin.core.annotation.Single
/**
* A global manager for displaying snackbars across the application. This allows ViewModels to trigger transient
* feedback messages without direct dependencies on UI components or `SnackbarHostState`.
*
* Events are buffered in a [Channel] and consumed exactly once by the host composable via `MeshtasticSnackbarHost`.
*
* @see AlertManager for the modal dialog equivalent.
*/
@Single
open class SnackbarManager {
data class SnackbarEvent(
val message: String,
val actionLabel: String? = null,
val withDismissAction: Boolean = false,
val duration: SnackbarDuration = SnackbarDuration.Short,
val onAction: (() -> Unit)? = null,
)
private val _events = Channel<SnackbarEvent>(Channel.BUFFERED)
open val events: Flow<SnackbarEvent> = _events.receiveAsFlow()
open fun showSnackbar(
message: String,
actionLabel: String? = null,
withDismissAction: Boolean = false,
duration: SnackbarDuration = if (actionLabel != null) SnackbarDuration.Indefinite else SnackbarDuration.Short,
onAction: (() -> Unit)? = null,
) {
_events.trySend(
SnackbarEvent(
message = message,
actionLabel = actionLabel,
withDismissAction = withDismissAction,
duration = duration,
onAction = onAction,
),
)
}
}

View File

@@ -57,6 +57,7 @@ import org.meshtastic.core.resources.compromised_keys
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.util.AlertManager
import org.meshtastic.core.ui.util.ComposableContent
import org.meshtastic.core.ui.util.SnackbarManager
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.SharedContact
@@ -80,6 +81,7 @@ class UIViewModel(
private val notificationManager: NotificationManager,
packetRepository: PacketRepository,
val alertManager: AlertManager,
val snackbarManager: SnackbarManager,
) : ViewModel() {
private val _navigationDeepLink = MutableSharedFlow<MeshtasticUri>(replay = 1)
@@ -165,6 +167,14 @@ class UIViewModel(
alertManager.dismissAlert()
}
fun showSnackbar(
message: String,
actionLabel: String? = null,
onAction: (() -> Unit)? = null,
) {
snackbarManager.showSnackbar(message = message, actionLabel = actionLabel, onAction = onAction)
}
fun setDeviceAddress(address: String) {
radioController.setDeviceAddress(address)
}

View File

@@ -0,0 +1,108 @@
/*
* 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.core.ui.util
import androidx.compose.material3.SnackbarDuration
import app.cash.turbine.test
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class SnackbarManagerTest {
private val snackbarManager = SnackbarManager()
@Test
fun showSnackbar_emits_event_with_message() = runTest {
snackbarManager.events.test {
snackbarManager.showSnackbar(message = "Hello")
val event = awaitItem()
assertEquals("Hello", event.message)
assertNull(event.actionLabel)
assertEquals(SnackbarDuration.Short, event.duration)
}
}
@Test
fun showSnackbar_with_action_defaults_to_indefinite_duration() = runTest {
snackbarManager.events.test {
snackbarManager.showSnackbar(message = "Deleted", actionLabel = "Undo")
val event = awaitItem()
assertEquals("Deleted", event.message)
assertEquals("Undo", event.actionLabel)
assertEquals(SnackbarDuration.Indefinite, event.duration)
}
}
@Test
fun showSnackbar_with_explicit_duration_overrides_default() = runTest {
snackbarManager.events.test {
snackbarManager.showSnackbar(
message = "Saved",
actionLabel = "View",
duration = SnackbarDuration.Long,
)
val event = awaitItem()
assertEquals(SnackbarDuration.Long, event.duration)
}
}
@Test
fun multiple_events_are_queued_and_consumed_in_order() = runTest {
snackbarManager.events.test {
snackbarManager.showSnackbar(message = "First")
snackbarManager.showSnackbar(message = "Second")
snackbarManager.showSnackbar(message = "Third")
assertEquals("First", awaitItem().message)
assertEquals("Second", awaitItem().message)
assertEquals("Third", awaitItem().message)
}
}
@Test
fun onAction_callback_is_preserved_in_event() = runTest {
var actionTriggered = false
snackbarManager.events.test {
snackbarManager.showSnackbar(
message = "Item removed",
actionLabel = "Undo",
onAction = { actionTriggered = true },
)
val event = awaitItem()
event.onAction?.invoke()
assertTrue(actionTriggered)
}
}
@Test
fun withDismissAction_is_passed_through() = runTest {
snackbarManager.events.test {
snackbarManager.showSnackbar(message = "Notice", withDismissAction = true)
val event = awaitItem()
assertTrue(event.withDismissAction)
}
}
}

View File

@@ -16,6 +16,7 @@
*/
package org.meshtastic.desktop.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Icon
@@ -26,6 +27,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.NavBackStack
@@ -39,6 +41,7 @@ import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.navigation.TopLevelDestination
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.ui.component.AlertHost
import org.meshtastic.core.ui.component.MeshtasticSnackbarHost
import org.meshtastic.core.ui.component.SharedDialogs
import org.meshtastic.core.ui.navigation.icon
import org.meshtastic.core.ui.viewmodel.UIViewModel
@@ -77,46 +80,52 @@ fun DesktopMainScreen(
AlertHost(uiViewModel.alertManager)
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Row(modifier = Modifier.fillMaxSize()) {
NavigationRail {
TopLevelDestination.entries.forEach { destination ->
NavigationRailItem(
selected = destination == selected,
onClick = {
if (destination != selected) {
backStack.add(destination.route)
while (backStack.size > 1) {
backStack.removeAt(0)
Box(modifier = Modifier.fillMaxSize()) {
Row(modifier = Modifier.fillMaxSize()) {
NavigationRail {
TopLevelDestination.entries.forEach { destination ->
NavigationRailItem(
selected = destination == selected,
onClick = {
if (destination != selected) {
backStack.add(destination.route)
while (backStack.size > 1) {
backStack.removeAt(0)
}
}
}
},
icon = {
if (destination == TopLevelDestination.Connections) {
org.meshtastic.feature.connections.ui.components.AnimatedConnectionsNavIcon(
connectionState = connectionState,
deviceType = DeviceType.fromAddress(selectedDevice ?: "NoDevice"),
meshActivityFlow = radioService.meshActivity,
colorScheme = colorScheme,
)
} else {
Icon(
imageVector = destination.icon,
contentDescription = stringResource(destination.label),
)
}
},
label = { Text(stringResource(destination.label)) },
)
},
icon = {
if (destination == TopLevelDestination.Connections) {
org.meshtastic.feature.connections.ui.components.AnimatedConnectionsNavIcon(
connectionState = connectionState,
deviceType = DeviceType.fromAddress(selectedDevice ?: "NoDevice"),
meshActivityFlow = radioService.meshActivity,
colorScheme = colorScheme,
)
} else {
Icon(
imageVector = destination.icon,
contentDescription = stringResource(destination.label),
)
}
},
label = { Text(stringResource(destination.label)) },
)
}
}
val provider = entryProvider<NavKey> { desktopNavGraph(backStack) }
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = provider,
modifier = Modifier.weight(1f).fillMaxSize(),
)
}
val provider = entryProvider<NavKey> { desktopNavGraph(backStack) }
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = provider,
modifier = Modifier.weight(1f).fillMaxSize(),
MeshtasticSnackbarHost(
snackbarManager = uiViewModel.snackbarManager,
modifier = Modifier.align(Alignment.BottomCenter),
)
}
}

View File

@@ -26,8 +26,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -81,21 +79,11 @@ actual fun NodeDetailScreen(
) {
LaunchedEffect(nodeId) { viewModel.start(nodeId) }
val snackbarHostState = remember { SnackbarHostState() }
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
if (effect is NodeRequestEffect.ShowFeedback) {
snackbarHostState.showSnackbar(effect.text.resolve())
}
}
}
NodeDetailScaffold(
modifier = modifier,
uiState = uiState,
snackbarHostState = snackbarHostState,
viewModel = viewModel,
navigateToMessages = navigateToMessages,
onNavigate = onNavigate,
@@ -109,7 +97,6 @@ actual fun NodeDetailScreen(
private fun NodeDetailScaffold(
modifier: Modifier,
uiState: NodeDetailUiState,
snackbarHostState: SnackbarHostState,
viewModel: NodeDetailViewModel,
navigateToMessages: (String) -> Unit,
onNavigate: (Route) -> Unit,
@@ -139,7 +126,6 @@ private fun NodeDetailScaffold(
onClickChip = {},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { paddingValues ->
NodeDetailContent(
uiState = uiState,

View File

@@ -36,15 +36,11 @@ import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
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.Modifier
@@ -64,7 +60,6 @@ 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.feature.node.detail.NodeRequestEffect
import org.meshtastic.proto.Config
import org.meshtastic.proto.Position
@@ -104,18 +99,6 @@ private fun ActionButtons(
@Composable
actual fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is NodeRequestEffect.ShowFeedback -> {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(effect.text.resolve())
}
}
}
}
val exportPositionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
@@ -144,7 +127,6 @@ actual fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Un
onClickChip = {},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
BoxWithConstraints(modifier = Modifier.padding(innerPadding)) {
val compactWidth = maxWidth < 600.dp

View File

@@ -18,11 +18,8 @@ package org.meshtastic.feature.node.detail
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@@ -46,12 +43,13 @@ import org.meshtastic.core.resources.requesting_from
import org.meshtastic.core.resources.signal_quality
import org.meshtastic.core.resources.traceroute
import org.meshtastic.core.resources.user_info
import org.meshtastic.core.ui.util.SnackbarManager
@Single(binds = [NodeRequestActions::class])
class CommonNodeRequestActions constructor(private val radioController: RadioController) : NodeRequestActions {
private val _effects = MutableSharedFlow<NodeRequestEffect>()
override val effects: SharedFlow<NodeRequestEffect> = _effects.asSharedFlow()
class CommonNodeRequestActions constructor(
private val radioController: RadioController,
private val snackbarManager: SnackbarManager,
) : NodeRequestActions {
private val _lastTracerouteTime = MutableStateFlow<Long?>(null)
override val lastTracerouteTime: StateFlow<Long?> = _lastTracerouteTime.asStateFlow()
@@ -59,15 +57,15 @@ class CommonNodeRequestActions constructor(private val radioController: RadioCon
private val _lastRequestNeighborTimes = MutableStateFlow<Map<Int, Long>>(emptyMap())
override val lastRequestNeighborTimes: StateFlow<Map<Int, Long>> = _lastRequestNeighborTimes.asStateFlow()
private suspend fun showFeedback(text: UiText) {
snackbarManager.showSnackbar(message = text.resolve())
}
override fun requestUserInfo(scope: CoroutineScope, destNum: Int, longName: String) {
scope.launch(ioDispatcher) {
Logger.i { "Requesting UserInfo for '$destNum'" }
radioController.requestUserInfo(destNum)
_effects.emit(
NodeRequestEffect.ShowFeedback(
UiText.Resource(Res.string.requesting_from, Res.string.user_info, longName),
),
)
showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.user_info, longName))
}
}
@@ -77,11 +75,7 @@ class CommonNodeRequestActions constructor(private val radioController: RadioCon
val packetId = radioController.getPacketId()
radioController.requestNeighborInfo(packetId, destNum)
_lastRequestNeighborTimes.update { it + (destNum to nowMillis) }
_effects.emit(
NodeRequestEffect.ShowFeedback(
UiText.Resource(Res.string.requesting_from, Res.string.neighbor_info, longName),
),
)
showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.neighbor_info, longName))
}
}
@@ -89,11 +83,7 @@ class CommonNodeRequestActions constructor(private val radioController: RadioCon
scope.launch(ioDispatcher) {
Logger.i { "Requesting position for '$destNum'" }
radioController.requestPosition(destNum, position)
_effects.emit(
NodeRequestEffect.ShowFeedback(
UiText.Resource(Res.string.requesting_from, Res.string.position, longName),
),
)
showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.position, longName))
}
}
@@ -114,9 +104,7 @@ class CommonNodeRequestActions constructor(private val radioController: RadioCon
TelemetryType.PAX -> Res.string.request_pax_metrics
}
_effects.emit(
NodeRequestEffect.ShowFeedback(UiText.Resource(Res.string.requesting_from, typeRes, longName)),
)
showFeedback(UiText.Resource(Res.string.requesting_from, typeRes, longName))
}
}
@@ -126,11 +114,7 @@ class CommonNodeRequestActions constructor(private val radioController: RadioCon
val packetId = radioController.getPacketId()
radioController.requestTraceroute(packetId, destNum)
_lastTracerouteTime.value = nowMillis
_effects.emit(
NodeRequestEffect.ShowFeedback(
UiText.Resource(Res.string.requesting_from, Res.string.traceroute, longName),
),
)
showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.traceroute, longName))
}
}
}

View File

@@ -21,7 +21,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
@@ -84,7 +83,6 @@ class NodeDetailViewModel(
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), NodeDetailUiState())
val effects: SharedFlow<NodeRequestEffect> = nodeRequestActions.effects
fun start(nodeId: Int) {
if (manualNodeId.value != nodeId) {

View File

@@ -17,19 +17,12 @@
package org.meshtastic.feature.node.detail
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.resources.UiText
sealed class NodeRequestEffect {
data class ShowFeedback(val text: UiText) : NodeRequestEffect()
}
/** Interface for high-level node request actions (e.g., requesting user info, position, telemetry). */
interface NodeRequestActions {
val effects: SharedFlow<NodeRequestEffect>
val lastTracerouteTime: StateFlow<Long?>
val lastRequestNeighborTimes: StateFlow<Map<Int, Long>>

View File

@@ -30,8 +30,6 @@ import androidx.compose.material.icons.rounded.Info
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -152,7 +150,6 @@ fun <T> BaseMetricScreen(
data: List<T>,
timeProvider: (T) -> Double,
infoData: List<InfoDialogData> = emptyList(),
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
onRequestTelemetry: (() -> Unit)? = null,
chartPart: @Composable (Modifier, Double?, VicoScrollState, (Double) -> Unit) -> Unit,
listPart: @Composable (Modifier, Double?, LazyListState, (Double) -> Unit) -> Unit,
@@ -192,7 +189,6 @@ fun <T> BaseMetricScreen(
onClickChip = {},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
if (displayInfoDialog) {

View File

@@ -36,7 +36,6 @@ import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -84,7 +83,6 @@ import org.meshtastic.core.ui.theme.GraphColors.Cyan
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.MS_PER_SEC
import org.meshtastic.proto.Telemetry
@@ -130,23 +128,12 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
val timeFrame by viewModel.timeFrame.collectAsStateWithLifecycle()
val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle()
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 } }
val hasVoltage = remember(data) { data.any { it.device_metrics?.voltage != null } }
val hasChUtil = remember(data) { data.any { it.device_metrics?.channel_utilization != null } }
val hasAirUtil = remember(data) { data.any { it.device_metrics?.air_util_tx != null } }
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is NodeRequestEffect.ShowFeedback -> {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(effect.text.resolve())
}
}
}
}
val filteredLegendData =
remember(hasBattery, hasVoltage, hasChUtil, hasAirUtil) {
@@ -193,7 +180,6 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
data = data,
timeProvider = { it.time.toDouble() },
infoData = infoItems,
snackbarHostState = snackbarHostState,
onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.DEVICE) },
controlPart = {
TimeFrameSelector(

View File

@@ -35,13 +35,10 @@ import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -68,7 +65,6 @@ import org.meshtastic.core.resources.uv_lux
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.MS_PER_SEC
import org.meshtastic.proto.Telemetry
@@ -79,18 +75,6 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Un
val filteredTelemetries by viewModel.filteredEnvironmentMetrics.collectAsStateWithLifecycle()
val timeFrame by viewModel.timeFrame.collectAsStateWithLifecycle()
val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is NodeRequestEffect.ShowFeedback -> {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(effect.text.resolve())
}
}
}
}
BaseMetricScreen(
onNavigateUp = onNavigateUp,
@@ -100,7 +84,6 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Un
data = filteredTelemetries,
timeProvider = { it.time.toDouble() },
infoData = listOf(InfoDialogData(Res.string.iaq, Res.string.iaq_definition, Environment.IAQ.color)),
snackbarHostState = snackbarHostState,
onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.ENVIRONMENT) },
controlPart = {
TimeFrameSelector(

View File

@@ -38,13 +38,9 @@ import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
@@ -68,25 +64,12 @@ 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.feature.node.detail.NodeRequestEffect
import org.meshtastic.proto.Telemetry
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun HostMetricsLogScreen(metricsViewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
val state by metricsViewModel.state.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
metricsViewModel.effects.collect { effect ->
when (effect) {
is NodeRequestEffect.ShowFeedback -> {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(effect.text.resolve())
}
}
}
}
val hostMetrics = state.hostMetrics
@@ -108,7 +91,6 @@ fun HostMetricsLogScreen(metricsViewModel: MetricsViewModel, onNavigateUp: () ->
onClickChip = {},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
LazyColumn(
modifier = Modifier.fillMaxSize().padding(innerPadding),

View File

@@ -23,7 +23,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
@@ -64,7 +63,6 @@ import org.meshtastic.core.ui.util.toMessageRes
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.map.model.TracerouteOverlay
import org.meshtastic.feature.node.detail.NodeRequestActions
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.TimeFrame
@@ -175,7 +173,6 @@ open class MetricsViewModel(
}
.stateInWhileSubscribed(emptyList())
val effects: SharedFlow<NodeRequestEffect> = nodeRequestActions.effects
val lastTraceRouteTime: StateFlow<Long?> = nodeRequestActions.lastTracerouteTime

View File

@@ -28,10 +28,7 @@ import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -55,25 +52,12 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
import org.meshtastic.core.ui.util.annotateNeighborInfo
import org.meshtastic.feature.node.component.CooldownIconButton
import org.meshtastic.feature.node.detail.NodeRequestEffect
@OptIn(ExperimentalFoundationApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun NeighborInfoLogScreen(modifier: Modifier = Modifier, viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is NodeRequestEffect.ShowFeedback -> {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(effect.text.resolve())
}
}
}
}
fun getUsername(nodeNum: Int): String = with(viewModel.getUser(nodeNum)) { "$long_name ($short_name)" }
@@ -104,7 +88,6 @@ fun NeighborInfoLogScreen(modifier: Modifier = Modifier, viewModel: MetricsViewM
onClickChip = {},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
LazyColumn(
modifier = modifier.fillMaxSize().padding(innerPadding),

View File

@@ -31,7 +31,6 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -70,7 +69,6 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons
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 org.meshtastic.proto.Paxcount as ProtoPaxcount
private enum class PaxSeries(val color: Color, val legendRes: StringResource) {
@@ -177,18 +175,6 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel, onNavigateUp: () -> Uni
val paxMetrics by metricsViewModel.filteredPaxMetrics.collectAsStateWithLifecycle()
val timeFrame by metricsViewModel.timeFrame.collectAsStateWithLifecycle()
val availableTimeFrames by metricsViewModel.availableTimeFrames.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
metricsViewModel.effects.collect { effect ->
when (effect) {
is NodeRequestEffect.ShowFeedback -> {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(effect.text.resolve())
}
}
}
}
// Prepare data for graph
val graphData =
@@ -211,7 +197,6 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel, onNavigateUp: () -> Uni
nodeName = state.node?.user?.long_name ?: "",
data = paxMetrics,
timeProvider = { (it.first.received_date / CommonCharts.MS_PER_SEC).toDouble() },
snackbarHostState = snackbarHostState,
onRequestTelemetry = { metricsViewModel.requestTelemetry(TelemetryType.PAX) },
controlPart = {
TimeFrameSelector(

View File

@@ -36,7 +36,6 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -73,7 +72,6 @@ import org.meshtastic.core.resources.power_metrics_log
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.MS_PER_SEC
import org.meshtastic.proto.Telemetry
@@ -112,18 +110,6 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle()
val data = state.powerMetrics.filter { it.time.toLong() >= timeFrame.timeThreshold() }
var selectedChannel by remember { mutableStateOf(PowerChannel.ONE) }
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is NodeRequestEffect.ShowFeedback -> {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(effect.text.resolve())
}
}
}
}
BaseMetricScreen(
onNavigateUp = onNavigateUp,
@@ -132,7 +118,6 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
nodeName = state.node?.user?.long_name ?: "",
data = data,
timeProvider = { it.time.toDouble() },
snackbarHostState = snackbarHostState,
onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.POWER) },
controlPart = {
Column {

View File

@@ -35,7 +35,6 @@ import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -67,7 +66,6 @@ import org.meshtastic.core.resources.snr_definition
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.MS_PER_SEC
import org.meshtastic.proto.MeshPacket
@@ -89,18 +87,6 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
val timeFrame by viewModel.timeFrame.collectAsStateWithLifecycle()
val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle()
val data = state.signalMetrics.filter { it.rx_time.toLong() >= timeFrame.timeThreshold() }
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is NodeRequestEffect.ShowFeedback -> {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(effect.text.resolve())
}
}
}
}
BaseMetricScreen(
onNavigateUp = onNavigateUp,
@@ -109,7 +95,6 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
nodeName = state.node?.user?.long_name ?: "",
data = data,
timeProvider = { it.rx_time.toDouble() },
snackbarHostState = snackbarHostState,
onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.LOCAL_STATS) },
infoData =
listOf(

View File

@@ -28,10 +28,7 @@ import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -72,7 +69,6 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
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.proto.RouteDiscovery
@OptIn(ExperimentalFoundationApi::class)
@@ -85,18 +81,6 @@ fun TracerouteLogScreen(
onViewOnMap: (requestId: Int, responseLogUuid: String) -> Unit = { _, _ -> },
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is NodeRequestEffect.ShowFeedback -> {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(effect.text.resolve())
}
}
}
}
fun getUsername(nodeNum: Int): String = with(viewModel.getUser(nodeNum)) { "$long_name ($short_name)" }
@@ -127,7 +111,6 @@ fun TracerouteLogScreen(
onClickChip = {},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
LazyColumn(
modifier = modifier.fillMaxSize().padding(innerPadding),

View File

@@ -57,7 +57,6 @@ class NodeDetailViewModelTest {
Dispatchers.setMain(testDispatcher)
every { getNodeDetailsUseCase(any()) } returns emptyFlow()
every { nodeRequestActions.effects } returns kotlinx.coroutines.flow.MutableSharedFlow()
viewModel = createViewModel(1234)
}

View File

@@ -78,7 +78,6 @@ class MetricsViewModelTest {
// Default setup for flows
every { serviceRepository.tracerouteResponse } returns MutableStateFlow(null)
every { nodeRequestActions.effects } returns mock()
every { nodeRequestActions.lastTracerouteTime } returns MutableStateFlow(null)
every { nodeRequestActions.lastRequestNeighborTimes } returns MutableStateFlow(emptyMap())
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap())