diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index d411a2b65..5cca049c9 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -54,6 +54,7 @@ kotlin { implementation(libs.jetbrains.compose.material3.adaptive) implementation(libs.jetbrains.compose.material3.adaptive.layout) implementation(libs.jetbrains.compose.material3.adaptive.navigation) + implementation(libs.jetbrains.compose.material3.adaptive.navigation.suite) implementation(libs.jetbrains.navigationevent.compose) implementation(libs.jetbrains.navigation3.ui) } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticNavigationSuite.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticNavigationSuite.kt index ddcdfe1ff..a115429a0 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticNavigationSuite.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticNavigationSuite.kt @@ -22,27 +22,21 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut -import androidx.compose.foundation.layout.Box -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.BadgedBox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor 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.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TooltipAnchorPosition import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipDefaults 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.runtime.Composable import androidx.compose.runtime.getValue @@ -53,7 +47,6 @@ import androidx.compose.ui.Modifier 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.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DeviceType @@ -70,8 +63,11 @@ import org.meshtastic.core.ui.navigation.icon 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 - * desktop targets. + * Shared adaptive navigation shell using [NavigationSuiteScaffold]. + * + * Automatically renders a [NavigationBar][androidx.compose.material3.NavigationBar] on compact screens and a + * [NavigationRail][androidx.compose.material3.NavigationRail] on medium/expanded widths, without manual branching. Uses + * [currentWindowAdaptiveInfo] with large-width breakpoint support for Desktop and External Display targets. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -86,7 +82,6 @@ fun MeshtasticNavigationSuite( val selectedDevice by uiViewModel.currentDeviceAddressFlow.collectAsStateWithLifecycle() 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) @@ -95,37 +90,48 @@ fun MeshtasticNavigationSuite( handleNavigation(destination, topLevelDestination, currentKey, backStack, uiViewModel) } - if (isCompact) { - Scaffold( - modifier = modifier, - bottomBar = { - MeshtasticNavigationBar( - topLevelDestination = topLevelDestination, - connectionState = connectionState, - unreadMessageCount = unreadMessageCount, - selectedDevice = selectedDevice, - uiViewModel = uiViewModel, - onNavigate = onNavigate, + // Cap the layout type at NavigationRail for expanded widths — we don't want a permanent + // NavigationDrawer. NavigationSuiteScaffoldDefaults resolves COMPACT → NavigationBar, + // MEDIUM/EXPANDED → NavigationRail already; passing the custom adaptiveInfo ensures the + // large-width (1200dp+) breakpoints are respected. + val layoutType = NavigationSuiteScaffoldDefaults.calculateFromAdaptiveInfo(adaptiveInfo).coerceNavigationType() + + NavigationSuiteScaffold( + modifier = modifier, + layoutType = layoutType, + navigationSuiteItems = { + TopLevelDestination.entries.forEach { destination -> + item( + 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)) }, ) - }, - ) { padding -> - Box(modifier = Modifier.fillMaxSize().padding(padding)) { 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() } - } + } + }, + ) { + 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( destination: TopLevelDestination, topLevelDestination: TopLevelDestination?, @@ -164,65 +170,6 @@ private fun handleNavigation( } } -@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)) }, - ) - } - } -} - @OptIn(ExperimentalMaterial3Api::class) @Composable private fun NavigationIconContent( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 22b011598..92bc240d1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -140,6 +140,7 @@ 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-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-suite = { module = "org.jetbrains.compose.material3:material3-adaptive-navigation-suite", version.ref = "compose-multiplatform-material3" } # Google firebase-analytics = { module = "com.google.firebase:firebase-analytics" }