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

@ -61,7 +61,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
- **Material 3:** The app uses Material 3.
- **Strings:** MUST use the **Compose Multiplatform Resource** library in `core:resources` (`stringResource(Res.string.your_key)`). For ViewModels or non-composable Coroutines, use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use hardcoded strings, and NEVER use the blocking `getString()` in a coroutine.
- **Dialogs:** Use centralized components in `core:ui` (e.g., `MeshtasticResourceDialog`).
- **Alerts:** Use `AlertHost(alertManager)` from `core:ui/commonMain` in each platform host shell (`Main.kt`, `DesktopMainScreen.kt`). Do NOT duplicate inline alert-rendering boilerplate. For shared QR/contact dialogs, use the `SharedDialogs` composable.
- **Alerts:** Use `AlertHost(alertManager)` from `core:ui/commonMain` in each platform host shell (`Main.kt`, `DesktopMainScreen.kt`). For global responses like traceroute and firmware validation, use the specialized common handlers: `TracerouteAlertHandler(uiViewModel)` and `FirmwareVersionCheck(uiViewModel)`. Do NOT duplicate inline alert-rendering logic or trigger alerts directly during composition. For shared QR/contact dialogs, use the `SharedDialogs(uiViewModel)` composable.
- **Placeholders:** For desktop/JVM features not yet implemented, use `PlaceholderScreen(name)` from `core:ui/commonMain`. Do NOT define inline placeholder composables in feature modules.
- **Theme Picker:** Use `ThemePickerDialog` and `ThemeOption` from `feature:settings/commonMain`. Do NOT duplicate the theme dialog or enum in platform-specific source sets.
- **Adaptive Layouts:** Use `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support the 2026 Desktop Experience breakpoints. Prioritize **higher information density** and mouse-precision interactions for Desktop and External Display (Android 16 QPR3) targets. **Investigate 3-pane "Power User" scenes** (e.g., Node List + Detail + Map/Charts) using Navigation 3 Scenes and `ThreePaneScaffold` for widths ≥ 1200dp.

View file

@ -61,7 +61,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
- **Material 3:** The app uses Material 3.
- **Strings:** MUST use the **Compose Multiplatform Resource** library in `core:resources` (`stringResource(Res.string.your_key)`). For ViewModels or non-composable Coroutines, use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use hardcoded strings, and NEVER use the blocking `getString()` in a coroutine.
- **Dialogs:** Use centralized components in `core:ui` (e.g., `MeshtasticResourceDialog`).
- **Alerts:** Use `AlertHost(alertManager)` from `core:ui/commonMain` in each platform host shell (`Main.kt`, `DesktopMainScreen.kt`). Do NOT duplicate inline alert-rendering boilerplate. For shared QR/contact dialogs, use the `SharedDialogs` composable.
- **Alerts:** Use `AlertHost(alertManager)` from `core:ui/commonMain` in each platform host shell (`Main.kt`, `DesktopMainScreen.kt`). For global responses like traceroute and firmware validation, use the specialized common handlers: `TracerouteAlertHandler(uiViewModel)` and `FirmwareVersionCheck(uiViewModel)`. Do NOT duplicate inline alert-rendering logic or trigger alerts directly during composition. For shared QR/contact dialogs, use the `SharedDialogs(uiViewModel)` composable.
- **Placeholders:** For desktop/JVM features not yet implemented, use `PlaceholderScreen(name)` from `core:ui/commonMain`. Do NOT define inline placeholder composables in feature modules.
- **Theme Picker:** Use `ThemePickerDialog` and `ThemeOption` from `feature:settings/commonMain`. Do NOT duplicate the theme dialog or enum in platform-specific source sets.
- **Adaptive Layouts:** Use `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support the 2026 Desktop Experience breakpoints. Prioritize **higher information density** and mouse-precision interactions for Desktop and External Display (Android 16 QPR3) targets. **Investigate 3-pane "Power User" scenes** (e.g., Node List + Detail + Map/Charts) using Navigation 3 Scenes and `ThreePaneScaffold` for widths ≥ 1200dp.

View file

@ -57,7 +57,6 @@ import org.meshtastic.app.node.metrics.getTracerouteMapOverlayInsets
import org.meshtastic.app.ui.MainScreen
import org.meshtastic.core.barcode.rememberBarcodeScanner
import org.meshtastic.core.common.util.toMeshtasticUri
import org.meshtastic.core.model.util.dispatchMeshtasticUri
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.nfc.NfcScannerEffect
import org.meshtastic.core.resources.Res
@ -217,11 +216,9 @@ class MainActivity : ComponentActivity() {
return
}
uri.dispatchMeshtasticUri(
onChannel = { model.setRequestChannelSet(it) },
onContact = { model.setSharedContactRequested(it) },
onInvalid = { lifecycleScope.launch { showToast(Res.string.channel_invalid) } },
)
model.handleScannedUri(uri.toMeshtasticUri()) {
lifecycleScope.launch { showToast(Res.string.channel_invalid) }
}
}
private fun createShareIntent(message: String): PendingIntent {

View file

@ -24,14 +24,12 @@ 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.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.recalculateWindowInsets
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ExperimentalMaterial3Api
@ -52,7 +50,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
@ -63,42 +60,28 @@ import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import co.touchlab.kermit.Logger
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.BuildConfig
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.MeshtasticNavSavedStateConfig
import org.meshtastic.core.navigation.NodeDetailRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.TopLevelDestination
import org.meshtastic.core.navigation.navigateTopLevel
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.app_too_old
import org.meshtastic.core.resources.connected
import org.meshtastic.core.resources.connecting
import org.meshtastic.core.resources.device_sleeping
import org.meshtastic.core.resources.disconnected
import org.meshtastic.core.resources.firmware_old
import org.meshtastic.core.resources.firmware_too_old
import org.meshtastic.core.resources.must_update
import org.meshtastic.core.resources.okay
import org.meshtastic.core.resources.should_update
import org.meshtastic.core.resources.should_update_firmware
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.MeshtasticCommonAppSetup
import org.meshtastic.core.ui.component.MeshtasticSnackbarProvider
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.component.SharedDialogs
import org.meshtastic.core.ui.navigation.icon
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
import org.meshtastic.feature.connections.ScannerViewModel
import org.meshtastic.feature.connections.navigation.connectionsGraph
@ -117,71 +100,16 @@ fun MainScreen(uIViewModel: UIViewModel = koinViewModel(), scanModel: ScannerVie
// LaunchedEffect(uIViewModel) { uIViewModel.navigationDeepLink.collectLatest { uri -> navController.navigate(uri) }
// }
val connectionState by uIViewModel.connectionState.collectAsStateWithLifecycle()
val requestChannelSet by uIViewModel.requestChannelSet.collectAsStateWithLifecycle()
val sharedContactRequested by uIViewModel.sharedContactRequested.collectAsStateWithLifecycle()
val unreadMessageCount by uIViewModel.unreadMessageCount.collectAsStateWithLifecycle()
SharedDialogs(
connectionState = connectionState,
sharedContactRequested = sharedContactRequested,
requestChannelSet = requestChannelSet,
onDismissSharedContact = { uIViewModel.clearSharedContactRequested() },
onDismissChannelSet = { uIViewModel.clearRequestChannelUrl() },
MeshtasticCommonAppSetup(
uiViewModel = uIViewModel,
onNavigateToTracerouteMap = { destNum, requestId, logUuid ->
backStack.add(NodeDetailRoutes.TracerouteMap(destNum = destNum, requestId = requestId, logUuid = logUuid))
},
)
VersionChecks(uIViewModel)
AlertHost(uIViewModel.alertManager)
val traceRouteResponse by uIViewModel.tracerouteResponse.collectAsStateWithLifecycle(null)
var dismissedTracerouteRequestId by remember { mutableStateOf<Int?>(null) }
traceRouteResponse
?.takeIf { it.requestId != dismissedTracerouteRequestId }
?.let { response ->
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
backStack.add(
NodeDetailRoutes.TracerouteMap(
destNum = response.destinationNodeNum,
requestId = response.requestId,
logUuid = response.logUuid,
),
)
} else {
uIViewModel.showAlert(titleRes = Res.string.traceroute, messageRes = errorRes)
uIViewModel.clearTracerouteResponse()
}
},
dismissTextRes = Res.string.okay,
onDismiss = {
uIViewModel.clearTracerouteResponse()
dismissedTracerouteRequestId = null
},
)
}
AndroidAppVersionCheck(uIViewModel)
val navSuiteType =
NavigationSuiteScaffoldDefaults.navigationSuiteType(
currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true),
@ -280,96 +208,70 @@ fun MainScreen(uIViewModel: UIViewModel = koinViewModel(), scanModel: ScannerVie
TopLevelDestination.Nodes -> {
val onNodesList = currentKey is NodesRoutes.Nodes
if (!onNodesList) {
if (backStack.isNotEmpty()) {
backStack[0] = destination.route
while (backStack.size > 1) backStack.removeAt(backStack.lastIndex)
} else {
backStack.add(destination.route)
}
backStack.navigateTopLevel(destination.route)
}
uIViewModel.emitScrollToTopEvent(ScrollToTopEvent.NodesTabPressed)
}
TopLevelDestination.Conversations -> {
val onConversationsList = currentKey is ContactsRoutes.Contacts
if (!onConversationsList) {
if (backStack.isNotEmpty()) {
backStack[0] = destination.route
while (backStack.size > 1) backStack.removeAt(backStack.lastIndex)
} else {
backStack.add(destination.route)
}
backStack.navigateTopLevel(destination.route)
}
uIViewModel.emitScrollToTopEvent(ScrollToTopEvent.ConversationsTabPressed)
}
else -> Unit
}
} else {
if (backStack.isNotEmpty()) {
backStack[0] = destination.route
while (backStack.size > 1) backStack.removeAt(backStack.lastIndex)
} else {
backStack.add(destination.route)
}
backStack.navigateTopLevel(destination.route)
}
},
)
}
},
) {
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(),
)
MeshtasticSnackbarProvider(
snackbarManager = uIViewModel.snackbarManager,
hostModifier = Modifier.safeDrawingPadding().padding(bottom = 16.dp),
) {
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(),
)
}
}
}
@Composable
@Suppress("LongMethod", "CyclomaticComplexMethod")
private fun VersionChecks(viewModel: UIViewModel) {
private fun AndroidAppVersionCheck(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}" } }
}
}
// Check if the device is running an old app version or firmware version
// Check if the device is running an old app version
LaunchedEffect(connectionState, myNodeInfo) {
if (connectionState == ConnectionState.Connected) {
Logger.i {
"[FW_CHECK] Connection state: $connectionState, " +
"myNodeInfo: ${if (myNodeInfo != null) "present" else "null"}, " +
"firmwareVersion: ${myFirmwareVersion ?: "null"}"
}
myNodeInfo?.let { info ->
val isOld = info.minAppVersion > BuildConfig.VERSION_CODE && BuildConfig.DEBUG.not()
Logger.d {
@ -384,49 +286,8 @@ private fun VersionChecks(viewModel: UIViewModel) {
messageRes = Res.string.must_update,
onConfirm = { viewModel.setDeviceAddress("n") },
)
} else {
myFirmwareVersion
?.takeIf { it.isNotBlank() }
?.let { fwVersion ->
val curVer = DeviceVersion(fwVersion)
Logger.i {
"[FW_CHECK] Firmware version comparison - " +
"device: $curVer (raw: $fwVersion), " +
"absoluteMin: ${MeshService.absoluteMinDeviceVersion}, " +
"min: ${MeshService.minDeviceVersion}"
}
if (curVer < MeshService.absoluteMinDeviceVersion) {
Logger.w {
"[FW_CHECK] Firmware too old - " +
"device: $curVer < absoluteMin: ${MeshService.absoluteMinDeviceVersion}"
}
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 < MeshService.minDeviceVersion) {
Logger.w {
"[FW_CHECK] Firmware should update - " +
"device: $curVer < min: ${MeshService.minDeviceVersion}"
}
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" }
}
}
?: run {
Logger.w { "[FW_CHECK] Firmware version is null or blank despite myNodeInfo being present" }
}
}
} ?: run { Logger.d { "[FW_CHECK] myNodeInfo is null, skipping firmware check" } }
} else {
Logger.d { "[FW_CHECK] Not connected (state: $connectionState), skipping firmware check" }
}
}
}
}

View file

@ -0,0 +1,34 @@
/*
* Copyright (c) 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.navigation
import androidx.navigation3.runtime.NavKey
/**
* Replaces the current back stack with the given top-level route. Clears the back stack and sets the new route as the
* root destination.
*/
fun MutableList<NavKey>.navigateTopLevel(route: NavKey) {
if (isNotEmpty()) {
this[0] = route
while (size > 1) {
removeAt(lastIndex)
}
} else {
add(route)
}
}

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)
}
}
}

View file

@ -51,7 +51,9 @@ The module depends on the JVM variants of KMP modules:
**DI:** A Koin DI graph is bootstrapped in `Main.kt` with platform-specific implementations injected.
**UI:** JetBrains Compose for Desktop with Material 3 theming. Desktop acts as a thin host shell, delegating almost entirely to fully shared KMP UI modules.
**UI:** JetBrains Compose for Desktop with Material 3 theming. Desktop acts as a thin host shell, delegating almost entirely to fully shared KMP UI modules. Includes native macOS notification support (via `TrayState` and `bundleID` identification) and a monochrome SVG tray icon for a native look and feel.
**Notifications:** Implements the common `NotificationManager` interface via `DesktopNotificationManager`. Repository-level notifications (messages, node events, alerts) are collected in `Main.kt` and forwarded to the system tray. macOS requires a consistent `bundleID` (configured in `build.gradle.kts`) and the `NSUserNotificationAlertStyle` key in `Info.plist` for notifications to appear correctly in the distributable.
**Localization:** Desktop exposes a language picker, persisting the selected BCP-47 tag in `UiPreferencesDataSource.locale`. `Main.kt` applies the override to the JVM default `Locale` and uses a `staticCompositionLocalOf`-backed recomposition trigger so Compose Multiplatform `stringResource()` calls update immediately without recreating the Navigation 3 backstack.
@ -64,6 +66,8 @@ The module depends on the JVM variants of KMP modules:
| `ui/DesktopMainScreen.kt` | Navigation 3 shell — `NavigationRail` + `NavDisplay` |
| `navigation/DesktopNavigation.kt` | Nav graph entry registrations for all top-level destinations (delegates to shared feature graphs) |
| `radio/DesktopRadioTransportFactory.kt` | Provides TCP, Serial/USB, and BLE transports |
| `notification/DesktopMeshServiceNotifications.kt` | Real implementation of notification triggers for Desktop |
| `DesktopNotificationManager.kt` | Bridge between repository notifications and Compose `TrayState` |
| `radio/DesktopMeshServiceController.kt` | Mesh service lifecycle — orchestrates `want_config` handshake chain |
| `radio/DesktopMessageQueue.kt` | Message queue for outbound mesh packets |
| `di/DesktopKoinModule.kt` | Koin module with stub implementations |
@ -83,6 +87,7 @@ The module depends on the JVM variants of KMP modules:
- [x] Implement real navigation with shared `core:navigation` routes (Navigation 3 shell)
- [x] Adopt JetBrains multiplatform forks for lifecycle and navigation3
- [x] Implement native macOS/Desktop notification support with `TrayState` and system tray
- [x] Wire `feature:settings` composables into the nav graph (first real feature — ~30 screens)
- [x] Wire `feature:node` composables into the nav graph (node list with shared ViewModel + NodeItem)
- [x] Wire `feature:messaging` composables into the nav graph (contacts list with shared ViewModel)

View file

@ -43,6 +43,12 @@ tasks.withType<Detekt>().configureEach { exclude("**/generated/**") }
compose.desktop {
application {
mainClass = "org.meshtastic.desktop.MainKt"
jvmArgs(
"-Xmx2G",
"-Dapple.awt.application.name=Meshtastic",
"-Dcom.apple.mrj.application.apple.menu.about.name=Meshtastic",
"-Dcom.apple.bundle.identifier=org.meshtastic.desktop",
)
buildTypes.release.proguard {
isEnabled.set(true)
@ -66,15 +72,28 @@ compose.desktop {
// Default JVM arguments for the packaged application
// Increase max heap size to prevent OOM issues on complex maps/data
jvmArgs("-Xmx2G")
jvmArgs(
"-Xmx2G",
"-Dapple.awt.application.name=Meshtastic",
"-Dcom.apple.mrj.application.apple.name=Meshtastic",
"-Dcom.apple.bundle.identifier=org.meshtastic.desktop",
)
// App Icon & OS Specific Configurations
macOS {
iconFile.set(project.file("src/main/resources/icon.icns"))
minimumSystemVersion = "12.0"
bundleID = "org.meshtastic.desktop"
infoPlist {
extraKeysRawXml =
"""
<key>NSUserNotificationAlertStyle</key>
<string>alert</string>
"""
.trimIndent()
}
// TODO: To prepare for real distribution on macOS, you'll need to sign and notarize.
// You can inject these from CI environment variables.
// bundleID = "org.meshtastic.desktop"
// sign = true
// notarize = true
// appleID = System.getenv("APPLE_ID")

View file

@ -27,6 +27,10 @@ import androidx.compose.ui.window.Notification as ComposeNotification
@Single
class DesktopNotificationManager(private val prefs: NotificationPrefs) : NotificationManager {
init {
co.touchlab.kermit.Logger.i { "DesktopNotificationManager initialized" }
}
private val _notifications = MutableSharedFlow<ComposeNotification>(extraBufferCapacity = 10)
val notifications: SharedFlow<ComposeNotification> = _notifications.asSharedFlow()
@ -40,6 +44,10 @@ class DesktopNotificationManager(private val prefs: NotificationPrefs) : Notific
Notification.Category.Service -> true
}
co.touchlab.kermit.Logger.d {
"DesktopNotificationManager dispatch: category=${notification.category}, enabled=$enabled"
}
if (!enabled) return
val composeType =
@ -50,7 +58,8 @@ class DesktopNotificationManager(private val prefs: NotificationPrefs) : Notific
Notification.Type.Error -> ComposeNotification.Type.Error
}
_notifications.tryEmit(ComposeNotification(notification.title, notification.message, composeType))
val success = _notifications.tryEmit(ComposeNotification(notification.title, notification.message, composeType))
co.touchlab.kermit.Logger.d { "DesktopNotificationManager emit: success=$success, title=${notification.title}" }
}
override fun cancel(id: Int) {

View file

@ -66,6 +66,7 @@ import org.meshtastic.core.common.util.MeshtasticUri
import org.meshtastic.core.navigation.MeshtasticNavSavedStateConfig
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.navigation.TopLevelDestination
import org.meshtastic.core.navigation.navigateTopLevel
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.service.MeshServiceOrchestrator
import org.meshtastic.core.ui.theme.AppTheme
@ -172,13 +173,21 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
val trayState = rememberTrayState()
val appIcon = classpathPainterResource("icon.png")
@Suppress("DEPRECATION")
val trayIcon =
androidx.compose.ui.res.painterResource(
if (isSystemInDarkTheme()) "tray_icon_white.svg" else "tray_icon_black.svg",
)
val notificationManager = remember { koinApp.koin.get<DesktopNotificationManager>() }
val alertManager = remember { koinApp.koin.get<org.meshtastic.core.ui.util.AlertManager>() }
val desktopPrefs = remember { koinApp.koin.get<DesktopPreferencesDataSource>() }
val windowState = rememberWindowState()
LaunchedEffect(Unit) {
notificationManager.notifications.collect { notification -> trayState.sendNotification(notification) }
notificationManager.notifications.collect { notification ->
Logger.d { "Main.kt: Received notification for Tray: title=${notification.title}" }
trayState.sendNotification(notification)
}
}
LaunchedEffect(Unit) {
@ -209,7 +218,9 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
Tray(
state = trayState,
icon = appIcon,
icon = trayIcon,
tooltip = "Meshtastic Desktop",
onAction = { isAppVisible = true },
menu = {
Item("Show Meshtastic", onClick = { isAppVisible = true })
Item(
@ -250,7 +261,7 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
if (
TopLevelDestination.Settings != TopLevelDestination.fromNavKey(backStack.lastOrNull())
) {
navigateTopLevel(backStack, TopLevelDestination.Settings.route)
backStack.navigateTopLevel(TopLevelDestination.Settings.route)
}
true
}
@ -261,22 +272,22 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
}
// ⌘1 → Conversations
event.key == Key.One -> {
navigateTopLevel(backStack, TopLevelDestination.Conversations.route)
backStack.navigateTopLevel(TopLevelDestination.Conversations.route)
true
}
// ⌘2 → Nodes
event.key == Key.Two -> {
navigateTopLevel(backStack, TopLevelDestination.Nodes.route)
backStack.navigateTopLevel(TopLevelDestination.Nodes.route)
true
}
// ⌘3 → Map
event.key == Key.Three -> {
navigateTopLevel(backStack, TopLevelDestination.Map.route)
backStack.navigateTopLevel(TopLevelDestination.Map.route)
true
}
// ⌘4 → Connections
event.key == Key.Four -> {
navigateTopLevel(backStack, TopLevelDestination.Connections.route)
backStack.navigateTopLevel(TopLevelDestination.Connections.route)
true
}
// ⌘/ → About
@ -310,19 +321,8 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
// re-reads Locale.current and all stringResource() calls update. Unlike key(), this
// preserves remembered state (including the navigation backstack).
CompositionLocalProvider(LocalAppLocale provides localePref) {
AppTheme(darkTheme = isDarkTheme) {
org.meshtastic.core.ui.component.AlertHost(alertManager)
DesktopMainScreen(backStack)
}
AppTheme(darkTheme = isDarkTheme) { DesktopMainScreen(backStack) }
}
}
}
}
/** Replaces the backstack with a single top-level destination route. */
private fun navigateTopLevel(backStack: MutableList<NavKey>, route: NavKey) {
backStack.add(route)
while (backStack.size > 1) {
backStack.removeAt(0)
}
}

View file

@ -137,6 +137,10 @@ private fun desktopPlatformStubsModule() = module {
locationManager = get(),
)
}
single { org.meshtastic.desktop.DesktopNotificationManager(prefs = get()) }
single<org.meshtastic.core.repository.NotificationManager> {
get<org.meshtastic.desktop.DesktopNotificationManager>()
}
single<MeshServiceNotifications> {
org.meshtastic.desktop.notification.DesktopMeshServiceNotifications(notificationManager = get())
}

View file

@ -16,8 +16,10 @@
*/
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.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationRail
@ -27,6 +29,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
@ -36,10 +39,12 @@ import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.navigation.NodeDetailRoutes
import org.meshtastic.core.navigation.TopLevelDestination
import org.meshtastic.core.navigation.navigateTopLevel
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.ui.component.AlertHost
import org.meshtastic.core.ui.component.SharedDialogs
import org.meshtastic.core.ui.component.MeshtasticCommonAppSetup
import org.meshtastic.core.ui.component.MeshtasticSnackbarProvider
import org.meshtastic.core.ui.navigation.icon
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.desktop.navigation.desktopNavGraph
@ -51,6 +56,7 @@ import org.meshtastic.desktop.navigation.desktopNavGraph
* app, proving the shared backstack architecture works across targets.
*/
@Composable
@Suppress("LongMethod")
fun DesktopMainScreen(
backStack: NavBackStack<NavKey>,
radioService: RadioInterfaceService = koinInject(),
@ -63,61 +69,60 @@ fun DesktopMainScreen(
val selectedDevice by radioService.currentDeviceAddressFlow.collectAsStateWithLifecycle()
val colorScheme = MaterialTheme.colorScheme
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
SharedDialogs(
connectionState = connectionState,
sharedContactRequested = sharedContactRequested,
requestChannelSet = requestChannelSet,
onDismissSharedContact = { uiViewModel.clearSharedContactRequested() },
onDismissChannelSet = { uiViewModel.clearRequestChannelUrl() },
MeshtasticCommonAppSetup(
uiViewModel = uiViewModel,
onNavigateToTracerouteMap = { destNum, requestId, logUuid ->
backStack.add(NodeDetailRoutes.TracerouteMap(destNum = destNum, requestId = requestId, logUuid = logUuid))
},
)
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.navigateTopLevel(destination.route)
}
}
},
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)) },
)
}
}
MeshtasticSnackbarProvider(
snackbarManager = uiViewModel.snackbarManager,
modifier = Modifier.weight(1f).fillMaxSize(),
hostModifier = Modifier.padding(bottom = 24.dp),
) {
val provider = entryProvider<NavKey> { desktopNavGraph(backStack) }
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = provider,
modifier = Modifier.fillMaxSize(),
)
}
}
val provider = entryProvider<NavKey> { desktopNavGraph(backStack) }
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = provider,
modifier = Modifier.weight(1f).fillMaxSize(),
)
}
}
}

View file

@ -1,12 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="24" height="24" viewBox="0 0 100 55" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.802386,0,0,0.460028,-421.748,-122.127)">
<g transform="matrix(0.579082,0,0,1.01004,460.975,-39.6867)">
<path d="M250.908,330.267L193.126,415.005L180.938,406.694L244.802,313.037C246.174,311.024 248.453,309.819 250.889,309.816C253.326,309.814 255.606,311.015 256.982,313.026L320.994,406.536L308.821,414.869L250.908,330.267Z"/>
</g>
<g transform="matrix(0.582378,0,0,1.01579,485.019,-211.182)">
<path d="M87.642,581.398L154.757,482.977L142.638,474.713L75.523,573.134L87.642,581.398Z"/>
</g>
</g>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="512" height="512"><path d="M125 0Q0 0 0 125v262q0 125 125 125h262q125 0 125-125V125Q512 0 387 0Z" style="stroke:#d5828b;stroke-width:0;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:#67ea94;fill-rule:nonzero;opacity:1" vector-effect="non-scaling-stroke"/><path d="m250.908 330.267-57.782 84.738-12.188-8.311 63.864-93.657a7.378 7.378 0 0 1 12.18-.011l64.012 93.51-12.173 8.333z" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:round;stroke-miterlimit:2;fill:#2c2d3c;fill-rule:evenodd;opacity:1" transform="translate(-135.496 -390.354)scale(1.79)" vector-effect="non-scaling-stroke"/><path d="m87.642 581.398 67.115-98.421-12.119-8.264-67.115 98.421z" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:round;stroke-miterlimit:2;fill:#2c2d3c;fill-rule:evenodd;opacity:1" transform="translate(-63.403 -699.639)scale(1.81)" vector-effect="non-scaling-stroke"/></svg>

Before

Width:  |  Height:  |  Size: 1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before After
Before After

View file

@ -1,12 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="24" height="24" viewBox="0 0 100 55" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.802386,0,0,0.460028,-421.748,-122.127)">
<g transform="matrix(0.579082,0,0,1.01004,460.975,-39.6867)">
<path d="M250.908,330.267L193.126,415.005L180.938,406.694L244.802,313.037C246.174,311.024 248.453,309.819 250.889,309.816C253.326,309.814 255.606,311.015 256.982,313.026L320.994,406.536L308.821,414.869L250.908,330.267Z" style="fill:white;"/>
</g>
<g transform="matrix(0.582378,0,0,1.01579,485.019,-211.182)">
<path d="M87.642,581.398L154.757,482.977L142.638,474.713L75.523,573.134L87.642,581.398Z" style="fill:white;"/>
</g>
</g>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="512" height="512"><path d="M125 0Q0 0 0 125v262q0 125 125 125h262q125 0 125-125V125Q512 0 387 0Z" style="stroke:#d5828b;stroke-width:0;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:#67ea94;fill-rule:nonzero;opacity:1" vector-effect="non-scaling-stroke"/><path d="m250.908 330.267-57.782 84.738-12.188-8.311 63.864-93.657a7.378 7.378 0 0 1 12.18-.011l64.012 93.51-12.173 8.333z" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:round;stroke-miterlimit:2;fill:#2c2d3c;fill-rule:evenodd;opacity:1" transform="translate(-135.496 -390.354)scale(1.79)" vector-effect="non-scaling-stroke"/><path d="m87.642 581.398 67.115-98.421-12.119-8.264-67.115 98.421z" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:round;stroke-miterlimit:2;fill:#2c2d3c;fill-rule:evenodd;opacity:1" transform="translate(-63.403 -699.639)scale(1.81)" vector-effect="non-scaling-stroke"/></svg>

Before

Width:  |  Height:  |  Size: 1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before After
Before After

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,14 @@ 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 +58,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 +76,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 +84,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 +105,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 +115,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,8 +83,6 @@ class NodeDetailViewModel(
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), NodeDetailUiState())
val effects: SharedFlow<NodeRequestEffect> = nodeRequestActions.effects
fun start(nodeId: Int) {
if (manualNodeId.value != nodeId) {
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,24 +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) {
LEGEND_DATA.filter { d ->
@ -193,7 +179,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,8 +173,6 @@ open class MetricsViewModel(
}
.stateInWhileSubscribed(emptyList())
val effects: SharedFlow<NodeRequestEffect> = nodeRequestActions.effects
val lastTraceRouteTime: StateFlow<Long?> = nodeRequestActions.lastTracerouteTime
val lastRequestNeighborsTime: StateFlow<Long?> =

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())