feat: refactor adaptive navigation using NavigationSuiteScaffold

- Integrate the `material3-adaptive-navigation-suite` library to streamline cross-platform navigation layouts.
- Replace manual branching logic for `NavigationBar` and `NavigationRail` with `NavigationSuiteScaffold` in `MeshtasticNavigationSuite`.
- Implement a `coerceNavigationType` helper to ensure `NavigationRail` is used for expanded widths instead of promoting to a `NavigationDrawer`.
- Utilize `currentWindowAdaptiveInfo` with large-width breakpoint support to improve responsiveness on Desktop and external displays.
- Remove redundant `MeshtasticNavigationBar` and `MeshtasticNavigationRail` private composables in favor of the standard navigation suite item API.
This commit is contained in:
James Rich 2026-03-26 12:23:26 -05:00
parent 3feec759a1
commit a5f72734ed
3 changed files with 48 additions and 99 deletions

View file

@ -54,6 +54,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.navigation.suite)
implementation(libs.jetbrains.navigationevent.compose) implementation(libs.jetbrains.navigationevent.compose)
implementation(libs.jetbrains.navigation3.ui) implementation(libs.jetbrains.navigation3.ui)
} }

View file

@ -22,27 +22,21 @@ 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.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
@ -53,7 +47,6 @@ import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey 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
@ -70,8 +63,11 @@ 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. *
* 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -86,7 +82,6 @@ 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 currentKey = backStack.lastOrNull()
val rootKey = backStack.firstOrNull() val rootKey = backStack.firstOrNull()
val topLevelDestination = TopLevelDestination.fromNavKey(rootKey) val topLevelDestination = TopLevelDestination.fromNavKey(rootKey)
@ -95,37 +90,48 @@ fun MeshtasticNavigationSuite(
handleNavigation(destination, topLevelDestination, currentKey, backStack, uiViewModel) handleNavigation(destination, topLevelDestination, currentKey, backStack, uiViewModel)
} }
if (isCompact) { // Cap the layout type at NavigationRail for expanded widths — we don't want a permanent
Scaffold( // NavigationDrawer. NavigationSuiteScaffoldDefaults resolves COMPACT → NavigationBar,
modifier = modifier, // MEDIUM/EXPANDED → NavigationRail already; passing the custom adaptiveInfo ensures the
bottomBar = { // large-width (1200dp+) breakpoints are respected.
MeshtasticNavigationBar( val layoutType = NavigationSuiteScaffoldDefaults.calculateFromAdaptiveInfo(adaptiveInfo).coerceNavigationType()
topLevelDestination = topLevelDestination,
connectionState = connectionState, NavigationSuiteScaffold(
unreadMessageCount = unreadMessageCount, modifier = modifier,
selectedDevice = selectedDevice, layoutType = layoutType,
uiViewModel = uiViewModel, navigationSuiteItems = {
onNavigate = onNavigate, 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() } ) {
} 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?,
@ -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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun NavigationIconContent( private fun NavigationIconContent(

View file

@ -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 = { 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-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" }