diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/tile/QuickTimerTile.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/tile/QuickTimerTile.kt index 49d1afb..6700f8c 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/tile/QuickTimerTile.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/tile/QuickTimerTile.kt @@ -17,37 +17,28 @@ import kotlin.time.Instant */ sealed interface QuickTimerTileModel { - /** What a tap does now. */ - val action: QuickTimerTileAction + /** Offering to start a timer of [duration]; a tap starts one. */ + data class AtRest(val duration: Duration) : QuickTimerTileModel - /** Offering to start a timer of [duration]. */ - data class AtRest(val duration: Duration) : QuickTimerTileModel { - override val action get() = QuickTimerTileAction.START + /** + * Its timer [timerId] active. A tap resets it — which, for a transient + * timer, stops it (or its ring) and deletes it, as the widget's second + * tap does. + */ + sealed interface Active : QuickTimerTileModel { + val timerId: Long } /** Its timer counting down, due at [endsAt]. */ - data class Running(val timerId: Long, val endsAt: Instant) : QuickTimerTileModel { - override val action get() = QuickTimerTileAction.RESET - } + data class Running(override val timerId: Long, val endsAt: Instant) : Active /** Its timer paused with [remaining] left. */ - data class Paused(val timerId: Long, val remaining: Duration) : QuickTimerTileModel { - override val action get() = QuickTimerTileAction.RESET - } + data class Paused(override val timerId: Long, val remaining: Duration) : Active /** Its timer ringing. */ - data class Expired(val timerId: Long) : QuickTimerTileModel { - override val action get() = QuickTimerTileAction.RESET - } + data class Expired(override val timerId: Long) : Active } -/** - * A tap at rest starts; a tap on an active timer resets it — which, for a - * transient timer, stops it (or its ring) and deletes it, exactly as the - * widget's second tap does. - */ -enum class QuickTimerTileAction { START, RESET } - object QuickTimerTilePresenter { /** diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/timer/QuickTileTimer.kt b/app/src/main/java/de/jeanlucmakiola/clockula/timer/QuickTileTimer.kt index 13e3ed4..c4c0d24 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/timer/QuickTileTimer.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/timer/QuickTileTimer.kt @@ -4,6 +4,7 @@ import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs import de.jeanlucmakiola.clockula.data.timers.QuickTileStateStore import de.jeanlucmakiola.clockula.data.timers.TimerRepository import de.jeanlucmakiola.clockula.domain.TimerDraft +import de.jeanlucmakiola.clockula.domain.TimerState import de.jeanlucmakiola.clockula.domain.tile.QuickTimerTileModel import de.jeanlucmakiola.clockula.domain.tile.QuickTimerTilePresenter import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock @@ -55,6 +56,7 @@ class QuickTileTimer @Inject constructor( suspend fun onTap(): Unit = mutex.withLock { when (val current = model.first()) { is QuickTimerTileModel.AtRest -> { + dropStrandedTimer() val id = timers.create(TimerDraft(duration = current.duration, deleteAfterUse = true)) // Remembered before it starts, so a start that throws, or a // process that dies in between, never leaves a running timer @@ -63,12 +65,22 @@ class QuickTileTimer @Inject constructor( state.setTimerId(id) engine.start(id) } - is QuickTimerTileModel.Running -> reset(current.timerId) - is QuickTimerTileModel.Paused -> reset(current.timerId) - is QuickTimerTileModel.Expired -> reset(current.timerId) + is QuickTimerTileModel.Active -> reset(current.timerId) } } + /** + * A transient timer the tile created but never got running — a start that + * threw, or a process that died in between — would sit idle in the Timers + * list for good, since only a reset deletes one and a reset of an idle + * timer does nothing. The next tap clears it before making another. + */ + private suspend fun dropStrandedTimer() { + val id = state.currentTimerId() ?: return + val timer = timers.find(id) ?: return + if (timer.deleteAfterUse && timer.state == TimerState.IDLE) engine.delete(id) + } + private suspend fun reset(timerId: Long) { engine.reset(timerId) state.setTimerId(null) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/tile/TilePreferencesActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/tile/TilePreferencesActivity.kt index c23e623..fa5bed1 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/tile/TilePreferencesActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/tile/TilePreferencesActivity.kt @@ -22,8 +22,6 @@ import de.jeanlucmakiola.clockula.R import de.jeanlucmakiola.clockula.alarm.AlarmIntents import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs import de.jeanlucmakiola.clockula.tile.NextAlarmTileService -import de.jeanlucmakiola.clockula.tile.QuickTimerTileService -import de.jeanlucmakiola.clockula.tile.requestTileUpdate import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme import de.jeanlucmakiola.clockula.ui.widget.config.QuickTimerPicker import de.jeanlucmakiola.clockula.ui.widget.openAppIntent @@ -56,8 +54,8 @@ class TilePreferencesActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - // Exported for System UI, so anything else that starts it explicitly - // is turned away rather than shown a settings screen. + // Answers only the action System UI sends. Not access control — + // nothing behind it needs any: it is one setting, changed by a tap. if (intent?.action != TileService.ACTION_QS_TILE_PREFERENCES) { finish() return @@ -98,8 +96,6 @@ class TilePreferencesActivity : ComponentActivity() { saving = true lifecycleScope.launch { withContext(NonCancellable) { settingsPrefs.setQuickTileDuration(duration) } - // The one write to the tile's state that `WidgetSync` doesn't watch. - requestTileUpdate(this@TilePreferencesActivity, QuickTimerTileService::class.java) finish() } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/widget/WidgetSync.kt b/app/src/main/java/de/jeanlucmakiola/clockula/widget/WidgetSync.kt index f3c0e49..f32cd1a 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/widget/WidgetSync.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/widget/WidgetSync.kt @@ -10,6 +10,7 @@ import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository import de.jeanlucmakiola.clockula.tile.NextAlarmTileService import de.jeanlucmakiola.clockula.tile.QuickTimerTileService import de.jeanlucmakiola.clockula.tile.requestTileUpdate +import de.jeanlucmakiola.clockula.timer.QuickTileTimer import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.updateAll import kotlinx.coroutines.CoroutineScope @@ -39,12 +40,11 @@ import kotlin.time.Duration.Companion.milliseconds * (`widget/QuickTimerWidget.kt`). The analog clock reads none of them; its * date is kept current by [MidnightRefresh]. * - * The two Quick Settings tiles (#26) ride the same collectors: both are - * *active* tiles, so nothing redraws them outside a click unless the app asks - * the system to bind them, which is what a change here does. The quick-timer - * tile follows the timers, as the quick-timer widget does; its duration is - * written only by `ui/tile/TilePreferencesActivity.kt`, which asks for its - * own redraw. + * The two Quick Settings tiles (#26) are kept here too: both are *active* + * tiles, so nothing redraws them outside a click unless the app asks the + * system to bind them, which is what a change here does. The next-alarm tile + * rides the next-alarm widget's flow; the quick-timer tile follows its own + * model, which covers its timer, its remembered id and its duration. * * Started once, from [de.jeanlucmakiola.clockula.ClockulaApp.onCreate], in the * app's `@ApplicationScope` — never driven by a `Ticker`, so an idle home @@ -58,11 +58,15 @@ class WidgetSync @Inject constructor( private val timers: TimerRepository, private val stopwatch: StopwatchRepository, private val worldClocks: WorldClockRepository, + private val quickTile: QuickTileTimer, ) { suspend fun run(): Unit = coroutineScope { sync(alarmEngine.upcoming(), NextAlarmWidget(), tiles = listOf(NextAlarmTileService::class.java)) - sync(timers.timers(), TimerWidget(), QuickTimerWidget(), tiles = listOf(QuickTimerTileService::class.java)) + sync(timers.timers(), TimerWidget(), QuickTimerWidget()) + // The tile's own model rather than the timers alone: its duration + // (a pick, or a restored backup) and its timer id are part of it too. + sync(quickTile.model, tiles = listOf(QuickTimerTileService::class.java)) // A lap leaves the run untouched, so the lap count is watched too. sync(combine(stopwatch.run(), stopwatch.laps()) { run, laps -> run to laps.size }, StopwatchWidget()) sync(worldClocks.worldClocks(), ClockWidget()) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/tile/QuickTimerTileModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/tile/QuickTimerTileModelTest.kt index 5cbfc47..155cd48 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/domain/tile/QuickTimerTileModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/tile/QuickTimerTileModelTest.kt @@ -14,11 +14,10 @@ import kotlin.time.Duration.Companion.seconds class QuickTimerTileModelTest { @Test - fun `nothing started is at rest on its duration, and a tap starts`() { + fun `nothing started is at rest on its duration`() { val model = QuickTimerTilePresenter.present(5.minutes, null, listOf(runningTimerWith(id = 1, left = 1.minutes)), Duration.ZERO, T0) assertThat(model).isEqualTo(QuickTimerTileModel.AtRest(5.minutes)) - assertThat(model.action).isEqualTo(QuickTimerTileAction.START) } @Test @@ -28,23 +27,20 @@ class QuickTimerTileModelTest { val model = QuickTimerTilePresenter.present(5.minutes, 2L, timers, Duration.ZERO, T0) assertThat(model).isEqualTo(QuickTimerTileModel.Running(2L, T0 + 90.seconds)) - assertThat(model.action).isEqualTo(QuickTimerTileAction.RESET) } @Test - fun `paused shows what is left, and a tap resets`() { + fun `paused shows what is left`() { val model = QuickTimerTilePresenter.present(5.minutes, 1L, listOf(pausedTimer(id = 1, remaining = 2.minutes)), Duration.ZERO, T0) assertThat(model).isEqualTo(QuickTimerTileModel.Paused(1L, 2.minutes)) - assertThat(model.action).isEqualTo(QuickTimerTileAction.RESET) } @Test - fun `expired is done, and a tap resets`() { + fun `expired is done`() { val model = QuickTimerTilePresenter.present(5.minutes, 1L, listOf(expiredTimer(id = 1)), Duration.ZERO, T0) assertThat(model).isEqualTo(QuickTimerTileModel.Expired(1L)) - assertThat(model.action).isEqualTo(QuickTimerTileAction.RESET) } @Test diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/timer/QuickTileTimerTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/timer/QuickTileTimerTest.kt index e017289..df5b7c6 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/timer/QuickTileTimerTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/timer/QuickTileTimerTest.kt @@ -74,6 +74,20 @@ class QuickTileTimerTest { assertThat(second.state).isEqualTo(TimerState.RUNNING) } + @Test + fun `a transient timer it created but never started is cleared by the next tap`(@TempDir tempDir: Path) = runTest { + val harness = timerEngineHarness(tempDir) + val (tile, state) = harness.quickTile() + val stranded = harness.timers.create(TimerDraft(duration = 5.minutes, deleteAfterUse = true)) + state.setTimerId(stranded) + + tile.onTap() + + val left = harness.timers.timers().first().single() + assertThat(left.id).isNotEqualTo(stranded) + assertThat(left.state).isEqualTo(TimerState.RUNNING) + } + @Test fun `two taps at once start one timer and reset it, never two`(@TempDir tempDir: Path) = runTest { val harness = timerEngineHarness(tempDir)