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