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() + } }