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:
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
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user