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

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

View file

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

View file

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