mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
Refactor nav3 architecture and enhance adaptive layouts (#4944)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
3feec759a1
commit
f2d09ff79d
29 changed files with 740 additions and 617 deletions
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue