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
+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