mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-06-02 06:24:16 +02: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:
@@ -42,7 +42,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
|
|||||||
| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`). |
|
| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`). |
|
||||||
| `core:di` | Common DI qualifiers and dispatchers. |
|
| `core:di` | Common DI qualifiers and dispatchers. |
|
||||||
| `core:navigation` | Shared navigation keys/routes for Navigation 3. |
|
| `core:navigation` | Shared navigation keys/routes for Navigation 3. |
|
||||||
| `core:ui` | Shared Compose UI components (`AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `MainAppBar`, dialogs, preferences) and platform abstractions. |
|
| `core:ui` | Shared Compose UI components (`MeshtasticAppShell`, `MeshtasticNavDisplay`, `MeshtasticNavigationSuite`, `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `MainAppBar`, dialogs, preferences) and platform abstractions. |
|
||||||
| `core:service` | KMP service layer; Android bindings stay in `androidMain`. |
|
| `core:service` | KMP service layer; Android bindings stay in `androidMain`. |
|
||||||
| `core:api` | Public AIDL/API integration module for external clients. |
|
| `core:api` | Public AIDL/API integration module for external clients. |
|
||||||
| `core:prefs` | KMP preferences layer built on DataStore abstractions. |
|
| `core:prefs` | KMP preferences layer built on DataStore abstractions. |
|
||||||
@@ -79,7 +79,8 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
|
|||||||
- **KMP file naming:** In KMP modules, `commonMain` and platform source sets (`androidMain`, `jvmMain`) share the same package namespace. If both contain a file with the same name (e.g., `LogExporter.kt`), the Kotlin/JVM compiler will produce a duplicate class error. Use distinct filenames: keep the `expect` declaration in `LogExporter.kt` and put shared helpers in a separate file like `LogFormatter.kt`.
|
- **KMP file naming:** In KMP modules, `commonMain` and platform source sets (`androidMain`, `jvmMain`) share the same package namespace. If both contain a file with the same name (e.g., `LogExporter.kt`), the Kotlin/JVM compiler will produce a duplicate class error. Use distinct filenames: keep the `expect` declaration in `LogExporter.kt` and put shared helpers in a separate file like `LogFormatter.kt`.
|
||||||
- **Concurrency:** Use Kotlin Coroutines and Flow.
|
- **Concurrency:** Use Kotlin Coroutines and Flow.
|
||||||
- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (`koin-plugin` in version catalog). The `koin-annotations` library version is unified with `koin-core` (both use `version.ref = "koin"`). The `KoinConventionPlugin` uses the typed `KoinGradleExtension` to configure the K2 plugin (e.g., `compileSafety.set(false)`). Keep root graph assembly in `app`.
|
- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (`koin-plugin` in version catalog). The `koin-annotations` library version is unified with `koin-core` (both use `version.ref = "koin"`). The `KoinConventionPlugin` uses the typed `KoinGradleExtension` to configure the K2 plugin (e.g., `compileSafety.set(false)`). Keep root graph assembly in `app`.
|
||||||
- **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`.
|
- **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`. Both `app` and `desktop` use `MeshtasticNavDisplay` from `core:ui/commonMain`, which configures `ViewModelStoreNavEntryDecorator` + `SaveableStateHolderNavEntryDecorator`. ViewModels obtained via `koinViewModel()` inside `entry<T>` blocks are scoped to the entry's backstack lifetime and cleared on pop.
|
||||||
|
- **Navigation host:** Use `MeshtasticNavDisplay` from `core:ui/commonMain` instead of calling `NavDisplay` directly. It provides entry-scoped ViewModel decoration, `DialogSceneStrategy` for dialog entries, and a shared 350 ms crossfade transition. Host modules (`app`, `desktop`) should NOT configure `entryDecorators`, `sceneStrategies`, or `transitionSpec` themselves.
|
||||||
- **BLE:** All Bluetooth communication must route through `core:ble` using Kable.
|
- **BLE:** All Bluetooth communication must route through `core:ble` using Kable.
|
||||||
- **Networking:** Pure **Ktor** — no OkHttp anywhere. Engines: `ktor-client-android` for Android, `ktor-client-java` for desktop/JVM. Use Ktor `Logging` plugin for HTTP debug logging (not OkHttp interceptors). `HttpClient` is provided via Koin in `app/di/NetworkModule` and `core:network/di/CoreNetworkAndroidModule`.
|
- **Networking:** Pure **Ktor** — no OkHttp anywhere. Engines: `ktor-client-android` for Android, `ktor-client-java` for desktop/JVM. Use Ktor `Logging` plugin for HTTP debug logging (not OkHttp interceptors). `HttpClient` is provided via Koin in `app/di/NetworkModule` and `core:network/di/CoreNetworkAndroidModule`.
|
||||||
- **Image Loading (Coil):** Use `coil-network-ktor3` with `KtorNetworkFetcherFactory` on **all** platforms. `ImageLoader` is configured in host modules only (`app` via Koin `@Single`, `desktop` via `setSingletonImageLoaderFactory`). Feature modules depend only on `libs.coil` (coil-compose) for `AsyncImage` — never add `coil-network-*` or `coil-svg` to feature modules.
|
- **Image Loading (Coil):** Use `coil-network-ktor3` with `KtorNetworkFetcherFactory` on **all** platforms. `ImageLoader` is configured in host modules only (`app` via Koin `@Single`, `desktop` via `setSingletonImageLoaderFactory`). Feature modules depend only on `libs.coil` (coil-compose) for `AsyncImage` — never add `coil-network-*` or `coil-svg` to feature modules.
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
|
|||||||
| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`, `BleRadioInterface`). |
|
| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`, `BleRadioInterface`). |
|
||||||
| `core:di` | Common DI qualifiers and dispatchers. |
|
| `core:di` | Common DI qualifiers and dispatchers. |
|
||||||
| `core:navigation` | Shared navigation keys/routes for Navigation 3, `DeepLinkRouter` for typed backstack synthesis, and `MeshtasticNavSavedStateConfig` for backstack persistence. |
|
| `core:navigation` | Shared navigation keys/routes for Navigation 3, `DeepLinkRouter` for typed backstack synthesis, and `MeshtasticNavSavedStateConfig` for backstack persistence. |
|
||||||
| `core:ui` | Shared Compose UI components (`MeshtasticAppShell`, `MeshtasticNavigationSuite`, `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `MainAppBar`, dialogs, preferences) and platform abstractions. |
|
| `core:ui` | Shared Compose UI components (`MeshtasticAppShell`, `MeshtasticNavDisplay`, `MeshtasticNavigationSuite`, `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `MainAppBar`, dialogs, preferences) and platform abstractions. |
|
||||||
| `core:service` | KMP service layer; Android bindings stay in `androidMain`. |
|
| `core:service` | KMP service layer; Android bindings stay in `androidMain`. |
|
||||||
| `core:api` | Public AIDL/API integration module for external clients. |
|
| `core:api` | Public AIDL/API integration module for external clients. |
|
||||||
| `core:prefs` | KMP preferences layer built on DataStore abstractions. |
|
| `core:prefs` | KMP preferences layer built on DataStore abstractions. |
|
||||||
@@ -64,7 +64,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
|
|||||||
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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, `extraPane()`, and draggable dividers (`VerticalDragHandle` + `paneExpansionState`) for widths ≥ 1200dp.
|
||||||
- **Platform/Flavor UI:** Inject platform-specific behavior (e.g., map providers) via `CompositionLocal` from `app`.
|
- **Platform/Flavor UI:** Inject platform-specific behavior (e.g., map providers) via `CompositionLocal` from `app`.
|
||||||
|
|
||||||
### B. Logic & Data Layer
|
### B. Logic & Data Layer
|
||||||
@@ -81,7 +81,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
|
|||||||
- **Feature navigation graphs:** Feature modules export Navigation 3 graph functions as extension functions on `EntryProviderScope<NavKey>` in `commonMain` (e.g., `fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>)`). Host shells (`app`, `desktop`) assemble these into a single `entryProvider<NavKey>` block. Do NOT define navigation graphs in platform-specific source sets.
|
- **Feature navigation graphs:** Feature modules export Navigation 3 graph functions as extension functions on `EntryProviderScope<NavKey>` in `commonMain` (e.g., `fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>)`). Host shells (`app`, `desktop`) assemble these into a single `entryProvider<NavKey>` block. Do NOT define navigation graphs in platform-specific source sets.
|
||||||
- **Concurrency:** Use Kotlin Coroutines and Flow.
|
- **Concurrency:** Use Kotlin Coroutines and Flow.
|
||||||
- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (`koin-plugin` in version catalog). The `koin-annotations` library version is unified with `koin-core` (both use `version.ref = "koin"`). The `KoinConventionPlugin` uses the typed `KoinGradleExtension` to configure the K2 plugin (e.g., `compileSafety.set(false)`). Keep root graph assembly in `app`.
|
- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (`koin-plugin` in version catalog). The `koin-annotations` library version is unified with `koin-core` (both use `version.ref = "koin"`). The `KoinConventionPlugin` uses the typed `KoinGradleExtension` to configure the K2 plugin (e.g., `compileSafety.set(false)`). Keep root graph assembly in `app`.
|
||||||
- **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`.
|
- **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`. Both `app` and `desktop` pass `ViewModelStoreNavEntryDecorator` to `NavDisplay`, so ViewModels obtained via `koinViewModel()` inside `entry<T>` blocks are scoped to the entry's backstack lifetime and cleared on pop.
|
||||||
- **BLE:** All Bluetooth communication must route through `core:ble` using Kable.
|
- **BLE:** All Bluetooth communication must route through `core:ble` using Kable.
|
||||||
- **Networking:** Pure **Ktor** — no OkHttp anywhere. Engines: `ktor-client-android` for Android, `ktor-client-java` for desktop/JVM. Use Ktor `Logging` plugin for HTTP debug logging (not OkHttp interceptors). `HttpClient` is provided via Koin in `app/di/NetworkModule` and `core:network/di/CoreNetworkAndroidModule`.
|
- **Networking:** Pure **Ktor** — no OkHttp anywhere. Engines: `ktor-client-android` for Android, `ktor-client-java` for desktop/JVM. Use Ktor `Logging` plugin for HTTP debug logging (not OkHttp interceptors). `HttpClient` is provided via Koin in `app/di/NetworkModule` and `core:network/di/CoreNetworkAndroidModule`.
|
||||||
- **Image Loading (Coil):** Use `coil-network-ktor3` with `KtorNetworkFetcherFactory` on **all** platforms. `ImageLoader` is configured in host modules only (`app` via Koin `@Single`, `desktop` via `setSingletonImageLoaderFactory`). Feature modules depend only on `libs.coil` (coil-compose) for `AsyncImage` — never add `coil-network-*` or `coil-svg` to feature modules.
|
- **Image Loading (Coil):** Use `coil-network-ktor3` with `KtorNetworkFetcherFactory` on **all** platforms. `ImageLoader` is configured in host modules only (`app` via Koin `@Single`, `desktop` via `setSingletonImageLoaderFactory`). Feature modules depend only on `libs.coil` (coil-compose) for `AsyncImage` — never add `coil-network-*` or `coil-svg` to feature modules.
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
|
|||||||
| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`, `BleRadioInterface`). |
|
| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`, `BleRadioInterface`). |
|
||||||
| `core:di` | Common DI qualifiers and dispatchers. |
|
| `core:di` | Common DI qualifiers and dispatchers. |
|
||||||
| `core:navigation` | Shared navigation keys/routes for Navigation 3. |
|
| `core:navigation` | Shared navigation keys/routes for Navigation 3. |
|
||||||
| `core:ui` | Shared Compose UI components (`AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `MainAppBar`, dialogs, preferences) and platform abstractions. |
|
| `core:ui` | Shared Compose UI components (`MeshtasticAppShell`, `MeshtasticNavDisplay`, `MeshtasticNavigationSuite`, `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `MainAppBar`, dialogs, preferences) and platform abstractions. |
|
||||||
| `core:service` | KMP service layer; Android bindings stay in `androidMain`. |
|
| `core:service` | KMP service layer; Android bindings stay in `androidMain`. |
|
||||||
| `core:api` | Public AIDL/API integration module for external clients. |
|
| `core:api` | Public AIDL/API integration module for external clients. |
|
||||||
| `core:prefs` | KMP preferences layer built on DataStore abstractions. |
|
| `core:prefs` | KMP preferences layer built on DataStore abstractions. |
|
||||||
@@ -79,7 +79,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
|
|||||||
- **KMP file naming:** In KMP modules, `commonMain` and platform source sets (`androidMain`, `jvmMain`) share the same package namespace. If both contain a file with the same name (e.g., `LogExporter.kt`), the Kotlin/JVM compiler will produce a duplicate class error. Use distinct filenames: keep the `expect` declaration in `LogExporter.kt` and put shared helpers in a separate file like `LogFormatter.kt`.
|
- **KMP file naming:** In KMP modules, `commonMain` and platform source sets (`androidMain`, `jvmMain`) share the same package namespace. If both contain a file with the same name (e.g., `LogExporter.kt`), the Kotlin/JVM compiler will produce a duplicate class error. Use distinct filenames: keep the `expect` declaration in `LogExporter.kt` and put shared helpers in a separate file like `LogFormatter.kt`.
|
||||||
- **Concurrency:** Use Kotlin Coroutines and Flow.
|
- **Concurrency:** Use Kotlin Coroutines and Flow.
|
||||||
- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (`koin-plugin` in version catalog). The `koin-annotations` library version is unified with `koin-core` (both use `version.ref = "koin"`). The `KoinConventionPlugin` uses the typed `KoinGradleExtension` to configure the K2 plugin (e.g., `compileSafety.set(false)`). Keep root graph assembly in `app`.
|
- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (`koin-plugin` in version catalog). The `koin-annotations` library version is unified with `koin-core` (both use `version.ref = "koin"`). The `KoinConventionPlugin` uses the typed `KoinGradleExtension` to configure the K2 plugin (e.g., `compileSafety.set(false)`). Keep root graph assembly in `app`.
|
||||||
- **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`.
|
- **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`. Both `app` and `desktop` pass `ViewModelStoreNavEntryDecorator` to `NavDisplay`, so ViewModels obtained via `koinViewModel()` inside `entry<T>` blocks are scoped to the entry's backstack lifetime and cleared on pop.
|
||||||
- **BLE:** All Bluetooth communication must route through `core:ble` using Kable.
|
- **BLE:** All Bluetooth communication must route through `core:ble` using Kable.
|
||||||
- **Dependencies:** Check `gradle/libs.versions.toml` before assuming a library is available.
|
- **Dependencies:** Check `gradle/libs.versions.toml` before assuming a library is available.
|
||||||
- **JetBrains fork aliases:** Version catalog aliases for JetBrains-forked AndroidX artifacts use the `jetbrains-*` prefix (e.g., `jetbrains-lifecycle-runtime-compose`, `jetbrains-navigation3-ui`). Plain `androidx-*` aliases are true Google AndroidX artifacts. Never mix them up in `commonMain`.
|
- **JetBrains fork aliases:** Version catalog aliases for JetBrains-forked AndroidX artifacts use the `jetbrains-*` prefix (e.g., `jetbrains-lifecycle-runtime-compose`, `jetbrains-navigation3-ui`). Plain `androidx-*` aliases are true Google AndroidX artifacts. Never mix them up in `commonMain`.
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ dependencies {
|
|||||||
implementation(projects.core.domain)
|
implementation(projects.core.domain)
|
||||||
implementation(projects.core.model)
|
implementation(projects.core.model)
|
||||||
implementation(projects.core.navigation)
|
implementation(projects.core.navigation)
|
||||||
|
implementation(libs.jetbrains.lifecycle.viewmodel.navigation3)
|
||||||
implementation(projects.core.network)
|
implementation(projects.core.network)
|
||||||
implementation(projects.core.nfc)
|
implementation(projects.core.nfc)
|
||||||
implementation(projects.core.prefs)
|
implementation(projects.core.prefs)
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
ReportDrawnWhen { true }
|
ReportDrawnWhen { true }
|
||||||
|
|
||||||
if (appIntroCompleted) {
|
if (appIntroCompleted) {
|
||||||
MainScreen(uIViewModel = model)
|
MainScreen()
|
||||||
} else {
|
} else {
|
||||||
val introViewModel = koinViewModel<IntroViewModel>()
|
val introViewModel = koinViewModel<IntroViewModel>()
|
||||||
AppIntroductionScreen(onDone = { model.onAppIntroCompleted() }, viewModel = introViewModel)
|
AppIntroductionScreen(onDone = { model.onAppIntroCompleted() }, viewModel = introViewModel)
|
||||||
@@ -174,7 +174,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider provides
|
org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider provides
|
||||||
{ destNum, requestId, logUuid, onNavigateUp ->
|
{ destNum, requestId, logUuid, onNavigateUp ->
|
||||||
val metricsViewModel =
|
val metricsViewModel =
|
||||||
koinViewModel<org.meshtastic.feature.node.metrics.MetricsViewModel>(key = "metrics-$destNum") {
|
koinViewModel<org.meshtastic.feature.node.metrics.MetricsViewModel> {
|
||||||
org.koin.core.parameter.parametersOf(destNum)
|
org.koin.core.parameter.parametersOf(destNum)
|
||||||
}
|
}
|
||||||
metricsViewModel.setNodeId(destNum)
|
metricsViewModel.setNodeId(destNum)
|
||||||
|
|||||||
@@ -19,30 +19,27 @@
|
|||||||
package org.meshtastic.app.ui
|
package org.meshtastic.app.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.recalculateWindowInsets
|
import androidx.compose.foundation.layout.recalculateWindowInsets
|
||||||
import androidx.compose.foundation.layout.safeDrawingPadding
|
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation3.runtime.NavKey
|
import androidx.navigation3.runtime.NavKey
|
||||||
import androidx.navigation3.runtime.entryProvider
|
import androidx.navigation3.runtime.entryProvider
|
||||||
import androidx.navigation3.runtime.rememberNavBackStack
|
|
||||||
import androidx.navigation3.ui.NavDisplay
|
|
||||||
import co.touchlab.kermit.Logger
|
import co.touchlab.kermit.Logger
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import org.meshtastic.app.BuildConfig
|
import org.meshtastic.app.BuildConfig
|
||||||
import org.meshtastic.core.model.ConnectionState
|
import org.meshtastic.core.model.ConnectionState
|
||||||
import org.meshtastic.core.navigation.MeshtasticNavSavedStateConfig
|
|
||||||
import org.meshtastic.core.navigation.NodesRoutes
|
import org.meshtastic.core.navigation.NodesRoutes
|
||||||
|
import org.meshtastic.core.navigation.rememberMultiBackstack
|
||||||
import org.meshtastic.core.resources.Res
|
import org.meshtastic.core.resources.Res
|
||||||
import org.meshtastic.core.resources.app_too_old
|
import org.meshtastic.core.resources.app_too_old
|
||||||
import org.meshtastic.core.resources.must_update
|
import org.meshtastic.core.resources.must_update
|
||||||
import org.meshtastic.core.ui.component.MeshtasticAppShell
|
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.core.ui.viewmodel.UIViewModel
|
||||||
import org.meshtastic.feature.connections.navigation.connectionsGraph
|
import org.meshtastic.feature.connections.navigation.connectionsGraph
|
||||||
import org.meshtastic.feature.firmware.navigation.firmwareGraph
|
import org.meshtastic.feature.firmware.navigation.firmwareGraph
|
||||||
@@ -52,31 +49,27 @@ import org.meshtastic.feature.node.navigation.nodesGraph
|
|||||||
import org.meshtastic.feature.settings.navigation.settingsGraph
|
import org.meshtastic.feature.settings.navigation.settingsGraph
|
||||||
import org.meshtastic.feature.settings.radio.channel.channelsGraph
|
import org.meshtastic.feature.settings.radio.channel.channelsGraph
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainScreen(uIViewModel: UIViewModel = koinViewModel()) {
|
fun MainScreen() {
|
||||||
val backStack = rememberNavBackStack(MeshtasticNavSavedStateConfig, NodesRoutes.NodesGraph as NavKey)
|
val viewModel: UIViewModel = koinViewModel()
|
||||||
|
val multiBackstack = rememberMultiBackstack(NodesRoutes.NodesGraph)
|
||||||
|
val backStack = multiBackstack.activeBackStack
|
||||||
|
|
||||||
AndroidAppVersionCheck(uIViewModel)
|
AndroidAppVersionCheck(viewModel)
|
||||||
|
|
||||||
MeshtasticAppShell(
|
MeshtasticAppShell(multiBackstack = multiBackstack, uiViewModel = viewModel, hostModifier = Modifier) {
|
||||||
backStack = backStack,
|
MeshtasticNavigationSuite(
|
||||||
uiViewModel = uIViewModel,
|
multiBackstack = multiBackstack,
|
||||||
hostModifier = Modifier.safeDrawingPadding().padding(bottom = 16.dp),
|
uiViewModel = viewModel,
|
||||||
) {
|
|
||||||
org.meshtastic.core.ui.component.MeshtasticNavigationSuite(
|
|
||||||
backStack = backStack,
|
|
||||||
uiViewModel = uIViewModel,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
val provider =
|
val provider =
|
||||||
entryProvider<NavKey> {
|
entryProvider<NavKey> {
|
||||||
contactsGraph(backStack, uIViewModel.scrollToTopEventFlow)
|
contactsGraph(backStack, viewModel.scrollToTopEventFlow)
|
||||||
nodesGraph(
|
nodesGraph(
|
||||||
backStack = backStack,
|
backStack = backStack,
|
||||||
scrollToTopEvents = uIViewModel.scrollToTopEventFlow,
|
scrollToTopEvents = viewModel.scrollToTopEventFlow,
|
||||||
onHandleDeepLink = uIViewModel::handleDeepLink,
|
onHandleDeepLink = viewModel::handleDeepLink,
|
||||||
)
|
)
|
||||||
mapGraph(backStack)
|
mapGraph(backStack)
|
||||||
channelsGraph(backStack)
|
channelsGraph(backStack)
|
||||||
@@ -84,8 +77,8 @@ fun MainScreen(uIViewModel: UIViewModel = koinViewModel()) {
|
|||||||
settingsGraph(backStack)
|
settingsGraph(backStack)
|
||||||
firmwareGraph(backStack)
|
firmwareGraph(backStack)
|
||||||
}
|
}
|
||||||
NavDisplay(
|
MeshtasticNavDisplay(
|
||||||
backStack = backStack,
|
multiBackstack = multiBackstack,
|
||||||
entryProvider = provider,
|
entryProvider = provider,
|
||||||
modifier = Modifier.fillMaxSize().recalculateWindowInsets().safeDrawingPadding(),
|
modifier = Modifier.fillMaxSize().recalculateWindowInsets().safeDrawingPadding(),
|
||||||
)
|
)
|
||||||
@@ -99,7 +92,6 @@ private fun AndroidAppVersionCheck(viewModel: UIViewModel) {
|
|||||||
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
|
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
|
||||||
val myNodeInfo by viewModel.myNodeInfo.collectAsStateWithLifecycle()
|
val myNodeInfo by viewModel.myNodeInfo.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// Check if the device is running an old app version
|
|
||||||
LaunchedEffect(connectionState, myNodeInfo) {
|
LaunchedEffect(connectionState, myNodeInfo) {
|
||||||
if (connectionState == ConnectionState.Connected) {
|
if (connectionState == ConnectionState.Connected) {
|
||||||
myNodeInfo?.let { info ->
|
myNodeInfo?.let { info ->
|
||||||
|
|||||||
+89
@@ -0,0 +1,89 @@
|
|||||||
|
/*
|
||||||
|
* 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.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.navigation3.runtime.NavBackStack
|
||||||
|
import androidx.navigation3.runtime.NavKey
|
||||||
|
import androidx.navigation3.runtime.rememberNavBackStack
|
||||||
|
|
||||||
|
/** Manages independent backstacks for multiple tabs. */
|
||||||
|
class MultiBackstack(val startTab: NavKey) {
|
||||||
|
var backStacks: Map<NavKey, NavBackStack<NavKey>> = emptyMap()
|
||||||
|
|
||||||
|
var currentTabRoute: NavKey by mutableStateOf(TopLevelDestination.fromNavKey(startTab)?.route ?: startTab)
|
||||||
|
private set
|
||||||
|
|
||||||
|
val activeBackStack: NavBackStack<NavKey>
|
||||||
|
get() = backStacks[currentTabRoute] ?: error("Stack for $currentTabRoute not found")
|
||||||
|
|
||||||
|
/** Switches to a new top-level tab route. */
|
||||||
|
fun navigateTopLevel(route: NavKey) {
|
||||||
|
val rootKey = TopLevelDestination.fromNavKey(route)?.route ?: route
|
||||||
|
|
||||||
|
if (currentTabRoute == rootKey) {
|
||||||
|
// Repressing the same tab resets its stack to just the root
|
||||||
|
activeBackStack.replaceAll(listOf(rootKey))
|
||||||
|
} else {
|
||||||
|
// Switching to a different tab
|
||||||
|
currentTabRoute = rootKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handles back navigation according to the "exit through home" pattern. */
|
||||||
|
fun goBack() {
|
||||||
|
val currentStack = activeBackStack
|
||||||
|
if (currentStack.size > 1) {
|
||||||
|
currentStack.removeLastOrNull()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're at the root of a non-start tab, switch back to the start tab
|
||||||
|
if (currentTabRoute != startTab) {
|
||||||
|
currentTabRoute = startTab
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the active tab and replaces its stack with the provided route path. */
|
||||||
|
fun handleDeepLink(navKeys: List<NavKey>) {
|
||||||
|
val rootKey = navKeys.firstOrNull() ?: return
|
||||||
|
val topLevel = TopLevelDestination.fromNavKey(rootKey)?.route ?: rootKey
|
||||||
|
currentTabRoute = topLevel
|
||||||
|
val stack = backStacks[topLevel] ?: return
|
||||||
|
stack.replaceAll(navKeys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remembers a [MultiBackstack] for managing independent tab navigation histories with Navigation 3. */
|
||||||
|
@Composable
|
||||||
|
fun rememberMultiBackstack(initialTab: NavKey = TopLevelDestination.Connections.route): MultiBackstack {
|
||||||
|
val stacks = mutableMapOf<NavKey, NavBackStack<NavKey>>()
|
||||||
|
|
||||||
|
TopLevelDestination.entries.forEach { dest ->
|
||||||
|
key(dest.route) { stacks[dest.route] = rememberNavBackStack(MeshtasticNavSavedStateConfig, dest.route) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val multiBackstack = remember { MultiBackstack(initialTab) }
|
||||||
|
multiBackstack.backStacks = stacks
|
||||||
|
|
||||||
|
return multiBackstack
|
||||||
|
}
|
||||||
+28
-6
@@ -19,16 +19,38 @@ package org.meshtastic.core.navigation
|
|||||||
import androidx.navigation3.runtime.NavKey
|
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
|
* Replaces the last entry in the back stack with the given route. If the back stack is empty, it simply adds the route.
|
||||||
* root destination.
|
|
||||||
*/
|
*/
|
||||||
fun MutableList<NavKey>.navigateTopLevel(route: NavKey) {
|
fun MutableList<NavKey>.replaceLast(route: NavKey) {
|
||||||
if (isNotEmpty()) {
|
if (isNotEmpty()) {
|
||||||
this[0] = route
|
if (this[lastIndex] != route) {
|
||||||
while (size > 1) {
|
this[lastIndex] = route
|
||||||
removeAt(lastIndex)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
add(route)
|
add(route)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the entire back stack with the given routes in a way that minimizes structural changes and prevents the back
|
||||||
|
* stack from temporarily becoming empty.
|
||||||
|
*/
|
||||||
|
fun MutableList<NavKey>.replaceAll(routes: List<NavKey>) {
|
||||||
|
if (routes.isEmpty()) {
|
||||||
|
clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (i in routes.indices) {
|
||||||
|
if (i < size) {
|
||||||
|
// Only mutate if the route actually changed, protecting Nav3's internal state matching.
|
||||||
|
if (this[i] != routes[i]) {
|
||||||
|
this[i] = routes[i]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
add(routes[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (size > routes.size) {
|
||||||
|
removeAt(lastIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
/*
|
||||||
|
* 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.NavBackStack
|
||||||
|
import androidx.navigation3.runtime.NavKey
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class MultiBackstackTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `navigateTopLevel to different tab preserves previous tab stack and activates new tab stack`() {
|
||||||
|
val startTab = TopLevelDestination.Nodes.route
|
||||||
|
val multiBackstack = MultiBackstack(startTab)
|
||||||
|
|
||||||
|
val nodesStack =
|
||||||
|
NavBackStack<NavKey>().apply { addAll(listOf(TopLevelDestination.Nodes.route, NodesRoutes.Nodes)) }
|
||||||
|
val mapStack = NavBackStack<NavKey>().apply { addAll(listOf(TopLevelDestination.Map.route)) }
|
||||||
|
|
||||||
|
multiBackstack.backStacks =
|
||||||
|
mapOf(TopLevelDestination.Nodes.route to nodesStack, TopLevelDestination.Map.route to mapStack)
|
||||||
|
|
||||||
|
assertEquals(TopLevelDestination.Nodes.route, multiBackstack.currentTabRoute)
|
||||||
|
assertEquals(2, multiBackstack.activeBackStack.size)
|
||||||
|
|
||||||
|
multiBackstack.navigateTopLevel(TopLevelDestination.Map.route)
|
||||||
|
|
||||||
|
assertEquals(TopLevelDestination.Map.route, multiBackstack.currentTabRoute)
|
||||||
|
assertEquals(1, multiBackstack.activeBackStack.size)
|
||||||
|
assertEquals(2, nodesStack.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `navigateTopLevel to same tab resets stack to root`() {
|
||||||
|
val startTab = TopLevelDestination.Nodes.route
|
||||||
|
val multiBackstack = MultiBackstack(startTab)
|
||||||
|
|
||||||
|
val nodesStack =
|
||||||
|
NavBackStack<NavKey>().apply { addAll(listOf(TopLevelDestination.Nodes.route, NodesRoutes.Nodes)) }
|
||||||
|
multiBackstack.backStacks = mapOf(TopLevelDestination.Nodes.route to nodesStack)
|
||||||
|
|
||||||
|
assertEquals(2, multiBackstack.activeBackStack.size)
|
||||||
|
|
||||||
|
multiBackstack.navigateTopLevel(TopLevelDestination.Nodes.route)
|
||||||
|
|
||||||
|
assertEquals(1, multiBackstack.activeBackStack.size)
|
||||||
|
assertEquals(TopLevelDestination.Nodes.route, multiBackstack.activeBackStack.first())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `goBack pops current stack if size is greater than 1`() {
|
||||||
|
val startTab = TopLevelDestination.Nodes.route
|
||||||
|
val multiBackstack = MultiBackstack(startTab)
|
||||||
|
|
||||||
|
val nodesStack =
|
||||||
|
NavBackStack<NavKey>().apply { addAll(listOf(TopLevelDestination.Nodes.route, NodesRoutes.Nodes)) }
|
||||||
|
multiBackstack.backStacks = mapOf(TopLevelDestination.Nodes.route to nodesStack)
|
||||||
|
|
||||||
|
multiBackstack.goBack()
|
||||||
|
|
||||||
|
assertEquals(1, multiBackstack.activeBackStack.size)
|
||||||
|
assertEquals(TopLevelDestination.Nodes.route, multiBackstack.activeBackStack.first())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `goBack on root of non-start tab returns to start tab`() {
|
||||||
|
val startTab = TopLevelDestination.Connections.route
|
||||||
|
val multiBackstack = MultiBackstack(startTab)
|
||||||
|
|
||||||
|
val mapStack = NavBackStack<NavKey>().apply { addAll(listOf(TopLevelDestination.Map.route)) }
|
||||||
|
val connectionsStack = NavBackStack<NavKey>().apply { addAll(listOf(TopLevelDestination.Connections.route)) }
|
||||||
|
|
||||||
|
multiBackstack.backStacks =
|
||||||
|
mapOf(TopLevelDestination.Map.route to mapStack, TopLevelDestination.Connections.route to connectionsStack)
|
||||||
|
|
||||||
|
multiBackstack.navigateTopLevel(TopLevelDestination.Map.route)
|
||||||
|
assertEquals(TopLevelDestination.Map.route, multiBackstack.currentTabRoute)
|
||||||
|
|
||||||
|
multiBackstack.goBack()
|
||||||
|
|
||||||
|
assertEquals(TopLevelDestination.Connections.route, multiBackstack.currentTabRoute)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `handleDeepLink sets target tab and populates stack`() {
|
||||||
|
val startTab = TopLevelDestination.Nodes.route
|
||||||
|
val multiBackstack = MultiBackstack(startTab)
|
||||||
|
|
||||||
|
val settingsStack = NavBackStack<NavKey>().apply { addAll(listOf(TopLevelDestination.Settings.route)) }
|
||||||
|
multiBackstack.backStacks = mapOf(TopLevelDestination.Settings.route to settingsStack)
|
||||||
|
|
||||||
|
val deepLinkPath = listOf(TopLevelDestination.Settings.route, SettingsRoutes.About)
|
||||||
|
multiBackstack.handleDeepLink(deepLinkPath)
|
||||||
|
|
||||||
|
assertEquals(TopLevelDestination.Settings.route, multiBackstack.currentTabRoute)
|
||||||
|
assertEquals(2, multiBackstack.activeBackStack.size)
|
||||||
|
assertEquals(SettingsRoutes.About, multiBackstack.activeBackStack.last())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,8 +54,12 @@ kotlin {
|
|||||||
implementation(libs.jetbrains.compose.material3.adaptive)
|
implementation(libs.jetbrains.compose.material3.adaptive)
|
||||||
implementation(libs.jetbrains.compose.material3.adaptive.layout)
|
implementation(libs.jetbrains.compose.material3.adaptive.layout)
|
||||||
implementation(libs.jetbrains.compose.material3.adaptive.navigation)
|
implementation(libs.jetbrains.compose.material3.adaptive.navigation)
|
||||||
|
implementation(libs.jetbrains.compose.material3.adaptive.navigation.suite)
|
||||||
implementation(libs.jetbrains.navigationevent.compose)
|
implementation(libs.jetbrains.navigationevent.compose)
|
||||||
implementation(libs.jetbrains.navigation3.ui)
|
implementation(libs.jetbrains.navigation3.ui)
|
||||||
|
implementation(libs.jetbrains.compose.material3.adaptive.navigation3)
|
||||||
|
implementation(libs.jetbrains.lifecycle.viewmodel.navigation3)
|
||||||
|
implementation(libs.jetbrains.lifecycle.viewmodel.compose)
|
||||||
}
|
}
|
||||||
|
|
||||||
val jvmAndroidMain by getting { dependencies { implementation(libs.compose.multiplatform.ui.tooling) } }
|
val jvmAndroidMain by getting { dependencies { implementation(libs.compose.multiplatform.ui.tooling) } }
|
||||||
|
|||||||
-113
@@ -1,113 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
|
||||||
import androidx.compose.material3.adaptive.layout.AnimatedPane
|
|
||||||
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
|
|
||||||
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
|
|
||||||
import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior
|
|
||||||
import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.key
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
|
||||||
import androidx.navigationevent.NavigationEventInfo
|
|
||||||
import androidx.navigationevent.compose.NavigationBackHandler
|
|
||||||
import androidx.navigationevent.compose.rememberNavigationEventState
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
|
||||||
@Composable
|
|
||||||
fun <T> AdaptiveListDetailScaffold(
|
|
||||||
navigator: ThreePaneScaffoldNavigator<T>,
|
|
||||||
scrollToTopEvents: Flow<ScrollToTopEvent>,
|
|
||||||
onBackToGraph: () -> Unit,
|
|
||||||
onTabPressedEvent: (ScrollToTopEvent) -> Boolean,
|
|
||||||
initialKey: T? = null,
|
|
||||||
listPane: @Composable (isActive: Boolean, contentKey: T?) -> Unit,
|
|
||||||
detailPane: @Composable (contentKey: T, handleBack: () -> Unit) -> Unit,
|
|
||||||
emptyDetailPane: @Composable () -> Unit,
|
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val backNavigationBehavior = BackNavigationBehavior.PopUntilScaffoldValueChange
|
|
||||||
|
|
||||||
val handleBack: () -> Unit = {
|
|
||||||
if (navigator.canNavigateBack(backNavigationBehavior)) {
|
|
||||||
scope.launch { navigator.navigateBack(backNavigationBehavior) }
|
|
||||||
} else {
|
|
||||||
onBackToGraph()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val navState = rememberNavigationEventState(NavigationEventInfo.None)
|
|
||||||
NavigationBackHandler(
|
|
||||||
state = navState,
|
|
||||||
isBackEnabled = navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail,
|
|
||||||
onBackCancelled = {},
|
|
||||||
onBackCompleted = { handleBack() },
|
|
||||||
)
|
|
||||||
|
|
||||||
LaunchedEffect(initialKey) {
|
|
||||||
if (initialKey != null) {
|
|
||||||
navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, initialKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(scrollToTopEvents) {
|
|
||||||
scrollToTopEvents.collect { event ->
|
|
||||||
if (onTabPressedEvent(event) && navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail) {
|
|
||||||
if (navigator.canNavigateBack(backNavigationBehavior)) {
|
|
||||||
navigator.navigateBack(backNavigationBehavior)
|
|
||||||
} else {
|
|
||||||
navigator.navigateTo(ListDetailPaneScaffoldRole.List)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ListDetailPaneScaffold(
|
|
||||||
directive = navigator.scaffoldDirective,
|
|
||||||
value = navigator.scaffoldValue,
|
|
||||||
listPane = {
|
|
||||||
AnimatedPane {
|
|
||||||
val focusManager = LocalFocusManager.current
|
|
||||||
// Prevent TextFields from auto-focusing when pane animates in
|
|
||||||
LaunchedEffect(Unit) { focusManager.clearFocus() }
|
|
||||||
|
|
||||||
listPane(
|
|
||||||
navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.List,
|
|
||||||
navigator.currentDestination?.contentKey,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
detailPane = {
|
|
||||||
AnimatedPane {
|
|
||||||
val focusManager = LocalFocusManager.current
|
|
||||||
|
|
||||||
navigator.currentDestination?.contentKey?.let { contentKey ->
|
|
||||||
key(contentKey) {
|
|
||||||
LaunchedEffect(contentKey) { focusManager.clearFocus() }
|
|
||||||
detailPane(contentKey, handleBack)
|
|
||||||
}
|
|
||||||
} ?: emptyDetailPane()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+7
-11
@@ -16,13 +16,10 @@
|
|||||||
*/
|
*/
|
||||||
package org.meshtastic.core.ui.component
|
package org.meshtastic.core.ui.component
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import org.meshtastic.core.navigation.MultiBackstack
|
||||||
import androidx.navigation3.runtime.NavBackStack
|
|
||||||
import androidx.navigation3.runtime.NavKey
|
|
||||||
import org.meshtastic.core.navigation.NodeDetailRoutes
|
import org.meshtastic.core.navigation.NodeDetailRoutes
|
||||||
import org.meshtastic.core.ui.viewmodel.UIViewModel
|
import org.meshtastic.core.ui.viewmodel.UIViewModel
|
||||||
|
|
||||||
@@ -34,22 +31,21 @@ import org.meshtastic.core.ui.viewmodel.UIViewModel
|
|||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun MeshtasticAppShell(
|
fun MeshtasticAppShell(
|
||||||
backStack: NavBackStack<NavKey>,
|
multiBackstack: MultiBackstack,
|
||||||
uiViewModel: UIViewModel,
|
uiViewModel: UIViewModel,
|
||||||
hostModifier: Modifier = Modifier.padding(bottom = 16.dp),
|
hostModifier: Modifier = Modifier,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(uiViewModel) {
|
LaunchedEffect(uiViewModel) {
|
||||||
uiViewModel.navigationDeepLink.collect { navKeys ->
|
uiViewModel.navigationDeepLink.collect { navKeys -> multiBackstack.handleDeepLink(navKeys) }
|
||||||
backStack.clear()
|
|
||||||
backStack.addAll(navKeys)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
MeshtasticCommonAppSetup(
|
MeshtasticCommonAppSetup(
|
||||||
uiViewModel = uiViewModel,
|
uiViewModel = uiViewModel,
|
||||||
onNavigateToTracerouteMap = { destNum, requestId, logUuid ->
|
onNavigateToTracerouteMap = { destNum, requestId, logUuid ->
|
||||||
backStack.add(NodeDetailRoutes.TracerouteMap(destNum = destNum, requestId = requestId, logUuid = logUuid))
|
multiBackstack.activeBackStack.add(
|
||||||
|
NodeDetailRoutes.TracerouteMap(destNum = destNum, requestId = requestId, logUuid = logUuid),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+148
@@ -0,0 +1,148 @@
|
|||||||
|
/*
|
||||||
|
* 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.animation.AnimatedContentTransitionScope
|
||||||
|
import androidx.compose.animation.ContentTransform
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.material3.VerticalDragHandle
|
||||||
|
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||||
|
import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState
|
||||||
|
import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneStrategy
|
||||||
|
import androidx.compose.material3.adaptive.navigation3.rememberSupportingPaneSceneStrategy
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||||
|
import androidx.navigation3.runtime.NavBackStack
|
||||||
|
import androidx.navigation3.runtime.NavEntry
|
||||||
|
import androidx.navigation3.runtime.NavKey
|
||||||
|
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||||
|
import androidx.navigation3.scene.DialogSceneStrategy
|
||||||
|
import androidx.navigation3.scene.Scene
|
||||||
|
import androidx.navigation3.scene.SinglePaneSceneStrategy
|
||||||
|
import androidx.navigation3.ui.NavDisplay
|
||||||
|
import org.meshtastic.core.navigation.MultiBackstack
|
||||||
|
|
||||||
|
/** Duration in milliseconds for the shared crossfade transition between navigation scenes. */
|
||||||
|
private const val TRANSITION_DURATION_MS = 350
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared [NavDisplay] wrapper that configures the standard Meshtastic entry decorators, scene strategies, and
|
||||||
|
* transition animations for all platform hosts.
|
||||||
|
*
|
||||||
|
* This version supports multiple backstacks by accepting a [MultiBackstack] state holder.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MeshtasticNavDisplay(
|
||||||
|
multiBackstack: MultiBackstack,
|
||||||
|
entryProvider: (key: NavKey) -> NavEntry<NavKey>,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val backStack = multiBackstack.activeBackStack
|
||||||
|
MeshtasticNavDisplay(
|
||||||
|
backStack = backStack,
|
||||||
|
onBack = { multiBackstack.goBack() },
|
||||||
|
entryProvider = entryProvider,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared [NavDisplay] wrapper for a single backstack. */
|
||||||
|
@Suppress("LongMethod")
|
||||||
|
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun MeshtasticNavDisplay(
|
||||||
|
backStack: NavBackStack<NavKey>,
|
||||||
|
onBack: (() -> Unit)? = null,
|
||||||
|
entryProvider: (key: NavKey) -> NavEntry<NavKey>,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val listDetailSceneStrategy =
|
||||||
|
rememberListDetailSceneStrategy<NavKey>(
|
||||||
|
paneExpansionState = rememberPaneExpansionState(),
|
||||||
|
paneExpansionDragHandle = { state ->
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
VerticalDragHandle(
|
||||||
|
modifier =
|
||||||
|
Modifier.paneExpansionDraggable(
|
||||||
|
state = state,
|
||||||
|
minTouchTargetSize = 48.dp,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
),
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val supportingPaneSceneStrategy =
|
||||||
|
rememberSupportingPaneSceneStrategy<NavKey>(
|
||||||
|
paneExpansionState = rememberPaneExpansionState(),
|
||||||
|
paneExpansionDragHandle = { state ->
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
VerticalDragHandle(
|
||||||
|
modifier =
|
||||||
|
Modifier.paneExpansionDraggable(
|
||||||
|
state = state,
|
||||||
|
minTouchTargetSize = 48.dp,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
),
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
val saveableDecorator = rememberSaveableStateHolderNavEntryDecorator<NavKey>()
|
||||||
|
val vmStoreDecorator = rememberViewModelStoreNavEntryDecorator<NavKey>()
|
||||||
|
|
||||||
|
val activeDecorators =
|
||||||
|
remember(backStack, saveableDecorator, vmStoreDecorator) { listOf(saveableDecorator, vmStoreDecorator) }
|
||||||
|
|
||||||
|
NavDisplay(
|
||||||
|
backStack = backStack,
|
||||||
|
entryProvider = entryProvider,
|
||||||
|
entryDecorators = activeDecorators,
|
||||||
|
onBack =
|
||||||
|
onBack
|
||||||
|
?: {
|
||||||
|
if (backStack.size > 1) {
|
||||||
|
backStack.removeLastOrNull()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sceneStrategies =
|
||||||
|
listOf(
|
||||||
|
DialogSceneStrategy(),
|
||||||
|
listDetailSceneStrategy,
|
||||||
|
supportingPaneSceneStrategy,
|
||||||
|
SinglePaneSceneStrategy(),
|
||||||
|
),
|
||||||
|
transitionSpec = meshtasticTransitionSpec(),
|
||||||
|
popTransitionSpec = meshtasticTransitionSpec(),
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared crossfade [ContentTransform] used for both forward and pop navigation. */
|
||||||
|
private fun meshtasticTransitionSpec(): AnimatedContentTransitionScope<Scene<NavKey>>.() -> ContentTransform = {
|
||||||
|
ContentTransform(
|
||||||
|
fadeIn(animationSpec = tween(TRANSITION_DURATION_MS)),
|
||||||
|
fadeOut(animationSpec = tween(TRANSITION_DURATION_MS)),
|
||||||
|
)
|
||||||
|
}
|
||||||
+58
-114
@@ -22,27 +22,22 @@ import androidx.compose.animation.fadeIn
|
|||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.scaleIn
|
import androidx.compose.animation.scaleIn
|
||||||
import androidx.compose.animation.scaleOut
|
import androidx.compose.animation.scaleOut
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.Badge
|
import androidx.compose.material3.Badge
|
||||||
import androidx.compose.material3.BadgedBox
|
import androidx.compose.material3.BadgedBox
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.LocalContentColor
|
import androidx.compose.material3.LocalContentColor
|
||||||
import androidx.compose.material3.MaterialTheme.colorScheme
|
import androidx.compose.material3.MaterialTheme.colorScheme
|
||||||
import androidx.compose.material3.NavigationBar
|
|
||||||
import androidx.compose.material3.NavigationBarItem
|
|
||||||
import androidx.compose.material3.NavigationRail
|
|
||||||
import androidx.compose.material3.NavigationRailItem
|
|
||||||
import androidx.compose.material3.PlainTooltip
|
import androidx.compose.material3.PlainTooltip
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TooltipAnchorPosition
|
import androidx.compose.material3.TooltipAnchorPosition
|
||||||
import androidx.compose.material3.TooltipBox
|
import androidx.compose.material3.TooltipBox
|
||||||
import androidx.compose.material3.TooltipDefaults
|
import androidx.compose.material3.TooltipDefaults
|
||||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||||
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
||||||
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldDefaults
|
||||||
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
|
||||||
import androidx.compose.material3.rememberTooltipState
|
import androidx.compose.material3.rememberTooltipState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -51,16 +46,13 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation3.runtime.NavBackStack
|
|
||||||
import androidx.navigation3.runtime.NavKey
|
|
||||||
import androidx.window.core.layout.WindowWidthSizeClass
|
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import org.meshtastic.core.model.ConnectionState
|
import org.meshtastic.core.model.ConnectionState
|
||||||
import org.meshtastic.core.model.DeviceType
|
import org.meshtastic.core.model.DeviceType
|
||||||
import org.meshtastic.core.navigation.ContactsRoutes
|
import org.meshtastic.core.navigation.ContactsRoutes
|
||||||
|
import org.meshtastic.core.navigation.MultiBackstack
|
||||||
import org.meshtastic.core.navigation.NodesRoutes
|
import org.meshtastic.core.navigation.NodesRoutes
|
||||||
import org.meshtastic.core.navigation.TopLevelDestination
|
import org.meshtastic.core.navigation.TopLevelDestination
|
||||||
import org.meshtastic.core.navigation.navigateTopLevel
|
|
||||||
import org.meshtastic.core.resources.Res
|
import org.meshtastic.core.resources.Res
|
||||||
import org.meshtastic.core.resources.connected
|
import org.meshtastic.core.resources.connected
|
||||||
import org.meshtastic.core.resources.connecting
|
import org.meshtastic.core.resources.connecting
|
||||||
@@ -70,13 +62,15 @@ import org.meshtastic.core.ui.navigation.icon
|
|||||||
import org.meshtastic.core.ui.viewmodel.UIViewModel
|
import org.meshtastic.core.ui.viewmodel.UIViewModel
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared adaptive navigation shell. Provides a Bottom Navigation bar on phones, and a Navigation Rail on tablets and
|
* Shared adaptive navigation shell using [NavigationSuiteScaffold].
|
||||||
* desktop targets.
|
*
|
||||||
|
* This implementation uses the [MultiBackstack] state holder to manage independent histories for each tab, aligning
|
||||||
|
* with Navigation 3 best practices for state preservation during tab switching.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MeshtasticNavigationSuite(
|
fun MeshtasticNavigationSuite(
|
||||||
backStack: NavBackStack<NavKey>,
|
multiBackstack: MultiBackstack,
|
||||||
uiViewModel: UIViewModel,
|
uiViewModel: UIViewModel,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
@@ -86,60 +80,69 @@ fun MeshtasticNavigationSuite(
|
|||||||
val selectedDevice by uiViewModel.currentDeviceAddressFlow.collectAsStateWithLifecycle()
|
val selectedDevice by uiViewModel.currentDeviceAddressFlow.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val adaptiveInfo = currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)
|
val adaptiveInfo = currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)
|
||||||
val isCompact = adaptiveInfo.windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT
|
|
||||||
val currentKey = backStack.lastOrNull()
|
|
||||||
val rootKey = backStack.firstOrNull()
|
|
||||||
val topLevelDestination = TopLevelDestination.fromNavKey(rootKey)
|
|
||||||
|
|
||||||
val onNavigate = { destination: TopLevelDestination ->
|
val currentTabRoute = multiBackstack.currentTabRoute
|
||||||
handleNavigation(destination, topLevelDestination, currentKey, backStack, uiViewModel)
|
val topLevelDestination = TopLevelDestination.fromNavKey(currentTabRoute)
|
||||||
}
|
|
||||||
|
|
||||||
if (isCompact) {
|
val layoutType = NavigationSuiteScaffoldDefaults.calculateFromAdaptiveInfo(adaptiveInfo).coerceNavigationType()
|
||||||
Scaffold(
|
val showLabels = layoutType == NavigationSuiteType.NavigationRail
|
||||||
modifier = modifier,
|
|
||||||
bottomBar = {
|
NavigationSuiteScaffold(
|
||||||
MeshtasticNavigationBar(
|
modifier = modifier,
|
||||||
topLevelDestination = topLevelDestination,
|
layoutType = layoutType,
|
||||||
connectionState = connectionState,
|
navigationSuiteItems = {
|
||||||
unreadMessageCount = unreadMessageCount,
|
TopLevelDestination.entries.forEach { destination ->
|
||||||
selectedDevice = selectedDevice,
|
val isSelected = destination == topLevelDestination
|
||||||
uiViewModel = uiViewModel,
|
item(
|
||||||
onNavigate = onNavigate,
|
selected = isSelected,
|
||||||
|
onClick = { handleNavigation(destination, topLevelDestination, multiBackstack, uiViewModel) },
|
||||||
|
icon = {
|
||||||
|
NavigationIconContent(
|
||||||
|
destination = destination,
|
||||||
|
isSelected = isSelected,
|
||||||
|
connectionState = connectionState,
|
||||||
|
unreadMessageCount = unreadMessageCount,
|
||||||
|
selectedDevice = selectedDevice,
|
||||||
|
uiViewModel = uiViewModel,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
label =
|
||||||
|
if (showLabels) {
|
||||||
|
{ Text(stringResource(destination.label)) }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
) { padding ->
|
},
|
||||||
Box(modifier = Modifier.fillMaxSize().padding(padding)) { content() }
|
) {
|
||||||
}
|
Row { content() }
|
||||||
} else {
|
|
||||||
Row(modifier = modifier.fillMaxSize()) {
|
|
||||||
MeshtasticNavigationRail(
|
|
||||||
topLevelDestination = topLevelDestination,
|
|
||||||
connectionState = connectionState,
|
|
||||||
unreadMessageCount = unreadMessageCount,
|
|
||||||
selectedDevice = selectedDevice,
|
|
||||||
uiViewModel = uiViewModel,
|
|
||||||
onNavigate = onNavigate,
|
|
||||||
)
|
|
||||||
Box(modifier = Modifier.weight(1f).fillMaxSize()) { content() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Caps [NavigationSuiteType] so that expanded/extra-large widths still use a NavigationRail instead of promoting to a
|
||||||
|
* permanent NavigationDrawer.
|
||||||
|
*/
|
||||||
|
private fun NavigationSuiteType.coerceNavigationType(): NavigationSuiteType = when (this) {
|
||||||
|
NavigationSuiteType.NavigationDrawer -> NavigationSuiteType.NavigationRail
|
||||||
|
else -> this
|
||||||
|
}
|
||||||
|
|
||||||
private fun handleNavigation(
|
private fun handleNavigation(
|
||||||
destination: TopLevelDestination,
|
destination: TopLevelDestination,
|
||||||
topLevelDestination: TopLevelDestination?,
|
topLevelDestination: TopLevelDestination?,
|
||||||
currentKey: NavKey?,
|
multiBackstack: MultiBackstack,
|
||||||
backStack: NavBackStack<NavKey>,
|
|
||||||
uiViewModel: UIViewModel,
|
uiViewModel: UIViewModel,
|
||||||
) {
|
) {
|
||||||
val isRepress = destination == topLevelDestination
|
val isRepress = destination == topLevelDestination
|
||||||
if (isRepress) {
|
if (isRepress) {
|
||||||
|
val currentKey = multiBackstack.activeBackStack.lastOrNull()
|
||||||
when (destination) {
|
when (destination) {
|
||||||
TopLevelDestination.Nodes -> {
|
TopLevelDestination.Nodes -> {
|
||||||
val onNodesList = currentKey is NodesRoutes.NodesGraph || currentKey is NodesRoutes.Nodes
|
val onNodesList = currentKey is NodesRoutes.NodesGraph || currentKey is NodesRoutes.Nodes
|
||||||
if (!onNodesList) {
|
if (!onNodesList) {
|
||||||
backStack.navigateTopLevel(destination.route)
|
multiBackstack.navigateTopLevel(destination.route)
|
||||||
} else {
|
} else {
|
||||||
uiViewModel.emitScrollToTopEvent(ScrollToTopEvent.NodesTabPressed)
|
uiViewModel.emitScrollToTopEvent(ScrollToTopEvent.NodesTabPressed)
|
||||||
}
|
}
|
||||||
@@ -148,78 +151,19 @@ private fun handleNavigation(
|
|||||||
val onConversationsList =
|
val onConversationsList =
|
||||||
currentKey is ContactsRoutes.ContactsGraph || currentKey is ContactsRoutes.Contacts
|
currentKey is ContactsRoutes.ContactsGraph || currentKey is ContactsRoutes.Contacts
|
||||||
if (!onConversationsList) {
|
if (!onConversationsList) {
|
||||||
backStack.navigateTopLevel(destination.route)
|
multiBackstack.navigateTopLevel(destination.route)
|
||||||
} else {
|
} else {
|
||||||
uiViewModel.emitScrollToTopEvent(ScrollToTopEvent.ConversationsTabPressed)
|
uiViewModel.emitScrollToTopEvent(ScrollToTopEvent.ConversationsTabPressed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
if (currentKey != destination.route) {
|
if (currentKey != destination.route) {
|
||||||
backStack.navigateTopLevel(destination.route)
|
multiBackstack.navigateTopLevel(destination.route)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
backStack.navigateTopLevel(destination.route)
|
multiBackstack.navigateTopLevel(destination.route)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun MeshtasticNavigationBar(
|
|
||||||
topLevelDestination: TopLevelDestination?,
|
|
||||||
connectionState: ConnectionState,
|
|
||||||
unreadMessageCount: Int,
|
|
||||||
selectedDevice: String?,
|
|
||||||
uiViewModel: UIViewModel,
|
|
||||||
onNavigate: (TopLevelDestination) -> Unit,
|
|
||||||
) {
|
|
||||||
NavigationBar {
|
|
||||||
TopLevelDestination.entries.forEach { destination ->
|
|
||||||
NavigationBarItem(
|
|
||||||
selected = destination == topLevelDestination,
|
|
||||||
onClick = { onNavigate(destination) },
|
|
||||||
icon = {
|
|
||||||
NavigationIconContent(
|
|
||||||
destination = destination,
|
|
||||||
isSelected = destination == topLevelDestination,
|
|
||||||
connectionState = connectionState,
|
|
||||||
unreadMessageCount = unreadMessageCount,
|
|
||||||
selectedDevice = selectedDevice,
|
|
||||||
uiViewModel = uiViewModel,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun MeshtasticNavigationRail(
|
|
||||||
topLevelDestination: TopLevelDestination?,
|
|
||||||
connectionState: ConnectionState,
|
|
||||||
unreadMessageCount: Int,
|
|
||||||
selectedDevice: String?,
|
|
||||||
uiViewModel: UIViewModel,
|
|
||||||
onNavigate: (TopLevelDestination) -> Unit,
|
|
||||||
) {
|
|
||||||
NavigationRail {
|
|
||||||
TopLevelDestination.entries.forEach { destination ->
|
|
||||||
NavigationRailItem(
|
|
||||||
selected = destination == topLevelDestination,
|
|
||||||
onClick = { onNavigate(destination) },
|
|
||||||
icon = {
|
|
||||||
NavigationIconContent(
|
|
||||||
destination = destination,
|
|
||||||
isSelected = destination == topLevelDestination,
|
|
||||||
connectionState = connectionState,
|
|
||||||
unreadMessageCount = unreadMessageCount,
|
|
||||||
selectedDevice = selectedDevice,
|
|
||||||
uiViewModel = uiViewModel,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
label = { Text(stringResource(destination.label)) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ dependencies {
|
|||||||
implementation(projects.core.di)
|
implementation(projects.core.di)
|
||||||
implementation(projects.core.model)
|
implementation(projects.core.model)
|
||||||
implementation(projects.core.navigation)
|
implementation(projects.core.navigation)
|
||||||
|
implementation(libs.jetbrains.lifecycle.viewmodel.navigation3)
|
||||||
implementation(projects.core.repository)
|
implementation(projects.core.repository)
|
||||||
implementation(projects.core.domain)
|
implementation(projects.core.domain)
|
||||||
implementation(projects.core.data)
|
implementation(projects.core.data)
|
||||||
@@ -192,8 +193,8 @@ dependencies {
|
|||||||
|
|
||||||
// Navigation 3 (JetBrains fork — multiplatform)
|
// Navigation 3 (JetBrains fork — multiplatform)
|
||||||
implementation(libs.jetbrains.navigation3.ui)
|
implementation(libs.jetbrains.navigation3.ui)
|
||||||
implementation(libs.jetbrains.lifecycle.viewmodel.compose)
|
|
||||||
implementation(libs.jetbrains.lifecycle.viewmodel.navigation3)
|
implementation(libs.jetbrains.lifecycle.viewmodel.navigation3)
|
||||||
|
implementation(libs.jetbrains.lifecycle.viewmodel.compose)
|
||||||
implementation(libs.jetbrains.lifecycle.runtime.compose)
|
implementation(libs.jetbrains.lifecycle.runtime.compose)
|
||||||
|
|
||||||
// Koin DI
|
// Koin DI
|
||||||
|
|||||||
@@ -36,20 +36,16 @@ import androidx.compose.ui.graphics.toComposeImageBitmap
|
|||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
import androidx.compose.ui.input.key.KeyEventType
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
import androidx.compose.ui.input.key.isMetaPressed
|
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.key
|
||||||
import androidx.compose.ui.input.key.type
|
import androidx.compose.ui.input.key.type
|
||||||
import androidx.compose.ui.unit.DpSize
|
import androidx.compose.ui.unit.DpSize
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Notification
|
|
||||||
import androidx.compose.ui.window.Tray
|
import androidx.compose.ui.window.Tray
|
||||||
import androidx.compose.ui.window.Window
|
import androidx.compose.ui.window.Window
|
||||||
import androidx.compose.ui.window.WindowPosition
|
import androidx.compose.ui.window.WindowPosition
|
||||||
import androidx.compose.ui.window.application
|
import androidx.compose.ui.window.application
|
||||||
import androidx.compose.ui.window.rememberTrayState
|
import androidx.compose.ui.window.rememberTrayState
|
||||||
import androidx.compose.ui.window.rememberWindowState
|
import androidx.compose.ui.window.rememberWindowState
|
||||||
import androidx.navigation3.runtime.NavKey
|
|
||||||
import androidx.navigation3.runtime.rememberNavBackStack
|
|
||||||
import co.touchlab.kermit.Logger
|
import co.touchlab.kermit.Logger
|
||||||
import coil3.ImageLoader
|
import coil3.ImageLoader
|
||||||
import coil3.compose.setSingletonImageLoaderFactory
|
import coil3.compose.setSingletonImageLoaderFactory
|
||||||
@@ -63,10 +59,9 @@ import okio.Path.Companion.toPath
|
|||||||
import org.jetbrains.skia.Image
|
import org.jetbrains.skia.Image
|
||||||
import org.koin.core.context.startKoin
|
import org.koin.core.context.startKoin
|
||||||
import org.meshtastic.core.common.util.MeshtasticUri
|
import org.meshtastic.core.common.util.MeshtasticUri
|
||||||
import org.meshtastic.core.navigation.MeshtasticNavSavedStateConfig
|
|
||||||
import org.meshtastic.core.navigation.SettingsRoutes
|
import org.meshtastic.core.navigation.SettingsRoutes
|
||||||
import org.meshtastic.core.navigation.TopLevelDestination
|
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.repository.UiPrefs
|
||||||
import org.meshtastic.core.service.MeshServiceOrchestrator
|
import org.meshtastic.core.service.MeshServiceOrchestrator
|
||||||
import org.meshtastic.core.ui.theme.AppTheme
|
import org.meshtastic.core.ui.theme.AppTheme
|
||||||
@@ -78,32 +73,12 @@ import org.meshtastic.desktop.ui.DesktopMainScreen
|
|||||||
import java.awt.Desktop
|
import java.awt.Desktop
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/** Meshtastic Desktop — the first non-Android target for the shared KMP module graph. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
private val LocalAppLocale = staticCompositionLocalOf { "" }
|
private val LocalAppLocale = staticCompositionLocalOf { "" }
|
||||||
|
|
||||||
private const val MEMORY_CACHE_MAX_BYTES = 64L * 1024L * 1024L // 64 MiB
|
private const val MEMORY_CACHE_MAX_BYTES = 64L * 1024L * 1024L // 64 MiB
|
||||||
private const val DISK_CACHE_MAX_BYTES = 32L * 1024L * 1024L // 32 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
|
@Composable
|
||||||
private fun classpathPainterResource(path: String): Painter {
|
private fun classpathPainterResource(path: String): Painter {
|
||||||
val bitmap: ImageBitmap =
|
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>() }
|
val meshServiceController = remember { koinApp.koin.get<MeshServiceOrchestrator>() }
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
meshServiceController.start()
|
meshServiceController.start()
|
||||||
@@ -153,18 +127,15 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val uiPrefs = remember { koinApp.koin.get<UiPrefs>() }
|
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 = "")
|
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)
|
Locale.setDefault(localePref.takeIf { it.isNotEmpty() }?.let(Locale::forLanguageTag) ?: systemLocale)
|
||||||
|
|
||||||
val isDarkTheme =
|
val isDarkTheme =
|
||||||
when (themePref) {
|
when (themePref) {
|
||||||
1 -> false // MODE_NIGHT_NO
|
1 -> false
|
||||||
2 -> true // MODE_NIGHT_YES
|
2 -> true
|
||||||
else -> isSystemInDarkTheme()
|
else -> isSystemInDarkTheme()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,10 +155,7 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
|
|||||||
val windowState = rememberWindowState()
|
val windowState = rememberWindowState()
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
notificationManager.notifications.collect { notification ->
|
notificationManager.notifications.collect { notification -> trayState.sendNotification(notification) }
|
||||||
Logger.d { "Main.kt: Received notification for Tray: title=${notification.title}" }
|
|
||||||
trayState.sendNotification(notification)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
@@ -223,25 +191,13 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
|
|||||||
onAction = { isAppVisible = true },
|
onAction = { isAppVisible = true },
|
||||||
menu = {
|
menu = {
|
||||||
Item("Show Meshtastic", onClick = { isAppVisible = true })
|
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)
|
Item("Quit", onClick = ::exitApplication)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isWindowReady && isAppVisible) {
|
if (isWindowReady && isAppVisible) {
|
||||||
val backStack =
|
val multiBackstack = rememberMultiBackstack(TopLevelDestination.Connections.route)
|
||||||
rememberNavBackStack(MeshtasticNavSavedStateConfig, TopLevelDestination.Connections.route as NavKey)
|
val backStack = multiBackstack.activeBackStack
|
||||||
|
|
||||||
Window(
|
Window(
|
||||||
onCloseRequest = { isAppVisible = false },
|
onCloseRequest = { isAppVisible = false },
|
||||||
@@ -251,46 +207,34 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
|
|||||||
onPreviewKeyEvent = { event ->
|
onPreviewKeyEvent = { event ->
|
||||||
if (event.type != KeyEventType.KeyDown || !event.isMetaPressed) return@Window false
|
if (event.type != KeyEventType.KeyDown || !event.isMetaPressed) return@Window false
|
||||||
when {
|
when {
|
||||||
// ⌘Q → Quit
|
|
||||||
event.key == Key.Q -> {
|
event.key == Key.Q -> {
|
||||||
exitApplication()
|
exitApplication()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
// ⌘, → Settings
|
|
||||||
event.key == Key.Comma -> {
|
event.key == Key.Comma -> {
|
||||||
if (
|
if (
|
||||||
TopLevelDestination.Settings != TopLevelDestination.fromNavKey(backStack.lastOrNull())
|
TopLevelDestination.Settings != TopLevelDestination.fromNavKey(backStack.lastOrNull())
|
||||||
) {
|
) {
|
||||||
backStack.navigateTopLevel(TopLevelDestination.Settings.route)
|
multiBackstack.navigateTopLevel(TopLevelDestination.Settings.route)
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
// ⌘⇧T → Toggle theme
|
|
||||||
event.key == Key.T && event.isShiftPressed -> {
|
|
||||||
uiPrefs.setTheme(if (isDarkTheme) 1 else 2)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
// ⌘1 → Conversations
|
|
||||||
event.key == Key.One -> {
|
event.key == Key.One -> {
|
||||||
backStack.navigateTopLevel(TopLevelDestination.Conversations.route)
|
multiBackstack.navigateTopLevel(TopLevelDestination.Conversations.route)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
// ⌘2 → Nodes
|
|
||||||
event.key == Key.Two -> {
|
event.key == Key.Two -> {
|
||||||
backStack.navigateTopLevel(TopLevelDestination.Nodes.route)
|
multiBackstack.navigateTopLevel(TopLevelDestination.Nodes.route)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
// ⌘3 → Map
|
|
||||||
event.key == Key.Three -> {
|
event.key == Key.Three -> {
|
||||||
backStack.navigateTopLevel(TopLevelDestination.Map.route)
|
multiBackstack.navigateTopLevel(TopLevelDestination.Map.route)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
// ⌘4 → Connections
|
|
||||||
event.key == Key.Four -> {
|
event.key == Key.Four -> {
|
||||||
backStack.navigateTopLevel(TopLevelDestination.Connections.route)
|
multiBackstack.navigateTopLevel(TopLevelDestination.Connections.route)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
// ⌘/ → About
|
|
||||||
event.key == Key.Slash -> {
|
event.key == Key.Slash -> {
|
||||||
backStack.add(SettingsRoutes.About)
|
backStack.add(SettingsRoutes.About)
|
||||||
true
|
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 ->
|
setSingletonImageLoaderFactory { context ->
|
||||||
val cacheDir = System.getProperty("user.home") + "/.meshtastic/image_cache"
|
val cacheDir = System.getProperty("user.home") + "/.meshtastic/image_cache_v3"
|
||||||
ImageLoader.Builder(context)
|
ImageLoader.Builder(context)
|
||||||
.components {
|
.components {
|
||||||
add(KtorNetworkFetcherFactory())
|
add(KtorNetworkFetcherFactory())
|
||||||
add(SvgDecoder.Factory())
|
add(SvgDecoder.Factory(renderToBitmap = false))
|
||||||
}
|
}
|
||||||
.memoryCache { MemoryCache.Builder().maxSizeBytes(MEMORY_CACHE_MAX_BYTES).build() }
|
.memoryCache { MemoryCache.Builder().maxSizeBytes(MEMORY_CACHE_MAX_BYTES).build() }
|
||||||
.diskCache {
|
.diskCache {
|
||||||
@@ -316,12 +258,8 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
|
|||||||
.build()
|
.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) {
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.NavigationRail
|
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.navigation3.runtime.NavBackStack
|
|
||||||
import androidx.navigation3.runtime.NavKey
|
import androidx.navigation3.runtime.NavKey
|
||||||
import androidx.navigation3.runtime.entryProvider
|
import androidx.navigation3.runtime.entryProvider
|
||||||
import androidx.navigation3.ui.NavDisplay
|
import org.meshtastic.core.navigation.MultiBackstack
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
|
||||||
import org.meshtastic.core.ui.component.MeshtasticAppShell
|
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.core.ui.viewmodel.UIViewModel
|
||||||
import org.meshtastic.desktop.navigation.desktopNavGraph
|
import org.meshtastic.desktop.navigation.desktopNavGraph
|
||||||
|
|
||||||
/**
|
/** Desktop main screen — uses shared navigation components. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
@Suppress("LongMethod")
|
fun DesktopMainScreen(uiViewModel: UIViewModel, multiBackstack: MultiBackstack) {
|
||||||
fun DesktopMainScreen(backStack: NavBackStack<NavKey>, uiViewModel: UIViewModel = koinViewModel()) {
|
val backStack = multiBackstack.activeBackStack
|
||||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
|
||||||
|
Surface(modifier = Modifier.fillMaxSize()) {
|
||||||
MeshtasticAppShell(
|
MeshtasticAppShell(
|
||||||
backStack = backStack,
|
multiBackstack = multiBackstack,
|
||||||
uiViewModel = uiViewModel,
|
uiViewModel = uiViewModel,
|
||||||
hostModifier = Modifier.padding(bottom = 24.dp),
|
hostModifier = Modifier.padding(bottom = 24.dp),
|
||||||
) {
|
) {
|
||||||
org.meshtastic.core.ui.component.MeshtasticNavigationSuite(
|
MeshtasticNavigationSuite(
|
||||||
backStack = backStack,
|
multiBackstack = multiBackstack,
|
||||||
uiViewModel = uiViewModel,
|
uiViewModel = uiViewModel,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
val provider = entryProvider<NavKey> { desktopNavGraph(backStack, uiViewModel) }
|
val provider = entryProvider<NavKey> { desktopNavGraph(backStack, uiViewModel) }
|
||||||
|
MeshtasticNavDisplay(
|
||||||
NavDisplay(
|
multiBackstack = multiBackstack,
|
||||||
backStack = backStack,
|
|
||||||
onBack = { backStack.removeLastOrNull() },
|
|
||||||
entryProvider = provider,
|
entryProvider = provider,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ Version note: align guidance with repository-pinned versions in `gradle/libs.ver
|
|||||||
- Do use the official KMP `NavigationBackHandler` from `androidx.navigationevent:navigationevent-compose` for back gestures.
|
- Do use the official KMP `NavigationBackHandler` from `androidx.navigationevent:navigationevent-compose` for back gestures.
|
||||||
- Don't parse deep links manually in platform code or push single routes without a backstack.
|
- Don't parse deep links manually in platform code or push single routes without a backstack.
|
||||||
- Do use `DeepLinkRouter.route()` in `core:navigation` to synthesize the correct typed backstack from RESTful paths.
|
- Do use `DeepLinkRouter.route()` in `core:navigation` to synthesize the correct typed backstack from RESTful paths.
|
||||||
|
- **Don't use a single `NavBackStack` list for multiple tabs, nor reuse the same `NavEntryDecorator` instances across different backstacks.**
|
||||||
|
- **Do** use `MultiBackstack` (from `core:navigation`) to manage independent `NavBackStack` instances per tab. When rendering the active tab in `MeshtasticNavDisplay`, you **must** supply a fresh set of decorators (using `remember(backStack) { ... }`) bound to the active backstack instance. Failure to swap decorators when swapping backstacks causes Navigation 3 to perceive the inactive entries as "popped", permanently destroying their `ViewModelStore` and saved UI state.
|
||||||
|
|
||||||
### Current code anchors (Navigation 3)
|
### Current code anchors (Navigation 3)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<!--
|
||||||
|
- 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.
|
||||||
|
-->
|
||||||
|
|
||||||
|
# Navigation 3 & Material 3 Adaptive — API Alignment Audit
|
||||||
|
|
||||||
|
**Date:** 2026-03-26
|
||||||
|
**Status:** Active
|
||||||
|
**Scope:** Adoption of Navigation 3 `1.1.0-beta01` Scene APIs, transition metadata, ViewModel scoping, and Material 3 Adaptive integration.
|
||||||
|
**Supersedes:** [`navigation3-parity-2026-03.md`](navigation3-parity-2026-03.md) Alpha04 Changelog section (versions updated).
|
||||||
|
|
||||||
|
## Current Dependency Baseline
|
||||||
|
|
||||||
|
| Library | Version | Group |
|
||||||
|
|---|---|---|
|
||||||
|
| Navigation 3 UI | `1.1.0-beta01` | `org.jetbrains.androidx.navigation3:navigation3-ui` |
|
||||||
|
| Navigation Event | `1.1.0-alpha01` | `org.jetbrains.androidx.navigationevent:navigationevent-compose` |
|
||||||
|
| Lifecycle ViewModel Navigation3 | `2.11.0-alpha02` | `org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3` |
|
||||||
|
| Material 3 Adaptive | `1.3.0-alpha06` | `org.jetbrains.compose.material3.adaptive:adaptive*` |
|
||||||
|
| Material 3 Adaptive Navigation Suite | `1.11.0-alpha05` | `org.jetbrains.compose.material3:material3-adaptive-navigation-suite` |
|
||||||
|
| Compose Multiplatform | `1.11.0-beta01` | `org.jetbrains.compose` |
|
||||||
|
| Compose Multiplatform Material 3 | `1.11.0-alpha05` | `org.jetbrains.compose.material3:material3` |
|
||||||
|
|
||||||
|
## API Audit: What's Available vs. What We Use
|
||||||
|
|
||||||
|
### 1. NavDisplay — Scene Architecture (available since `1.1.0-alpha04`, stable in `beta01`)
|
||||||
|
|
||||||
|
**Available APIs we're NOT using:**
|
||||||
|
|
||||||
|
| API | Purpose | Status in project |
|
||||||
|
|---|---|---|
|
||||||
|
| `sceneStrategies: List<SceneStrategy<T>>` | Allows NavDisplay to render multi-pane Scenes | ❌ Not used — defaulting to `SinglePaneSceneStrategy` |
|
||||||
|
| `SceneStrategy<T>` interface | Custom scene calculation from backstack entries | ❌ Not used |
|
||||||
|
| `DialogSceneStrategy` | Renders `entry<T>(metadata = dialog())` entries as overlay Dialogs | ❌ Not used — dialogs handled manually |
|
||||||
|
| `SceneDecoratorStrategy<T>` | Wraps/decorates scenes with additional UI | ❌ Not used |
|
||||||
|
| `NavEntry.metadata` | Attaches typed metadata to entries (transitions, dialog hints, Scene classification) | ❌ Not used |
|
||||||
|
| `NavDisplay.TransitionKey` / `PopTransitionKey` / `PredictivePopTransitionKey` | Per-entry custom transitions via metadata | ❌ Not used |
|
||||||
|
| `transitionSpec` / `popTransitionSpec` / `predictivePopTransitionSpec` params | Default transition animations for NavDisplay | ❌ Not used — no transitions at all |
|
||||||
|
| `sharedTransitionScope: SharedTransitionScope?` | Shared element transitions between scenes | ❌ Not used |
|
||||||
|
| `entryDecorators: List<NavEntryDecorator<T>>` | Wraps entry content with additional behavior | ❌ Not used (defaulting to `SaveableStateHolderNavEntryDecorator`) |
|
||||||
|
|
||||||
|
**APIs we ARE using correctly:**
|
||||||
|
|
||||||
|
| API | Usage |
|
||||||
|
|---|---|
|
||||||
|
| `NavDisplay(backStack, entryProvider, modifier)` | Both `app/Main.kt` and `desktop/DesktopMainScreen.kt` |
|
||||||
|
| `rememberNavBackStack(SavedStateConfiguration, startKey)` | Backstack persistence |
|
||||||
|
| `entryProvider<NavKey> { entry<T> { ... } }` | All feature graph registrations |
|
||||||
|
| `NavigationBackHandler` from `navigationevent-compose` | Used in `AdaptiveListDetailScaffold` |
|
||||||
|
|
||||||
|
### 2. ViewModel Scoping (`lifecycle-viewmodel-navigation3` `2.11.0-alpha02`)
|
||||||
|
|
||||||
|
**Key finding:** The `ViewModelStoreNavEntryDecorator` is available and provides automatic per-entry ViewModel scoping tied to backstack lifetime. The project declares this dependency in `desktop/build.gradle.kts` but does **not** pass it as an `entryDecorator` to `NavDisplay`.
|
||||||
|
|
||||||
|
Currently, `koinViewModel()` calls inside `entry<T>` blocks use the nearest `ViewModelStoreOwner` from the composition — which is the Activity/Window level. This means:
|
||||||
|
- ViewModels are **not** automatically cleared when their entry is popped from the backstack.
|
||||||
|
- The project works around this with manual `key = "metrics-$destNum"` parameter keying.
|
||||||
|
|
||||||
|
**Opportunity:** Adding `rememberViewModelStoreNavEntryDecorator()` to `NavDisplay.entryDecorators` would give each backstack entry its own `ViewModelStoreOwner`, so `koinViewModel()` calls would be automatically scoped to the entry's lifetime.
|
||||||
|
|
||||||
|
### 3. Material 3 Adaptive — Nav3 Scene Integration
|
||||||
|
|
||||||
|
**Key finding:** The JetBrains `adaptive-navigation` artifact at `1.3.0-alpha06` does **NOT** include `MaterialListDetailSceneStrategy`. That API only exists in the Google AndroidX version (`androidx.compose.material3.adaptive:adaptive-navigation:1.3.0-alpha09+`).
|
||||||
|
|
||||||
|
This means the project **cannot** currently use the official M3 Adaptive Scene bridge through `NavDisplay(sceneStrategies = ...)`. The current approach of hosting `ListDetailPaneScaffold` inside `entry<T>` blocks (via `AdaptiveListDetailScaffold`) is the correct pattern for the JetBrains fork at this version.
|
||||||
|
|
||||||
|
**When to revisit:** Monitor the JetBrains adaptive fork for `MaterialListDetailSceneStrategy` inclusion. It will likely arrive when the JetBrains fork catches up to the AndroidX `1.3.0-alpha09+` feature set.
|
||||||
|
|
||||||
|
### 4. NavigationSuiteScaffold (`1.11.0-alpha05`)
|
||||||
|
|
||||||
|
**Status:** ✅ Adopted (2026-03-26). `MeshtasticNavigationSuite` now uses `NavigationSuiteScaffold` with `calculateFromAdaptiveInfo()` and custom `NavigationSuiteType` coercion. No further alignment needed.
|
||||||
|
|
||||||
|
## Prioritized Opportunities
|
||||||
|
|
||||||
|
### P0: Add `ViewModelStoreNavEntryDecorator` to NavDisplay (high-value, low-risk)
|
||||||
|
|
||||||
|
**Status:** ✅ Adopted (2026-03-26). Each backstack entry now gets its own `ViewModelStoreOwner` via `rememberViewModelStoreNavEntryDecorator()`. ViewModels obtained via `koinViewModel()` are automatically cleared when their entry is popped. Encapsulated in `MeshtasticNavDisplay` in `core:ui/commonMain`.
|
||||||
|
|
||||||
|
**Impact:** Fixes subtle ViewModel leaks where popped entries retain their ViewModel in the Activity/Window store. Eliminates the need for manual `key = "metrics-$destNum"` ViewModel keying patterns over time.
|
||||||
|
|
||||||
|
### P1: Add default NavDisplay transitions (medium-value, low-risk)
|
||||||
|
|
||||||
|
**Status:** ✅ Adopted (2026-03-26). A shared 350 ms crossfade (`fadeIn` + `fadeOut`) is applied for both forward and pop navigation via `MeshtasticNavDisplay`. This replaces the library's platform defaults (Android: 700 ms fade; Desktop: no animation) with a faster, consistent transition.
|
||||||
|
|
||||||
|
**Impact:** Immediate UX improvement on both Android and Desktop. Desktop now has visible navigation transitions.
|
||||||
|
|
||||||
|
### P2: Adopt `DialogSceneStrategy` for navigation-driven dialogs (medium-value, medium-risk)
|
||||||
|
|
||||||
|
**Status:** ✅ Adopted (2026-03-26). `MeshtasticNavDisplay` includes `DialogSceneStrategy` in `sceneStrategies` before `SinglePaneSceneStrategy`. Feature modules can now use `entry<T>(metadata = DialogSceneStrategy.dialog()) { ... }` to render entries as overlay Dialogs with proper backstack lifecycle and predictive-back support.
|
||||||
|
|
||||||
|
**Impact:** Cleaner dialog lifecycle management available for future dialog routes. Existing dialogs via `AlertHost` are unaffected.
|
||||||
|
|
||||||
|
### Consolidation: `MeshtasticNavDisplay` shared wrapper
|
||||||
|
|
||||||
|
**Status:** ✅ Adopted (2026-03-26). A new `MeshtasticNavDisplay` composable in `core:ui/commonMain` encapsulates the standard `NavDisplay` configuration:
|
||||||
|
- Entry decorators: `rememberSaveableStateHolderNavEntryDecorator` + `rememberViewModelStoreNavEntryDecorator`
|
||||||
|
- Scene strategies: `DialogSceneStrategy` + `SinglePaneSceneStrategy`
|
||||||
|
- Transition specs: 350 ms crossfade (forward + pop)
|
||||||
|
|
||||||
|
Both `app/Main.kt` and `desktop/DesktopMainScreen.kt` now call `MeshtasticNavDisplay` instead of configuring `NavDisplay` directly. The `lifecycle-viewmodel-navigation3` dependency was moved from host modules to `core:ui`.
|
||||||
|
|
||||||
|
### P3: Per-entry transition metadata (low-value until Scene adoption)
|
||||||
|
|
||||||
|
Individual entries can declare custom transitions via `entry<T>(metadata = NavDisplay.transitionSpec { ... })`. This is most useful when different route types should animate differently (e.g., detail screens slide in, settings screens fade).
|
||||||
|
|
||||||
|
**Impact:** Polish improvement. Low priority until default transitions (P1) are established. Now unblocked by P1 adoption.
|
||||||
|
|
||||||
|
### Deferred: Scene-based multi-pane layout
|
||||||
|
|
||||||
|
The `MaterialListDetailSceneStrategy` is not available in the JetBrains adaptive fork at `1.3.0-alpha06`. The project's `AdaptiveListDetailScaffold` wrapper is the correct approach for now. Revisit when the JetBrains fork includes the Scene bridge, or consider writing a custom `SceneStrategy` that integrates with the existing `ListDetailPaneScaffold`.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
~~Adopt **P0** (ViewModel scoping) and **P1** (default transitions) now. Defer P2/P3 and Scene-based multi-pane until the JetBrains adaptive fork adds `MaterialListDetailSceneStrategy`.~~
|
||||||
|
|
||||||
|
**Updated 2026-03-26:** P0, P1, and P2 adopted and consolidated into `MeshtasticNavDisplay` in `core:ui/commonMain`. P3 (per-entry transitions) is available for incremental adoption by feature modules. Scene-based multi-pane remains deferred.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Navigation 3 source: `navigation3-ui` `1.1.0-beta01` (inspected from Gradle cache)
|
||||||
|
- [`NavDisplay.kt`](https://cs.android.com/androidx/platform/frameworks/support/+/main:navigation3/navigation3-ui/src/commonMain/kotlin/androidx/navigation3/ui/NavDisplay.kt) (upstream)
|
||||||
|
- [`SceneStrategy.kt`](https://cs.android.com/androidx/platform/frameworks/support/+/main:navigation3/navigation3-ui/src/commonMain/kotlin/androidx/navigation3/scene/SceneStrategy.kt) (upstream)
|
||||||
|
- Material 3 Adaptive JetBrains fork: `org.jetbrains.compose.material3.adaptive` `1.3.0-alpha06`
|
||||||
@@ -36,19 +36,28 @@ Both modules still define separate graph-builder files (`app/navigation/*.kt`, `
|
|||||||
4. **Predictive back handling is KMP native.**
|
4. **Predictive back handling is KMP native.**
|
||||||
- Custom `PredictiveBackHandler` wrapper was removed in favor of Jetpack's official KMP `NavigationBackHandler` from `androidx.navigationevent:navigationevent-compose`.
|
- Custom `PredictiveBackHandler` wrapper was removed in favor of Jetpack's official KMP `NavigationBackHandler` from `androidx.navigationevent:navigationevent-compose`.
|
||||||
|
|
||||||
## Alpha04 Changelog Impact Check (2026-03-13)
|
## Alpha04 → Beta01 Changelog Impact Check
|
||||||
|
|
||||||
Source reviewed: Compose Multiplatform `v1.11.0-alpha04` release notes.
|
Source reviewed: Navigation 3 `1.1.0-beta01` (JetBrains fork), CMP `1.11.0-beta01`, Lifecycle `2.11.0-alpha02`.
|
||||||
|
|
||||||
1. **No direct Navigation 3 API breakage called out.**
|
> **Superseded by:** [`navigation3-api-alignment-2026-03.md`](navigation3-api-alignment-2026-03.md) for the full API surface audit and Scene architecture adoption plan.
|
||||||
- Release notes include component version bumps for Navigation 3 (`1.1.0-alpha04`) but no `NavBackStack`, `NavDisplay`, or `entryProvider` API migration requirements.
|
|
||||||
- Existing shell patterns in `app` and `desktop` remain valid.
|
1. **NavDisplay API updated to Scene-based architecture.**
|
||||||
2. **Primary risk is dependency wiring drift, not runtime behavior.**
|
- The `sceneStrategy: SceneStrategy<T>` parameter is deprecated in favor of `sceneStrategies: List<SceneStrategy<T>>`.
|
||||||
|
- New `sceneDecoratorStrategies: List<SceneDecoratorStrategy<T>>` parameter available.
|
||||||
|
- New `sharedTransitionScope: SharedTransitionScope?` parameter for shared element transitions.
|
||||||
|
- Existing shell patterns in `app` and `desktop` remain valid using the default `SinglePaneSceneStrategy`.
|
||||||
|
2. **Entry-scoped ViewModel lifecycle adopted.**
|
||||||
|
- Both `app` and `desktop` now pass `ViewModelStoreNavEntryDecorator` + `SaveableStateHolderNavEntryDecorator` as explicit `entryDecorators` to `NavDisplay`.
|
||||||
|
- ViewModels obtained via `koinViewModel()` inside `entry<T>` blocks are now scoped to the entry's backstack lifetime.
|
||||||
|
3. **No direct Navigation 3 API breakage.**
|
||||||
|
- Release is beta (API stabilized). No migration from alpha04 was required for existing usage patterns.
|
||||||
|
4. **Primary risk is dependency wiring drift, not runtime behavior.**
|
||||||
- JetBrains Navigation 3 currently publishes `navigation3-ui` coordinates (no separate `navigation3-runtime` artifact in Maven Central). The `jetbrains-navigation3-runtime` alias intentionally points to `navigation3-ui` and is documented in the version catalog.
|
- JetBrains Navigation 3 currently publishes `navigation3-ui` coordinates (no separate `navigation3-runtime` artifact in Maven Central). The `jetbrains-navigation3-runtime` alias intentionally points to `navigation3-ui` and is documented in the version catalog.
|
||||||
3. **Saved-state and typed-route parity risk remains unchanged.**
|
- Note: The `remember*` composable factory functions from `navigation3-runtime` are not visible in non-KMP Android modules due to Kotlin metadata resolution. Use direct class constructors instead (as done in `app/Main.kt`).
|
||||||
- Desktop still uses manual serializer registration; this is an existing risk and not introduced by alpha04.
|
5. **Saved-state and typed-route parity risk remains unchanged.**
|
||||||
4. **Compose-wide migration notes do not currently impact navigation codepaths.**
|
- Desktop still uses manual serializer registration; this is an existing risk and not introduced by beta01.
|
||||||
- `Shader` wrapper changes and `Canvas.nativeCanvas` deprecations are not used in the Navigation 3 shell files.
|
6. **Updated active docs to reflect the current dependency baseline (`1.11.0-beta01`, `1.1.0-beta01`, `1.3.0-alpha06`, `2.11.0-alpha02`).**
|
||||||
|
|
||||||
### Actions Taken
|
### Actions Taken
|
||||||
|
|
||||||
|
|||||||
+9
-4
@@ -155,14 +155,19 @@ Remaining to be extracted from `:app` or unified in `commonMain`:
|
|||||||
|
|
||||||
| Dependency | Version | Why |
|
| Dependency | Version | Why |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Compose Multiplatform | `1.11.0-alpha04` | Required for JetBrains Adaptive `1.3.0-alpha06` |
|
| Compose Multiplatform | `1.11.0-beta01` | Required for JetBrains Adaptive `1.3.0-alpha06` and Material 3 `1.11.0-alpha05` |
|
||||||
| Koin | `4.2.0-RC2` | Nav3 + K2 compiler plugin support |
|
| Compose Multiplatform Material 3 | `1.11.0-alpha05` | Material 3 components including `NavigationSuiteScaffold` |
|
||||||
| JetBrains Lifecycle | `2.10.0-beta01` | Multiplatform ViewModel/lifecycle |
|
| Koin | `4.2.0` | Nav3 + K2 compiler plugin support |
|
||||||
| JetBrains Navigation 3 | `1.1.0-alpha04` | Multiplatform navigation |
|
| JetBrains Lifecycle | `2.11.0-alpha02` | Multiplatform ViewModel/lifecycle; includes `lifecycle-viewmodel-navigation3` for entry-scoped ViewModels |
|
||||||
|
| JetBrains Navigation 3 | `1.1.0-beta01` | Multiplatform navigation with Scene architecture, `NavEntry.metadata`, transition specs |
|
||||||
|
| JetBrains Navigation Event | `1.1.0-alpha01` | KMP `NavigationBackHandler` for predictive back |
|
||||||
|
| JetBrains Material 3 Adaptive | `1.3.0-alpha06` | `ListDetailPaneScaffold`, `ThreePaneScaffold`, Large/XL breakpoints |
|
||||||
| Kable BLE | `0.42.0` | Provides fully multiplatform BLE support |
|
| Kable BLE | `0.42.0` | Provides fully multiplatform BLE support |
|
||||||
|
|
||||||
**Policy:** Stable by default. RC when it unlocks KMP functionality. Alpha only behind hard abstraction seams. Do not downgrade CMP or Koin — they enable critical KMP features.
|
**Policy:** Stable by default. RC when it unlocks KMP functionality. Alpha only behind hard abstraction seams. Do not downgrade CMP or Koin — they enable critical KMP features.
|
||||||
|
|
||||||
|
> See [`decisions/navigation3-api-alignment-2026-03.md`](./decisions/navigation3-api-alignment-2026-03.md) for the full Navigation 3 API surface audit and Scene architecture adoption plan.
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
- Roadmap: [`docs/roadmap.md`](./roadmap.md)
|
- Roadmap: [`docs/roadmap.md`](./roadmap.md)
|
||||||
|
|||||||
+2
-4
@@ -19,13 +19,12 @@ package org.meshtastic.feature.intro
|
|||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.navigation3.runtime.NavKey
|
|
||||||
import androidx.navigation3.runtime.rememberNavBackStack
|
import androidx.navigation3.runtime.rememberNavBackStack
|
||||||
import androidx.navigation3.ui.NavDisplay
|
|
||||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||||
import com.google.accompanist.permissions.PermissionState
|
import com.google.accompanist.permissions.PermissionState
|
||||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||||
import com.google.accompanist.permissions.rememberPermissionState
|
import com.google.accompanist.permissions.rememberPermissionState
|
||||||
|
import org.meshtastic.core.ui.component.MeshtasticNavDisplay
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main application introduction screen. This Composable hosts the navigation flow and hoists the permission states.
|
* Main application introduction screen. This Composable hosts the navigation flow and hoists the permission states.
|
||||||
@@ -58,9 +57,8 @@ fun AppIntroductionScreen(onDone: () -> Unit, viewModel: IntroViewModel) {
|
|||||||
|
|
||||||
val backStack = rememberNavBackStack(Welcome)
|
val backStack = rememberNavBackStack(Welcome)
|
||||||
|
|
||||||
NavDisplay<NavKey>(
|
MeshtasticNavDisplay(
|
||||||
backStack = backStack,
|
backStack = backStack,
|
||||||
onBack = { backStack.removeLastOrNull() },
|
|
||||||
entryProvider =
|
entryProvider =
|
||||||
introNavGraph(
|
introNavGraph(
|
||||||
backStack = backStack,
|
backStack = backStack,
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ kotlin {
|
|||||||
implementation(libs.jetbrains.compose.material3.adaptive)
|
implementation(libs.jetbrains.compose.material3.adaptive)
|
||||||
implementation(libs.jetbrains.compose.material3.adaptive.layout)
|
implementation(libs.jetbrains.compose.material3.adaptive.layout)
|
||||||
implementation(libs.jetbrains.compose.material3.adaptive.navigation)
|
implementation(libs.jetbrains.compose.material3.adaptive.navigation)
|
||||||
|
implementation(libs.jetbrains.compose.material3.adaptive.navigation3)
|
||||||
}
|
}
|
||||||
|
|
||||||
androidMain.dependencies { implementation(libs.androidx.work.runtime.ktx) }
|
androidMain.dependencies { implementation(libs.androidx.work.runtime.ktx) }
|
||||||
|
|||||||
+23
-40
@@ -16,6 +16,8 @@
|
|||||||
*/
|
*/
|
||||||
package org.meshtastic.feature.messaging.navigation
|
package org.meshtastic.feature.messaging.navigation
|
||||||
|
|
||||||
|
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||||
|
import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
@@ -26,6 +28,7 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import org.meshtastic.core.navigation.ContactsRoutes
|
import org.meshtastic.core.navigation.ContactsRoutes
|
||||||
|
import org.meshtastic.core.navigation.replaceLast
|
||||||
import org.meshtastic.core.ui.component.ScrollToTopEvent
|
import org.meshtastic.core.ui.component.ScrollToTopEvent
|
||||||
import org.meshtastic.feature.messaging.QuickChatScreen
|
import org.meshtastic.feature.messaging.QuickChatScreen
|
||||||
import org.meshtastic.feature.messaging.QuickChatViewModel
|
import org.meshtastic.feature.messaging.QuickChatViewModel
|
||||||
@@ -33,55 +36,54 @@ import org.meshtastic.feature.messaging.ui.contact.AdaptiveContactsScreen
|
|||||||
import org.meshtastic.feature.messaging.ui.contact.ContactsViewModel
|
import org.meshtastic.feature.messaging.ui.contact.ContactsViewModel
|
||||||
import org.meshtastic.feature.messaging.ui.sharing.ShareScreen
|
import org.meshtastic.feature.messaging.ui.sharing.ShareScreen
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||||
@Suppress("LongMethod")
|
@Suppress("LongMethod")
|
||||||
fun EntryProviderScope<NavKey>.contactsGraph(
|
fun EntryProviderScope<NavKey>.contactsGraph(
|
||||||
backStack: NavBackStack<NavKey>,
|
backStack: NavBackStack<NavKey>,
|
||||||
scrollToTopEvents: Flow<ScrollToTopEvent> = MutableSharedFlow(),
|
scrollToTopEvents: Flow<ScrollToTopEvent> = MutableSharedFlow(),
|
||||||
) {
|
) {
|
||||||
entry<ContactsRoutes.ContactsGraph> {
|
entry<ContactsRoutes.ContactsGraph>(metadata = { ListDetailSceneStrategy.listPane() }) {
|
||||||
ContactsEntryContent(backStack = backStack, scrollToTopEvents = scrollToTopEvents)
|
ContactsEntryContent(backStack = backStack, scrollToTopEvents = scrollToTopEvents)
|
||||||
}
|
}
|
||||||
|
|
||||||
entry<ContactsRoutes.Contacts> {
|
entry<ContactsRoutes.Contacts>(metadata = { ListDetailSceneStrategy.listPane() }) {
|
||||||
ContactsEntryContent(backStack = backStack, scrollToTopEvents = scrollToTopEvents)
|
ContactsEntryContent(backStack = backStack, scrollToTopEvents = scrollToTopEvents)
|
||||||
}
|
}
|
||||||
|
|
||||||
entry<ContactsRoutes.Messages> { args ->
|
entry<ContactsRoutes.Messages>(metadata = { ListDetailSceneStrategy.detailPane() }) { args ->
|
||||||
ContactsEntryContent(
|
val contactKey = args.contactKey
|
||||||
backStack = backStack,
|
val messageViewModel: org.meshtastic.feature.messaging.MessageViewModel =
|
||||||
scrollToTopEvents = scrollToTopEvents,
|
koinViewModel(key = "messages-$contactKey")
|
||||||
initialContactKey = args.contactKey,
|
messageViewModel.setContactKey(contactKey)
|
||||||
initialMessage = args.message,
|
|
||||||
|
org.meshtastic.feature.messaging.MessageScreen(
|
||||||
|
contactKey = contactKey,
|
||||||
|
message = args.message,
|
||||||
|
viewModel = messageViewModel,
|
||||||
|
navigateToNodeDetails = { backStack.add(org.meshtastic.core.navigation.NodesRoutes.NodeDetailGraph(it)) },
|
||||||
|
navigateToQuickChatOptions = { backStack.add(org.meshtastic.core.navigation.ContactsRoutes.QuickChat) },
|
||||||
|
onNavigateBack = { backStack.removeLastOrNull() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
entry<ContactsRoutes.Share> { args ->
|
entry<ContactsRoutes.Share>(metadata = { ListDetailSceneStrategy.extraPane() }) { args ->
|
||||||
val message = args.message
|
val message = args.message
|
||||||
val viewModel = koinViewModel<ContactsViewModel>()
|
val viewModel = koinViewModel<ContactsViewModel>()
|
||||||
ShareScreen(
|
ShareScreen(
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onConfirm = {
|
onConfirm = { contactKey -> backStack.replaceLast(ContactsRoutes.Messages(contactKey, message)) },
|
||||||
// Navigation 3 - replace Top with Messages manually, but for now we just pop and add
|
|
||||||
backStack.removeLastOrNull()
|
|
||||||
backStack.add(ContactsRoutes.Messages(it, message))
|
|
||||||
},
|
|
||||||
onNavigateUp = { backStack.removeLastOrNull() },
|
onNavigateUp = { backStack.removeLastOrNull() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
entry<ContactsRoutes.QuickChat> {
|
entry<ContactsRoutes.QuickChat>(metadata = { ListDetailSceneStrategy.extraPane() }) {
|
||||||
val viewModel = koinViewModel<QuickChatViewModel>()
|
val viewModel = koinViewModel<QuickChatViewModel>()
|
||||||
QuickChatScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
|
QuickChatScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ContactsEntryContent(
|
fun ContactsEntryContent(backStack: NavBackStack<NavKey>, scrollToTopEvents: Flow<ScrollToTopEvent>) {
|
||||||
backStack: NavBackStack<NavKey>,
|
|
||||||
scrollToTopEvents: Flow<ScrollToTopEvent>,
|
|
||||||
initialContactKey: String? = null,
|
|
||||||
initialMessage: String = "",
|
|
||||||
) {
|
|
||||||
val uiViewModel: org.meshtastic.core.ui.viewmodel.UIViewModel = koinViewModel()
|
val uiViewModel: org.meshtastic.core.ui.viewmodel.UIViewModel = koinViewModel()
|
||||||
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
|
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
|
||||||
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
|
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
|
||||||
@@ -90,30 +92,11 @@ fun ContactsEntryContent(
|
|||||||
AdaptiveContactsScreen(
|
AdaptiveContactsScreen(
|
||||||
backStack = backStack,
|
backStack = backStack,
|
||||||
contactsViewModel = contactsViewModel,
|
contactsViewModel = contactsViewModel,
|
||||||
messageViewModel = koinViewModel(), // Ignored by custom detail pane below
|
|
||||||
scrollToTopEvents = scrollToTopEvents,
|
scrollToTopEvents = scrollToTopEvents,
|
||||||
sharedContactRequested = sharedContactRequested,
|
sharedContactRequested = sharedContactRequested,
|
||||||
requestChannelSet = requestChannelSet,
|
requestChannelSet = requestChannelSet,
|
||||||
onHandleDeepLink = uiViewModel::handleDeepLink,
|
onHandleDeepLink = uiViewModel::handleDeepLink,
|
||||||
onClearSharedContactRequested = uiViewModel::clearSharedContactRequested,
|
onClearSharedContactRequested = uiViewModel::clearSharedContactRequested,
|
||||||
onClearRequestChannelUrl = uiViewModel::clearRequestChannelUrl,
|
onClearRequestChannelUrl = uiViewModel::clearRequestChannelUrl,
|
||||||
initialContactKey = initialContactKey,
|
|
||||||
initialMessage = initialMessage,
|
|
||||||
detailPaneCustom = { contactKey ->
|
|
||||||
val messageViewModel: org.meshtastic.feature.messaging.MessageViewModel =
|
|
||||||
koinViewModel(key = "messages-$contactKey")
|
|
||||||
messageViewModel.setContactKey(contactKey)
|
|
||||||
|
|
||||||
org.meshtastic.feature.messaging.MessageScreen(
|
|
||||||
contactKey = contactKey,
|
|
||||||
message = if (contactKey == initialContactKey) initialMessage else "",
|
|
||||||
viewModel = messageViewModel,
|
|
||||||
navigateToNodeDetails = {
|
|
||||||
backStack.add(org.meshtastic.core.navigation.NodesRoutes.NodeDetailGraph(it))
|
|
||||||
},
|
|
||||||
navigateToQuickChatOptions = { backStack.add(org.meshtastic.core.navigation.ContactsRoutes.QuickChat) },
|
|
||||||
onNavigateBack = { backStack.removeLastOrNull() },
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-89
@@ -16,118 +16,41 @@
|
|||||||
*/
|
*/
|
||||||
package org.meshtastic.feature.messaging.ui.contact
|
package org.meshtastic.feature.messaging.ui.contact
|
||||||
|
|
||||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
|
||||||
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
|
|
||||||
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.navigation3.runtime.NavBackStack
|
import androidx.navigation3.runtime.NavBackStack
|
||||||
import androidx.navigation3.runtime.NavKey
|
import androidx.navigation3.runtime.NavKey
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.jetbrains.compose.resources.stringResource
|
|
||||||
import org.meshtastic.core.common.util.MeshtasticUri
|
import org.meshtastic.core.common.util.MeshtasticUri
|
||||||
import org.meshtastic.core.navigation.ChannelsRoutes
|
import org.meshtastic.core.navigation.ChannelsRoutes
|
||||||
import org.meshtastic.core.navigation.ContactsRoutes
|
import org.meshtastic.core.navigation.ContactsRoutes
|
||||||
import org.meshtastic.core.navigation.NodesRoutes
|
import org.meshtastic.core.navigation.NodesRoutes
|
||||||
import org.meshtastic.core.resources.Res
|
|
||||||
import org.meshtastic.core.resources.conversations
|
|
||||||
import org.meshtastic.core.ui.component.AdaptiveListDetailScaffold
|
|
||||||
import org.meshtastic.core.ui.component.EmptyDetailPlaceholder
|
|
||||||
import org.meshtastic.core.ui.component.ScrollToTopEvent
|
import org.meshtastic.core.ui.component.ScrollToTopEvent
|
||||||
import org.meshtastic.core.ui.icon.Conversations
|
|
||||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
|
||||||
import org.meshtastic.feature.messaging.MessageScreen
|
|
||||||
import org.meshtastic.feature.messaging.MessageViewModel
|
|
||||||
import org.meshtastic.proto.ChannelSet
|
import org.meshtastic.proto.ChannelSet
|
||||||
import org.meshtastic.proto.SharedContact
|
import org.meshtastic.proto.SharedContact
|
||||||
|
|
||||||
@Suppress("LongMethod", "LongParameterList", "CyclomaticComplexMethod")
|
|
||||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AdaptiveContactsScreen(
|
fun AdaptiveContactsScreen(
|
||||||
backStack: NavBackStack<NavKey>,
|
backStack: NavBackStack<NavKey>,
|
||||||
contactsViewModel: ContactsViewModel,
|
contactsViewModel: ContactsViewModel,
|
||||||
messageViewModel: MessageViewModel,
|
|
||||||
scrollToTopEvents: Flow<ScrollToTopEvent>,
|
scrollToTopEvents: Flow<ScrollToTopEvent>,
|
||||||
sharedContactRequested: SharedContact?,
|
sharedContactRequested: SharedContact?,
|
||||||
requestChannelSet: ChannelSet?,
|
requestChannelSet: ChannelSet?,
|
||||||
onHandleDeepLink: (MeshtasticUri, onInvalid: () -> Unit) -> Unit,
|
onHandleDeepLink: (MeshtasticUri, onInvalid: () -> Unit) -> Unit,
|
||||||
onClearSharedContactRequested: () -> Unit,
|
onClearSharedContactRequested: () -> Unit,
|
||||||
onClearRequestChannelUrl: () -> Unit,
|
onClearRequestChannelUrl: () -> Unit,
|
||||||
initialContactKey: String? = null,
|
|
||||||
initialMessage: String = "",
|
|
||||||
detailPaneCustom: @Composable ((contactKey: String) -> Unit)? = null,
|
|
||||||
) {
|
) {
|
||||||
val navigator = rememberListDetailPaneScaffoldNavigator<String>()
|
ContactsScreen(
|
||||||
val scope = rememberCoroutineScope()
|
onNavigateToShare = { backStack.add(ChannelsRoutes.ChannelsGraph) },
|
||||||
|
sharedContactRequested = sharedContactRequested,
|
||||||
val onBackToGraph: () -> Unit = {
|
requestChannelSet = requestChannelSet,
|
||||||
val currentKey = backStack.lastOrNull()
|
onHandleDeepLink = onHandleDeepLink,
|
||||||
|
onClearSharedContactRequested = onClearSharedContactRequested,
|
||||||
if (
|
onClearRequestChannelUrl = onClearRequestChannelUrl,
|
||||||
currentKey is ContactsRoutes.Messages ||
|
viewModel = contactsViewModel,
|
||||||
currentKey is ContactsRoutes.Contacts ||
|
onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
|
||||||
currentKey is ContactsRoutes.ContactsGraph
|
onNavigateToMessages = { contactKey -> backStack.add(ContactsRoutes.Messages(contactKey)) },
|
||||||
) {
|
onNavigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
|
||||||
// Check if we navigated here from another screen (e.g., from Nodes or Map)
|
|
||||||
val previousKey = if (backStack.size > 1) backStack[backStack.size - 2] else null
|
|
||||||
val isFromDifferentGraph =
|
|
||||||
previousKey != null &&
|
|
||||||
previousKey !is ContactsRoutes.ContactsGraph &&
|
|
||||||
previousKey !is ContactsRoutes.Contacts &&
|
|
||||||
previousKey !is ContactsRoutes.Messages
|
|
||||||
|
|
||||||
if (isFromDifferentGraph) {
|
|
||||||
// Navigate back via NavController to return to the previous screen (e.g. Node Details)
|
|
||||||
backStack.removeLastOrNull()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AdaptiveListDetailScaffold(
|
|
||||||
navigator = navigator,
|
|
||||||
scrollToTopEvents = scrollToTopEvents,
|
scrollToTopEvents = scrollToTopEvents,
|
||||||
onBackToGraph = onBackToGraph,
|
activeContactKey = null,
|
||||||
onTabPressedEvent = { it is ScrollToTopEvent.ConversationsTabPressed },
|
|
||||||
initialKey = initialContactKey,
|
|
||||||
listPane = { isActive, activeContactKey ->
|
|
||||||
ContactsScreen(
|
|
||||||
onNavigateToShare = { backStack.add(ChannelsRoutes.ChannelsGraph) },
|
|
||||||
sharedContactRequested = sharedContactRequested,
|
|
||||||
requestChannelSet = requestChannelSet,
|
|
||||||
onHandleDeepLink = onHandleDeepLink,
|
|
||||||
onClearSharedContactRequested = onClearSharedContactRequested,
|
|
||||||
onClearRequestChannelUrl = onClearRequestChannelUrl,
|
|
||||||
viewModel = contactsViewModel,
|
|
||||||
onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
|
|
||||||
onNavigateToMessages = { contactKey ->
|
|
||||||
scope.launch { navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, contactKey) }
|
|
||||||
},
|
|
||||||
onNavigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
|
|
||||||
scrollToTopEvents = scrollToTopEvents,
|
|
||||||
activeContactKey = activeContactKey,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
detailPane = { contentKey, handleBack ->
|
|
||||||
if (detailPaneCustom != null) {
|
|
||||||
detailPaneCustom(contentKey)
|
|
||||||
} else {
|
|
||||||
MessageScreen(
|
|
||||||
contactKey = contentKey,
|
|
||||||
message = if (contentKey == initialContactKey) initialMessage else "",
|
|
||||||
viewModel = messageViewModel,
|
|
||||||
navigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
|
|
||||||
navigateToQuickChatOptions = { backStack.add(ContactsRoutes.QuickChat) },
|
|
||||||
onNavigateBack = handleBack,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
emptyDetailPane = {
|
|
||||||
EmptyDetailPlaceholder(
|
|
||||||
icon = MeshtasticIcons.Conversations,
|
|
||||||
title = stringResource(Res.string.conversations),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ kotlin {
|
|||||||
implementation(libs.jetbrains.compose.material3.adaptive)
|
implementation(libs.jetbrains.compose.material3.adaptive)
|
||||||
implementation(libs.jetbrains.compose.material3.adaptive.layout)
|
implementation(libs.jetbrains.compose.material3.adaptive.layout)
|
||||||
implementation(libs.jetbrains.compose.material3.adaptive.navigation)
|
implementation(libs.jetbrains.compose.material3.adaptive.navigation)
|
||||||
|
implementation(libs.jetbrains.compose.material3.adaptive.navigation3)
|
||||||
}
|
}
|
||||||
|
|
||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
|
|||||||
+6
-68
@@ -16,93 +16,31 @@
|
|||||||
*/
|
*/
|
||||||
package org.meshtastic.feature.node.navigation
|
package org.meshtastic.feature.node.navigation
|
||||||
|
|
||||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
|
||||||
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
|
|
||||||
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.navigation3.runtime.NavBackStack
|
import androidx.navigation3.runtime.NavBackStack
|
||||||
import androidx.navigation3.runtime.NavKey
|
import androidx.navigation3.runtime.NavKey
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.jetbrains.compose.resources.stringResource
|
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import org.meshtastic.core.navigation.ChannelsRoutes
|
import org.meshtastic.core.navigation.ChannelsRoutes
|
||||||
import org.meshtastic.core.navigation.NodesRoutes
|
import org.meshtastic.core.navigation.NodesRoutes
|
||||||
import org.meshtastic.core.navigation.Route
|
|
||||||
import org.meshtastic.core.resources.Res
|
|
||||||
import org.meshtastic.core.resources.nodes
|
|
||||||
import org.meshtastic.core.ui.component.AdaptiveListDetailScaffold
|
|
||||||
import org.meshtastic.core.ui.component.EmptyDetailPlaceholder
|
|
||||||
import org.meshtastic.core.ui.component.ScrollToTopEvent
|
import org.meshtastic.core.ui.component.ScrollToTopEvent
|
||||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
|
||||||
import org.meshtastic.core.ui.icon.Nodes
|
|
||||||
import org.meshtastic.feature.node.compass.CompassViewModel
|
|
||||||
import org.meshtastic.feature.node.detail.NodeDetailScreen
|
|
||||||
import org.meshtastic.feature.node.detail.NodeDetailViewModel
|
|
||||||
import org.meshtastic.feature.node.list.NodeListScreen
|
import org.meshtastic.feature.node.list.NodeListScreen
|
||||||
import org.meshtastic.feature.node.list.NodeListViewModel
|
import org.meshtastic.feature.node.list.NodeListViewModel
|
||||||
|
|
||||||
@Suppress("LongMethod")
|
|
||||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AdaptiveNodeListScreen(
|
fun AdaptiveNodeListScreen(
|
||||||
backStack: NavBackStack<NavKey>,
|
backStack: NavBackStack<NavKey>,
|
||||||
scrollToTopEvents: Flow<ScrollToTopEvent>,
|
scrollToTopEvents: Flow<ScrollToTopEvent>,
|
||||||
initialNodeId: Int? = null,
|
|
||||||
onNavigate: (Route) -> Unit = {},
|
|
||||||
onNavigateToMessages: (String) -> Unit = {},
|
|
||||||
onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
|
onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
|
||||||
) {
|
) {
|
||||||
val nodeListViewModel: NodeListViewModel = koinViewModel()
|
val nodeListViewModel: NodeListViewModel = koinViewModel()
|
||||||
val navigator = rememberListDetailPaneScaffoldNavigator<Int>()
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
val onBackToGraph: () -> Unit = {
|
NodeListScreen(
|
||||||
val currentKey = backStack.lastOrNull()
|
viewModel = nodeListViewModel,
|
||||||
val isNodesRoute = currentKey is NodesRoutes.Nodes || currentKey is NodesRoutes.NodesGraph
|
navigateToNodeDetails = { nodeId -> backStack.add(NodesRoutes.NodeDetail(nodeId)) },
|
||||||
val previousKey = if (backStack.size > 1) backStack[backStack.size - 2] else null
|
onNavigateToChannels = { backStack.add(ChannelsRoutes.ChannelsGraph) },
|
||||||
val isFromDifferentGraph =
|
|
||||||
previousKey != null && previousKey !is NodesRoutes.NodesGraph && previousKey !is NodesRoutes.Nodes
|
|
||||||
|
|
||||||
if (isFromDifferentGraph && !isNodesRoute) {
|
|
||||||
// Navigate back via NavController to return to the previous screen
|
|
||||||
backStack.removeLastOrNull()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AdaptiveListDetailScaffold(
|
|
||||||
navigator = navigator,
|
|
||||||
scrollToTopEvents = scrollToTopEvents,
|
scrollToTopEvents = scrollToTopEvents,
|
||||||
onBackToGraph = onBackToGraph,
|
activeNodeId = null,
|
||||||
onTabPressedEvent = { it is ScrollToTopEvent.NodesTabPressed },
|
onHandleDeepLink = onHandleDeepLink,
|
||||||
initialKey = initialNodeId,
|
|
||||||
listPane = { isActive, activeNodeId ->
|
|
||||||
NodeListScreen(
|
|
||||||
viewModel = nodeListViewModel,
|
|
||||||
navigateToNodeDetails = { nodeId ->
|
|
||||||
scope.launch { navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, nodeId) }
|
|
||||||
},
|
|
||||||
onNavigateToChannels = { backStack.add(ChannelsRoutes.ChannelsGraph) },
|
|
||||||
scrollToTopEvents = scrollToTopEvents,
|
|
||||||
activeNodeId = activeNodeId,
|
|
||||||
onHandleDeepLink = onHandleDeepLink,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
detailPane = { contentKey, handleBack ->
|
|
||||||
val nodeDetailViewModel: NodeDetailViewModel = koinViewModel()
|
|
||||||
val compassViewModel: CompassViewModel = koinViewModel()
|
|
||||||
NodeDetailScreen(
|
|
||||||
nodeId = contentKey,
|
|
||||||
viewModel = nodeDetailViewModel,
|
|
||||||
compassViewModel = compassViewModel,
|
|
||||||
navigateToMessages = onNavigateToMessages,
|
|
||||||
onNavigate = onNavigate,
|
|
||||||
onNavigateUp = handleBack,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
emptyDetailPane = {
|
|
||||||
EmptyDetailPlaceholder(icon = MeshtasticIcons.Nodes, title = stringResource(Res.string.nodes))
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-24
@@ -26,6 +26,8 @@ import androidx.compose.material.icons.rounded.People
|
|||||||
import androidx.compose.material.icons.rounded.PermScanWifi
|
import androidx.compose.material.icons.rounded.PermScanWifi
|
||||||
import androidx.compose.material.icons.rounded.Power
|
import androidx.compose.material.icons.rounded.Power
|
||||||
import androidx.compose.material.icons.rounded.Router
|
import androidx.compose.material.icons.rounded.Router
|
||||||
|
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||||
|
import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.navigation3.runtime.EntryProviderScope
|
import androidx.navigation3.runtime.EntryProviderScope
|
||||||
@@ -51,6 +53,9 @@ import org.meshtastic.core.resources.power
|
|||||||
import org.meshtastic.core.resources.signal
|
import org.meshtastic.core.resources.signal
|
||||||
import org.meshtastic.core.resources.traceroute
|
import org.meshtastic.core.resources.traceroute
|
||||||
import org.meshtastic.core.ui.component.ScrollToTopEvent
|
import org.meshtastic.core.ui.component.ScrollToTopEvent
|
||||||
|
import org.meshtastic.feature.node.compass.CompassViewModel
|
||||||
|
import org.meshtastic.feature.node.detail.NodeDetailScreen
|
||||||
|
import org.meshtastic.feature.node.detail.NodeDetailViewModel
|
||||||
import org.meshtastic.feature.node.metrics.DeviceMetricsScreen
|
import org.meshtastic.feature.node.metrics.DeviceMetricsScreen
|
||||||
import org.meshtastic.feature.node.metrics.EnvironmentMetricsScreen
|
import org.meshtastic.feature.node.metrics.EnvironmentMetricsScreen
|
||||||
import org.meshtastic.feature.node.metrics.HostMetricsLogScreen
|
import org.meshtastic.feature.node.metrics.HostMetricsLogScreen
|
||||||
@@ -63,28 +68,25 @@ import org.meshtastic.feature.node.metrics.SignalMetricsScreen
|
|||||||
import org.meshtastic.feature.node.metrics.TracerouteLogScreen
|
import org.meshtastic.feature.node.metrics.TracerouteLogScreen
|
||||||
import kotlin.reflect.KClass
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||||
@Suppress("LongMethod")
|
@Suppress("LongMethod")
|
||||||
fun EntryProviderScope<NavKey>.nodesGraph(
|
fun EntryProviderScope<NavKey>.nodesGraph(
|
||||||
backStack: NavBackStack<NavKey>,
|
backStack: NavBackStack<NavKey>,
|
||||||
scrollToTopEvents: Flow<ScrollToTopEvent> = MutableSharedFlow(),
|
scrollToTopEvents: Flow<ScrollToTopEvent> = MutableSharedFlow(),
|
||||||
onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
|
onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
|
||||||
) {
|
) {
|
||||||
entry<NodesRoutes.NodesGraph> {
|
entry<NodesRoutes.NodesGraph>(metadata = { ListDetailSceneStrategy.listPane() }) {
|
||||||
AdaptiveNodeListScreen(
|
AdaptiveNodeListScreen(
|
||||||
backStack = backStack,
|
backStack = backStack,
|
||||||
scrollToTopEvents = scrollToTopEvents,
|
scrollToTopEvents = scrollToTopEvents,
|
||||||
onNavigate = { backStack.add(it) },
|
|
||||||
onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
|
|
||||||
onHandleDeepLink = onHandleDeepLink,
|
onHandleDeepLink = onHandleDeepLink,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
entry<NodesRoutes.Nodes> {
|
entry<NodesRoutes.Nodes>(metadata = { ListDetailSceneStrategy.listPane() }) {
|
||||||
AdaptiveNodeListScreen(
|
AdaptiveNodeListScreen(
|
||||||
backStack = backStack,
|
backStack = backStack,
|
||||||
scrollToTopEvents = scrollToTopEvents,
|
scrollToTopEvents = scrollToTopEvents,
|
||||||
onNavigate = { backStack.add(it) },
|
|
||||||
onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
|
|
||||||
onHandleDeepLink = onHandleDeepLink,
|
onHandleDeepLink = onHandleDeepLink,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -92,42 +94,42 @@ fun EntryProviderScope<NavKey>.nodesGraph(
|
|||||||
nodeDetailGraph(backStack, scrollToTopEvents, onHandleDeepLink)
|
nodeDetailGraph(backStack, scrollToTopEvents, onHandleDeepLink)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||||
@Suppress("LongMethod")
|
@Suppress("LongMethod")
|
||||||
fun EntryProviderScope<NavKey>.nodeDetailGraph(
|
fun EntryProviderScope<NavKey>.nodeDetailGraph(
|
||||||
backStack: NavBackStack<NavKey>,
|
backStack: NavBackStack<NavKey>,
|
||||||
scrollToTopEvents: Flow<ScrollToTopEvent>,
|
scrollToTopEvents: Flow<ScrollToTopEvent>,
|
||||||
onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
|
onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
|
||||||
) {
|
) {
|
||||||
entry<NodesRoutes.NodeDetailGraph> { args ->
|
entry<NodesRoutes.NodeDetailGraph>(metadata = { ListDetailSceneStrategy.listPane() }) { args ->
|
||||||
AdaptiveNodeListScreen(
|
AdaptiveNodeListScreen(
|
||||||
backStack = backStack,
|
backStack = backStack,
|
||||||
scrollToTopEvents = scrollToTopEvents,
|
scrollToTopEvents = scrollToTopEvents,
|
||||||
initialNodeId = args.destNum,
|
|
||||||
onNavigate = { backStack.add(it) },
|
|
||||||
onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
|
|
||||||
onHandleDeepLink = onHandleDeepLink,
|
onHandleDeepLink = onHandleDeepLink,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
entry<NodesRoutes.NodeDetail> { args ->
|
entry<NodesRoutes.NodeDetail>(metadata = { ListDetailSceneStrategy.detailPane() }) { args ->
|
||||||
AdaptiveNodeListScreen(
|
val nodeDetailViewModel: NodeDetailViewModel = koinViewModel()
|
||||||
backStack = backStack,
|
val compassViewModel: CompassViewModel = koinViewModel()
|
||||||
scrollToTopEvents = scrollToTopEvents,
|
val destNum = args.destNum ?: 0 // Handle nullable destNum if needed
|
||||||
initialNodeId = args.destNum,
|
NodeDetailScreen(
|
||||||
|
nodeId = destNum,
|
||||||
|
viewModel = nodeDetailViewModel,
|
||||||
|
compassViewModel = compassViewModel,
|
||||||
|
navigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
|
||||||
onNavigate = { backStack.add(it) },
|
onNavigate = { backStack.add(it) },
|
||||||
onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
|
onNavigateUp = { backStack.removeLastOrNull() },
|
||||||
onHandleDeepLink = onHandleDeepLink,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
entry<NodeDetailRoutes.NodeMap> { args ->
|
entry<NodeDetailRoutes.NodeMap>(metadata = { ListDetailSceneStrategy.extraPane() }) { args ->
|
||||||
val mapScreen = org.meshtastic.core.ui.util.LocalNodeMapScreenProvider.current
|
val mapScreen = org.meshtastic.core.ui.util.LocalNodeMapScreenProvider.current
|
||||||
mapScreen(args.destNum) { backStack.removeLastOrNull() }
|
mapScreen(args.destNum) { backStack.removeLastOrNull() }
|
||||||
}
|
}
|
||||||
|
|
||||||
entry<NodeDetailRoutes.TracerouteLog> { args ->
|
entry<NodeDetailRoutes.TracerouteLog>(metadata = { ListDetailSceneStrategy.extraPane() }) { args ->
|
||||||
val metricsViewModel =
|
val metricsViewModel = koinViewModel<MetricsViewModel> { parametersOf(args.destNum) }
|
||||||
koinViewModel<MetricsViewModel>(key = "metrics-${args.destNum}") { parametersOf(args.destNum) }
|
|
||||||
metricsViewModel.setNodeId(args.destNum)
|
metricsViewModel.setNodeId(args.destNum)
|
||||||
|
|
||||||
TracerouteLogScreen(
|
TracerouteLogScreen(
|
||||||
@@ -145,7 +147,7 @@ fun EntryProviderScope<NavKey>.nodeDetailGraph(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
entry<NodeDetailRoutes.TracerouteMap> { args ->
|
entry<NodeDetailRoutes.TracerouteMap>(metadata = { ListDetailSceneStrategy.extraPane() }) { args ->
|
||||||
val tracerouteMapScreen = org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider.current
|
val tracerouteMapScreen = org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider.current
|
||||||
tracerouteMapScreen(args.destNum, args.requestId, args.logUuid) { backStack.removeLastOrNull() }
|
tracerouteMapScreen(args.destNum, args.requestId, args.logUuid) { backStack.removeLastOrNull() }
|
||||||
}
|
}
|
||||||
@@ -175,14 +177,15 @@ fun EntryProviderScope<NavKey>.nodeDetailGraph(
|
|||||||
|
|
||||||
fun NavKey.isNodeDetailRoute(): Boolean = NodeDetailRoute.entries.any { this::class == it.routeClass }
|
fun NavKey.isNodeDetailRoute(): Boolean = NodeDetailRoute.entries.any { this::class == it.routeClass }
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||||
private inline fun <reified R : Route> EntryProviderScope<NavKey>.addNodeDetailScreenComposable(
|
private inline fun <reified R : Route> EntryProviderScope<NavKey>.addNodeDetailScreenComposable(
|
||||||
backStack: NavBackStack<NavKey>,
|
backStack: NavBackStack<NavKey>,
|
||||||
routeInfo: NodeDetailRoute,
|
routeInfo: NodeDetailRoute,
|
||||||
crossinline getDestNum: (R) -> Int,
|
crossinline getDestNum: (R) -> Int,
|
||||||
) {
|
) {
|
||||||
entry<R> { args ->
|
entry<R>(metadata = { ListDetailSceneStrategy.extraPane() }) { args ->
|
||||||
val destNum = getDestNum(args)
|
val destNum = getDestNum(args)
|
||||||
val metricsViewModel = koinViewModel<MetricsViewModel>(key = "metrics-$destNum") { parametersOf(destNum) }
|
val metricsViewModel = koinViewModel<MetricsViewModel> { parametersOf(destNum) }
|
||||||
metricsViewModel.setNodeId(destNum)
|
metricsViewModel.setNodeId(destNum)
|
||||||
|
|
||||||
routeInfo.screenComposable(metricsViewModel) { backStack.removeLastOrNull() }
|
routeInfo.screenComposable(metricsViewModel) { backStack.removeLastOrNull() }
|
||||||
|
|||||||
@@ -140,6 +140,8 @@ compose-multiplatform-materialIconsExtended = { module = "org.jetbrains.compose.
|
|||||||
jetbrains-compose-material3-adaptive = { module = "org.jetbrains.compose.material3.adaptive:adaptive", version.ref = "jetbrains-adaptive" }
|
jetbrains-compose-material3-adaptive = { module = "org.jetbrains.compose.material3.adaptive:adaptive", version.ref = "jetbrains-adaptive" }
|
||||||
jetbrains-compose-material3-adaptive-layout = { module = "org.jetbrains.compose.material3.adaptive:adaptive-layout", version.ref = "jetbrains-adaptive" }
|
jetbrains-compose-material3-adaptive-layout = { module = "org.jetbrains.compose.material3.adaptive:adaptive-layout", version.ref = "jetbrains-adaptive" }
|
||||||
jetbrains-compose-material3-adaptive-navigation = { module = "org.jetbrains.compose.material3.adaptive:adaptive-navigation", version.ref = "jetbrains-adaptive" }
|
jetbrains-compose-material3-adaptive-navigation = { module = "org.jetbrains.compose.material3.adaptive:adaptive-navigation", version.ref = "jetbrains-adaptive" }
|
||||||
|
jetbrains-compose-material3-adaptive-navigation3 = { module = "org.jetbrains.compose.material3.adaptive:adaptive-navigation3", version.ref = "jetbrains-adaptive" }
|
||||||
|
jetbrains-compose-material3-adaptive-navigation-suite = { module = "org.jetbrains.compose.material3:material3-adaptive-navigation-suite", version.ref = "compose-multiplatform-material3" }
|
||||||
|
|
||||||
# Google
|
# Google
|
||||||
firebase-analytics = { module = "com.google.firebase:firebase-analytics" }
|
firebase-analytics = { module = "com.google.firebase:firebase-analytics" }
|
||||||
|
|||||||
Reference in New Issue
Block a user