refactor(shell): the pill's stopwatch half goes through the engine

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.
This commit is contained in:
2026-09-22 10:24:08 +02:00
parent 7b727e0d40
commit 5e73e81dc3
7 changed files with 139 additions and 17 deletions
@@ -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,
)
}
@@ -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
}
@@ -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
}
@@ -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()
}
}
}
@@ -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)
}
}
@@ -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()
}
}
@@ -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()
}
}