feat(shell): swipe between tabs

The four tabs are now pages of one HorizontalPager on a single NavHost
route, so a sideways swipe moves between Alarms, Timers, Stopwatch and
World clock as well as tapping the bar. The pager is the source of truth
for the selected tab; editors, the timer setup page and Settings stay
NavHost destinations pushed over it. A tab tap from one of them pops
back to the tabs, and a deep link always lands on its tab rather than on
whatever nested screen was last open there.

Swiping is on only under a navigation bar: beside a rail the tabs are
stacked vertically, and a horizontal swipe has no counterpart there.

Closes #2
This commit is contained in:
2026-10-05 19:42:14 +02:00
parent 45e5a51c98
commit be526711d7
6 changed files with 358 additions and 216 deletions
@@ -3,8 +3,14 @@ package de.jeanlucmakiola.clockula.ui.shell
import androidx.annotation.StringRes
import de.jeanlucmakiola.clockula.R
/** The shell's four top-level destinations, in tab order. No Compose types. */
enum class ClockulaDestination(val route: String, @StringRes val titleRes: Int) {
/**
* The shell's four top-level destinations, in tab order — which is also their
* page order in the shell's pager. No Compose types.
*
* [key] is the tab's page key: stable and `Bundle`-saveable, so a tab's
* `rememberSaveable` state is filed under it rather than under its position.
*/
enum class ClockulaDestination(val key: String, @StringRes val titleRes: Int) {
ALARMS("alarms", R.string.tab_alarms),
TIMERS("timers", R.string.tab_timers),
STOPWATCH("stopwatch", R.string.tab_stopwatch),
@@ -8,15 +8,22 @@ import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteItem
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -47,6 +54,10 @@ import de.jeanlucmakiola.clockula.ui.timers.TimersScreen
import de.jeanlucmakiola.clockula.ui.worldclock.WorldClockScreen
import de.jeanlucmakiola.floret.identity.fadeThrough
import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.sign
/**
* The adaptive navigation shell: four tabs, the live pill, predictive back.
@@ -55,10 +66,16 @@ import de.jeanlucmakiola.floret.identity.predictiveBack
* size — a short navigation bar on a phone, a wide rail on a tablet — because
* `NavigationSuiteScaffold`'s default already answers the tabletop posture and
* the compact-*height* case that an `if (compact) bar else rail` would miss.
* The shell computes that default itself, with the scaffold's own function, so
* the pager can ask the same answer whether it may swipe.
*
* The `NavHost` is the single source of truth for the selected tab: Navigation
* Compose already saves its back stack through a `SavedStateHandle`, so the
* selection survives rotation and process death with no mirror of our own.
* The four tabs are the pages of one [HorizontalPager] on a single `NavHost`
* route, [ShellNavigation.TABS_ROUTE], so they can be swiped between under a
* navigation bar ([ShellNavigation.swipesBetweenTabs] says why not under a
* rail). The pager is the single source of truth for the selected tab — its
* state is saveable, so the tab survives rotation and process death with no
* mirror of ours to drift — and the `NavHost` holds only what is pushed over
* the tabs: the editors, the timer setup page, Settings.
*/
@Composable
fun ClockulaShell(
@@ -71,11 +88,24 @@ fun ClockulaShell(
val pill by viewModel.livePill.collectAsStateWithLifecycle()
val entry by navController.currentBackStackEntryAsState()
val route = entry?.destination?.route
val selected = ShellNavigation.selectedDestinationOf(route) ?: ShellNavigation.start
val back = ShellNavigation.onBack(route)
val scope = rememberCoroutineScope()
val reduceMotion = rememberReduceMotion()
// Peer destinations have no spatial relationship, so tabs fade through
// rather than sliding: a slide would claim a hierarchy that is not there.
// Hoisted above the NavHost, so it outlives the tabs route while a nested
// screen covers it, and a tab tap from an editor can still move it.
val pagerState = rememberPagerState(initialPage = ShellNavigation.pageOf(ShellNavigation.start)) {
ShellNavigation.topLevel.size
}
// A tapped tab is selected from the tap on, not from whichever page the
// slide happens to pass through on its way there.
var tabInFlight by remember { mutableStateOf<ClockulaDestination?>(null) }
val selected = tabInFlight ?: ShellNavigation.destinationAt(pagerState.targetPage)
val back = ShellNavigation.onBack(route, selected)
val navigationSuiteType = NavigationSuiteScaffoldDefaults.navigationSuiteType(currentWindowAdaptiveInfo())
// A nested screen is no peer of the tabs and has no spatial relationship
// to them, so it fades through rather than sliding.
val fade = fadeThrough()
// The `AlarmClock` contract's chooser, held across a rotation but not a
@@ -83,35 +113,49 @@ fun ClockulaShell(
// would be a dialog with no provenance (M9 D13).
var dismissCandidates by rememberSaveable { mutableStateOf(LongArray(0)) }
// A notification's deep link — or the exported door's outcome — replayed as
// exactly the command a tab tap produces, which is what the user expects,
// and it leaves M4's asserted back policy alone: the start destination is
// still Alarms (M6 D24, M9 D23).
// Replays a TabSelection — the policy's decision — onto the controller
// and the pager.
fun select(selection: TabSelection) {
if (selection.popToTabs) navController.popBackStack(ShellNavigation.TABS_ROUTE, inclusive = false)
val page = ShellNavigation.pageOf(selection.destination)
if (!selection.animate || reduceMotion) {
// Safe while the pager is not composed: the request is read on its
// next measure, which is when the tabs route comes back.
pagerState.requestScrollToPage(page)
return
}
scope.launch {
tabInFlight = selection.destination
try {
pagerState.slideTo(page)
} finally {
tabInFlight = null
}
}
}
// A notification's or widget's deep link — or the exported door's
// outcome — lands on its tab whatever was open there: a nested screen is
// popped rather than resumed. The start tab is still Alarms, so M4's
// asserted back policy is left alone (M6 D24, M9 D23).
LaunchedEffect(openRequest) {
when (val request = openRequest) {
null -> return@LaunchedEffect
is ShellRequest.OpenTab ->
navController.replay(ShellNavigation.onTabSelected(route, request.destination))
is ShellRequest.OpenTab -> select(ShellNavigation.onTabRequested(route, request.destination))
is ShellRequest.OpenAlarmEditor -> {
// The same two steps the FAB takes, so the back stack is the one
// M5 asserted: the tab, then the editor on top of it.
navController.replay(
ShellNavigation.onTabSelected(route, ClockulaDestination.ALARMS),
)
select(ShellNavigation.onTabRequested(route, ClockulaDestination.ALARMS))
navController.navigate(AlarmRoutes.editor(request.alarmId))
}
ShellRequest.ComposeTimer -> {
// The same two steps the FAB takes: the tab, then the
// full-screen setup page on top of it (M9 D7).
navController.replay(
ShellNavigation.onTabSelected(route, ClockulaDestination.TIMERS),
)
select(ShellNavigation.onTabRequested(route, ClockulaDestination.TIMERS))
navController.navigate(TimerRoutes.NEW)
}
is ShellRequest.ChooseAlarmToDismiss -> {
navController.replay(
ShellNavigation.onTabSelected(route, ClockulaDestination.ALARMS),
)
select(ShellNavigation.onTabRequested(route, ClockulaDestination.ALARMS))
dismissCandidates = request.alarmIds.toLongArray()
}
}
@@ -124,9 +168,7 @@ fun ClockulaShell(
val current = destination == selected
NavigationSuiteItem(
selected = current,
onClick = {
navController.replay(ShellNavigation.onTabSelected(route, destination))
},
onClick = { select(ShellNavigation.onTabSelected(route, destination)) },
// The label is right there beside it; announcing the glyph
// too would only say everything twice.
icon = { Icon(destination.icon(current), contentDescription = null) },
@@ -136,14 +178,13 @@ fun ClockulaShell(
},
modifier = modifier.predictiveBack(
onBack = {
if (back is ShellBackAction.GoTo) {
navController.replay(ShellNavigation.onTabSelected(route, back.destination))
}
if (back is ShellBackAction.GoTo) select(ShellNavigation.onTabSelected(route, back.destination))
},
// On Alarms the handler stands aside, so the gesture previews
// *leaving the app* — which is what it is about to do.
enabled = back is ShellBackAction.GoTo,
),
navigationSuiteType = navigationSuiteType,
) {
Box(Modifier.fillMaxSize()) {
CompositionLocalProvider(
@@ -151,24 +192,36 @@ fun ClockulaShell(
) {
NavHost(
navController = navController,
startDestination = ShellNavigation.start.route,
startDestination = ShellNavigation.TABS_ROUTE,
enterTransition = { fade.targetContentEnter },
exitTransition = { fade.initialContentExit },
popEnterTransition = { fade.targetContentEnter },
popExitTransition = { fade.initialContentExit },
) {
composable(ClockulaDestination.ALARMS.route) {
AlarmsScreen(
onEditAlarm = { navController.navigate(AlarmRoutes.editor(it)) },
onCreateAlarm = { navController.navigate(AlarmRoutes.newEditor(it)) },
onOpenSettings = { navController.navigate(SettingsRoutes.MAIN) },
dismissCandidates = dismissCandidates,
onDismissCandidatesConsumed = { dismissCandidates = LongArray(0) },
)
composable(ShellNavigation.TABS_ROUTE) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
// beyondViewportPageCount stays 0: a tab is composed
// only while it is on screen, so the stopwatch's
// ~60 Hz readout costs nothing from next door. An
// off-screen tab keeps its saveable state (scroll
// position and the like) under its key, and its
// ViewModel in this route's store.
key = { ShellNavigation.destinationAt(it).key },
userScrollEnabled = ShellNavigation.swipesBetweenTabs(navigationSuiteType),
) { page ->
Tab(
destination = ShellNavigation.destinationAt(page),
navController = navController,
dismissCandidates = dismissCandidates,
onDismissCandidatesConsumed = { dismissCandidates = LongArray(0) },
)
}
}
// A sibling route in the same flat host, not a nested one:
// it is no tab, so the Alarms tab stays selected and the
// shell stands aside on back (M5 D1).
// Nested screens in the same flat host: none is a tab, so
// the pager keeps the tab underneath selected and the shell
// stands aside on back (M5 D1).
composable(
route = AlarmRoutes.EDITOR_PATTERN,
arguments = listOf(
@@ -184,16 +237,6 @@ fun ClockulaShell(
openTimePicker = entry.arguments?.getBoolean(AlarmRoutes.ARG_PICK_TIME) == true,
)
}
composable(ClockulaDestination.TIMERS.route) {
TimersScreen(
onEditTimer = { navController.navigate(TimerRoutes.editor(it)) },
onAddTimer = { navController.navigate(TimerRoutes.NEW) },
onOpenSettings = { navController.navigate(SettingsRoutes.MAIN) },
)
}
// Sibling routes in the same flat host, exactly as the
// alarm editor is: neither is a tab, so the Timers tab
// stays selected and the shell stands aside on back (M5 D1).
composable(
route = TimerRoutes.EDITOR_PATTERN,
arguments = listOf(
@@ -205,19 +248,8 @@ fun ClockulaShell(
composable(TimerRoutes.NEW) {
TimerSetupScreen(onBack = { navController.popBackStack() })
}
composable(ClockulaDestination.STOPWATCH.route) {
StopwatchScreen(
onOpenSettings = { navController.navigate(SettingsRoutes.MAIN) },
)
}
composable(ClockulaDestination.WORLD_CLOCK.route) {
WorldClockScreen(
onOpenSettings = { navController.navigate(SettingsRoutes.MAIN) },
)
}
// Sibling routes, reachable from any tab via the gear — no
// single tab owns Settings, which is why ShellNavigation
// deliberately maps none of these to a parent destination.
// Reachable from any tab via the gear — no single tab owns
// Settings, and the tab it was opened from stays selected.
composable(SettingsRoutes.MAIN) {
SettingsScreen(
onBack = { navController.popBackStack() },
@@ -255,14 +287,39 @@ fun ClockulaShell(
}
}
/** Replays a [TabNavCommand] — the policy's decision — onto the controller. */
private fun NavHostController.replay(command: TabNavCommand) {
navigate(command.route) {
popUpTo(command.popUpTo) {
inclusive = command.popUpToInclusive
saveState = command.saveState
}
launchSingleTop = command.launchSingleTop
restoreState = command.restoreState
/** One tab's screen, as a page of the shell's pager. */
@Composable
private fun Tab(
destination: ClockulaDestination,
navController: NavHostController,
dismissCandidates: LongArray,
onDismissCandidatesConsumed: () -> Unit,
) {
val openSettings = { navController.navigate(SettingsRoutes.MAIN) }
when (destination) {
ClockulaDestination.ALARMS -> AlarmsScreen(
onEditAlarm = { navController.navigate(AlarmRoutes.editor(it)) },
onCreateAlarm = { navController.navigate(AlarmRoutes.newEditor(it)) },
onOpenSettings = openSettings,
dismissCandidates = dismissCandidates,
onDismissCandidatesConsumed = onDismissCandidatesConsumed,
)
ClockulaDestination.TIMERS -> TimersScreen(
onEditTimer = { navController.navigate(TimerRoutes.editor(it)) },
onAddTimer = { navController.navigate(TimerRoutes.NEW) },
onOpenSettings = openSettings,
)
ClockulaDestination.STOPWATCH -> StopwatchScreen(onOpenSettings = openSettings)
ClockulaDestination.WORLD_CLOCK -> WorldClockScreen(onOpenSettings = openSettings)
}
}
/**
* Slides to [page] from its neighbour: a tap two or three tabs away would
* otherwise compose — and flash past — every tab in between.
*/
private suspend fun PagerState.slideTo(page: Int) {
val distance = page - currentPage
if (abs(distance) > 1) scrollToPage(page - distance.sign)
animateScrollToPage(page)
}
@@ -1,20 +1,25 @@
package de.jeanlucmakiola.clockula.ui.shell
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
import de.jeanlucmakiola.clockula.interop.InteropIntents
import de.jeanlucmakiola.clockula.stopwatch.StopwatchIntents
import de.jeanlucmakiola.clockula.timer.TimerIntents
import de.jeanlucmakiola.clockula.ui.alarms.AlarmRoutes
import de.jeanlucmakiola.clockula.ui.timers.TimerRoutes
/** What a tab tap does to the back stack. Pure data; the composable replays it. */
data class TabNavCommand(
val route: String,
val popUpTo: String,
val popUpToInclusive: Boolean,
val saveState: Boolean,
val restoreState: Boolean,
val launchSingleTop: Boolean,
/**
* What selecting a tab does. Pure data; the shell replays it onto the
* `NavController` and the pager.
*/
data class TabSelection(
val destination: ClockulaDestination,
/** Pop whatever is open over the tabs — an editor, Settings — back to them first. */
val popToTabs: Boolean,
/**
* Slide the pager there. False jumps instead: over a nested screen the
* `NavHost`'s own fade carries the change, and a slide would play unseen
* underneath it.
*/
val animate: Boolean,
)
/**
@@ -47,45 +52,65 @@ object ShellNavigation {
*/
const val ACTION_SHOW_WORLD_CLOCK: String = "de.jeanlucmakiola.clockula.action.SHOW_WORLD_CLOCK"
/**
* The one `NavHost` route the four tabs share: a pager holds them, and the
* pager — not the back stack — says which one is showing. Everything else in
* the host (the editors, the timer setup page, Settings) is a nested screen
* pushed over it.
*/
const val TABS_ROUTE: String = "tabs"
val start: ClockulaDestination = ClockulaDestination.ALARMS
val topLevel: List<ClockulaDestination> = ClockulaDestination.entries.toList()
private val byRoute: Map<String, ClockulaDestination> = topLevel.associateBy { it.route }
/** The tab on pager page [page]; out of range clamps rather than throws. */
fun destinationAt(page: Int): ClockulaDestination = topLevel[page.coerceIn(0, topLevel.lastIndex)]
/** Exact match only: a nested route is not a tab. */
fun destinationOf(route: String?): ClockulaDestination? = byRoute[route]
/** The pager page [destination] lives on. */
fun pageOf(destination: ClockulaDestination): Int = topLevel.indexOf(destination)
/**
* Keep the parent tab highlighted while its editor — or setup page — is open.
*
* Settings (M10) deliberately has **no** entry here: it opens from a gear on
* every tab, not from one owning tab, so there is no single parent to
* highlight. It falls through to [destinationOf], which returns null for it,
* and the caller's `selected ?: start` then leaves Alarms highlighted
* underneath — the same fallback an unrecognised route already gets.
* Whether [route] is a screen pushed over the tabs. Null — before the host
* has its first destination — is not: there is nothing to pop yet.
*/
fun selectedDestinationOf(route: String?): ClockulaDestination? = when (route) {
AlarmRoutes.EDITOR_PATTERN -> ClockulaDestination.ALARMS
TimerRoutes.EDITOR_PATTERN, TimerRoutes.NEW -> ClockulaDestination.TIMERS
else -> destinationOf(route)
fun isNested(route: String?): Boolean = route != null && route != TABS_ROUTE
/**
* A tab tap. Over a nested screen it pops back to the tabs first, so
* re-selecting the tab underneath an editor closes the editor — the
* standard behaviour, and the contract M5's editor relies on. Every nested
* screen writes as the user goes (M5 D22), so popping one loses nothing.
*/
fun onTabSelected(currentRoute: String?, target: ClockulaDestination): TabSelection {
val nested = isNested(currentRoute)
return TabSelection(target, popToTabs = nested, animate = !nested)
}
/**
* The canonical multi-back-stack command. Re-selecting the tab the user is
* already on does *not* restore its saved stack, which pops that tab back
* to its own root — the standard behaviour, and the contract M5's editor
* will rely on.
* A tab an intent asks for — a notification, a widget, the exported door.
* The same pop as a tap, so it lands on the tab itself whatever was open,
* but a jump rather than a slide: the user did not come from the
* neighbouring tab, they came from outside the app.
*/
fun onTabSelected(currentRoute: String?, target: ClockulaDestination): TabNavCommand =
TabNavCommand(
route = target.route,
popUpTo = start.route,
popUpToInclusive = false,
saveState = true,
restoreState = currentRoute != target.route,
launchSingleTop = true,
)
fun onTabRequested(currentRoute: String?, target: ClockulaDestination): TabSelection =
onTabSelected(currentRoute, target).copy(animate = false)
/**
* Whether a horizontal swipe pages between the tabs, given the navigation
* component the window gets.
*
* Only under a navigation **bar**. A bar is a horizontal row of the same
* four tabs, so a sideways swipe maps onto it one to one. A rail stacks them
* vertically down the side of a tablet or an unfolded foldable, where a
* sideways swipe has no counterpart in the navigation the user can see —
* and a wide window's content is where a horizontal gesture is most likely
* meant for the content itself. There, tabs change by tapping the rail.
*/
fun swipesBetweenTabs(type: NavigationSuiteType): Boolean =
type == NavigationSuiteType.ShortNavigationBarCompact ||
type == NavigationSuiteType.ShortNavigationBarMedium ||
type == NavigationSuiteType.NavigationBar
/**
* The tab an incoming intent action asks for. Null for null and for
@@ -131,11 +156,13 @@ object ShellNavigation {
/**
* Alarms is the shell's root: back from any other tab returns to it, and
* back from Alarms leaves the app. An unrecognised route is a nested
* destination the `NavController` pops itself, so the shell stands aside.
* back from Alarms leaves the app. A nested screen is the `NavController`'s
* to pop, and a null route has nothing under it, so the shell stands aside.
*/
fun onBack(currentRoute: String?): ShellBackAction = when (destinationOf(currentRoute)) {
null, start -> ShellBackAction.Exit
else -> ShellBackAction.GoTo(start)
}
fun onBack(currentRoute: String?, current: ClockulaDestination): ShellBackAction =
if (currentRoute != TABS_ROUTE || current == start) {
ShellBackAction.Exit
} else {
ShellBackAction.GoTo(start)
}
}
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.clockula.ui.alarms
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.ui.shell.ClockulaDestination
import de.jeanlucmakiola.clockula.ui.shell.ShellBackAction
import de.jeanlucmakiola.clockula.ui.shell.ShellNavigation
import org.junit.jupiter.api.Test
@@ -23,12 +24,13 @@ class AlarmRoutesTest {
}
@Test
fun `the editor is no tab, so the alarms tab stays selected`() {
assertThat(ShellNavigation.destinationOf(AlarmRoutes.editor(7L))).isNull()
fun `the editor is a screen nested over the tabs`() {
assertThat(ShellNavigation.isNested(AlarmRoutes.EDITOR_PATTERN)).isTrue()
}
@Test
fun `the shell stands aside on back, leaving the editor to pop itself`() {
assertThat(ShellNavigation.onBack(AlarmRoutes.editor(7L))).isEqualTo(ShellBackAction.Exit)
assertThat(ShellNavigation.onBack(AlarmRoutes.EDITOR_PATTERN, ClockulaDestination.ALARMS))
.isEqualTo(ShellBackAction.Exit)
}
}
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.clockula.ui.shell
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.stopwatch.StopwatchIntents
import de.jeanlucmakiola.clockula.timer.TimerIntents
@@ -10,8 +11,9 @@ import org.junit.jupiter.api.Test
/**
* The shell's navigation policy as pure data: which tab is the root, what a tab
* tap does to the back stack, and where Back goes. The four lines that replay a
* [TabNavCommand] onto a `NavController` are not worth testing; this is.
* tap does to the back stack and the pager, and where Back goes. The lines that
* replay a [TabSelection] onto a `NavController` and a `PagerState` are not
* worth testing; this is.
*/
class ShellNavigationTest {
@@ -28,10 +30,10 @@ class ShellNavigationTest {
}
@Test
fun `every tab has its own non-blank route`() {
val routes = ShellNavigation.topLevel.map { it.route }
fun `every tab has its own non-blank page key`() {
val keys = ShellNavigation.topLevel.map { it.key }
assertThat(routes.filter { it.isNotBlank() }.toSet()).hasSize(4)
assertThat(keys.filter { it.isNotBlank() }.toSet()).hasSize(4)
}
@Test
@@ -46,133 +48,156 @@ class ShellNavigationTest {
assertThat(ShellNavigation.start).isEqualTo(ClockulaDestination.ALARMS)
}
// --- #2: the tabs are pages of one pager ---
@Test
fun `each tab's own route resolves back to it`() {
val resolved = ClockulaDestination.entries.map { ShellNavigation.destinationOf(it.route) }
fun `each tab's own page resolves back to it`() {
val resolved = ClockulaDestination.entries.map {
ShellNavigation.destinationAt(ShellNavigation.pageOf(it))
}
assertThat(resolved).isEqualTo(ClockulaDestination.entries.toList())
}
@Test
fun `a null route resolves to no destination`() {
assertThat(ShellNavigation.destinationOf(null)).isNull()
fun `pages follow tab order`() {
val pages = ShellNavigation.topLevel.map(ShellNavigation::pageOf)
assertThat(pages).containsExactly(0, 1, 2, 3).inOrder()
}
@Test
fun `an unknown route resolves to no destination`() {
assertThat(ShellNavigation.destinationOf("nope")).isNull()
fun `an out-of-range page clamps instead of throwing`() {
assertThat(ShellNavigation.destinationAt(-1)).isEqualTo(ClockulaDestination.ALARMS)
assertThat(ShellNavigation.destinationAt(9)).isEqualTo(ClockulaDestination.WORLD_CLOCK)
}
@Test
fun `a nested route does not resolve by prefix`() {
assertThat(ShellNavigation.destinationOf("alarms/12")).isNull()
fun `the tabs route and an unknown host are not nested`() {
assertThat(ShellNavigation.isNested(ShellNavigation.TABS_ROUTE)).isFalse()
assertThat(ShellNavigation.isNested(null)).isFalse()
}
@Test
fun `selecting another tab saves and restores the tab's own stack`() {
val command = ShellNavigation.onTabSelected("alarms", ClockulaDestination.TIMERS)
fun `editors, the timer setup page and settings are nested over the tabs`() {
val routes = listOf(
AlarmRoutes.EDITOR_PATTERN,
TimerRoutes.EDITOR_PATTERN,
TimerRoutes.NEW,
SettingsRoutes.MAIN,
SettingsRoutes.SELF_CHECK,
SettingsRoutes.BACKUP,
)
assertThat(command).isEqualTo(
TabNavCommand(
route = "timers",
popUpTo = "alarms",
popUpToInclusive = false,
saveState = true,
restoreState = true,
launchSingleTop = true,
),
assertThat(routes.map(ShellNavigation::isNested).toSet()).containsExactly(true)
}
@Test
fun `a tap on the tabs slides there without touching the back stack`() {
val selection = ShellNavigation.onTabSelected(ShellNavigation.TABS_ROUTE, ClockulaDestination.TIMERS)
assertThat(selection).isEqualTo(
TabSelection(ClockulaDestination.TIMERS, popToTabs = false, animate = true),
)
}
@Test
fun `re-selecting the current tab pops its own stack instead of restoring it`() {
val command = ShellNavigation.onTabSelected("timers", ClockulaDestination.TIMERS)
fun `a tap over a nested screen pops back to the tabs and jumps`() {
val selection = ShellNavigation.onTabSelected(SettingsRoutes.MAIN, ClockulaDestination.STOPWATCH)
assertThat(command).isEqualTo(
TabNavCommand(
route = "timers",
popUpTo = "alarms",
popUpToInclusive = false,
saveState = true,
restoreState = false,
launchSingleTop = true,
),
assertThat(selection).isEqualTo(
TabSelection(ClockulaDestination.STOPWATCH, popToTabs = true, animate = false),
)
}
@Test
fun `selecting a tab before the first destination is known restores its stack`() {
val command = ShellNavigation.onTabSelected(null, ClockulaDestination.TIMERS)
fun `re-selecting the tab under its editor pops the editor`() {
val selection = ShellNavigation.onTabSelected(TimerRoutes.EDITOR_PATTERN, ClockulaDestination.TIMERS)
assertThat(command.restoreState).isTrue()
assertThat(selection.popToTabs).isTrue()
assertThat(selection.destination).isEqualTo(ClockulaDestination.TIMERS)
}
@Test
fun `every tab tap pops up to the start destination`() {
val popUpTos = ClockulaDestination.entries.flatMap { current ->
ClockulaDestination.entries.map { target ->
ShellNavigation.onTabSelected(current.route, target).popUpTo
}
}
fun `a tab asked for by an intent never slides, wherever the app was`() {
val routes = listOf(null, ShellNavigation.TABS_ROUTE, AlarmRoutes.EDITOR_PATTERN, SettingsRoutes.BACKUP)
assertThat(popUpTos.toSet()).containsExactly(ShellNavigation.start.route)
val animated = routes.map { ShellNavigation.onTabRequested(it, ClockulaDestination.WORLD_CLOCK).animate }
assertThat(animated.toSet()).containsExactly(false)
}
@Test
fun `a tab asked for by an intent pops any nested screen, so it lands on the tab itself`() {
val selection = ShellNavigation.onTabRequested(SettingsRoutes.MAIN, ClockulaDestination.ALARMS)
assertThat(selection).isEqualTo(
TabSelection(ClockulaDestination.ALARMS, popToTabs = true, animate = false),
)
}
@Test
fun `swiping pages the tabs under a navigation bar`() {
val bars = listOf(
NavigationSuiteType.ShortNavigationBarCompact,
NavigationSuiteType.ShortNavigationBarMedium,
NavigationSuiteType.NavigationBar,
)
assertThat(bars.map(ShellNavigation::swipesBetweenTabs).toSet()).containsExactly(true)
}
@Test
fun `swiping is off beside a rail, a drawer, or no navigation at all`() {
val others = listOf(
NavigationSuiteType.WideNavigationRailCollapsed,
NavigationSuiteType.WideNavigationRailExpanded,
NavigationSuiteType.NavigationRail,
NavigationSuiteType.NavigationDrawer,
NavigationSuiteType.None,
)
assertThat(others.map(ShellNavigation::swipesBetweenTabs).toSet()).containsExactly(false)
}
// --- back ---
@Test
fun `back from alarms leaves the app`() {
assertThat(ShellNavigation.onBack("alarms")).isEqualTo(ShellBackAction.Exit)
assertThat(ShellNavigation.onBack(ShellNavigation.TABS_ROUTE, ClockulaDestination.ALARMS))
.isEqualTo(ShellBackAction.Exit)
}
@Test
fun `back from any other tab returns to alarms`() {
val actions = listOf("timers", "stopwatch", "world_clock").map(ShellNavigation::onBack)
val actions = listOf(
ClockulaDestination.TIMERS,
ClockulaDestination.STOPWATCH,
ClockulaDestination.WORLD_CLOCK,
).map { ShellNavigation.onBack(ShellNavigation.TABS_ROUTE, it) }
assertThat(actions).containsExactly(
ShellBackAction.GoTo(ClockulaDestination.ALARMS),
ShellBackAction.GoTo(ClockulaDestination.ALARMS),
ShellBackAction.GoTo(ClockulaDestination.ALARMS),
)
assertThat(actions.toSet()).containsExactly(ShellBackAction.GoTo(ClockulaDestination.ALARMS))
}
@Test
fun `back before the first destination is known leaves the app`() {
assertThat(ShellNavigation.onBack(null)).isEqualTo(ShellBackAction.Exit)
assertThat(ShellNavigation.onBack(null, ClockulaDestination.TIMERS)).isEqualTo(ShellBackAction.Exit)
}
@Test
fun `back from a nested destination is not the shell's to intercept`() {
assertThat(ShellNavigation.onBack("alarms/12")).isEqualTo(ShellBackAction.Exit)
fun `back from a nested screen is not the shell's to intercept, whatever tab is under it`() {
val actions = ClockulaDestination.entries.map { ShellNavigation.onBack(SettingsRoutes.MAIN, it) }
assertThat(actions.toSet()).containsExactly(ShellBackAction.Exit)
}
// --- M6 §5.15: the timer editor's route, and the notification's deep link ---
/** §5.15 #1 */
@Test
fun `the timer editor is no tab, so the timers tab stays selected`() {
assertThat(ShellNavigation.destinationOf(TimerRoutes.editor(7L))).isNull()
}
// --- M10: settings has no single owning tab ---
@Test
fun `settings has no owning tab, so the caller falls back to the start destination`() {
assertThat(ShellNavigation.selectedDestinationOf(SettingsRoutes.MAIN)).isNull()
assertThat(ShellNavigation.selectedDestinationOf(SettingsRoutes.SELF_CHECK)).isNull()
assertThat(ShellNavigation.selectedDestinationOf(SettingsRoutes.BACKUP)).isNull()
}
@Test
fun `editors highlight their parent tab`() {
assertThat(ShellNavigation.selectedDestinationOf(AlarmRoutes.EDITOR_PATTERN))
.isEqualTo(ClockulaDestination.ALARMS)
assertThat(ShellNavigation.selectedDestinationOf(TimerRoutes.EDITOR_PATTERN))
.isEqualTo(ClockulaDestination.TIMERS)
}
/** §5.15 #2 */
@Test
fun `the shell stands aside on back, leaving the timer editor to pop itself`() {
assertThat(ShellNavigation.onBack(TimerRoutes.editor(7L))).isEqualTo(ShellBackAction.Exit)
assertThat(ShellNavigation.onBack(TimerRoutes.EDITOR_PATTERN, ClockulaDestination.TIMERS))
.isEqualTo(ShellBackAction.Exit)
}
/** §5.15 #3 */
+42 -17
View File
@@ -1304,27 +1304,46 @@ default rather than writing `if (compact) NavigationBar else NavigationRail` is
the point — the default also answers the tabletop posture and the compact-*height*
case a hand-rolled branch quietly gets wrong.
The `NavHost` is the single source of truth for the selected tab. There is no
`var selectedTab by rememberSaveable`: the selection is derived from
`currentBackStackEntryAsState()`, and Navigation Compose already saves its back
stack through a `SavedStateHandle`, so the tab survives rotation and process
death with no mirror of ours to drift.
**The tabs are pages of one `HorizontalPager` (#2).** Until then each tab was
its own `NavHost` route and the back stack was the source of truth for the
selected tab; the four tabs could only be tapped. Now they sit on a single
route, `ShellNavigation.TABS_ROUTE`, and the **pager** is the single source of
truth for the selected tab: its state is saveable, hoisted above the `NavHost`
so a tap from an editor can still move it, and the tab survives rotation and
process death with no mirror of ours to drift. The `NavHost` keeps only what is
pushed over the tabs — the two editors, the timer setup page, Settings. Each tab
is composed only while it is on screen (`beyondViewportPageCount` stays 0), keeps
its saveable state under its page key while off screen, and its ViewModel in the
tabs route's store.
The policy that back stack follows is a pure function, `ShellNavigation`:
Swiping is on only under a navigation **bar**: the bar is a horizontal row of
the same four tabs, so a sideways swipe maps onto it one to one. Beside a rail —
tablets, unfolded foldables — the tabs are stacked vertically and a sideways
swipe has no counterpart the user can see, so there the pager is tap-only. The
shell computes `NavigationSuiteScaffoldDefaults.navigationSuiteType` itself and
hands the same value to the scaffold, so the two cannot disagree.
- a tab tap is `popUpTo(start) { saveState = true }` + `launchSingleTop` +
`restoreState`; re-selecting the tab you are already on is the same command
with `restoreState = false`, which pops that tab's own inner stack back to its
root — the standard behaviour, and the contract M5's editor will rely on;
- back from any of the other three tabs returns to **Alarms**; back from Alarms,
from a null route and from any route the shell does not recognise leaves the
app — a nested destination's back belongs to the `NavController`, not to us.
The policy the shell follows is a pure function, `ShellNavigation`:
- a tab tap on the tabs slides the pager there — from the neighbouring page, so
a jump across two tabs does not compose and flash past the ones in between;
- a tab tap over a nested screen pops back to the tabs first and jumps, and
re-selecting the tab under an editor closes the editor — the contract M5's
editor relies on. A nested screen is no longer kept per tab and restored when
its tab is re-selected: every one writes as the user goes (M5 D22), so popping
one loses nothing;
- a tab an intent asks for (a notification, a widget, the exported door) is the
same pop and a jump — it always lands on the tab itself, never on whatever
nested screen was last open there;
- back from any of the other three tabs returns to **Alarms**; back from Alarms
and from a null route leaves the app, and a nested screen's back belongs to
the `NavController`, not to us.
Predictive back is the kit's `Modifier.predictiveBack`, gated on exactly that
policy: on Alarms the handler is *off*, so the gesture falls through to the
system and previews leaving the app, which is the truthful preview. Tab switches
use the kit's `fadeThrough()` — peer destinations have no spatial relationship,
and a slide would claim a hierarchy that is not there.
system and previews leaving the app, which is the truthful preview. Nested
screens use the kit's `fadeThrough()` — they have no spatial relationship to the
tabs, and a slide would claim one.
### The live pill
@@ -1439,6 +1458,10 @@ tab stays highlighted while the editor is open; `onBack` of that route is
`Exit`, so the shell's predictive-back handler stands aside and the editor pops
itself; and a tab tap's `popUpTo(start.route)` pops the editor, which is the
right behaviour and exactly the contract §12 wrote down for this milestone.
Since the tabs became pager pages (#2) the list lives on the shared tabs route
instead, and the same three properties hold through the pager: it keeps Alarms
selected under the editor, the shell stands aside on a nested route's back, and
a tab tap over the editor pops it.
There is **no "new alarm" sentinel id**. The FAB creates the alarm — enabled,
non-repeating, at the next whole hour, every override null — and then navigates
@@ -1940,7 +1963,9 @@ deep link: the content intent carries `TimerIntents.ACTION_SHOW_TIMERS`,
`ShellNavigation.onTabSelected` — the same command a tab tap produces.
`startDestination` stays **Alarms**: it is the `popUpTo` anchor and the root of
M4's asserted back policy, so changing it for a notification tap would rewrite
that policy.
that policy. (Since #2 the shell replays `ShellNavigation.onTabRequested`
instead: a tap's pop, but a jump rather than a slide, onto the pager whose start
page is still Alarms — §12.)
M9 finished that surface and widened it by one step. `tabForAction` gained
exactly **one** action, `AlarmIntents.ACTION_SHOW_ALARMS`: the platform's own