feat(shell): the navigation host, four tabs and the live pill

`NavigationSuiteScaffold` gives the bar on a phone and the rail on a wide
layout from one declaration. Each tab keeps its own back stack; re-selecting
a tab returns to its root, and predictive back is wired only where back
actually goes somewhere.

The pill is the running-state surface from PLAN §9: it floats above the bar,
speaks for whichever subject is closest to needing attention, and pauses or
stops it from any tab. Its callbacks are gated on a live subject, so a second
tap during its exit animation cannot restart a timer the user just reset.

The notification permission is asked once, and the flag is written from the
request's result rather than before it — a process death mid-dialog leaves
the ask due again rather than recorded and never made. Three tabs are empty
shells until M5 through M8 fill them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
This commit is contained in:
makiolaj committed 2026-09-12 13:07:18 +02:00
1 parent 199005e795
commit 83c5e9fe3f
21 files changed
+1544 -28

No files matched your search

@@ -0,0 +1,181 @@
package de.jeanlucmakiola.clockula.ui.shell
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.StopwatchRun
import de.jeanlucmakiola.clockula.domain.StopwatchState
import de.jeanlucmakiola.clockula.domain.Timer
import de.jeanlucmakiola.clockula.domain.TimerState
import de.jeanlucmakiola.clockula.domain.live.LivePillMode
import de.jeanlucmakiola.clockula.domain.live.LivePillSubject
import de.jeanlucmakiola.clockula.testing.BERLIN
import de.jeanlucmakiola.clockula.testing.FakeElapsedRealtimeClock
import de.jeanlucmakiola.clockula.testing.FakeStopwatchRepository
import de.jeanlucmakiola.clockula.testing.FakeTicker
import de.jeanlucmakiola.clockula.testing.FakeTimerRepository
import de.jeanlucmakiola.clockula.testing.FakeWallClock
import de.jeanlucmakiola.clockula.testing.instantAt
import de.jeanlucmakiola.clockula.testing.runningTimer
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
/**
* The pill's wiring: two repositories and one injected ticker, combined into a
* single distinct-until-changed stream. A repository write lands at once; the
* seconds ticking down cost nothing but a tick.
*/
class LivePillSourceTest {
private val now = 1.hours
private val wallInstant = instantAt(BERLIN, "2026-06-10T12:00")
private val timers = FakeTimerRepository()
private val stopwatch = FakeStopwatchRepository()
private val elapsed = FakeElapsedRealtimeClock(now)
private val wall = FakeWallClock(wallInstant)
private val ticker = FakeTicker()
private val source = LivePillSource(timers, stopwatch, elapsed, wall, ticker)
private fun running(id: Long = 7L, label: String = "Pasta", left: Duration): Timer =
runningTimer().copy(
id = id,
label = label,
state = TimerState.RUNNING,
remaining = left,
startedAtElapsedRealtime = Duration.ZERO,
endsAtElapsedRealtime = now + left,
endsAtWallClock = wallInstant + left,
sortOrder = 0,
)
private fun pausedForm(timer: Timer): Timer = timer.copy(
state = TimerState.PAUSED,
startedAtElapsedRealtime = null,
endsAtElapsedRealtime = null,
endsAtWallClock = null,
)
@Test
fun `nothing active emits no pill`() = runTest {
source.state().test {
assertThat(awaitItem()).isNull()
cancel()
}
}
@Test
fun `a timer appearing in the repository raises the pill`() = runTest {
source.state().test {
assertThat(awaitItem()).isNull()
timers.set(listOf(running(left = 4.minutes)))
assertThat(awaitItem()?.subject).isEqualTo(LivePillSubject.TimerSubject(7L, "Pasta"))
cancel()
}
}
@Test
fun `the readout is driven at the shell's pill cadence`() = runTest {
source.state().test {
awaitItem()
assertThat(ticker.requestedPeriods).containsExactly(ShellDefaults.PillTick)
cancel()
}
}
@Test
fun `a tick advances the readout without a repository write`() = runTest {
timers.set(listOf(running(left = 3.minutes)))
source.state().test {
assertThat(awaitItem()?.value).isEqualTo(3.minutes)
elapsed.advance(60.seconds)
ticker.tick()
assertThat(awaitItem()?.value).isEqualTo(2.minutes)
assertThat(timers.calls).isEmpty()
cancel()
}
}
@Test
fun `a tick against a frozen clock emits nothing`() = runTest {
timers.set(listOf(running(left = 3.minutes)))
source.state().test {
awaitItem()
ticker.tick()
ticker.tick()
expectNoEvents()
cancel()
}
}
@Test
fun `pausing the timer reaches the pill without waiting for a tick`() = runTest {
val timer = running(left = 3.minutes)
timers.set(listOf(timer))
source.state().test {
assertThat(awaitItem()?.mode).isEqualTo(LivePillMode.RUNNING)
timers.set(listOf(pausedForm(timer)))
assertThat(awaitItem()?.mode).isEqualTo(LivePillMode.PAUSED)
cancel()
}
}
@Test
fun `emptying the repository lowers the pill`() = runTest {
timers.set(listOf(running(left = 3.minutes)))
source.state().test {
assertThat(awaitItem()).isNotNull()
timers.set(emptyList())
assertThat(awaitItem()).isNull()
cancel()
}
}
@Test
fun `a timer takes the pill over from a running stopwatch`() = runTest {
stopwatch.set(
StopwatchRun(
state = StopwatchState.RUNNING,
startedAtElapsedRealtime = now - 30.seconds,
),
)
source.state().test {
assertThat(awaitItem()?.subject).isEqualTo(LivePillSubject.StopwatchSubject)
timers.set(listOf(running(left = 3.minutes)))
assertThat(awaitItem()?.subject).isEqualTo(LivePillSubject.TimerSubject(7L, "Pasta"))
cancel()
}
}
@Test
fun `a fresh collector is never blank for a cadence`() = runTest {
timers.set(listOf(running(left = 3.minutes)))
source.state().test {
assertThat(awaitItem()?.value).isEqualTo(3.minutes)
cancel()
}
}
}
@@ -0,0 +1,49 @@
package de.jeanlucmakiola.clockula.ui.shell
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/**
* The one-shot notification ask, minus Compose: whether a request is due, and —
* the part that was a bug — that "record the ask" is never the answer when a
* dialog still has to be put up. The preference is the effect's key, so a write
* before the launch cancels the very effect that was about to ask, and the
* install ends up recorded as asked while the user was never prompted.
*/
class NotificationPermissionTest {
@Test
fun `a request that is due asks, and does not record the ask first`() {
val decision = notificationAskDecision(asked = false, alreadyRequested = false, granted = false)
assertThat(decision).isEqualTo(NotificationAsk.REQUEST)
}
@Test
fun `an unread flag reads as asked, so nothing happens on a default`() {
val decision = notificationAskDecision(asked = true, alreadyRequested = false, granted = false)
assertThat(decision).isEqualTo(NotificationAsk.NOTHING)
}
@Test
fun `an already granted permission is recorded without a dialog`() {
val decision = notificationAskDecision(asked = false, alreadyRequested = false, granted = true)
assertThat(decision).isEqualTo(NotificationAsk.MARK_ONLY)
}
@Test
fun `a request already up is not put up twice`() {
val decision = notificationAskDecision(asked = false, alreadyRequested = true, granted = false)
assertThat(decision).isEqualTo(NotificationAsk.NOTHING)
}
@Test
fun `having asked once, a later grant changes nothing`() {
val decision = notificationAskDecision(asked = true, alreadyRequested = true, granted = true)
assertThat(decision).isEqualTo(NotificationAsk.NOTHING)
}
}
@@ -0,0 +1,141 @@
package de.jeanlucmakiola.clockula.ui.shell
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/**
* The shell's navigation policy as pure data: which tab is the root, what a tab
* tap does to the back stack, and where Back goes. The four lines that replay a
* [TabNavCommand] onto a `NavController` are not worth testing; this is.
*/
class ShellNavigationTest {
@Test
fun `the four tabs are listed in tab order`() {
val tabs = ShellNavigation.topLevel
assertThat(tabs).containsExactly(
ClockulaDestination.ALARMS,
ClockulaDestination.TIMERS,
ClockulaDestination.STOPWATCH,
ClockulaDestination.WORLD_CLOCK,
).inOrder()
}
@Test
fun `every tab has its own non-blank route`() {
val routes = ShellNavigation.topLevel.map { it.route }
assertThat(routes.filter { it.isNotBlank() }.toSet()).hasSize(4)
}
@Test
fun `every tab has its own title resource`() {
val titles = ShellNavigation.topLevel.map { it.titleRes }
assertThat(titles.filter { it != 0 }.toSet()).hasSize(4)
}
@Test
fun `alarms is the start destination`() {
assertThat(ShellNavigation.start).isEqualTo(ClockulaDestination.ALARMS)
}
@Test
fun `each tab's own route resolves back to it`() {
val resolved = ClockulaDestination.entries.map { ShellNavigation.destinationOf(it.route) }
assertThat(resolved).isEqualTo(ClockulaDestination.entries.toList())
}
@Test
fun `a null route resolves to no destination`() {
assertThat(ShellNavigation.destinationOf(null)).isNull()
}
@Test
fun `an unknown route resolves to no destination`() {
assertThat(ShellNavigation.destinationOf("nope")).isNull()
}
@Test
fun `a nested route does not resolve by prefix`() {
assertThat(ShellNavigation.destinationOf("alarms/12")).isNull()
}
@Test
fun `selecting another tab saves and restores the tab's own stack`() {
val command = ShellNavigation.onTabSelected("alarms", ClockulaDestination.TIMERS)
assertThat(command).isEqualTo(
TabNavCommand(
route = "timers",
popUpTo = "alarms",
popUpToInclusive = false,
saveState = true,
restoreState = true,
launchSingleTop = true,
),
)
}
@Test
fun `re-selecting the current tab pops its own stack instead of restoring it`() {
val command = ShellNavigation.onTabSelected("timers", ClockulaDestination.TIMERS)
assertThat(command).isEqualTo(
TabNavCommand(
route = "timers",
popUpTo = "alarms",
popUpToInclusive = false,
saveState = true,
restoreState = false,
launchSingleTop = true,
),
)
}
@Test
fun `selecting a tab before the first destination is known restores its stack`() {
val command = ShellNavigation.onTabSelected(null, ClockulaDestination.TIMERS)
assertThat(command.restoreState).isTrue()
}
@Test
fun `every tab tap pops up to the start destination`() {
val popUpTos = ClockulaDestination.entries.flatMap { current ->
ClockulaDestination.entries.map { target ->
ShellNavigation.onTabSelected(current.route, target).popUpTo
}
}
assertThat(popUpTos.toSet()).containsExactly(ShellNavigation.start.route)
}
@Test
fun `back from alarms leaves the app`() {
assertThat(ShellNavigation.onBack("alarms")).isEqualTo(ShellBackAction.Exit)
}
@Test
fun `back from any other tab returns to alarms`() {
val actions = listOf("timers", "stopwatch", "world_clock").map(ShellNavigation::onBack)
assertThat(actions).containsExactly(
ShellBackAction.GoTo(ClockulaDestination.ALARMS),
ShellBackAction.GoTo(ClockulaDestination.ALARMS),
ShellBackAction.GoTo(ClockulaDestination.ALARMS),
)
}
@Test
fun `back before the first destination is known leaves the app`() {
assertThat(ShellNavigation.onBack(null)).isEqualTo(ShellBackAction.Exit)
}
@Test
fun `back from a nested destination is not the shell's to intercept`() {
assertThat(ShellNavigation.onBack("alarms/12")).isEqualTo(ShellBackAction.Exit)
}
}
@@ -0,0 +1,163 @@
package de.jeanlucmakiola.clockula.ui.shell
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.StopwatchRun
import de.jeanlucmakiola.clockula.domain.StopwatchState
import de.jeanlucmakiola.clockula.domain.Timer
import de.jeanlucmakiola.clockula.domain.TimerState
import de.jeanlucmakiola.clockula.domain.live.LivePillMode
import de.jeanlucmakiola.clockula.domain.live.LivePillState
import de.jeanlucmakiola.clockula.domain.live.LivePillSubject
import de.jeanlucmakiola.clockula.testing.BERLIN
import de.jeanlucmakiola.clockula.testing.FakeElapsedRealtimeClock
import de.jeanlucmakiola.clockula.testing.FakeStopwatchRepository
import de.jeanlucmakiola.clockula.testing.FakeTicker
import de.jeanlucmakiola.clockula.testing.FakeTimerRepository
import de.jeanlucmakiola.clockula.testing.FakeWallClock
import de.jeanlucmakiola.clockula.testing.MainDispatcherExtension
import de.jeanlucmakiola.clockula.testing.instantAt
import de.jeanlucmakiola.clockula.testing.runningTimer
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
/**
* The pill's two buttons, and the promise behind them: the primary one pauses
* or resumes whatever the pill is showing, and Stop is always `reset` — the
* pill must not be able to destroy a timer the user will want again.
*/
class ShellViewModelTest {
@JvmField
@RegisterExtension
val main = MainDispatcherExtension()
private val now = 1.hours
private val wallInstant = instantAt(BERLIN, "2026-06-10T12:00")
private val timers = FakeTimerRepository()
private val stopwatch = FakeStopwatchRepository()
private val ticker = FakeTicker()
private fun viewModel() = ShellViewModel(
source = LivePillSource(
timers = timers,
stopwatch = stopwatch,
elapsed = FakeElapsedRealtimeClock(now),
wall = FakeWallClock(wallInstant),
ticker = ticker,
),
timers = timers,
stopwatch = stopwatch,
)
private fun timerState(id: Long = 7L, mode: LivePillMode) = LivePillState(
subject = LivePillSubject.TimerSubject(id, "Pasta"),
mode = mode,
value = 4.minutes,
otherActiveTimers = 0,
)
private fun stopwatchState(mode: LivePillMode) = LivePillState(
subject = LivePillSubject.StopwatchSubject,
mode = mode,
value = 4.minutes,
otherActiveTimers = 0,
)
private fun runningTimerRow(id: Long = 7L, left: Duration = 4.minutes): Timer =
runningTimer().copy(
id = id,
state = TimerState.RUNNING,
remaining = left,
startedAtElapsedRealtime = Duration.ZERO,
endsAtElapsedRealtime = now + left,
endsAtWallClock = wallInstant + left,
)
@Test
fun `the pill starts empty and then follows the source`() = runTest(main.dispatcher) {
val viewModel = viewModel()
viewModel.livePill.test {
assertThat(awaitItem()).isNull()
timers.set(listOf(runningTimerRow()))
assertThat(awaitItem()?.subject).isEqualTo(LivePillSubject.TimerSubject(7L, "Pasta"))
cancel()
}
}
@Test
fun `the primary action pauses a running timer`() = runTest(main.dispatcher) {
viewModel().onPillPrimaryAction(timerState(mode = LivePillMode.RUNNING))
assertThat(timers.calls.last()).isEqualTo("pause(7)")
}
@Test
fun `the primary action resumes a paused timer`() = runTest(main.dispatcher) {
viewModel().onPillPrimaryAction(timerState(mode = LivePillMode.PAUSED))
assertThat(timers.calls.last()).isEqualTo("start(7)")
}
@Test
fun `the primary action pauses a running stopwatch`() = runTest(main.dispatcher) {
viewModel().onPillPrimaryAction(stopwatchState(mode = LivePillMode.RUNNING))
assertThat(stopwatch.calls.last()).isEqualTo("pause()")
}
@Test
fun `the primary action resumes a paused stopwatch`() = runTest(main.dispatcher) {
viewModel().onPillPrimaryAction(stopwatchState(mode = LivePillMode.PAUSED))
assertThat(stopwatch.calls.last()).isEqualTo("start()")
}
@Test
fun `an expired timer has nothing left to pause`() = runTest(main.dispatcher) {
viewModel().onPillPrimaryAction(timerState(mode = LivePillMode.EXPIRED))
assertThat(timers.calls + stopwatch.calls).isEmpty()
}
@Test
fun `stop resets a timer in any mode and never deletes it`() = runTest(main.dispatcher) {
val viewModel = viewModel()
LivePillMode.entries.forEach { viewModel.onPillStop(timerState(mode = it)) }
assertThat(timers.calls).containsExactly("reset(7)", "reset(7)", "reset(7)")
}
@Test
fun `stop resets the stopwatch`() = runTest(main.dispatcher) {
viewModel().onPillStop(stopwatchState(mode = LivePillMode.RUNNING))
assertThat(stopwatch.calls.last()).isEqualTo("reset()")
}
@Test
fun `stopping the only active subject lowers the pill`() = runTest(main.dispatcher) {
timers.set(listOf(runningTimerRow()))
stopwatch.set(StopwatchRun(state = StopwatchState.IDLE))
val viewModel = viewModel()
viewModel.livePill.test {
val showing = awaitItem()
assertThat(showing).isNotNull()
viewModel.onPillStop(showing!!)
assertThat(awaitItem()).isNull()
cancel()
}
}
}