From 5e73e81dc3717e656f855216dc65cfdea0414ffc Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Tue, 22 Sep 2026 10:24:08 +0200 Subject: [PATCH] refactor(shell): the pill's stopwatch half goes through the engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M6 left this as M7's: the pill wrote to the stopwatch repository directly because there was no engine to ask. There is one now, so the pill's buttons mean exactly what the tab's and the shade's mean, service transitions included. `LivePillSelector` is re-pointed at `StopwatchReadings` — the same extraction M6 did for the timers — and its existing cases pass unmodified, which is the proof that the reading moved rather than changed. A stopwatch notification now opens the Stopwatch tab. --- .../clockula/domain/live/LivePill.kt | 20 ++++-- .../clockula/ui/shell/ShellDefaults.kt | 5 +- .../clockula/ui/shell/ShellNavigation.kt | 2 + .../clockula/ui/shell/ShellViewModel.kt | 10 +-- .../domain/live/LivePillSelectorTest.kt | 27 +++++++ .../clockula/ui/shell/ShellNavigationTest.kt | 22 ++++++ .../clockula/ui/shell/ShellViewModelTest.kt | 70 +++++++++++++++++-- 7 files changed, 139 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/live/LivePill.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/live/LivePill.kt index 18d58a3..85ef90e 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/live/LivePill.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/live/LivePill.kt @@ -3,7 +3,7 @@ package de.jeanlucmakiola.clockula.domain.live import de.jeanlucmakiola.clockula.domain.StopwatchRun import de.jeanlucmakiola.clockula.domain.StopwatchState import de.jeanlucmakiola.clockula.domain.Timer -import de.jeanlucmakiola.clockula.domain.snapshotAt +import de.jeanlucmakiola.clockula.domain.stopwatch.StopwatchReadings import de.jeanlucmakiola.clockula.domain.timer.TimerMode import de.jeanlucmakiola.clockula.domain.timer.TimerReadings import kotlin.time.Duration @@ -58,14 +58,20 @@ object LivePillSelector { ) } - if (stopwatch.state == StopwatchState.IDLE) return null - // The snapshot, not the stored row: a run whose anchor did not survive a - // reboot reads as paused at what it banked rather than as running. - val snapshot = stopwatch.snapshotAt(elapsedRealtime) + if (!StopwatchReadings.isActive(stopwatch)) return null + // The reading, not the stored row, and not a second copy of it: the pill, + // the notification and the tab all ask "what does this read right now", + // and `StopwatchReadings` is the one answer (M7 D6). A run whose anchor + // did not survive a reboot reads as paused at what it banked. + val reading = StopwatchReadings.of(stopwatch, elapsedRealtime) return LivePillState( subject = LivePillSubject.StopwatchSubject, - mode = if (snapshot.isRunning) LivePillMode.RUNNING else LivePillMode.PAUSED, - value = snapshot.elapsed, + mode = if (reading.mode == StopwatchState.RUNNING) { + LivePillMode.RUNNING + } else { + LivePillMode.PAUSED + }, + value = reading.elapsed, otherActiveTimers = 0, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellDefaults.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellDefaults.kt index d81c665..2165940 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellDefaults.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellDefaults.kt @@ -29,8 +29,9 @@ object ShellDefaults { /** * One second: the pill formats to whole seconds, so anything faster is - * recomposition nobody can see. The stopwatch tab's centisecond readout is - * M7's, and will ask the ticker for its own period. + * recomposition nobody can see. The stopwatch tab's centisecond readout + * asks the ticker for its own period, `StopwatchDefaults.ReadoutTick` + * (~60 Hz), and only while the stopwatch is running (M7 D9). */ val PillTick: Duration = 1.seconds } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt index 77dc8a2..34c94bc 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.clockula.ui.shell +import de.jeanlucmakiola.clockula.stopwatch.StopwatchIntents import de.jeanlucmakiola.clockula.timer.TimerIntents /** What a tab tap does to the back stack. Pure data; the composable replays it. */ @@ -55,6 +56,7 @@ object ShellNavigation { */ fun tabForAction(action: String?): ClockulaDestination? = when (action) { TimerIntents.ACTION_SHOW_TIMERS -> ClockulaDestination.TIMERS + StopwatchIntents.ACTION_SHOW_STOPWATCH -> ClockulaDestination.STOPWATCH else -> null } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellViewModel.kt index 2e5636d..f7ecdb9 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellViewModel.kt @@ -3,11 +3,11 @@ package de.jeanlucmakiola.clockula.ui.shell import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchRepository import de.jeanlucmakiola.clockula.data.timers.TimerRepository 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.stopwatch.StopwatchEngine import de.jeanlucmakiola.clockula.timer.TimerEngine import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -20,7 +20,9 @@ import javax.inject.Inject class ShellViewModel @Inject internal constructor( source: LivePillSource, private val timers: TimerRepository, - private val stopwatch: StopwatchRepository, + // M7 D8: the pill's stopwatch branch goes through the engine too, so the + // foreground service moves with the state it shows. + private val stopwatchEngine: StopwatchEngine, // M6 D25: the pill's timer branch goes through the engine, so pausing from // the pill moves the expiry slot and the service with it. private val timerEngine: TimerEngine, @@ -42,7 +44,7 @@ class ShellViewModel @Inject internal constructor( if (resume) timerEngine.start(subject.timerId) else timerEngine.pause(subject.timerId) LivePillSubject.StopwatchSubject -> - if (resume) stopwatch.start() else stopwatch.pause() + if (resume) stopwatchEngine.start() else stopwatchEngine.pause() } } } @@ -56,7 +58,7 @@ class ShellViewModel @Inject internal constructor( // makes a timer its own preset (D13). Through the engine, so // stopping a *sounding* timer closes the ring session with it. is LivePillSubject.TimerSubject -> timerEngine.reset(subject.timerId) - LivePillSubject.StopwatchSubject -> stopwatch.reset() + LivePillSubject.StopwatchSubject -> stopwatchEngine.reset() } } } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/live/LivePillSelectorTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/live/LivePillSelectorTest.kt index ab787bf..6ec385f 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/domain/live/LivePillSelectorTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/live/LivePillSelectorTest.kt @@ -5,6 +5,7 @@ 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.stopwatch.StopwatchReadings import de.jeanlucmakiola.clockula.testing.BERLIN import de.jeanlucmakiola.clockula.testing.instantAt import de.jeanlucmakiola.clockula.testing.runningTimer @@ -386,4 +387,30 @@ class LivePillSelectorTest { assertThat(first).isEqualTo(second) } + + + // --- M7 §5.10: the pill and the tab share one reading (D6) --- + + /** §5.10 #1 */ + @Test + fun `the pill's stopwatch reading is StopwatchReadings' own`() { + val runs = listOf( + StopwatchRun(StopwatchState.RUNNING, 10.seconds, now - 30.seconds), + StopwatchRun(StopwatchState.PAUSED, 42.seconds, null), + StopwatchRun(StopwatchState.RUNNING, 25.seconds, now + 500.seconds), + idleStopwatch, + ) + + val pills = runs.map { select(emptyList(), run = it) } + + val expected = runs.map { run -> + val reading = StopwatchReadings.of(run, now) + when (reading.mode) { + StopwatchState.IDLE -> null + StopwatchState.RUNNING -> LivePillMode.RUNNING to reading.elapsed + StopwatchState.PAUSED -> LivePillMode.PAUSED to reading.elapsed + } + } + assertThat(pills.map { it?.let { pill -> pill.mode to pill.value } }).isEqualTo(expected) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationTest.kt index 8f6a482..2f4ebd0 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationTest.kt @@ -1,6 +1,7 @@ package de.jeanlucmakiola.clockula.ui.shell import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.stopwatch.StopwatchIntents import de.jeanlucmakiola.clockula.timer.TimerIntents import de.jeanlucmakiola.clockula.ui.timers.TimerRoutes import org.junit.jupiter.api.Test @@ -172,4 +173,25 @@ class ShellNavigationTest { assertThat(answers).containsExactly(null, null) } + + // --- M7 §5.11: the stopwatch notification's deep link (D16) --- + + /** §5.11 #1 */ + @Test + fun `a stopwatch notification asks for the stopwatch tab`() { + assertThat(ShellNavigation.tabForAction(StopwatchIntents.ACTION_SHOW_STOPWATCH)) + .isEqualTo(ClockulaDestination.STOPWATCH) + } + + /** §5.11 #2 */ + @Test + fun `the third action changes none of the other answers`() { + val answers = listOf( + ShellNavigation.tabForAction(TimerIntents.ACTION_SHOW_TIMERS), + ShellNavigation.tabForAction(null), + ShellNavigation.tabForAction("android.intent.action.VIEW"), + ) + + assertThat(answers).containsExactly(ClockulaDestination.TIMERS, null, null).inOrder() + } } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellViewModelTest.kt index 4c6fad5..45ff35a 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellViewModelTest.kt @@ -15,6 +15,7 @@ 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.StopwatchEngineSeams import de.jeanlucmakiola.clockula.testing.TimerEngineSeams import de.jeanlucmakiola.clockula.testing.MainDispatcherExtension import de.jeanlucmakiola.clockula.testing.instantAt @@ -57,6 +58,15 @@ class ShellViewModelTest { */ private val seams = TimerEngineSeams(timers = timers, now = wallInstant, uptime = now) + /** + * And the stopwatch branch goes through the **real** [StopwatchEngine] over + * the same repository (M7 D8): without it, pausing from the pill would + * leave the shade showing a stopwatch that is no longer running. The stored + * record — not a call log — is therefore what the stopwatch cases assert on. + */ + private val stopwatchSeams = + StopwatchEngineSeams(stopwatch = stopwatch, now = wallInstant, uptime = now) + private fun viewModel() = ShellViewModel( source = LivePillSource( timers = timers, @@ -66,7 +76,7 @@ class ShellViewModelTest { ticker = ticker, ), timers = timers, - stopwatch = stopwatch, + stopwatchEngine = stopwatchSeams.engine, timerEngine = seams.engine, ) @@ -77,6 +87,12 @@ class ShellViewModelTest { otherActiveTimers = 0, ) + private fun runningStopwatch(elapsedSoFar: Duration = 4.minutes): StopwatchRun = StopwatchRun( + state = StopwatchState.RUNNING, + accumulated = elapsedSoFar, + startedAtElapsedRealtime = now, + ) + private fun stopwatchState(mode: LivePillMode) = LivePillState( subject = LivePillSubject.StopwatchSubject, mode = mode, @@ -153,16 +169,20 @@ class ShellViewModelTest { @Test fun `the primary action pauses a running stopwatch`() = runTest(main.dispatcher) { + stopwatch.set(runningStopwatch()) + viewModel().onPillPrimaryAction(stopwatchState(mode = LivePillMode.RUNNING)) - assertThat(stopwatch.calls.last()).isEqualTo("pause()") + assertThat(stopwatch.currentRun().state).isEqualTo(StopwatchState.PAUSED) } @Test fun `the primary action resumes a paused stopwatch`() = runTest(main.dispatcher) { + stopwatch.set(StopwatchRun(state = StopwatchState.PAUSED, accumulated = 4.minutes)) + viewModel().onPillPrimaryAction(stopwatchState(mode = LivePillMode.PAUSED)) - assertThat(stopwatch.calls.last()).isEqualTo("start()") + assertThat(stopwatch.currentRun().state).isEqualTo(StopwatchState.RUNNING) } @Test @@ -188,9 +208,11 @@ class ShellViewModelTest { @Test fun `stop resets the stopwatch`() = runTest(main.dispatcher) { + stopwatch.set(runningStopwatch()) + viewModel().onPillStop(stopwatchState(mode = LivePillMode.RUNNING)) - assertThat(stopwatch.calls.last()).isEqualTo("reset()") + assertThat(stopwatch.currentRun()).isEqualTo(StopwatchRun()) } @Test @@ -258,4 +280,44 @@ class ShellViewModelTest { assertThat(seams.scheduler.scheduled).isEmpty() assertThat(seams.service.transitions).isEmpty() } + + // --- M7 §5.9: the pill's stopwatch branch goes through the engine (D8) --- + + /** §5.9 #1 */ + @Test + fun `pausing from the pill leaves the stopwatch service up`() = runTest(main.dispatcher) { + stopwatch.set(runningStopwatch()) + + viewModel().onPillPrimaryAction(stopwatchState(mode = LivePillMode.RUNNING)) + + assertThat(stopwatch.currentRun().state).isEqualTo(StopwatchState.PAUSED) + assertThat(stopwatchSeams.service.active).isTrue() + } + + /** §5.9 #2 */ + @Test + fun `stop resets the stopwatch and takes its service down`() = runTest(main.dispatcher) { + stopwatch.set(runningStopwatch()) + val viewModel = viewModel() + viewModel.onPillPrimaryAction(stopwatchState(mode = LivePillMode.RUNNING)) + + viewModel.onPillStop(stopwatchState(mode = LivePillMode.PAUSED)) + + assertThat(stopwatch.currentRun()).isEqualTo(StopwatchRun()) + assertThat(stopwatch.laps().first()).isEmpty() + assertThat(stopwatchSeams.service.transitions.last()).isFalse() + } + + /** §5.9 #3 */ + @Test + fun `the timer's half of the pill does not reach the stopwatch engine`() = + runTest(main.dispatcher) { + timers.set(listOf(runningTimerRow())) + val viewModel = viewModel() + + viewModel.onPillPrimaryAction(timerState(mode = LivePillMode.RUNNING)) + viewModel.onPillStop(timerState(mode = LivePillMode.RUNNING)) + + assertThat(stopwatchSeams.service.transitions).isEmpty() + } }