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 141b54ff9c
commit 9cdc78ac47
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.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)
}

View File

@@ -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(

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-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" }