From 553ca2f8edb22801714b0180e0e171088f11d67d Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 24 Mar 2026 17:31:40 -0500 Subject: [PATCH] feat: implement global SnackbarManager and consolidate common UI setup (#4909) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- AGENTS.md | 2 +- GEMINI.md | 2 +- .../kotlin/org/meshtastic/app/MainActivity.kt | 9 +- .../main/kotlin/org/meshtastic/app/ui/Main.kt | 233 ++++-------------- .../core/navigation/NavBackStackExt.kt | 34 +++ .../core/ui/component/FirmwareVersionCheck.kt | 97 ++++++++ .../ui/component/MeshtasticCommonAppSetup.kt | 37 +++ .../ui/component/MeshtasticSnackbarHost.kt | 67 +++++ .../core/ui/component/SharedDialogs.kt | 27 +- .../ui/component/TracerouteAlertHandler.kt | 98 ++++++++ .../core/ui/util/SnackbarManager.kt | 63 +++++ .../core/ui/viewmodel/UIViewModel.kt | 6 + .../core/ui/util/SnackbarManagerTest.kt | 103 ++++++++ desktop/README.md | 7 +- desktop/build.gradle.kts | 23 +- .../desktop/DesktopNotificationManager.kt | 11 +- .../kotlin/org/meshtastic/desktop/Main.kt | 40 +-- .../desktop/di/DesktopKoinModule.kt | 4 + .../desktop/ui/DesktopMainScreen.kt | 105 ++++---- .../src/main/resources/tray_icon_black.svg | 13 +- .../src/main/resources/tray_icon_white.svg | 13 +- .../feature/node/detail/NodeDetailScreen.kt | 14 -- .../feature/node/metrics/PositionLog.kt | 18 -- .../node/detail/CommonNodeRequestActions.kt | 45 ++-- .../node/detail/NodeDetailViewModel.kt | 3 - .../feature/node/detail/NodeRequestActions.kt | 7 - .../feature/node/metrics/BaseMetricChart.kt | 4 - .../feature/node/metrics/DeviceMetrics.kt | 15 -- .../node/metrics/EnvironmentMetrics.kt | 17 -- .../feature/node/metrics/HostMetricsLog.kt | 18 -- .../feature/node/metrics/MetricsViewModel.kt | 4 - .../feature/node/metrics/NeighborInfoLog.kt | 17 -- .../feature/node/metrics/PaxMetrics.kt | 15 -- .../feature/node/metrics/PowerMetrics.kt | 15 -- .../feature/node/metrics/SignalMetrics.kt | 15 -- .../feature/node/metrics/TracerouteLog.kt | 17 -- .../node/detail/NodeDetailViewModelTest.kt | 1 - .../node/metrics/MetricsViewModelTest.kt | 1 - 38 files changed, 705 insertions(+), 515 deletions(-) create mode 100644 core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavBackStackExt.kt create mode 100644 core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/FirmwareVersionCheck.kt create mode 100644 core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticCommonAppSetup.kt create mode 100644 core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticSnackbarHost.kt create mode 100644 core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/TracerouteAlertHandler.kt create mode 100644 core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/SnackbarManager.kt create mode 100644 core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/SnackbarManagerTest.kt diff --git a/AGENTS.md b/AGENTS.md index 82f6c153a..829ec4d12 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/GEMINI.md b/GEMINI.md index 0e2d85567..86f17e61b 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -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. diff --git a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt index c191c576e..3bb562098 100644 --- a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt +++ b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt @@ -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 { diff --git a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt index 69eefcd30..a323cc997 100644 --- a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt +++ b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt @@ -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(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 { - contactsGraph(backStack, uIViewModel.scrollToTopEventFlow) - nodesGraph( - backStack = backStack, - scrollToTopEvents = uIViewModel.scrollToTopEventFlow, - nodeMapScreen = { destNum, onNavigateUp -> - val vm = - org.koin.compose.viewmodel.koinViewModel() - 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 { + 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" } + } } } } diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavBackStackExt.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavBackStackExt.kt new file mode 100644 index 000000000..5638814f8 --- /dev/null +++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavBackStackExt.kt @@ -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 . + */ +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.navigateTopLevel(route: NavKey) { + if (isNotEmpty()) { + this[0] = route + while (size > 1) { + removeAt(lastIndex) + } + } else { + add(route) + } +} diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/FirmwareVersionCheck.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/FirmwareVersionCheck.kt new file mode 100644 index 000000000..2291ac9eb --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/FirmwareVersionCheck.kt @@ -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 . + */ +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" } + } + } + } + } + } +} diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticCommonAppSetup.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticCommonAppSetup.kt new file mode 100644 index 000000000..19e73495d --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticCommonAppSetup.kt @@ -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 . + */ +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) +} diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticSnackbarHost.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticSnackbarHost.kt new file mode 100644 index 000000000..6b6da135f --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticSnackbarHost.kt @@ -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 . + */ +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), + ) + } +} diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SharedDialogs.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SharedDialogs.kt index 6a3e16dfe..c990c916e 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SharedDialogs.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SharedDialogs.kt @@ -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() }) + } } } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/TracerouteAlertHandler.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/TracerouteAlertHandler.kt new file mode 100644 index 000000000..100c6fecb --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/TracerouteAlertHandler.kt @@ -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 . + */ +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(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 + }, + ) + } + } +} diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/SnackbarManager.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/SnackbarManager.kt new file mode 100644 index 000000000..463b75f09 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/SnackbarManager.kt @@ -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 . + */ +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(Channel.BUFFERED) + open val events: Flow = _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, + ), + ) + } +} diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt index 9ff6239c8..6b743363f 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt @@ -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(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) } diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/SnackbarManagerTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/SnackbarManagerTest.kt new file mode 100644 index 000000000..f53178aa9 --- /dev/null +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/SnackbarManagerTest.kt @@ -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 . + */ +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) + } + } +} diff --git a/desktop/README.md b/desktop/README.md index ea17d0eb7..a981d2d2e 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -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) diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index 1ffb0f96a..99a2079a8 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -43,6 +43,12 @@ tasks.withType().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 = + """ + NSUserNotificationAlertStyle + alert + """ + .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") diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt index 5a871efd6..86b1fb4db 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt @@ -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(extraBufferCapacity = 10) val notifications: SharedFlow = _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) { diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt index 96b121524..7e8962b49 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt @@ -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) = 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() } - val alertManager = remember { koinApp.koin.get() } val desktopPrefs = remember { koinApp.koin.get() } 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) = 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) = 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) = 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) = 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, route: NavKey) { - backStack.add(route) - while (backStack.size > 1) { - backStack.removeAt(0) - } -} diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt index 21b9ed84c..efb8f5740 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt @@ -137,6 +137,10 @@ private fun desktopPlatformStubsModule() = module { locationManager = get(), ) } + single { org.meshtastic.desktop.DesktopNotificationManager(prefs = get()) } + single { + get() + } single { org.meshtastic.desktop.notification.DesktopMeshServiceNotifications(notificationManager = get()) } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt index fff4df006..7099781e3 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt @@ -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, 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 { desktopNavGraph(backStack) } + + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + entryProvider = provider, + modifier = Modifier.fillMaxSize(), ) } } - - val provider = entryProvider { desktopNavGraph(backStack) } - - NavDisplay( - backStack = backStack, - onBack = { backStack.removeLastOrNull() }, - entryProvider = provider, - modifier = Modifier.weight(1f).fillMaxSize(), - ) } } } diff --git a/desktop/src/main/resources/tray_icon_black.svg b/desktop/src/main/resources/tray_icon_black.svg index bf1a8916e..451ae8562 100644 --- a/desktop/src/main/resources/tray_icon_black.svg +++ b/desktop/src/main/resources/tray_icon_black.svg @@ -1,12 +1 @@ - - - - - - - - - - - - + \ No newline at end of file diff --git a/desktop/src/main/resources/tray_icon_white.svg b/desktop/src/main/resources/tray_icon_white.svg index 89bf128f4..451ae8562 100644 --- a/desktop/src/main/resources/tray_icon_white.svg +++ b/desktop/src/main/resources/tray_icon_white.svg @@ -1,12 +1 @@ - - - - - - - - - - - - + \ No newline at end of file diff --git a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt b/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt index 0d673afd9..853017d94 100644 --- a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt +++ b/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt @@ -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, diff --git a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/PositionLog.kt b/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/PositionLog.kt index 2ed2fa7cd..5862a0ed9 100644 --- a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/PositionLog.kt +++ b/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/PositionLog.kt @@ -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 diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt index 2ec8c9d50..ce4bfcf9a 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt @@ -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() - override val effects: SharedFlow = _effects.asSharedFlow() +class CommonNodeRequestActions +constructor( + private val radioController: RadioController, + private val snackbarManager: SnackbarManager, +) : NodeRequestActions { private val _lastTracerouteTime = MutableStateFlow(null) override val lastTracerouteTime: StateFlow = _lastTracerouteTime.asStateFlow() @@ -59,15 +58,15 @@ class CommonNodeRequestActions constructor(private val radioController: RadioCon private val _lastRequestNeighborTimes = MutableStateFlow>(emptyMap()) override val lastRequestNeighborTimes: StateFlow> = _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)) } } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt index 553607a9a..35b33a9c3 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt @@ -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 = nodeRequestActions.effects - fun start(nodeId: Int) { if (manualNodeId.value != nodeId) { manualNodeId.value = nodeId diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeRequestActions.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeRequestActions.kt index 1908cbbe3..3c396d8a9 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeRequestActions.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeRequestActions.kt @@ -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 val lastTracerouteTime: StateFlow val lastRequestNeighborTimes: StateFlow> diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt index ffc8b698a..b31061ded 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt @@ -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 BaseMetricScreen( data: List, timeProvider: (T) -> Double, infoData: List = 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 BaseMetricScreen( onClickChip = {}, ) }, - snackbarHost = { SnackbarHost(snackbarHostState) }, ) { innerPadding -> Column(modifier = Modifier.padding(innerPadding)) { if (displayInfoDialog) { diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt index 03407da05..ebd4fcbd1 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt @@ -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( diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt index 1447a2f59..400a014ce 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt @@ -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( diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsLog.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsLog.kt index 4aad82977..ad43346f3 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsLog.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsLog.kt @@ -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), diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt index bafa7a2b0..93bfb5212 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt @@ -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 = nodeRequestActions.effects - val lastTraceRouteTime: StateFlow = nodeRequestActions.lastTracerouteTime val lastRequestNeighborsTime: StateFlow = diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/NeighborInfoLog.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/NeighborInfoLog.kt index 218b271bc..21e8b65bb 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/NeighborInfoLog.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/NeighborInfoLog.kt @@ -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), diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt index 35f7c15ef..ec810308a 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt @@ -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( diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt index 9c1c9ba0e..8743efeb1 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt @@ -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 { diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt index c170d6d16..d53776354 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt @@ -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( diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt index 49d870da2..4d00c684a 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt @@ -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), diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModelTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModelTest.kt index d56d6c635..c3ed67b5b 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModelTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModelTest.kt @@ -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) } diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt index 689d4b214..34e411af0 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt @@ -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())