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:
makiolaj committed 2026-10-05 19:42:14 +02:00
1 parent 45e5a51c98
commit be526711d7
6 files changed
+358 -216

No files matched your search

@@ -3,8 +3,14 @@ package de.jeanlucmakiola.clockula.ui.shell
import androidx.annotation.StringRes import androidx.annotation.StringRes
import de.jeanlucmakiola.clockula.R 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), ALARMS("alarms", R.string.tab_alarms),
TIMERS("timers", R.string.tab_timers), TIMERS("timers", R.string.tab_timers),
STOPWATCH("stopwatch", R.string.tab_stopwatch), 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.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding 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.Icon
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteItem import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteItem
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf 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.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment 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.clockula.ui.worldclock.WorldClockScreen
import de.jeanlucmakiola.floret.identity.fadeThrough import de.jeanlucmakiola.floret.identity.fadeThrough
import de.jeanlucmakiola.floret.identity.predictiveBack 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. * 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 * size — a short navigation bar on a phone, a wide rail on a tablet — because
* `NavigationSuiteScaffold`'s default already answers the tabletop posture and * `NavigationSuiteScaffold`'s default already answers the tabletop posture and
* the compact-*height* case that an `if (compact) bar else rail` would miss. * 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 * The four tabs are the pages of one [HorizontalPager] on a single `NavHost`
* Compose already saves its back stack through a `SavedStateHandle`, so the * route, [ShellNavigation.TABS_ROUTE], so they can be swiped between under a
* selection survives rotation and process death with no mirror of our own. * 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 @Composable
fun ClockulaShell( fun ClockulaShell(
@@ -71,11 +88,24 @@ fun ClockulaShell(
val pill by viewModel.livePill.collectAsStateWithLifecycle() val pill by viewModel.livePill.collectAsStateWithLifecycle()
val entry by navController.currentBackStackEntryAsState() val entry by navController.currentBackStackEntryAsState()
val route = entry?.destination?.route val route = entry?.destination?.route
val selected = ShellNavigation.selectedDestinationOf(route) ?: ShellNavigation.start val scope = rememberCoroutineScope()
val back = ShellNavigation.onBack(route) val reduceMotion = rememberReduceMotion()
// Peer destinations have no spatial relationship, so tabs fade through // Hoisted above the NavHost, so it outlives the tabs route while a nested
// rather than sliding: a slide would claim a hierarchy that is not there. // 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() val fade = fadeThrough()
// The `AlarmClock` contract's chooser, held across a rotation but not a // 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). // would be a dialog with no provenance (M9 D13).
var dismissCandidates by rememberSaveable { mutableStateOf(LongArray(0)) } var dismissCandidates by rememberSaveable { mutableStateOf(LongArray(0)) }
// A notification's deep link — or the exported door's outcome — replayed as // Replays a TabSelection — the policy's decision — onto the controller
// exactly the command a tab tap produces, which is what the user expects, // and the pager.
// and it leaves M4's asserted back policy alone: the start destination is fun select(selection: TabSelection) {
// still Alarms (M6 D24, M9 D23). 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) { LaunchedEffect(openRequest) {
when (val request = openRequest) { when (val request = openRequest) {
null -> return@LaunchedEffect null -> return@LaunchedEffect
is ShellRequest.OpenTab -> is ShellRequest.OpenTab -> select(ShellNavigation.onTabRequested(route, request.destination))
navController.replay(ShellNavigation.onTabSelected(route, request.destination))
is ShellRequest.OpenAlarmEditor -> { is ShellRequest.OpenAlarmEditor -> {
// The same two steps the FAB takes, so the back stack is the one // 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. // M5 asserted: the tab, then the editor on top of it.
navController.replay( select(ShellNavigation.onTabRequested(route, ClockulaDestination.ALARMS))
ShellNavigation.onTabSelected(route, ClockulaDestination.ALARMS),
)
navController.navigate(AlarmRoutes.editor(request.alarmId)) navController.navigate(AlarmRoutes.editor(request.alarmId))
} }
ShellRequest.ComposeTimer -> { ShellRequest.ComposeTimer -> {
// The same two steps the FAB takes: the tab, then the // The same two steps the FAB takes: the tab, then the
// full-screen setup page on top of it (M9 D7). // full-screen setup page on top of it (M9 D7).
navController.replay( select(ShellNavigation.onTabRequested(route, ClockulaDestination.TIMERS))
ShellNavigation.onTabSelected(route, ClockulaDestination.TIMERS),
)
navController.navigate(TimerRoutes.NEW) navController.navigate(TimerRoutes.NEW)
} }
is ShellRequest.ChooseAlarmToDismiss -> { is ShellRequest.ChooseAlarmToDismiss -> {
navController.replay( select(ShellNavigation.onTabRequested(route, ClockulaDestination.ALARMS))
ShellNavigation.onTabSelected(route, ClockulaDestination.ALARMS),
)
dismissCandidates = request.alarmIds.toLongArray() dismissCandidates = request.alarmIds.toLongArray()
} }
} }
@@ -124,9 +168,7 @@ fun ClockulaShell(
val current = destination == selected val current = destination == selected
NavigationSuiteItem( NavigationSuiteItem(
selected = current, selected = current,
onClick = { onClick = { select(ShellNavigation.onTabSelected(route, destination)) },
navController.replay(ShellNavigation.onTabSelected(route, destination))
},
// The label is right there beside it; announcing the glyph // The label is right there beside it; announcing the glyph
// too would only say everything twice. // too would only say everything twice.
icon = { Icon(destination.icon(current), contentDescription = null) }, icon = { Icon(destination.icon(current), contentDescription = null) },
@@ -136,14 +178,13 @@ fun ClockulaShell(
}, },
modifier = modifier.predictiveBack( modifier = modifier.predictiveBack(
onBack = { onBack = {
if (back is ShellBackAction.GoTo) { if (back is ShellBackAction.GoTo) select(ShellNavigation.onTabSelected(route, back.destination))
navController.replay(ShellNavigation.onTabSelected(route, back.destination))
}
}, },
// On Alarms the handler stands aside, so the gesture previews // On Alarms the handler stands aside, so the gesture previews
// *leaving the app* — which is what it is about to do. // *leaving the app* — which is what it is about to do.
enabled = back is ShellBackAction.GoTo, enabled = back is ShellBackAction.GoTo,
), ),
navigationSuiteType = navigationSuiteType,
) { ) {
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
CompositionLocalProvider( CompositionLocalProvider(
@@ -151,24 +192,36 @@ fun ClockulaShell(
) { ) {
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = ShellNavigation.start.route, startDestination = ShellNavigation.TABS_ROUTE,
enterTransition = { fade.targetContentEnter }, enterTransition = { fade.targetContentEnter },
exitTransition = { fade.initialContentExit }, exitTransition = { fade.initialContentExit },
popEnterTransition = { fade.targetContentEnter }, popEnterTransition = { fade.targetContentEnter },
popExitTransition = { fade.initialContentExit }, popExitTransition = { fade.initialContentExit },
) { ) {
composable(ClockulaDestination.ALARMS.route) { composable(ShellNavigation.TABS_ROUTE) {
AlarmsScreen( HorizontalPager(
onEditAlarm = { navController.navigate(AlarmRoutes.editor(it)) }, state = pagerState,
onCreateAlarm = { navController.navigate(AlarmRoutes.newEditor(it)) }, modifier = Modifier.fillMaxSize(),
onOpenSettings = { navController.navigate(SettingsRoutes.MAIN) }, // beyondViewportPageCount stays 0: a tab is composed
dismissCandidates = dismissCandidates, // only while it is on screen, so the stopwatch's
onDismissCandidatesConsumed = { dismissCandidates = LongArray(0) }, // ~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: // Nested screens in the same flat host: none is a tab, so
// it is no tab, so the Alarms tab stays selected and the // the pager keeps the tab underneath selected and the shell
// shell stands aside on back (M5 D1). // stands aside on back (M5 D1).
composable( composable(
route = AlarmRoutes.EDITOR_PATTERN, route = AlarmRoutes.EDITOR_PATTERN,
arguments = listOf( arguments = listOf(
@@ -184,16 +237,6 @@ fun ClockulaShell(
openTimePicker = entry.arguments?.getBoolean(AlarmRoutes.ARG_PICK_TIME) == true, 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( composable(
route = TimerRoutes.EDITOR_PATTERN, route = TimerRoutes.EDITOR_PATTERN,
arguments = listOf( arguments = listOf(
@@ -205,19 +248,8 @@ fun ClockulaShell(
composable(TimerRoutes.NEW) { composable(TimerRoutes.NEW) {
TimerSetupScreen(onBack = { navController.popBackStack() }) TimerSetupScreen(onBack = { navController.popBackStack() })
} }
composable(ClockulaDestination.STOPWATCH.route) { // Reachable from any tab via the gear — no single tab owns
StopwatchScreen( // Settings, and the tab it was opened from stays selected.
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.
composable(SettingsRoutes.MAIN) { composable(SettingsRoutes.MAIN) {
SettingsScreen( SettingsScreen(
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
@@ -255,14 +287,39 @@ fun ClockulaShell(
} }
} }
/** Replays a [TabNavCommand] — the policy's decision — onto the controller. */ /** One tab's screen, as a page of the shell's pager. */
private fun NavHostController.replay(command: TabNavCommand) { @Composable
navigate(command.route) { private fun Tab(
popUpTo(command.popUpTo) { destination: ClockulaDestination,
inclusive = command.popUpToInclusive navController: NavHostController,
saveState = command.saveState dismissCandidates: LongArray,
} onDismissCandidatesConsumed: () -> Unit,
launchSingleTop = command.launchSingleTop ) {
restoreState = command.restoreState 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 package de.jeanlucmakiola.clockula.ui.shell
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
import de.jeanlucmakiola.clockula.alarm.AlarmIntents import de.jeanlucmakiola.clockula.alarm.AlarmIntents
import de.jeanlucmakiola.clockula.interop.InteropIntents import de.jeanlucmakiola.clockula.interop.InteropIntents
import de.jeanlucmakiola.clockula.stopwatch.StopwatchIntents import de.jeanlucmakiola.clockula.stopwatch.StopwatchIntents
import de.jeanlucmakiola.clockula.timer.TimerIntents 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( * What selecting a tab does. Pure data; the shell replays it onto the
val route: String, * `NavController` and the pager.
val popUpTo: String, */
val popUpToInclusive: Boolean, data class TabSelection(
val saveState: Boolean, val destination: ClockulaDestination,
val restoreState: Boolean, /** Pop whatever is open over the tabs — an editor, Settings — back to them first. */
val launchSingleTop: Boolean, 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" 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 start: ClockulaDestination = ClockulaDestination.ALARMS
val topLevel: List<ClockulaDestination> = ClockulaDestination.entries.toList() 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. */ /** The pager page [destination] lives on. */
fun destinationOf(route: String?): ClockulaDestination? = byRoute[route] fun pageOf(destination: ClockulaDestination): Int = topLevel.indexOf(destination)
/** /**
* Keep the parent tab highlighted while its editor — or setup page — is open. * 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.
* 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.
*/ */
fun selectedDestinationOf(route: String?): ClockulaDestination? = when (route) { fun isNested(route: String?): Boolean = route != null && route != TABS_ROUTE
AlarmRoutes.EDITOR_PATTERN -> ClockulaDestination.ALARMS
TimerRoutes.EDITOR_PATTERN, TimerRoutes.NEW -> ClockulaDestination.TIMERS /**
else -> destinationOf(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 * A tab an intent asks for — a notification, a widget, the exported door.
* already on does *not* restore its saved stack, which pops that tab back * The same pop as a tap, so it lands on the tab itself whatever was open,
* to its own root — the standard behaviour, and the contract M5's editor * but a jump rather than a slide: the user did not come from the
* will rely on. * neighbouring tab, they came from outside the app.
*/ */
fun onTabSelected(currentRoute: String?, target: ClockulaDestination): TabNavCommand = fun onTabRequested(currentRoute: String?, target: ClockulaDestination): TabSelection =
TabNavCommand( onTabSelected(currentRoute, target).copy(animate = false)
route = target.route,
popUpTo = start.route, /**
popUpToInclusive = false, * Whether a horizontal swipe pages between the tabs, given the navigation
saveState = true, * component the window gets.
restoreState = currentRoute != target.route, *
launchSingleTop = true, * 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 * 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 * 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 * back from Alarms leaves the app. A nested screen is the `NavController`'s
* destination the `NavController` pops itself, so the shell stands aside. * to pop, and a null route has nothing under it, so the shell stands aside.
*/ */
fun onBack(currentRoute: String?): ShellBackAction = when (destinationOf(currentRoute)) { fun onBack(currentRoute: String?, current: ClockulaDestination): ShellBackAction =
null, start -> ShellBackAction.Exit if (currentRoute != TABS_ROUTE || current == start) {
else -> ShellBackAction.GoTo(start) ShellBackAction.Exit
} } else {
ShellBackAction.GoTo(start)
}
} }
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.clockula.ui.alarms package de.jeanlucmakiola.clockula.ui.alarms
import com.google.common.truth.Truth.assertThat 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.ShellBackAction
import de.jeanlucmakiola.clockula.ui.shell.ShellNavigation import de.jeanlucmakiola.clockula.ui.shell.ShellNavigation
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@@ -23,12 +24,13 @@ class AlarmRoutesTest {
} }
@Test @Test
fun `the editor is no tab, so the alarms tab stays selected`() { fun `the editor is a screen nested over the tabs`() {
assertThat(ShellNavigation.destinationOf(AlarmRoutes.editor(7L))).isNull() assertThat(ShellNavigation.isNested(AlarmRoutes.EDITOR_PATTERN)).isTrue()
} }
@Test @Test
fun `the shell stands aside on back, leaving the editor to pop itself`() { 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 package de.jeanlucmakiola.clockula.ui.shell
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.stopwatch.StopwatchIntents import de.jeanlucmakiola.clockula.stopwatch.StopwatchIntents
import de.jeanlucmakiola.clockula.timer.TimerIntents 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 * 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 * tap does to the back stack and the pager, and where Back goes. The lines that
* [TabNavCommand] onto a `NavController` are not worth testing; this is. * replay a [TabSelection] onto a `NavController` and a `PagerState` are not
* worth testing; this is.
*/ */
class ShellNavigationTest { class ShellNavigationTest {
@@ -28,10 +30,10 @@ class ShellNavigationTest {
} }
@Test @Test
fun `every tab has its own non-blank route`() { fun `every tab has its own non-blank page key`() {
val routes = ShellNavigation.topLevel.map { it.route } val keys = ShellNavigation.topLevel.map { it.key }
assertThat(routes.filter { it.isNotBlank() }.toSet()).hasSize(4) assertThat(keys.filter { it.isNotBlank() }.toSet()).hasSize(4)
} }
@Test @Test
@@ -46,133 +48,156 @@ class ShellNavigationTest {
assertThat(ShellNavigation.start).isEqualTo(ClockulaDestination.ALARMS) assertThat(ShellNavigation.start).isEqualTo(ClockulaDestination.ALARMS)
} }
// --- #2: the tabs are pages of one pager ---
@Test @Test
fun `each tab's own route resolves back to it`() { fun `each tab's own page resolves back to it`() {
val resolved = ClockulaDestination.entries.map { ShellNavigation.destinationOf(it.route) } val resolved = ClockulaDestination.entries.map {
ShellNavigation.destinationAt(ShellNavigation.pageOf(it))
}
assertThat(resolved).isEqualTo(ClockulaDestination.entries.toList()) assertThat(resolved).isEqualTo(ClockulaDestination.entries.toList())
} }
@Test @Test
fun `a null route resolves to no destination`() { fun `pages follow tab order`() {
assertThat(ShellNavigation.destinationOf(null)).isNull() val pages = ShellNavigation.topLevel.map(ShellNavigation::pageOf)
assertThat(pages).containsExactly(0, 1, 2, 3).inOrder()
} }
@Test @Test
fun `an unknown route resolves to no destination`() { fun `an out-of-range page clamps instead of throwing`() {
assertThat(ShellNavigation.destinationOf("nope")).isNull() assertThat(ShellNavigation.destinationAt(-1)).isEqualTo(ClockulaDestination.ALARMS)
assertThat(ShellNavigation.destinationAt(9)).isEqualTo(ClockulaDestination.WORLD_CLOCK)
} }
@Test @Test
fun `a nested route does not resolve by prefix`() { fun `the tabs route and an unknown host are not nested`() {
assertThat(ShellNavigation.destinationOf("alarms/12")).isNull() assertThat(ShellNavigation.isNested(ShellNavigation.TABS_ROUTE)).isFalse()
assertThat(ShellNavigation.isNested(null)).isFalse()
} }
@Test @Test
fun `selecting another tab saves and restores the tab's own stack`() { fun `editors, the timer setup page and settings are nested over the tabs`() {
val command = ShellNavigation.onTabSelected("alarms", ClockulaDestination.TIMERS) val routes = listOf(
AlarmRoutes.EDITOR_PATTERN,
TimerRoutes.EDITOR_PATTERN,
TimerRoutes.NEW,
SettingsRoutes.MAIN,
SettingsRoutes.SELF_CHECK,
SettingsRoutes.BACKUP,
)
assertThat(command).isEqualTo( assertThat(routes.map(ShellNavigation::isNested).toSet()).containsExactly(true)
TabNavCommand( }
route = "timers",
popUpTo = "alarms", @Test
popUpToInclusive = false, fun `a tap on the tabs slides there without touching the back stack`() {
saveState = true, val selection = ShellNavigation.onTabSelected(ShellNavigation.TABS_ROUTE, ClockulaDestination.TIMERS)
restoreState = true,
launchSingleTop = true, assertThat(selection).isEqualTo(
), TabSelection(ClockulaDestination.TIMERS, popToTabs = false, animate = true),
) )
} }
@Test @Test
fun `re-selecting the current tab pops its own stack instead of restoring it`() { fun `a tap over a nested screen pops back to the tabs and jumps`() {
val command = ShellNavigation.onTabSelected("timers", ClockulaDestination.TIMERS) val selection = ShellNavigation.onTabSelected(SettingsRoutes.MAIN, ClockulaDestination.STOPWATCH)
assertThat(command).isEqualTo( assertThat(selection).isEqualTo(
TabNavCommand( TabSelection(ClockulaDestination.STOPWATCH, popToTabs = true, animate = false),
route = "timers",
popUpTo = "alarms",
popUpToInclusive = false,
saveState = true,
restoreState = false,
launchSingleTop = true,
),
) )
} }
@Test @Test
fun `selecting a tab before the first destination is known restores its stack`() { fun `re-selecting the tab under its editor pops the editor`() {
val command = ShellNavigation.onTabSelected(null, ClockulaDestination.TIMERS) val selection = ShellNavigation.onTabSelected(TimerRoutes.EDITOR_PATTERN, ClockulaDestination.TIMERS)
assertThat(command.restoreState).isTrue() assertThat(selection.popToTabs).isTrue()
assertThat(selection.destination).isEqualTo(ClockulaDestination.TIMERS)
} }
@Test @Test
fun `every tab tap pops up to the start destination`() { fun `a tab asked for by an intent never slides, wherever the app was`() {
val popUpTos = ClockulaDestination.entries.flatMap { current -> val routes = listOf(null, ShellNavigation.TABS_ROUTE, AlarmRoutes.EDITOR_PATTERN, SettingsRoutes.BACKUP)
ClockulaDestination.entries.map { target ->
ShellNavigation.onTabSelected(current.route, target).popUpTo
}
}
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 @Test
fun `back from alarms leaves the app`() { 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 @Test
fun `back from any other tab returns to alarms`() { 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( assertThat(actions.toSet()).containsExactly(ShellBackAction.GoTo(ClockulaDestination.ALARMS))
ShellBackAction.GoTo(ClockulaDestination.ALARMS),
ShellBackAction.GoTo(ClockulaDestination.ALARMS),
ShellBackAction.GoTo(ClockulaDestination.ALARMS),
)
} }
@Test @Test
fun `back before the first destination is known leaves the app`() { 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 @Test
fun `back from a nested destination is not the shell's to intercept`() { fun `back from a nested screen is not the shell's to intercept, whatever tab is under it`() {
assertThat(ShellNavigation.onBack("alarms/12")).isEqualTo(ShellBackAction.Exit) 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 --- // --- 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 */ /** §5.15 #2 */
@Test @Test
fun `the shell stands aside on back, leaving the timer editor to pop itself`() { 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 */ /** §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* the point — the default also answers the tabletop posture and the compact-*height*
case a hand-rolled branch quietly gets wrong. case a hand-rolled branch quietly gets wrong.
The `NavHost` is the single source of truth for the selected tab. There is no **The tabs are pages of one `HorizontalPager` (#2).** Until then each tab was
`var selectedTab by rememberSaveable`: the selection is derived from its own `NavHost` route and the back stack was the source of truth for the
`currentBackStackEntryAsState()`, and Navigation Compose already saves its back selected tab; the four tabs could only be tapped. Now they sit on a single
stack through a `SavedStateHandle`, so the tab survives rotation and process route, `ShellNavigation.TABS_ROUTE`, and the **pager** is the single source of
death with no mirror of ours to drift. 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` + The policy the shell follows is a pure function, `ShellNavigation`:
`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 - a tab tap on the tabs slides the pager there — from the neighbouring page, so
root — the standard behaviour, and the contract M5's editor will rely on; a jump across two tabs does not compose and flash past the ones in between;
- back from any of the other three tabs returns to **Alarms**; back from Alarms, - a tab tap over a nested screen pops back to the tabs first and jumps, and
from a null route and from any route the shell does not recognise leaves the re-selecting the tab under an editor closes the editor — the contract M5's
app — a nested destination's back belongs to the `NavController`, not to us. 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 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 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 system and previews leaving the app, which is the truthful preview. Nested
use the kit's `fadeThrough()` — peer destinations have no spatial relationship, screens use the kit's `fadeThrough()` — they have no spatial relationship to the
and a slide would claim a hierarchy that is not there. tabs, and a slide would claim one.
### The live pill ### 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 `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 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. 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, 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 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. `ShellNavigation.onTabSelected` — the same command a tab tap produces.
`startDestination` stays **Alarms**: it is the `popUpTo` anchor and the root of `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 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 M9 finished that surface and widened it by one step. `tabForAction` gained
exactly **one** action, `AlarmIntents.ACTION_SHOW_ALARMS`: the platform's own exactly **one** action, `AlarmIntents.ACTION_SHOW_ALARMS`: the platform's own