mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: implement global SnackbarManager and consolidate common UI setup (#4909)
Some checks are pending
Dependency Submission / dependency-submission (push) Waiting to run
Main CI (Verify & Build) / validate-and-build (push) Waiting to run
Main Push Changelog / Generate main push changelog (push) Waiting to run
Some checks are pending
Dependency Submission / dependency-submission (push) Waiting to run
Main CI (Verify & Build) / validate-and-build (push) Waiting to run
Main Push Changelog / Generate main push changelog (push) Waiting to run
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
9b8ac6a460
commit
553ca2f8ed
38 changed files with 705 additions and 515 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -24,14 +24,12 @@ import androidx.compose.animation.fadeIn
|
|||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.recalculateWindowInsets
|
||||
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.BadgedBox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
|
|
@ -52,7 +50,6 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -63,42 +60,28 @@ import androidx.navigation3.runtime.entryProvider
|
|||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import co.touchlab.kermit.Logger
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.meshtastic.app.BuildConfig
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.DeviceType
|
||||
import org.meshtastic.core.model.DeviceVersion
|
||||
import org.meshtastic.core.navigation.ContactsRoutes
|
||||
import org.meshtastic.core.navigation.MeshtasticNavSavedStateConfig
|
||||
import org.meshtastic.core.navigation.NodeDetailRoutes
|
||||
import org.meshtastic.core.navigation.NodesRoutes
|
||||
import org.meshtastic.core.navigation.TopLevelDestination
|
||||
import org.meshtastic.core.navigation.navigateTopLevel
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.app_too_old
|
||||
import org.meshtastic.core.resources.connected
|
||||
import org.meshtastic.core.resources.connecting
|
||||
import org.meshtastic.core.resources.device_sleeping
|
||||
import org.meshtastic.core.resources.disconnected
|
||||
import org.meshtastic.core.resources.firmware_old
|
||||
import org.meshtastic.core.resources.firmware_too_old
|
||||
import org.meshtastic.core.resources.must_update
|
||||
import org.meshtastic.core.resources.okay
|
||||
import org.meshtastic.core.resources.should_update
|
||||
import org.meshtastic.core.resources.should_update_firmware
|
||||
import org.meshtastic.core.resources.traceroute
|
||||
import org.meshtastic.core.resources.view_on_map
|
||||
import org.meshtastic.core.service.MeshService
|
||||
import org.meshtastic.core.ui.component.AlertHost
|
||||
import org.meshtastic.core.ui.component.MeshtasticCommonAppSetup
|
||||
import org.meshtastic.core.ui.component.MeshtasticSnackbarProvider
|
||||
import org.meshtastic.core.ui.component.ScrollToTopEvent
|
||||
import org.meshtastic.core.ui.component.SharedDialogs
|
||||
import org.meshtastic.core.ui.navigation.icon
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
|
||||
import org.meshtastic.core.ui.util.annotateTraceroute
|
||||
import org.meshtastic.core.ui.util.toMessageRes
|
||||
import org.meshtastic.core.ui.viewmodel.UIViewModel
|
||||
import org.meshtastic.feature.connections.ScannerViewModel
|
||||
import org.meshtastic.feature.connections.navigation.connectionsGraph
|
||||
|
|
@ -117,71 +100,16 @@ fun MainScreen(uIViewModel: UIViewModel = koinViewModel(), scanModel: ScannerVie
|
|||
// LaunchedEffect(uIViewModel) { uIViewModel.navigationDeepLink.collectLatest { uri -> navController.navigate(uri) }
|
||||
// }
|
||||
val connectionState by uIViewModel.connectionState.collectAsStateWithLifecycle()
|
||||
val requestChannelSet by uIViewModel.requestChannelSet.collectAsStateWithLifecycle()
|
||||
val sharedContactRequested by uIViewModel.sharedContactRequested.collectAsStateWithLifecycle()
|
||||
val unreadMessageCount by uIViewModel.unreadMessageCount.collectAsStateWithLifecycle()
|
||||
|
||||
SharedDialogs(
|
||||
connectionState = connectionState,
|
||||
sharedContactRequested = sharedContactRequested,
|
||||
requestChannelSet = requestChannelSet,
|
||||
onDismissSharedContact = { uIViewModel.clearSharedContactRequested() },
|
||||
onDismissChannelSet = { uIViewModel.clearRequestChannelUrl() },
|
||||
MeshtasticCommonAppSetup(
|
||||
uiViewModel = uIViewModel,
|
||||
onNavigateToTracerouteMap = { destNum, requestId, logUuid ->
|
||||
backStack.add(NodeDetailRoutes.TracerouteMap(destNum = destNum, requestId = requestId, logUuid = logUuid))
|
||||
},
|
||||
)
|
||||
|
||||
VersionChecks(uIViewModel)
|
||||
|
||||
AlertHost(uIViewModel.alertManager)
|
||||
|
||||
val traceRouteResponse by uIViewModel.tracerouteResponse.collectAsStateWithLifecycle(null)
|
||||
var dismissedTracerouteRequestId by remember { mutableStateOf<Int?>(null) }
|
||||
traceRouteResponse
|
||||
?.takeIf { it.requestId != dismissedTracerouteRequestId }
|
||||
?.let { response ->
|
||||
uIViewModel.showAlert(
|
||||
titleRes = Res.string.traceroute,
|
||||
composableMessage = {
|
||||
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
Text(
|
||||
text =
|
||||
annotateTraceroute(
|
||||
response.message,
|
||||
statusGreen = colorScheme.StatusGreen,
|
||||
statusYellow = colorScheme.StatusYellow,
|
||||
statusOrange = colorScheme.StatusOrange,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmTextRes = Res.string.view_on_map,
|
||||
onConfirm = {
|
||||
val availability =
|
||||
uIViewModel.tracerouteMapAvailability(
|
||||
forwardRoute = response.forwardRoute,
|
||||
returnRoute = response.returnRoute,
|
||||
)
|
||||
val errorRes = availability.toMessageRes()
|
||||
if (errorRes == null) {
|
||||
dismissedTracerouteRequestId = response.requestId
|
||||
backStack.add(
|
||||
NodeDetailRoutes.TracerouteMap(
|
||||
destNum = response.destinationNodeNum,
|
||||
requestId = response.requestId,
|
||||
logUuid = response.logUuid,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
uIViewModel.showAlert(titleRes = Res.string.traceroute, messageRes = errorRes)
|
||||
uIViewModel.clearTracerouteResponse()
|
||||
}
|
||||
},
|
||||
dismissTextRes = Res.string.okay,
|
||||
onDismiss = {
|
||||
uIViewModel.clearTracerouteResponse()
|
||||
dismissedTracerouteRequestId = null
|
||||
},
|
||||
)
|
||||
}
|
||||
AndroidAppVersionCheck(uIViewModel)
|
||||
val navSuiteType =
|
||||
NavigationSuiteScaffoldDefaults.navigationSuiteType(
|
||||
currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true),
|
||||
|
|
@ -280,96 +208,70 @@ fun MainScreen(uIViewModel: UIViewModel = koinViewModel(), scanModel: ScannerVie
|
|||
TopLevelDestination.Nodes -> {
|
||||
val onNodesList = currentKey is NodesRoutes.Nodes
|
||||
if (!onNodesList) {
|
||||
if (backStack.isNotEmpty()) {
|
||||
backStack[0] = destination.route
|
||||
while (backStack.size > 1) backStack.removeAt(backStack.lastIndex)
|
||||
} else {
|
||||
backStack.add(destination.route)
|
||||
}
|
||||
backStack.navigateTopLevel(destination.route)
|
||||
}
|
||||
uIViewModel.emitScrollToTopEvent(ScrollToTopEvent.NodesTabPressed)
|
||||
}
|
||||
TopLevelDestination.Conversations -> {
|
||||
val onConversationsList = currentKey is ContactsRoutes.Contacts
|
||||
if (!onConversationsList) {
|
||||
if (backStack.isNotEmpty()) {
|
||||
backStack[0] = destination.route
|
||||
while (backStack.size > 1) backStack.removeAt(backStack.lastIndex)
|
||||
} else {
|
||||
backStack.add(destination.route)
|
||||
}
|
||||
backStack.navigateTopLevel(destination.route)
|
||||
}
|
||||
uIViewModel.emitScrollToTopEvent(ScrollToTopEvent.ConversationsTabPressed)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
} else {
|
||||
if (backStack.isNotEmpty()) {
|
||||
backStack[0] = destination.route
|
||||
while (backStack.size > 1) backStack.removeAt(backStack.lastIndex)
|
||||
} else {
|
||||
backStack.add(destination.route)
|
||||
}
|
||||
backStack.navigateTopLevel(destination.route)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
val provider =
|
||||
entryProvider<NavKey> {
|
||||
contactsGraph(backStack, uIViewModel.scrollToTopEventFlow)
|
||||
nodesGraph(
|
||||
backStack = backStack,
|
||||
scrollToTopEvents = uIViewModel.scrollToTopEventFlow,
|
||||
nodeMapScreen = { destNum, onNavigateUp ->
|
||||
val vm =
|
||||
org.koin.compose.viewmodel.koinViewModel<org.meshtastic.feature.map.node.NodeMapViewModel>()
|
||||
vm.setDestNum(destNum)
|
||||
org.meshtastic.app.map.node.NodeMapScreen(vm, onNavigateUp = onNavigateUp)
|
||||
},
|
||||
)
|
||||
mapGraph(backStack)
|
||||
channelsGraph(backStack)
|
||||
connectionsGraph(backStack)
|
||||
settingsGraph(backStack)
|
||||
firmwareGraph(backStack)
|
||||
}
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
entryProvider = provider,
|
||||
modifier = Modifier.fillMaxSize().recalculateWindowInsets().safeDrawingPadding(),
|
||||
)
|
||||
MeshtasticSnackbarProvider(
|
||||
snackbarManager = uIViewModel.snackbarManager,
|
||||
hostModifier = Modifier.safeDrawingPadding().padding(bottom = 16.dp),
|
||||
) {
|
||||
val provider =
|
||||
entryProvider<NavKey> {
|
||||
contactsGraph(backStack, uIViewModel.scrollToTopEventFlow)
|
||||
nodesGraph(
|
||||
backStack = backStack,
|
||||
scrollToTopEvents = uIViewModel.scrollToTopEventFlow,
|
||||
nodeMapScreen = { destNum, onNavigateUp ->
|
||||
val vm =
|
||||
org.koin.compose.viewmodel.koinViewModel<
|
||||
org.meshtastic.feature.map.node.NodeMapViewModel,
|
||||
>()
|
||||
vm.setDestNum(destNum)
|
||||
org.meshtastic.app.map.node.NodeMapScreen(vm, onNavigateUp = onNavigateUp)
|
||||
},
|
||||
)
|
||||
mapGraph(backStack)
|
||||
channelsGraph(backStack)
|
||||
connectionsGraph(backStack)
|
||||
settingsGraph(backStack)
|
||||
firmwareGraph(backStack)
|
||||
}
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
entryProvider = provider,
|
||||
modifier = Modifier.fillMaxSize().recalculateWindowInsets().safeDrawingPadding(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
private fun VersionChecks(viewModel: UIViewModel) {
|
||||
private fun AndroidAppVersionCheck(viewModel: UIViewModel) {
|
||||
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
|
||||
val myNodeInfo by viewModel.myNodeInfo.collectAsStateWithLifecycle()
|
||||
|
||||
val myFirmwareVersion = myNodeInfo?.firmwareVersion
|
||||
|
||||
val firmwareEdition by viewModel.firmwareEdition.collectAsStateWithLifecycle(null)
|
||||
|
||||
val latestStableFirmwareRelease by
|
||||
viewModel.latestStableFirmwareRelease.collectAsStateWithLifecycle(DeviceVersion("2.6.4"))
|
||||
LaunchedEffect(connectionState, firmwareEdition) {
|
||||
if (connectionState == ConnectionState.Connected) {
|
||||
firmwareEdition?.let { edition -> Logger.d { "FirmwareEdition: ${edition.name}" } }
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the device is running an old app version or firmware version
|
||||
// Check if the device is running an old app version
|
||||
LaunchedEffect(connectionState, myNodeInfo) {
|
||||
if (connectionState == ConnectionState.Connected) {
|
||||
Logger.i {
|
||||
"[FW_CHECK] Connection state: $connectionState, " +
|
||||
"myNodeInfo: ${if (myNodeInfo != null) "present" else "null"}, " +
|
||||
"firmwareVersion: ${myFirmwareVersion ?: "null"}"
|
||||
}
|
||||
|
||||
myNodeInfo?.let { info ->
|
||||
val isOld = info.minAppVersion > BuildConfig.VERSION_CODE && BuildConfig.DEBUG.not()
|
||||
Logger.d {
|
||||
|
|
@ -384,49 +286,8 @@ private fun VersionChecks(viewModel: UIViewModel) {
|
|||
messageRes = Res.string.must_update,
|
||||
onConfirm = { viewModel.setDeviceAddress("n") },
|
||||
)
|
||||
} else {
|
||||
myFirmwareVersion
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { fwVersion ->
|
||||
val curVer = DeviceVersion(fwVersion)
|
||||
Logger.i {
|
||||
"[FW_CHECK] Firmware version comparison - " +
|
||||
"device: $curVer (raw: $fwVersion), " +
|
||||
"absoluteMin: ${MeshService.absoluteMinDeviceVersion}, " +
|
||||
"min: ${MeshService.minDeviceVersion}"
|
||||
}
|
||||
|
||||
if (curVer < MeshService.absoluteMinDeviceVersion) {
|
||||
Logger.w {
|
||||
"[FW_CHECK] Firmware too old - " +
|
||||
"device: $curVer < absoluteMin: ${MeshService.absoluteMinDeviceVersion}"
|
||||
}
|
||||
val title = getString(Res.string.firmware_too_old)
|
||||
val message = getString(Res.string.firmware_old)
|
||||
viewModel.showAlert(
|
||||
title = title,
|
||||
html = message,
|
||||
onConfirm = { viewModel.setDeviceAddress("n") },
|
||||
)
|
||||
} else if (curVer < MeshService.minDeviceVersion) {
|
||||
Logger.w {
|
||||
"[FW_CHECK] Firmware should update - " +
|
||||
"device: $curVer < min: ${MeshService.minDeviceVersion}"
|
||||
}
|
||||
val title = getString(Res.string.should_update_firmware)
|
||||
val message = getString(Res.string.should_update, latestStableFirmwareRelease.asString)
|
||||
viewModel.showAlert(title = title, message = message, onConfirm = {})
|
||||
} else {
|
||||
Logger.i { "[FW_CHECK] Firmware version OK - device: $curVer meets requirements" }
|
||||
}
|
||||
}
|
||||
?: run {
|
||||
Logger.w { "[FW_CHECK] Firmware version is null or blank despite myNodeInfo being present" }
|
||||
}
|
||||
}
|
||||
} ?: run { Logger.d { "[FW_CHECK] myNodeInfo is null, skipping firmware check" } }
|
||||
} else {
|
||||
Logger.d { "[FW_CHECK] Not connected (state: $connectionState), skipping firmware check" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.navigation
|
||||
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
|
||||
/**
|
||||
* Replaces the current back stack with the given top-level route. Clears the back stack and sets the new route as the
|
||||
* root destination.
|
||||
*/
|
||||
fun MutableList<NavKey>.navigateTopLevel(route: NavKey) {
|
||||
if (isNotEmpty()) {
|
||||
this[0] = route
|
||||
while (size > 1) {
|
||||
removeAt(lastIndex)
|
||||
}
|
||||
} else {
|
||||
add(route)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ui.component
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import co.touchlab.kermit.Logger
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.DeviceVersion
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.firmware_old
|
||||
import org.meshtastic.core.resources.firmware_too_old
|
||||
import org.meshtastic.core.resources.should_update
|
||||
import org.meshtastic.core.resources.should_update_firmware
|
||||
import org.meshtastic.core.ui.viewmodel.UIViewModel
|
||||
|
||||
/**
|
||||
* Common component to check the connected device's firmware version against the minimum required version. Will display
|
||||
* a dismissable alert if the firmware is old, or a blocking alert if it is too old.
|
||||
*/
|
||||
@Composable
|
||||
fun FirmwareVersionCheck(viewModel: UIViewModel) {
|
||||
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
|
||||
val myNodeInfo by viewModel.myNodeInfo.collectAsStateWithLifecycle()
|
||||
|
||||
val myFirmwareVersion = myNodeInfo?.firmwareVersion
|
||||
|
||||
val firmwareEdition by viewModel.firmwareEdition.collectAsStateWithLifecycle(null)
|
||||
|
||||
val latestStableFirmwareRelease by
|
||||
viewModel.latestStableFirmwareRelease.collectAsStateWithLifecycle(DeviceVersion("2.6.4"))
|
||||
|
||||
LaunchedEffect(connectionState, firmwareEdition) {
|
||||
if (connectionState == ConnectionState.Connected) {
|
||||
firmwareEdition?.let { edition -> Logger.d { "FirmwareEdition: ${edition.name}" } }
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(connectionState, myNodeInfo) {
|
||||
if (connectionState == ConnectionState.Connected) {
|
||||
myNodeInfo?.let { info ->
|
||||
myFirmwareVersion
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { fwVersion ->
|
||||
val curVer = DeviceVersion(fwVersion)
|
||||
Logger.i {
|
||||
"[FW_CHECK] Firmware version comparison - " +
|
||||
"device: $curVer (raw: $fwVersion), " +
|
||||
"absoluteMin: ${DeviceVersion(DeviceVersion.ABS_MIN_FW_VERSION)}, " +
|
||||
"min: ${DeviceVersion(DeviceVersion.MIN_FW_VERSION)}"
|
||||
}
|
||||
|
||||
if (curVer < DeviceVersion(DeviceVersion.ABS_MIN_FW_VERSION)) {
|
||||
Logger.w {
|
||||
"[FW_CHECK] Firmware too old - " +
|
||||
"device: $curVer < absoluteMin: ${DeviceVersion(DeviceVersion.ABS_MIN_FW_VERSION)}"
|
||||
}
|
||||
val title = getString(Res.string.firmware_too_old)
|
||||
val message = getString(Res.string.firmware_old)
|
||||
viewModel.showAlert(
|
||||
title = title,
|
||||
html = message,
|
||||
onConfirm = { viewModel.setDeviceAddress("n") },
|
||||
)
|
||||
} else if (curVer < DeviceVersion(DeviceVersion.MIN_FW_VERSION)) {
|
||||
Logger.w {
|
||||
"[FW_CHECK] Firmware should update - " +
|
||||
"device: $curVer < min: ${DeviceVersion(DeviceVersion.MIN_FW_VERSION)}"
|
||||
}
|
||||
val title = getString(Res.string.should_update_firmware)
|
||||
val message = getString(Res.string.should_update, latestStableFirmwareRelease.asString)
|
||||
viewModel.showAlert(title = title, message = message, onConfirm = {})
|
||||
} else {
|
||||
Logger.i { "[FW_CHECK] Firmware version OK - device: $curVer meets requirements" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ui.component
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import org.meshtastic.core.ui.viewmodel.UIViewModel
|
||||
|
||||
/**
|
||||
* Encapsulates the headless, global UI components (dialogs, version checks, traceroute alerts) that need to be active
|
||||
* across all platforms at the root of the application hierarchy.
|
||||
*
|
||||
* This deduplicates the setup boilerplate from Android's MainScreen and DesktopMainScreen.
|
||||
*/
|
||||
@Composable
|
||||
fun MeshtasticCommonAppSetup(
|
||||
uiViewModel: UIViewModel,
|
||||
onNavigateToTracerouteMap: (destinationNodeNum: Int, requestId: Int, logUuid: String?) -> Unit,
|
||||
) {
|
||||
SharedDialogs(uiViewModel = uiViewModel)
|
||||
FirmwareVersionCheck(viewModel = uiViewModel)
|
||||
AlertHost(alertManager = uiViewModel.alertManager)
|
||||
TracerouteAlertHandler(uiViewModel = uiViewModel, onNavigateToMap = onNavigateToTracerouteMap)
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ui.component
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import org.meshtastic.core.ui.util.SnackbarManager
|
||||
|
||||
/**
|
||||
* Shared composable that observes [SnackbarManager.events] and provides a global [SnackbarHostState].
|
||||
*
|
||||
* It renders a [SnackbarHost] using the provided [hostModifier] over the provided [content].
|
||||
*/
|
||||
@Composable
|
||||
fun MeshtasticSnackbarProvider(
|
||||
snackbarManager: SnackbarManager,
|
||||
modifier: Modifier = Modifier,
|
||||
hostModifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
LaunchedEffect(snackbarManager) {
|
||||
snackbarManager.events.collect { event ->
|
||||
val result =
|
||||
snackbarHostState.showSnackbar(
|
||||
message = event.message,
|
||||
actionLabel = event.actionLabel,
|
||||
withDismissAction = event.withDismissAction,
|
||||
duration = event.duration,
|
||||
)
|
||||
if (result == SnackbarResult.ActionPerformed) {
|
||||
event.onAction?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
content()
|
||||
SnackbarHost(
|
||||
hostState = snackbarHostState,
|
||||
modifier = Modifier.align(Alignment.BottomCenter).then(hostModifier),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ui.component
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.okay
|
||||
import org.meshtastic.core.resources.traceroute
|
||||
import org.meshtastic.core.resources.view_on_map
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
|
||||
import org.meshtastic.core.ui.util.annotateTraceroute
|
||||
import org.meshtastic.core.ui.util.toMessageRes
|
||||
import org.meshtastic.core.ui.viewmodel.UIViewModel
|
||||
|
||||
/**
|
||||
* Handles the display of the traceroute alert when a response is received. Consolidates the side effect logic from the
|
||||
* main application screens into common code.
|
||||
*/
|
||||
@Composable
|
||||
fun TracerouteAlertHandler(
|
||||
uiViewModel: UIViewModel,
|
||||
onNavigateToMap: (destinationNodeNum: Int, requestId: Int, logUuid: String?) -> Unit,
|
||||
) {
|
||||
val traceRouteResponse by uiViewModel.tracerouteResponse.collectAsStateWithLifecycle(null)
|
||||
var dismissedTracerouteRequestId by remember { mutableStateOf<Int?>(null) }
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
LaunchedEffect(traceRouteResponse, dismissedTracerouteRequestId) {
|
||||
val response = traceRouteResponse
|
||||
if (response != null && response.requestId != dismissedTracerouteRequestId) {
|
||||
uiViewModel.showAlert(
|
||||
titleRes = Res.string.traceroute,
|
||||
composableMessage = {
|
||||
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
Text(
|
||||
text =
|
||||
annotateTraceroute(
|
||||
response.message,
|
||||
statusGreen = colorScheme.StatusGreen,
|
||||
statusYellow = colorScheme.StatusYellow,
|
||||
statusOrange = colorScheme.StatusOrange,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmTextRes = Res.string.view_on_map,
|
||||
onConfirm = {
|
||||
val availability =
|
||||
uiViewModel.tracerouteMapAvailability(
|
||||
forwardRoute = response.forwardRoute,
|
||||
returnRoute = response.returnRoute,
|
||||
)
|
||||
val errorRes = availability.toMessageRes()
|
||||
if (errorRes == null) {
|
||||
dismissedTracerouteRequestId = response.requestId
|
||||
onNavigateToMap(response.destinationNodeNum, response.requestId, response.logUuid)
|
||||
} else {
|
||||
uiViewModel.showAlert(titleRes = Res.string.traceroute, messageRes = errorRes)
|
||||
uiViewModel.clearTracerouteResponse()
|
||||
}
|
||||
},
|
||||
dismissTextRes = Res.string.okay,
|
||||
onDismiss = {
|
||||
uiViewModel.clearTracerouteResponse()
|
||||
dismissedTracerouteRequestId = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ui.util
|
||||
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
/**
|
||||
* A global manager for displaying snackbars across the application. This allows ViewModels to trigger transient
|
||||
* feedback messages without direct dependencies on UI components or `SnackbarHostState`.
|
||||
*
|
||||
* Events are buffered in a [Channel] and consumed exactly once by the host composable via `MeshtasticSnackbarHost`.
|
||||
*
|
||||
* @see AlertManager for the modal dialog equivalent.
|
||||
*/
|
||||
@Single
|
||||
open class SnackbarManager {
|
||||
data class SnackbarEvent(
|
||||
val message: String,
|
||||
val actionLabel: String? = null,
|
||||
val withDismissAction: Boolean = false,
|
||||
val duration: SnackbarDuration = SnackbarDuration.Short,
|
||||
val onAction: (() -> Unit)? = null,
|
||||
)
|
||||
|
||||
private val _events = Channel<SnackbarEvent>(Channel.BUFFERED)
|
||||
open val events: Flow<SnackbarEvent> = _events.receiveAsFlow()
|
||||
|
||||
open fun showSnackbar(
|
||||
message: String,
|
||||
actionLabel: String? = null,
|
||||
withDismissAction: Boolean = false,
|
||||
duration: SnackbarDuration = if (actionLabel != null) SnackbarDuration.Indefinite else SnackbarDuration.Short,
|
||||
onAction: (() -> Unit)? = null,
|
||||
) {
|
||||
_events.trySend(
|
||||
SnackbarEvent(
|
||||
message = message,
|
||||
actionLabel = actionLabel,
|
||||
withDismissAction = withDismissAction,
|
||||
duration = duration,
|
||||
onAction = onAction,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -57,6 +57,7 @@ import org.meshtastic.core.resources.compromised_keys
|
|||
import org.meshtastic.core.ui.component.ScrollToTopEvent
|
||||
import org.meshtastic.core.ui.util.AlertManager
|
||||
import org.meshtastic.core.ui.util.ComposableContent
|
||||
import org.meshtastic.core.ui.util.SnackbarManager
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import org.meshtastic.proto.ClientNotification
|
||||
import org.meshtastic.proto.SharedContact
|
||||
|
|
@ -80,6 +81,7 @@ class UIViewModel(
|
|||
private val notificationManager: NotificationManager,
|
||||
packetRepository: PacketRepository,
|
||||
val alertManager: AlertManager,
|
||||
val snackbarManager: SnackbarManager,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _navigationDeepLink = MutableSharedFlow<MeshtasticUri>(replay = 1)
|
||||
|
|
@ -165,6 +167,10 @@ class UIViewModel(
|
|||
alertManager.dismissAlert()
|
||||
}
|
||||
|
||||
fun showSnackbar(message: String, actionLabel: String? = null, onAction: (() -> Unit)? = null) {
|
||||
snackbarManager.showSnackbar(message = message, actionLabel = actionLabel, onAction = onAction)
|
||||
}
|
||||
|
||||
fun setDeviceAddress(address: String) {
|
||||
radioController.setDeviceAddress(address)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,103 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ui.util
|
||||
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import app.cash.turbine.test
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SnackbarManagerTest {
|
||||
|
||||
private val snackbarManager = SnackbarManager()
|
||||
|
||||
@Test
|
||||
fun showSnackbar_emits_event_with_message() = runTest {
|
||||
snackbarManager.events.test {
|
||||
snackbarManager.showSnackbar(message = "Hello")
|
||||
|
||||
val event = awaitItem()
|
||||
assertEquals("Hello", event.message)
|
||||
assertNull(event.actionLabel)
|
||||
assertEquals(SnackbarDuration.Short, event.duration)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showSnackbar_with_action_defaults_to_indefinite_duration() = runTest {
|
||||
snackbarManager.events.test {
|
||||
snackbarManager.showSnackbar(message = "Deleted", actionLabel = "Undo")
|
||||
|
||||
val event = awaitItem()
|
||||
assertEquals("Deleted", event.message)
|
||||
assertEquals("Undo", event.actionLabel)
|
||||
assertEquals(SnackbarDuration.Indefinite, event.duration)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showSnackbar_with_explicit_duration_overrides_default() = runTest {
|
||||
snackbarManager.events.test {
|
||||
snackbarManager.showSnackbar(message = "Saved", actionLabel = "View", duration = SnackbarDuration.Long)
|
||||
|
||||
val event = awaitItem()
|
||||
assertEquals(SnackbarDuration.Long, event.duration)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multiple_events_are_queued_and_consumed_in_order() = runTest {
|
||||
snackbarManager.events.test {
|
||||
snackbarManager.showSnackbar(message = "First")
|
||||
snackbarManager.showSnackbar(message = "Second")
|
||||
snackbarManager.showSnackbar(message = "Third")
|
||||
|
||||
assertEquals("First", awaitItem().message)
|
||||
assertEquals("Second", awaitItem().message)
|
||||
assertEquals("Third", awaitItem().message)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onAction_callback_is_preserved_in_event() = runTest {
|
||||
var actionTriggered = false
|
||||
snackbarManager.events.test {
|
||||
snackbarManager.showSnackbar(
|
||||
message = "Item removed",
|
||||
actionLabel = "Undo",
|
||||
onAction = { actionTriggered = true },
|
||||
)
|
||||
|
||||
val event = awaitItem()
|
||||
event.onAction?.invoke()
|
||||
assertTrue(actionTriggered)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun withDismissAction_is_passed_through() = runTest {
|
||||
snackbarManager.events.test {
|
||||
snackbarManager.showSnackbar(message = "Notice", withDismissAction = true)
|
||||
|
||||
val event = awaitItem()
|
||||
assertTrue(event.withDismissAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -43,6 +43,12 @@ tasks.withType<Detekt>().configureEach { exclude("**/generated/**") }
|
|||
compose.desktop {
|
||||
application {
|
||||
mainClass = "org.meshtastic.desktop.MainKt"
|
||||
jvmArgs(
|
||||
"-Xmx2G",
|
||||
"-Dapple.awt.application.name=Meshtastic",
|
||||
"-Dcom.apple.mrj.application.apple.menu.about.name=Meshtastic",
|
||||
"-Dcom.apple.bundle.identifier=org.meshtastic.desktop",
|
||||
)
|
||||
|
||||
buildTypes.release.proguard {
|
||||
isEnabled.set(true)
|
||||
|
|
@ -66,15 +72,28 @@ compose.desktop {
|
|||
|
||||
// Default JVM arguments for the packaged application
|
||||
// Increase max heap size to prevent OOM issues on complex maps/data
|
||||
jvmArgs("-Xmx2G")
|
||||
jvmArgs(
|
||||
"-Xmx2G",
|
||||
"-Dapple.awt.application.name=Meshtastic",
|
||||
"-Dcom.apple.mrj.application.apple.name=Meshtastic",
|
||||
"-Dcom.apple.bundle.identifier=org.meshtastic.desktop",
|
||||
)
|
||||
|
||||
// App Icon & OS Specific Configurations
|
||||
macOS {
|
||||
iconFile.set(project.file("src/main/resources/icon.icns"))
|
||||
minimumSystemVersion = "12.0"
|
||||
bundleID = "org.meshtastic.desktop"
|
||||
infoPlist {
|
||||
extraKeysRawXml =
|
||||
"""
|
||||
<key>NSUserNotificationAlertStyle</key>
|
||||
<string>alert</string>
|
||||
"""
|
||||
.trimIndent()
|
||||
}
|
||||
// TODO: To prepare for real distribution on macOS, you'll need to sign and notarize.
|
||||
// You can inject these from CI environment variables.
|
||||
// bundleID = "org.meshtastic.desktop"
|
||||
// sign = true
|
||||
// notarize = true
|
||||
// appleID = System.getenv("APPLE_ID")
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@ import androidx.compose.ui.window.Notification as ComposeNotification
|
|||
|
||||
@Single
|
||||
class DesktopNotificationManager(private val prefs: NotificationPrefs) : NotificationManager {
|
||||
init {
|
||||
co.touchlab.kermit.Logger.i { "DesktopNotificationManager initialized" }
|
||||
}
|
||||
|
||||
private val _notifications = MutableSharedFlow<ComposeNotification>(extraBufferCapacity = 10)
|
||||
val notifications: SharedFlow<ComposeNotification> = _notifications.asSharedFlow()
|
||||
|
||||
|
|
@ -40,6 +44,10 @@ class DesktopNotificationManager(private val prefs: NotificationPrefs) : Notific
|
|||
Notification.Category.Service -> true
|
||||
}
|
||||
|
||||
co.touchlab.kermit.Logger.d {
|
||||
"DesktopNotificationManager dispatch: category=${notification.category}, enabled=$enabled"
|
||||
}
|
||||
|
||||
if (!enabled) return
|
||||
|
||||
val composeType =
|
||||
|
|
@ -50,7 +58,8 @@ class DesktopNotificationManager(private val prefs: NotificationPrefs) : Notific
|
|||
Notification.Type.Error -> ComposeNotification.Type.Error
|
||||
}
|
||||
|
||||
_notifications.tryEmit(ComposeNotification(notification.title, notification.message, composeType))
|
||||
val success = _notifications.tryEmit(ComposeNotification(notification.title, notification.message, composeType))
|
||||
co.touchlab.kermit.Logger.d { "DesktopNotificationManager emit: success=$success, title=${notification.title}" }
|
||||
}
|
||||
|
||||
override fun cancel(id: Int) {
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ import org.meshtastic.core.common.util.MeshtasticUri
|
|||
import org.meshtastic.core.navigation.MeshtasticNavSavedStateConfig
|
||||
import org.meshtastic.core.navigation.SettingsRoutes
|
||||
import org.meshtastic.core.navigation.TopLevelDestination
|
||||
import org.meshtastic.core.navigation.navigateTopLevel
|
||||
import org.meshtastic.core.repository.UiPrefs
|
||||
import org.meshtastic.core.service.MeshServiceOrchestrator
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
|
|
@ -172,13 +173,21 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
|
|||
val trayState = rememberTrayState()
|
||||
val appIcon = classpathPainterResource("icon.png")
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
val trayIcon =
|
||||
androidx.compose.ui.res.painterResource(
|
||||
if (isSystemInDarkTheme()) "tray_icon_white.svg" else "tray_icon_black.svg",
|
||||
)
|
||||
|
||||
val notificationManager = remember { koinApp.koin.get<DesktopNotificationManager>() }
|
||||
val alertManager = remember { koinApp.koin.get<org.meshtastic.core.ui.util.AlertManager>() }
|
||||
val desktopPrefs = remember { koinApp.koin.get<DesktopPreferencesDataSource>() }
|
||||
val windowState = rememberWindowState()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
notificationManager.notifications.collect { notification -> trayState.sendNotification(notification) }
|
||||
notificationManager.notifications.collect { notification ->
|
||||
Logger.d { "Main.kt: Received notification for Tray: title=${notification.title}" }
|
||||
trayState.sendNotification(notification)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
|
|
@ -209,7 +218,9 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
|
|||
|
||||
Tray(
|
||||
state = trayState,
|
||||
icon = appIcon,
|
||||
icon = trayIcon,
|
||||
tooltip = "Meshtastic Desktop",
|
||||
onAction = { isAppVisible = true },
|
||||
menu = {
|
||||
Item("Show Meshtastic", onClick = { isAppVisible = true })
|
||||
Item(
|
||||
|
|
@ -250,7 +261,7 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
|
|||
if (
|
||||
TopLevelDestination.Settings != TopLevelDestination.fromNavKey(backStack.lastOrNull())
|
||||
) {
|
||||
navigateTopLevel(backStack, TopLevelDestination.Settings.route)
|
||||
backStack.navigateTopLevel(TopLevelDestination.Settings.route)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
|
@ -261,22 +272,22 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
|
|||
}
|
||||
// ⌘1 → Conversations
|
||||
event.key == Key.One -> {
|
||||
navigateTopLevel(backStack, TopLevelDestination.Conversations.route)
|
||||
backStack.navigateTopLevel(TopLevelDestination.Conversations.route)
|
||||
true
|
||||
}
|
||||
// ⌘2 → Nodes
|
||||
event.key == Key.Two -> {
|
||||
navigateTopLevel(backStack, TopLevelDestination.Nodes.route)
|
||||
backStack.navigateTopLevel(TopLevelDestination.Nodes.route)
|
||||
true
|
||||
}
|
||||
// ⌘3 → Map
|
||||
event.key == Key.Three -> {
|
||||
navigateTopLevel(backStack, TopLevelDestination.Map.route)
|
||||
backStack.navigateTopLevel(TopLevelDestination.Map.route)
|
||||
true
|
||||
}
|
||||
// ⌘4 → Connections
|
||||
event.key == Key.Four -> {
|
||||
navigateTopLevel(backStack, TopLevelDestination.Connections.route)
|
||||
backStack.navigateTopLevel(TopLevelDestination.Connections.route)
|
||||
true
|
||||
}
|
||||
// ⌘/ → About
|
||||
|
|
@ -310,19 +321,8 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
|
|||
// re-reads Locale.current and all stringResource() calls update. Unlike key(), this
|
||||
// preserves remembered state (including the navigation backstack).
|
||||
CompositionLocalProvider(LocalAppLocale provides localePref) {
|
||||
AppTheme(darkTheme = isDarkTheme) {
|
||||
org.meshtastic.core.ui.component.AlertHost(alertManager)
|
||||
DesktopMainScreen(backStack)
|
||||
}
|
||||
AppTheme(darkTheme = isDarkTheme) { DesktopMainScreen(backStack) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Replaces the backstack with a single top-level destination route. */
|
||||
private fun navigateTopLevel(backStack: MutableList<NavKey>, route: NavKey) {
|
||||
backStack.add(route)
|
||||
while (backStack.size > 1) {
|
||||
backStack.removeAt(0)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -137,6 +137,10 @@ private fun desktopPlatformStubsModule() = module {
|
|||
locationManager = get(),
|
||||
)
|
||||
}
|
||||
single { org.meshtastic.desktop.DesktopNotificationManager(prefs = get()) }
|
||||
single<org.meshtastic.core.repository.NotificationManager> {
|
||||
get<org.meshtastic.desktop.DesktopNotificationManager>()
|
||||
}
|
||||
single<MeshServiceNotifications> {
|
||||
org.meshtastic.desktop.notification.DesktopMeshServiceNotifications(notificationManager = get())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,8 +16,10 @@
|
|||
*/
|
||||
package org.meshtastic.desktop.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationRail
|
||||
|
|
@ -27,6 +29,7 @@ import androidx.compose.material3.Text
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
|
|
@ -36,10 +39,12 @@ import org.jetbrains.compose.resources.stringResource
|
|||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.meshtastic.core.model.DeviceType
|
||||
import org.meshtastic.core.navigation.NodeDetailRoutes
|
||||
import org.meshtastic.core.navigation.TopLevelDestination
|
||||
import org.meshtastic.core.navigation.navigateTopLevel
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.ui.component.AlertHost
|
||||
import org.meshtastic.core.ui.component.SharedDialogs
|
||||
import org.meshtastic.core.ui.component.MeshtasticCommonAppSetup
|
||||
import org.meshtastic.core.ui.component.MeshtasticSnackbarProvider
|
||||
import org.meshtastic.core.ui.navigation.icon
|
||||
import org.meshtastic.core.ui.viewmodel.UIViewModel
|
||||
import org.meshtastic.desktop.navigation.desktopNavGraph
|
||||
|
|
@ -51,6 +56,7 @@ import org.meshtastic.desktop.navigation.desktopNavGraph
|
|||
* app, proving the shared backstack architecture works across targets.
|
||||
*/
|
||||
@Composable
|
||||
@Suppress("LongMethod")
|
||||
fun DesktopMainScreen(
|
||||
backStack: NavBackStack<NavKey>,
|
||||
radioService: RadioInterfaceService = koinInject(),
|
||||
|
|
@ -63,61 +69,60 @@ fun DesktopMainScreen(
|
|||
val selectedDevice by radioService.currentDeviceAddressFlow.collectAsStateWithLifecycle()
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
|
||||
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
|
||||
|
||||
SharedDialogs(
|
||||
connectionState = connectionState,
|
||||
sharedContactRequested = sharedContactRequested,
|
||||
requestChannelSet = requestChannelSet,
|
||||
onDismissSharedContact = { uiViewModel.clearSharedContactRequested() },
|
||||
onDismissChannelSet = { uiViewModel.clearRequestChannelUrl() },
|
||||
MeshtasticCommonAppSetup(
|
||||
uiViewModel = uiViewModel,
|
||||
onNavigateToTracerouteMap = { destNum, requestId, logUuid ->
|
||||
backStack.add(NodeDetailRoutes.TracerouteMap(destNum = destNum, requestId = requestId, logUuid = logUuid))
|
||||
},
|
||||
)
|
||||
|
||||
AlertHost(uiViewModel.alertManager)
|
||||
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
NavigationRail {
|
||||
TopLevelDestination.entries.forEach { destination ->
|
||||
NavigationRailItem(
|
||||
selected = destination == selected,
|
||||
onClick = {
|
||||
if (destination != selected) {
|
||||
backStack.add(destination.route)
|
||||
while (backStack.size > 1) {
|
||||
backStack.removeAt(0)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
NavigationRail {
|
||||
TopLevelDestination.entries.forEach { destination ->
|
||||
NavigationRailItem(
|
||||
selected = destination == selected,
|
||||
onClick = {
|
||||
if (destination != selected) {
|
||||
backStack.navigateTopLevel(destination.route)
|
||||
}
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
if (destination == TopLevelDestination.Connections) {
|
||||
org.meshtastic.feature.connections.ui.components.AnimatedConnectionsNavIcon(
|
||||
connectionState = connectionState,
|
||||
deviceType = DeviceType.fromAddress(selectedDevice ?: "NoDevice"),
|
||||
meshActivityFlow = radioService.meshActivity,
|
||||
colorScheme = colorScheme,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = destination.icon,
|
||||
contentDescription = stringResource(destination.label),
|
||||
)
|
||||
}
|
||||
},
|
||||
label = { Text(stringResource(destination.label)) },
|
||||
},
|
||||
icon = {
|
||||
if (destination == TopLevelDestination.Connections) {
|
||||
org.meshtastic.feature.connections.ui.components.AnimatedConnectionsNavIcon(
|
||||
connectionState = connectionState,
|
||||
deviceType = DeviceType.fromAddress(selectedDevice ?: "NoDevice"),
|
||||
meshActivityFlow = radioService.meshActivity,
|
||||
colorScheme = colorScheme,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = destination.icon,
|
||||
contentDescription = stringResource(destination.label),
|
||||
)
|
||||
}
|
||||
},
|
||||
label = { Text(stringResource(destination.label)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
MeshtasticSnackbarProvider(
|
||||
snackbarManager = uiViewModel.snackbarManager,
|
||||
modifier = Modifier.weight(1f).fillMaxSize(),
|
||||
hostModifier = Modifier.padding(bottom = 24.dp),
|
||||
) {
|
||||
val provider = entryProvider<NavKey> { desktopNavGraph(backStack) }
|
||||
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
onBack = { backStack.removeLastOrNull() },
|
||||
entryProvider = provider,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val provider = entryProvider<NavKey> { desktopNavGraph(backStack) }
|
||||
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
onBack = { backStack.removeLastOrNull() },
|
||||
entryProvider = provider,
|
||||
modifier = Modifier.weight(1f).fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="24" height="24" viewBox="0 0 100 55" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g transform="matrix(0.802386,0,0,0.460028,-421.748,-122.127)">
|
||||
<g transform="matrix(0.579082,0,0,1.01004,460.975,-39.6867)">
|
||||
<path d="M250.908,330.267L193.126,415.005L180.938,406.694L244.802,313.037C246.174,311.024 248.453,309.819 250.889,309.816C253.326,309.814 255.606,311.015 256.982,313.026L320.994,406.536L308.821,414.869L250.908,330.267Z"/>
|
||||
</g>
|
||||
<g transform="matrix(0.582378,0,0,1.01579,485.019,-211.182)">
|
||||
<path d="M87.642,581.398L154.757,482.977L142.638,474.713L75.523,573.134L87.642,581.398Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="512" height="512"><path d="M125 0Q0 0 0 125v262q0 125 125 125h262q125 0 125-125V125Q512 0 387 0Z" style="stroke:#d5828b;stroke-width:0;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:#67ea94;fill-rule:nonzero;opacity:1" vector-effect="non-scaling-stroke"/><path d="m250.908 330.267-57.782 84.738-12.188-8.311 63.864-93.657a7.378 7.378 0 0 1 12.18-.011l64.012 93.51-12.173 8.333z" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:round;stroke-miterlimit:2;fill:#2c2d3c;fill-rule:evenodd;opacity:1" transform="translate(-135.496 -390.354)scale(1.79)" vector-effect="non-scaling-stroke"/><path d="m87.642 581.398 67.115-98.421-12.119-8.264-67.115 98.421z" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:round;stroke-miterlimit:2;fill:#2c2d3c;fill-rule:evenodd;opacity:1" transform="translate(-63.403 -699.639)scale(1.81)" vector-effect="non-scaling-stroke"/></svg>
|
||||
|
Before Width: | Height: | Size: 1 KiB After Width: | Height: | Size: 1.1 KiB |
|
|
@ -1,12 +1 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="24" height="24" viewBox="0 0 100 55" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g transform="matrix(0.802386,0,0,0.460028,-421.748,-122.127)">
|
||||
<g transform="matrix(0.579082,0,0,1.01004,460.975,-39.6867)">
|
||||
<path d="M250.908,330.267L193.126,415.005L180.938,406.694L244.802,313.037C246.174,311.024 248.453,309.819 250.889,309.816C253.326,309.814 255.606,311.015 256.982,313.026L320.994,406.536L308.821,414.869L250.908,330.267Z" style="fill:white;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.582378,0,0,1.01579,485.019,-211.182)">
|
||||
<path d="M87.642,581.398L154.757,482.977L142.638,474.713L75.523,573.134L87.642,581.398Z" style="fill:white;"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="512" height="512"><path d="M125 0Q0 0 0 125v262q0 125 125 125h262q125 0 125-125V125Q512 0 387 0Z" style="stroke:#d5828b;stroke-width:0;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:#67ea94;fill-rule:nonzero;opacity:1" vector-effect="non-scaling-stroke"/><path d="m250.908 330.267-57.782 84.738-12.188-8.311 63.864-93.657a7.378 7.378 0 0 1 12.18-.011l64.012 93.51-12.173 8.333z" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:round;stroke-miterlimit:2;fill:#2c2d3c;fill-rule:evenodd;opacity:1" transform="translate(-135.496 -390.354)scale(1.79)" vector-effect="non-scaling-stroke"/><path d="m87.642 581.398 67.115-98.421-12.119-8.264-67.115 98.421z" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:round;stroke-miterlimit:2;fill:#2c2d3c;fill-rule:evenodd;opacity:1" transform="translate(-63.403 -699.639)scale(1.81)" vector-effect="non-scaling-stroke"/></svg>
|
||||
|
Before Width: | Height: | Size: 1 KiB After Width: | Height: | Size: 1.1 KiB |
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -18,11 +18,8 @@ package org.meshtastic.feature.node.detail
|
|||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -46,12 +43,14 @@ import org.meshtastic.core.resources.requesting_from
|
|||
import org.meshtastic.core.resources.signal_quality
|
||||
import org.meshtastic.core.resources.traceroute
|
||||
import org.meshtastic.core.resources.user_info
|
||||
import org.meshtastic.core.ui.util.SnackbarManager
|
||||
|
||||
@Single(binds = [NodeRequestActions::class])
|
||||
class CommonNodeRequestActions constructor(private val radioController: RadioController) : NodeRequestActions {
|
||||
|
||||
private val _effects = MutableSharedFlow<NodeRequestEffect>()
|
||||
override val effects: SharedFlow<NodeRequestEffect> = _effects.asSharedFlow()
|
||||
class CommonNodeRequestActions
|
||||
constructor(
|
||||
private val radioController: RadioController,
|
||||
private val snackbarManager: SnackbarManager,
|
||||
) : NodeRequestActions {
|
||||
|
||||
private val _lastTracerouteTime = MutableStateFlow<Long?>(null)
|
||||
override val lastTracerouteTime: StateFlow<Long?> = _lastTracerouteTime.asStateFlow()
|
||||
|
|
@ -59,15 +58,15 @@ class CommonNodeRequestActions constructor(private val radioController: RadioCon
|
|||
private val _lastRequestNeighborTimes = MutableStateFlow<Map<Int, Long>>(emptyMap())
|
||||
override val lastRequestNeighborTimes: StateFlow<Map<Int, Long>> = _lastRequestNeighborTimes.asStateFlow()
|
||||
|
||||
private suspend fun showFeedback(text: UiText) {
|
||||
snackbarManager.showSnackbar(message = text.resolve())
|
||||
}
|
||||
|
||||
override fun requestUserInfo(scope: CoroutineScope, destNum: Int, longName: String) {
|
||||
scope.launch(ioDispatcher) {
|
||||
Logger.i { "Requesting UserInfo for '$destNum'" }
|
||||
radioController.requestUserInfo(destNum)
|
||||
_effects.emit(
|
||||
NodeRequestEffect.ShowFeedback(
|
||||
UiText.Resource(Res.string.requesting_from, Res.string.user_info, longName),
|
||||
),
|
||||
)
|
||||
showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.user_info, longName))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -77,11 +76,7 @@ class CommonNodeRequestActions constructor(private val radioController: RadioCon
|
|||
val packetId = radioController.getPacketId()
|
||||
radioController.requestNeighborInfo(packetId, destNum)
|
||||
_lastRequestNeighborTimes.update { it + (destNum to nowMillis) }
|
||||
_effects.emit(
|
||||
NodeRequestEffect.ShowFeedback(
|
||||
UiText.Resource(Res.string.requesting_from, Res.string.neighbor_info, longName),
|
||||
),
|
||||
)
|
||||
showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.neighbor_info, longName))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -89,11 +84,7 @@ class CommonNodeRequestActions constructor(private val radioController: RadioCon
|
|||
scope.launch(ioDispatcher) {
|
||||
Logger.i { "Requesting position for '$destNum'" }
|
||||
radioController.requestPosition(destNum, position)
|
||||
_effects.emit(
|
||||
NodeRequestEffect.ShowFeedback(
|
||||
UiText.Resource(Res.string.requesting_from, Res.string.position, longName),
|
||||
),
|
||||
)
|
||||
showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.position, longName))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -114,9 +105,7 @@ class CommonNodeRequestActions constructor(private val radioController: RadioCon
|
|||
TelemetryType.PAX -> Res.string.request_pax_metrics
|
||||
}
|
||||
|
||||
_effects.emit(
|
||||
NodeRequestEffect.ShowFeedback(UiText.Resource(Res.string.requesting_from, typeRes, longName)),
|
||||
)
|
||||
showFeedback(UiText.Resource(Res.string.requesting_from, typeRes, longName))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -126,11 +115,7 @@ class CommonNodeRequestActions constructor(private val radioController: RadioCon
|
|||
val packetId = radioController.getPacketId()
|
||||
radioController.requestTraceroute(packetId, destNum)
|
||||
_lastTracerouteTime.value = nowMillis
|
||||
_effects.emit(
|
||||
NodeRequestEffect.ShowFeedback(
|
||||
UiText.Resource(Res.string.requesting_from, Res.string.traceroute, longName),
|
||||
),
|
||||
)
|
||||
showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.traceroute, longName))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ import androidx.lifecycle.ViewModel
|
|||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
|
|
@ -84,8 +83,6 @@ class NodeDetailViewModel(
|
|||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), NodeDetailUiState())
|
||||
|
||||
val effects: SharedFlow<NodeRequestEffect> = nodeRequestActions.effects
|
||||
|
||||
fun start(nodeId: Int) {
|
||||
if (manualNodeId.value != nodeId) {
|
||||
manualNodeId.value = nodeId
|
||||
|
|
|
|||
|
|
@ -17,19 +17,12 @@
|
|||
package org.meshtastic.feature.node.detail
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.meshtastic.core.model.Position
|
||||
import org.meshtastic.core.model.TelemetryType
|
||||
import org.meshtastic.core.resources.UiText
|
||||
|
||||
sealed class NodeRequestEffect {
|
||||
data class ShowFeedback(val text: UiText) : NodeRequestEffect()
|
||||
}
|
||||
|
||||
/** Interface for high-level node request actions (e.g., requesting user info, position, telemetry). */
|
||||
interface NodeRequestActions {
|
||||
val effects: SharedFlow<NodeRequestEffect>
|
||||
val lastTracerouteTime: StateFlow<Long?>
|
||||
val lastRequestNeighborTimes: StateFlow<Map<Int, Long>>
|
||||
|
||||
|
|
|
|||
|
|
@ -30,8 +30,6 @@ import androidx.compose.material.icons.rounded.Info
|
|||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
|
|
@ -152,7 +150,6 @@ fun <T> BaseMetricScreen(
|
|||
data: List<T>,
|
||||
timeProvider: (T) -> Double,
|
||||
infoData: List<InfoDialogData> = emptyList(),
|
||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
||||
onRequestTelemetry: (() -> Unit)? = null,
|
||||
chartPart: @Composable (Modifier, Double?, VicoScrollState, (Double) -> Unit) -> Unit,
|
||||
listPart: @Composable (Modifier, Double?, LazyListState, (Double) -> Unit) -> Unit,
|
||||
|
|
@ -192,7 +189,6 @@ fun <T> BaseMetricScreen(
|
|||
onClickChip = {},
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
) { innerPadding ->
|
||||
Column(modifier = Modifier.padding(innerPadding)) {
|
||||
if (displayInfoDialog) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ import androidx.lifecycle.ViewModel
|
|||
import androidx.lifecycle.viewModelScope
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
|
|
@ -64,7 +63,6 @@ import org.meshtastic.core.ui.util.toMessageRes
|
|||
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
|
||||
import org.meshtastic.feature.map.model.TracerouteOverlay
|
||||
import org.meshtastic.feature.node.detail.NodeRequestActions
|
||||
import org.meshtastic.feature.node.detail.NodeRequestEffect
|
||||
import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase
|
||||
import org.meshtastic.feature.node.model.MetricsState
|
||||
import org.meshtastic.feature.node.model.TimeFrame
|
||||
|
|
@ -175,8 +173,6 @@ open class MetricsViewModel(
|
|||
}
|
||||
.stateInWhileSubscribed(emptyList())
|
||||
|
||||
val effects: SharedFlow<NodeRequestEffect> = nodeRequestActions.effects
|
||||
|
||||
val lastTraceRouteTime: StateFlow<Long?> = nodeRequestActions.lastTracerouteTime
|
||||
|
||||
val lastRequestNeighborsTime: StateFlow<Long?> =
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue