feat: implement global SnackbarManager and consolidate common UI setup (#4909)
Some checks are pending
Dependency Submission / dependency-submission (push) Waiting to run
Main CI (Verify & Build) / validate-and-build (push) Waiting to run
Main Push Changelog / Generate main push changelog (push) Waiting to run

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-24 17:31:40 -05:00 committed by GitHub
parent 9b8ac6a460
commit 553ca2f8ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 705 additions and 515 deletions

View file

@ -0,0 +1,97 @@
/*
* 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.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.touchlab.kermit.Logger
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.firmware_old
import org.meshtastic.core.resources.firmware_too_old
import org.meshtastic.core.resources.should_update
import org.meshtastic.core.resources.should_update_firmware
import org.meshtastic.core.ui.viewmodel.UIViewModel
/**
* Common component to check the connected device's firmware version against the minimum required version. Will display
* a dismissable alert if the firmware is old, or a blocking alert if it is too old.
*/
@Composable
fun FirmwareVersionCheck(viewModel: UIViewModel) {
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val myNodeInfo by viewModel.myNodeInfo.collectAsStateWithLifecycle()
val myFirmwareVersion = myNodeInfo?.firmwareVersion
val firmwareEdition by viewModel.firmwareEdition.collectAsStateWithLifecycle(null)
val latestStableFirmwareRelease by
viewModel.latestStableFirmwareRelease.collectAsStateWithLifecycle(DeviceVersion("2.6.4"))
LaunchedEffect(connectionState, firmwareEdition) {
if (connectionState == ConnectionState.Connected) {
firmwareEdition?.let { edition -> Logger.d { "FirmwareEdition: ${edition.name}" } }
}
}
LaunchedEffect(connectionState, myNodeInfo) {
if (connectionState == ConnectionState.Connected) {
myNodeInfo?.let { info ->
myFirmwareVersion
?.takeIf { it.isNotBlank() }
?.let { fwVersion ->
val curVer = DeviceVersion(fwVersion)
Logger.i {
"[FW_CHECK] Firmware version comparison - " +
"device: $curVer (raw: $fwVersion), " +
"absoluteMin: ${DeviceVersion(DeviceVersion.ABS_MIN_FW_VERSION)}, " +
"min: ${DeviceVersion(DeviceVersion.MIN_FW_VERSION)}"
}
if (curVer < DeviceVersion(DeviceVersion.ABS_MIN_FW_VERSION)) {
Logger.w {
"[FW_CHECK] Firmware too old - " +
"device: $curVer < absoluteMin: ${DeviceVersion(DeviceVersion.ABS_MIN_FW_VERSION)}"
}
val title = getString(Res.string.firmware_too_old)
val message = getString(Res.string.firmware_old)
viewModel.showAlert(
title = title,
html = message,
onConfirm = { viewModel.setDeviceAddress("n") },
)
} else if (curVer < DeviceVersion(DeviceVersion.MIN_FW_VERSION)) {
Logger.w {
"[FW_CHECK] Firmware should update - " +
"device: $curVer < min: ${DeviceVersion(DeviceVersion.MIN_FW_VERSION)}"
}
val title = getString(Res.string.should_update_firmware)
val message = getString(Res.string.should_update, latestStableFirmwareRelease.asString)
viewModel.showAlert(title = title, message = message, onConfirm = {})
} else {
Logger.i { "[FW_CHECK] Firmware version OK - device: $curVer meets requirements" }
}
}
}
}
}
}

View file

@ -0,0 +1,37 @@
/*
* 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.runtime.Composable
import org.meshtastic.core.ui.viewmodel.UIViewModel
/**
* Encapsulates the headless, global UI components (dialogs, version checks, traceroute alerts) that need to be active
* across all platforms at the root of the application hierarchy.
*
* This deduplicates the setup boilerplate from Android's MainScreen and DesktopMainScreen.
*/
@Composable
fun MeshtasticCommonAppSetup(
uiViewModel: UIViewModel,
onNavigateToTracerouteMap: (destinationNodeNum: Int, requestId: Int, logUuid: String?) -> Unit,
) {
SharedDialogs(uiViewModel = uiViewModel)
FirmwareVersionCheck(viewModel = uiViewModel)
AlertHost(alertManager = uiViewModel.alertManager)
TracerouteAlertHandler(uiViewModel = uiViewModel, onNavigateToMap = onNavigateToTracerouteMap)
}

View file

@ -0,0 +1,67 @@
/*
* 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.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
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.Alignment
import androidx.compose.ui.Modifier
import org.meshtastic.core.ui.util.SnackbarManager
/**
* Shared composable that observes [SnackbarManager.events] and provides a global [SnackbarHostState].
*
* It renders a [SnackbarHost] using the provided [hostModifier] over the provided [content].
*/
@Composable
fun MeshtasticSnackbarProvider(
snackbarManager: SnackbarManager,
modifier: Modifier = Modifier,
hostModifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
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()
}
}
}
Box(modifier = modifier.fillMaxSize()) {
content()
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter).then(hostModifier),
)
}
}

View file

@ -17,11 +17,12 @@
package org.meshtastic.core.ui.component
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
import org.meshtastic.core.ui.share.SharedContactDialog
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.SharedContact
import org.meshtastic.core.ui.viewmodel.UIViewModel
/**
* Shared composable that conditionally renders [SharedContactDialog] and [ScannedQrCodeDialog] when the device is
@ -30,16 +31,18 @@ import org.meshtastic.proto.SharedContact
* This eliminates identical boilerplate from Android `MainScreen` and Desktop `DesktopMainScreen`.
*/
@Composable
fun SharedDialogs(
connectionState: ConnectionState,
sharedContactRequested: SharedContact?,
requestChannelSet: ChannelSet?,
onDismissSharedContact: () -> Unit,
onDismissChannelSet: () -> Unit,
) {
if (connectionState == ConnectionState.Connected) {
sharedContactRequested?.let { SharedContactDialog(sharedContact = it, onDismiss = onDismissSharedContact) }
fun SharedDialogs(uiViewModel: UIViewModel) {
val connectionState by uiViewModel.connectionState.collectAsStateWithLifecycle()
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
requestChannelSet?.let { newChannelSet -> ScannedQrCodeDialog(newChannelSet, onDismiss = onDismissChannelSet) }
if (connectionState == ConnectionState.Connected) {
sharedContactRequested?.let {
SharedContactDialog(sharedContact = it, onDismiss = { uiViewModel.clearSharedContactRequested() })
}
requestChannelSet?.let { newChannelSet ->
ScannedQrCodeDialog(newChannelSet, onDismiss = { uiViewModel.clearRequestChannelUrl() })
}
}
}

View file

@ -0,0 +1,98 @@
/*
* 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.foundation.layout.Column
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.okay
import org.meshtastic.core.resources.traceroute
import org.meshtastic.core.resources.view_on_map
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
import org.meshtastic.core.ui.util.annotateTraceroute
import org.meshtastic.core.ui.util.toMessageRes
import org.meshtastic.core.ui.viewmodel.UIViewModel
/**
* Handles the display of the traceroute alert when a response is received. Consolidates the side effect logic from the
* main application screens into common code.
*/
@Composable
fun TracerouteAlertHandler(
uiViewModel: UIViewModel,
onNavigateToMap: (destinationNodeNum: Int, requestId: Int, logUuid: String?) -> Unit,
) {
val traceRouteResponse by uiViewModel.tracerouteResponse.collectAsStateWithLifecycle(null)
var dismissedTracerouteRequestId by remember { mutableStateOf<Int?>(null) }
val colorScheme = MaterialTheme.colorScheme
LaunchedEffect(traceRouteResponse, dismissedTracerouteRequestId) {
val response = traceRouteResponse
if (response != null && response.requestId != dismissedTracerouteRequestId) {
uiViewModel.showAlert(
titleRes = Res.string.traceroute,
composableMessage = {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
Text(
text =
annotateTraceroute(
response.message,
statusGreen = colorScheme.StatusGreen,
statusYellow = colorScheme.StatusYellow,
statusOrange = colorScheme.StatusOrange,
),
)
}
},
confirmTextRes = Res.string.view_on_map,
onConfirm = {
val availability =
uiViewModel.tracerouteMapAvailability(
forwardRoute = response.forwardRoute,
returnRoute = response.returnRoute,
)
val errorRes = availability.toMessageRes()
if (errorRes == null) {
dismissedTracerouteRequestId = response.requestId
onNavigateToMap(response.destinationNodeNum, response.requestId, response.logUuid)
} else {
uiViewModel.showAlert(titleRes = Res.string.traceroute, messageRes = errorRes)
uiViewModel.clearTracerouteResponse()
}
},
dismissTextRes = Res.string.okay,
onDismiss = {
uiViewModel.clearTracerouteResponse()
dismissedTracerouteRequestId = null
},
)
}
}
}

View file

@ -0,0 +1,63 @@
/*
* 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,10 @@ 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,103 @@
/*
* 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)
}
}
}