Refactor nav3 architecture and enhance adaptive layouts (#4944)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-27 09:43:44 -05:00 committed by GitHub
parent 3feec759a1
commit f2d09ff79d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 740 additions and 617 deletions

View file

@ -36,20 +36,16 @@ import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isMetaPressed
import androidx.compose.ui.input.key.isShiftPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Notification
import androidx.compose.ui.window.Tray
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberTrayState
import androidx.compose.ui.window.rememberWindowState
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.rememberNavBackStack
import co.touchlab.kermit.Logger
import coil3.ImageLoader
import coil3.compose.setSingletonImageLoaderFactory
@ -63,10 +59,9 @@ import okio.Path.Companion.toPath
import org.jetbrains.skia.Image
import org.koin.core.context.startKoin
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.navigation.rememberMultiBackstack
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.service.MeshServiceOrchestrator
import org.meshtastic.core.ui.theme.AppTheme
@ -78,32 +73,12 @@ import org.meshtastic.desktop.ui.DesktopMainScreen
import java.awt.Desktop
import java.util.Locale
/**
* Meshtastic Desktop the first non-Android target for the shared KMP module graph.
*
* Launches a Compose Desktop window with a Navigation 3 shell that mirrors the Android app's navigation architecture:
* shared routes from `core:navigation`, a `NavigationRail` for top-level destinations, and `NavDisplay` for rendering
* the current backstack entry.
*/
/**
* Static CompositionLocal used as a recomposition trigger for locale changes. When the value changes,
* [staticCompositionLocalOf] forces the **entire subtree** under the provider to recompose unlike [key] which
* destroys and recreates state (including the navigation backstack). During recomposition, CMP Resources'
* `rememberResourceEnvironment` re-reads `Locale.current` (which wraps `java.util.Locale.getDefault()`) and picks up
* the new locale, causing all `stringResource()` calls to resolve in the updated language.
*/
/** Meshtastic Desktop — the first non-Android target for the shared KMP module graph. */
private val LocalAppLocale = staticCompositionLocalOf { "" }
private const val MEMORY_CACHE_MAX_BYTES = 64L * 1024L * 1024L // 64 MiB
private const val DISK_CACHE_MAX_BYTES = 32L * 1024L * 1024L // 32 MiB
/**
* Loads a [Painter] from a Java classpath resource path (e.g. `"icon.png"`).
*
* This replaces the deprecated `androidx.compose.ui.res.painterResource(String)` API. Desktop native-distribution icons
* (`.icns`, `.ico`) remain in `src/main/resources` for the packaging plugin; this helper reads the same directory at
* runtime.
*/
@Composable
private fun classpathPainterResource(path: String): Painter {
val bitmap: ImageBitmap =
@ -145,7 +120,6 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
}
}
// Start the mesh service processing chain (desktop equivalent of Android's MeshService)
val meshServiceController = remember { koinApp.koin.get<MeshServiceOrchestrator>() }
DisposableEffect(Unit) {
meshServiceController.start()
@ -153,18 +127,15 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
}
val uiPrefs = remember { koinApp.koin.get<UiPrefs>() }
val themePref by uiPrefs.theme.collectAsState(initial = -1) // -1 is SYSTEM usually
val themePref by uiPrefs.theme.collectAsState(initial = -1)
val localePref by uiPrefs.locale.collectAsState(initial = "")
// Apply persisted locale to the JVM default synchronously so CMP Resources sees
// it during the current composition frame. Empty string falls back to the startup
// system locale captured before any app-specific override was applied.
Locale.setDefault(localePref.takeIf { it.isNotEmpty() }?.let(Locale::forLanguageTag) ?: systemLocale)
val isDarkTheme =
when (themePref) {
1 -> false // MODE_NIGHT_NO
2 -> true // MODE_NIGHT_YES
1 -> false
2 -> true
else -> isSystemInDarkTheme()
}
@ -184,10 +155,7 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
val windowState = rememberWindowState()
LaunchedEffect(Unit) {
notificationManager.notifications.collect { notification ->
Logger.d { "Main.kt: Received notification for Tray: title=${notification.title}" }
trayState.sendNotification(notification)
}
notificationManager.notifications.collect { notification -> trayState.sendNotification(notification) }
}
LaunchedEffect(Unit) {
@ -223,25 +191,13 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
onAction = { isAppVisible = true },
menu = {
Item("Show Meshtastic", onClick = { isAppVisible = true })
Item(
"Test Notification",
onClick = {
trayState.sendNotification(
Notification(
"Meshtastic",
"This is a test notification from the System Tray",
Notification.Type.Info,
),
)
},
)
Item("Quit", onClick = ::exitApplication)
},
)
if (isWindowReady && isAppVisible) {
val backStack =
rememberNavBackStack(MeshtasticNavSavedStateConfig, TopLevelDestination.Connections.route as NavKey)
val multiBackstack = rememberMultiBackstack(TopLevelDestination.Connections.route)
val backStack = multiBackstack.activeBackStack
Window(
onCloseRequest = { isAppVisible = false },
@ -251,46 +207,34 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
onPreviewKeyEvent = { event ->
if (event.type != KeyEventType.KeyDown || !event.isMetaPressed) return@Window false
when {
// ⌘Q → Quit
event.key == Key.Q -> {
exitApplication()
true
}
// ⌘, → Settings
event.key == Key.Comma -> {
if (
TopLevelDestination.Settings != TopLevelDestination.fromNavKey(backStack.lastOrNull())
) {
backStack.navigateTopLevel(TopLevelDestination.Settings.route)
multiBackstack.navigateTopLevel(TopLevelDestination.Settings.route)
}
true
}
// ⌘⇧T → Toggle theme
event.key == Key.T && event.isShiftPressed -> {
uiPrefs.setTheme(if (isDarkTheme) 1 else 2)
true
}
// ⌘1 → Conversations
event.key == Key.One -> {
backStack.navigateTopLevel(TopLevelDestination.Conversations.route)
multiBackstack.navigateTopLevel(TopLevelDestination.Conversations.route)
true
}
// ⌘2 → Nodes
event.key == Key.Two -> {
backStack.navigateTopLevel(TopLevelDestination.Nodes.route)
multiBackstack.navigateTopLevel(TopLevelDestination.Nodes.route)
true
}
// ⌘3 → Map
event.key == Key.Three -> {
backStack.navigateTopLevel(TopLevelDestination.Map.route)
multiBackstack.navigateTopLevel(TopLevelDestination.Map.route)
true
}
// ⌘4 → Connections
event.key == Key.Four -> {
backStack.navigateTopLevel(TopLevelDestination.Connections.route)
multiBackstack.navigateTopLevel(TopLevelDestination.Connections.route)
true
}
// ⌘/ → About
event.key == Key.Slash -> {
backStack.add(SettingsRoutes.About)
true
@ -299,14 +243,12 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
}
},
) {
// Configure Coil ImageLoader for desktop with SVG decoding and network fetching.
// This is the desktop equivalent of the Android app's NetworkModule.provideImageLoader().
setSingletonImageLoaderFactory { context ->
val cacheDir = System.getProperty("user.home") + "/.meshtastic/image_cache"
val cacheDir = System.getProperty("user.home") + "/.meshtastic/image_cache_v3"
ImageLoader.Builder(context)
.components {
add(KtorNetworkFetcherFactory())
add(SvgDecoder.Factory())
add(SvgDecoder.Factory(renderToBitmap = false))
}
.memoryCache { MemoryCache.Builder().maxSizeBytes(MEMORY_CACHE_MAX_BYTES).build() }
.diskCache {
@ -316,12 +258,8 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
.build()
}
// Providing localePref via a staticCompositionLocalOf forces the entire subtree to
// recompose when the locale changes — CMP Resources' rememberResourceEnvironment then
// 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) { DesktopMainScreen(backStack) }
AppTheme(darkTheme = isDarkTheme) { DesktopMainScreen(uiViewModel, multiBackstack) }
}
}
}

View file

@ -18,45 +18,38 @@ package org.meshtastic.desktop.ui
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.ui.NavDisplay
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.navigation.MultiBackstack
import org.meshtastic.core.ui.component.MeshtasticAppShell
import org.meshtastic.core.ui.component.MeshtasticNavDisplay
import org.meshtastic.core.ui.component.MeshtasticNavigationSuite
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.desktop.navigation.desktopNavGraph
/**
* Desktop main screen Navigation 3 shell with a persistent [NavigationRail] and [NavDisplay].
*
* Uses the same shared routes from `core:navigation` and the same `NavDisplay` + `entryProvider` pattern as the Android
* app, proving the shared backstack architecture works across targets.
*/
/** Desktop main screen — uses shared navigation components. */
@Composable
@Suppress("LongMethod")
fun DesktopMainScreen(backStack: NavBackStack<NavKey>, uiViewModel: UIViewModel = koinViewModel()) {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
fun DesktopMainScreen(uiViewModel: UIViewModel, multiBackstack: MultiBackstack) {
val backStack = multiBackstack.activeBackStack
Surface(modifier = Modifier.fillMaxSize()) {
MeshtasticAppShell(
backStack = backStack,
multiBackstack = multiBackstack,
uiViewModel = uiViewModel,
hostModifier = Modifier.padding(bottom = 24.dp),
) {
org.meshtastic.core.ui.component.MeshtasticNavigationSuite(
backStack = backStack,
MeshtasticNavigationSuite(
multiBackstack = multiBackstack,
uiViewModel = uiViewModel,
modifier = Modifier.fillMaxSize(),
) {
val provider = entryProvider<NavKey> { desktopNavGraph(backStack, uiViewModel) }
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
MeshtasticNavDisplay(
multiBackstack = multiBackstack,
entryProvider = provider,
modifier = Modifier.fillMaxSize(),
)