From da9854abf82b6a843179749f031a04c972aaa4e6 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 22 Oct 2026 08:09:14 +0200 Subject: [PATCH 1/3] feat(tiles): Quick Settings tiles for the next alarm and a quick timer "Next alarm" reads the next-alarm widget's model and shows the weekday and time of the next alarm, or "No alarms"; a tap opens the Alarms tab, behind an unlock on a locked device, through the PendingIntent overload of startActivityAndCollapse on API 34+. "Quick timer" is the quick-timer widget's twin: at rest it shows its duration and a tap starts a transient timer through the timer engine; while that timer is active the tile is on, shows when it ends (or that it is paused or done), and a tap resets it. It works on a locked device, as the timer notification does. Its duration is a new backed-up setting, chosen by long-pressing the tile, with the widget's bounds and picker. Both are active tiles, redrawn by TileSync when the state they mirror changes rather than bound on every panel open. No new permission. Fixes #26 --- CHANGELOG.md | 4 + app/src/main/AndroidManifest.xml | 50 ++++++++ .../de/jeanlucmakiola/clockula/ClockulaApp.kt | 6 + .../clockula/backup/BackupEngine.kt | 1 + .../clockula/data/backup/BackupCodec.kt | 3 + .../clockula/data/backup/BackupDto.kt | 2 + .../clockula/data/prefs/SettingsPrefs.kt | 8 ++ .../clockula/data/prefs/TimerPrefs.kt | 19 +++ .../data/timers/QuickTileStateStore.kt | 24 ++++ .../clockula/domain/backup/BackupContent.kt | 3 + .../clockula/domain/tile/QuickTimerTile.kt | 75 ++++++++++++ .../clockula/tile/NextAlarmTileService.kt | 94 +++++++++++++++ .../clockula/tile/QuickTimerTileService.kt | 112 ++++++++++++++++++ .../jeanlucmakiola/clockula/tile/TileKit.kt | 94 +++++++++++++++ .../jeanlucmakiola/clockula/tile/TileSync.kt | 55 +++++++++ .../clockula/timer/QuickTileTimer.kt | 74 ++++++++++++ .../ui/tile/TilePreferencesActivity.kt | 99 ++++++++++++++++ .../widget/config/QuickTimerConfigActivity.kt | 13 +- app/src/main/res/drawable/tile_ic_timer.xml | 11 ++ app/src/main/res/values/strings.xml | 10 ++ .../clockula/ManifestRulesTest.kt | 41 ++++++- .../clockula/data/backup/BackupCodecTest.kt | 15 +++ .../clockula/data/prefs/SettingsPrefsTest.kt | 2 + .../domain/tile/QuickTimerTileModelTest.kt | 57 +++++++++ .../clockula/timer/QuickTileTimerTest.kt | 101 ++++++++++++++++ docs/ARCHITECTURE.md | 8 +- docs/BACKUP.md | 7 +- docs/ROADMAP.md | 8 +- 28 files changed, 988 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/timers/QuickTileStateStore.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/tile/QuickTimerTile.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/tile/NextAlarmTileService.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/tile/QuickTimerTileService.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/tile/TileKit.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/tile/TileSync.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/timer/QuickTileTimer.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/tile/TilePreferencesActivity.kt create mode 100644 app/src/main/res/drawable/tile_ic_timer.xml create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/tile/QuickTimerTileModelTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/timer/QuickTileTimerTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 76d4d0d..e500573 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,3 +62,7 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock. analog and digital clocks that show your world clocks at larger sizes. Each widget can follow the system theme or stay light or dark, and choose what a tap opens. +- Quick Settings tiles: "Next alarm" shows the day and time of your next alarm + and opens the Alarms tab, and "Quick timer" starts a timer with one tap, + shows when it ends, and resets it on a second tap. Long-press the quick + timer tile to choose its length. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cfe76b4..4d16595 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -439,6 +439,56 @@ android:name="android.appwidget.provider" android:resource="@xml/widget_quick_timer_info" /> + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt index 3a6c66e..2f9c217 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt @@ -6,6 +6,7 @@ import de.jeanlucmakiola.clockula.alarm.AlarmEngine import de.jeanlucmakiola.clockula.alarm.ring.RingNotifications import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine import de.jeanlucmakiola.clockula.stopwatch.service.StopwatchNotifications +import de.jeanlucmakiola.clockula.tile.TileSync import de.jeanlucmakiola.clockula.timer.TimerEngine import de.jeanlucmakiola.clockula.timer.service.TimerNotifications import de.jeanlucmakiola.clockula.widget.WidgetSync @@ -41,6 +42,9 @@ class ClockulaApp : Application() { @Inject lateinit var widgetSync: WidgetSync + @Inject + lateinit var tileSync: TileSync + @Inject @ApplicationScope lateinit var scope: CoroutineScope @@ -85,5 +89,7 @@ class ClockulaApp : Application() { // Never completes on its own — it is the widgets' one refresh trigger // for as long as the process lives (M10, docs/PLAN.md §9). scope.launch { widgetSync.run() } + // And the Quick Settings tiles' (#26), the same way. + scope.launch { tileSync.run() } } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt b/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt index f0e1f1c..8c363fd 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt @@ -103,5 +103,6 @@ class BackupEngine @Inject constructor( dynamicColor = settings.dynamicColor.first(), defaults = settings.currentDefaults(), timerPresets = settings.timerPresets.first(), + quickTileDuration = settings.quickTileDuration.first(), ) } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt index c6daf96..56d6dac 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt @@ -12,6 +12,7 @@ import de.jeanlucmakiola.clockula.domain.backup.BackupSettings import de.jeanlucmakiola.clockula.domain.backup.BackupTimer import de.jeanlucmakiola.clockula.domain.backup.BackupWorldClock import de.jeanlucmakiola.clockula.domain.timer.TimerPresets +import de.jeanlucmakiola.clockula.domain.widget.parseQuickTimerDuration import de.jeanlucmakiola.floret.prefs.ThemeMode import de.jeanlucmakiola.floret.prefs.toEnum import kotlinx.serialization.SerializationException @@ -128,6 +129,7 @@ object BackupCodec { dismissChallenge = defaults.dismissChallenge.name, homeZoneId = defaults.homeZoneId, timerPresets = timerPresets.map { it.inWholeMilliseconds }, + quickTileDurationMillis = quickTileDuration.inWholeMilliseconds, ) // --- decode + validate --- @@ -235,6 +237,7 @@ object BackupCodec { homeZoneId = settingsDto.homeZoneId?.let(Zones::normalise), ), timerPresets = settingsDto.timerPresets?.map { it.milliseconds } ?: TimerPresets.BUILT_IN, + quickTileDuration = parseQuickTimerDuration(settingsDto.quickTileDurationMillis), ) return BackupResult.Success( diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt index 118e99e..00e8186 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt @@ -77,4 +77,6 @@ data class SettingsDto( * built-in ones. */ @SerialName("timer_presets") val timerPresets: List? = null, + /** Absent (a file from before #26) or out of range keeps the tile's default. */ + @SerialName("quick_tile_duration_millis") val quickTileDurationMillis: Long? = null, ) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt index b76785b..a8a44fd 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt @@ -93,6 +93,7 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) { prefs[ClockPrefs.dismissChallenge] = backup.defaults.dismissChallenge prefs[ClockPrefs.homeZoneId] = backup.defaults.homeZoneId prefs[TimerPrefs.presets] = backup.timerPresets + prefs[TimerPrefs.quickTileDuration] = backup.quickTileDuration } } @@ -133,6 +134,13 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) { store.update(TimerPrefs.presets) { TimerPresets.minus(it, value) } } + // --- quick-timer tile (#26) --- + + /** What the quick-timer Quick Settings tile starts; bounded as the quick-timer widget's duration is. */ + val quickTileDuration: Flow = store.flow(TimerPrefs.quickTileDuration) + + suspend fun setQuickTileDuration(value: Duration) = store.set(TimerPrefs.quickTileDuration, value) + // --- first-launch bookkeeping (M4) --- /** diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/TimerPrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/TimerPrefs.kt index e8182aa..e1e4043 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/TimerPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/TimerPrefs.kt @@ -1,6 +1,7 @@ package de.jeanlucmakiola.clockula.data.prefs import de.jeanlucmakiola.clockula.domain.timer.TimerPresets +import de.jeanlucmakiola.clockula.domain.widget.parseQuickTimerDuration import de.jeanlucmakiola.floret.prefs.Pref import de.jeanlucmakiola.floret.prefs.nullableLongPref import de.jeanlucmakiola.floret.prefs.nullableStringPref @@ -41,4 +42,22 @@ object TimerPrefs { decode = { millis -> millis?.let(Instant::fromEpochMilliseconds) }, encode = { instant -> instant?.toEpochMilliseconds() }, ) + + /** + * The duration the quick-timer Quick Settings tile starts (#26). The + * widget's own bounds and default (`QuickTimerWidgetSettings`), sanitised + * on write as well as on read, so a stored value is always one the tile + * can start. A setting, so it is in the backup. + */ + val quickTileDuration: Pref = nullableLongPref("quick_tile_duration_millis").map( + decode = ::parseQuickTimerDuration, + encode = { duration -> parseQuickTimerDuration(duration.inWholeMilliseconds).inWholeMilliseconds }, + ) + + /** + * The transient timer the quick-timer tile last started, so the tile can + * show and control that one. Volatile state, like [ringSoundingSince]: it + * names a row that is deleted on reset, and stays out of the backup. + */ + val quickTileTimerId: Pref = nullableLongPref("quick_tile_timer_id") } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/QuickTileStateStore.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/QuickTileStateStore.kt new file mode 100644 index 0000000..8b28051 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/QuickTileStateStore.kt @@ -0,0 +1,24 @@ +package de.jeanlucmakiola.clockula.data.timers + +import de.jeanlucmakiola.clockula.data.prefs.TimerPrefs +import de.jeanlucmakiola.floret.prefs.PrefStore +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Which transient timer the quick-timer Quick Settings tile last started + * (#26) — the tile's counterpart of the quick-timer widget's per-instance + * Glance state. One volatile record, so DataStore rather than Room, and out + * of the backup for the reason [TimerRingStateStore] gives. + */ +@Singleton +class QuickTileStateStore @Inject constructor(private val store: PrefStore) { + + /** Built once, distinct-until-changed (ARCHITECTURE.md §6). */ + val timerId: Flow = store.flow(TimerPrefs.quickTileTimerId) + + suspend fun currentTimerId(): Long? = store.get(TimerPrefs.quickTileTimerId) + + suspend fun setTimerId(id: Long?) = store.set(TimerPrefs.quickTileTimerId, id) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt index eac440d..640aa54 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt @@ -4,6 +4,7 @@ import de.jeanlucmakiola.clockula.domain.ClockDefaults import de.jeanlucmakiola.clockula.domain.DismissChallenge import de.jeanlucmakiola.clockula.domain.RepeatDays import de.jeanlucmakiola.clockula.domain.TimeOfDay +import de.jeanlucmakiola.clockula.domain.widget.QuickTimerWidgetSettings import de.jeanlucmakiola.floret.prefs.ThemeMode import kotlin.time.Duration import kotlin.time.Instant @@ -56,4 +57,6 @@ data class BackupSettings( val dynamicColor: Boolean, val defaults: ClockDefaults, val timerPresets: List, + /** What the quick-timer Quick Settings tile starts (#26). */ + val quickTileDuration: Duration = QuickTimerWidgetSettings.DEFAULT, ) 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 new file mode 100644 index 0000000..900e89c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/tile/QuickTimerTile.kt @@ -0,0 +1,75 @@ +package de.jeanlucmakiola.clockula.domain.tile + +import de.jeanlucmakiola.clockula.domain.Timer +import de.jeanlucmakiola.clockula.domain.timer.TimerMode +import de.jeanlucmakiola.clockula.domain.widget.QuickTimerWidgetPresenter +import kotlin.time.Duration +import kotlin.time.Instant + +/** + * What the quick-timer Quick Settings tile shows. The tile is the quick-timer + * widget's twin (`domain/widget/QuickTimerWidgetSettings.kt`): one duration, + * and the transient timer it last started while that one is still active. + * + * Nothing here ticks. A tile is redrawn only when the app asks it to be, so a + * running timer shows when it **ends** — an absolute wall-clock time that + * cannot go stale between the writes that refresh it — never a countdown. + */ +sealed interface QuickTimerTileModel { + + /** What a tap does now. */ + val action: QuickTimerTileAction + + /** Offering to start a timer of [duration]. */ + data class AtRest(val duration: Duration) : QuickTimerTileModel { + override val action get() = QuickTimerTileAction.START + } + + /** Its timer counting down, due at [endsAt]. */ + data class Running(val timerId: Long, val endsAt: Instant) : QuickTimerTileModel { + override val action get() = QuickTimerTileAction.RESET + } + + /** Its timer paused with [remaining] left. */ + data class Paused(val timerId: Long, val remaining: Duration) : QuickTimerTileModel { + override val action get() = QuickTimerTileAction.RESET + } + + /** Its timer ringing. */ + data class Expired(val timerId: Long) : QuickTimerTileModel { + override val action get() = QuickTimerTileAction.RESET + } +} + +/** + * 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 { + + /** + * [timerId] is the timer the tile last started; once it is deleted or back + * to idle, the tile is at rest again. The same selection as the widget's, + * so the two can never disagree about whether a quick timer is "theirs". + */ + fun present( + duration: Duration, + timerId: Long?, + timers: List, + elapsedRealtime: Duration, + wallClock: Instant, + ): QuickTimerTileModel { + val active = QuickTimerWidgetPresenter.present(duration, timerId, timers, elapsedRealtime, wallClock).active + ?: return QuickTimerTileModel.AtRest(duration) + return when (active.mode) { + TimerMode.RUNNING -> QuickTimerTileModel.Running(active.id, wallClock + active.remaining) + TimerMode.PAUSED -> QuickTimerTileModel.Paused(active.id, active.remaining) + TimerMode.EXPIRED -> QuickTimerTileModel.Expired(active.id) + // Never active, so never reached; at rest is the honest reading. + TimerMode.IDLE -> QuickTimerTileModel.AtRest(duration) + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/tile/NextAlarmTileService.kt b/app/src/main/java/de/jeanlucmakiola/clockula/tile/NextAlarmTileService.kt new file mode 100644 index 0000000..0c563f8 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/tile/NextAlarmTileService.kt @@ -0,0 +1,94 @@ +package de.jeanlucmakiola.clockula.tile + +import android.service.quicksettings.Tile +import android.service.quicksettings.TileService +import dagger.hilt.android.AndroidEntryPoint +import de.jeanlucmakiola.clockula.R +import de.jeanlucmakiola.clockula.alarm.AlarmEngine +import de.jeanlucmakiola.clockula.alarm.AlarmIntents +import de.jeanlucmakiola.clockula.domain.time.WallClock +import de.jeanlucmakiola.clockula.domain.time.ZoneProvider +import de.jeanlucmakiola.clockula.domain.widget.NextAlarmWidgetModel +import de.jeanlucmakiola.clockula.domain.widget.NextAlarmWidgetPresenter +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import java.time.LocalTime +import java.time.format.TextStyle +import javax.inject.Inject + +/** + * The next-alarm Quick Settings tile (#26): "Next alarm" over the day and + * time of the next one, or "No alarms"; a tap opens the Alarms tab. + * + * It reads the next-alarm widget's own model ([NextAlarmWidgetPresenter]), + * so the tile, the widget and the Alarms tab cannot disagree about which + * alarm is next. The day is always the weekday's name, never "Today": an + * active tile is redrawn only when an alarm changes (`tile/TileSync.kt`), and + * "Today" would go stale at midnight with nothing to redraw it. + */ +@AndroidEntryPoint +class NextAlarmTileService : TileService() { + + @Inject lateinit var alarmEngine: AlarmEngine + + @Inject lateinit var wallClock: WallClock + + @Inject lateinit var zones: ZoneProvider + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + + private var listening: Job? = null + + override fun onTileAdded() { + super.onTileAdded() + requestTileUpdate(this, NextAlarmTileService::class.java) + } + + override fun onStartListening() { + super.onStartListening() + listening?.cancel() + listening = scope.launch { + alarmEngine.upcoming() + .map { NextAlarmWidgetPresenter.present(it, wallClock.now(), zones.current()) } + .collect(::show) + } + } + + override fun onStopListening() { + listening?.cancel() + listening = null + super.onStopListening() + } + + override fun onClick() { + super.onClick() + openApp(AlarmIntents.ACTION_SHOW_ALARMS) + } + + override fun onDestroy() { + scope.cancel() + super.onDestroy() + } + + private fun show(model: NextAlarmWidgetModel) { + val tile = qsTile ?: return + val label = getString(R.string.tile_next_alarm_label) + val day = model.dayOfWeek + if (!model.hasAlarm || day == null) { + render(tile, Tile.STATE_INACTIVE, label, getString(R.string.tile_next_alarm_empty), R.drawable.widget_ic_alarm_off) + return + } + val locale = resources.configuration.locales[0] + val subtitle = getString( + R.string.tile_next_alarm_subtitle, + day.getDisplayName(TextStyle.SHORT, locale), + tileTime(LocalTime.of(model.hour, model.minute)), + ) + render(tile, Tile.STATE_ACTIVE, label, subtitle, R.drawable.widget_ic_alarm) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/tile/QuickTimerTileService.kt b/app/src/main/java/de/jeanlucmakiola/clockula/tile/QuickTimerTileService.kt new file mode 100644 index 0000000..16ad8fd --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/tile/QuickTimerTileService.kt @@ -0,0 +1,112 @@ +package de.jeanlucmakiola.clockula.tile + +import android.service.quicksettings.Tile +import android.service.quicksettings.TileService +import dagger.hilt.android.AndroidEntryPoint +import de.jeanlucmakiola.clockula.R +import de.jeanlucmakiola.clockula.domain.format.ClockFormat +import de.jeanlucmakiola.clockula.domain.tile.QuickTimerTileModel +import de.jeanlucmakiola.clockula.domain.time.ZoneProvider +import de.jeanlucmakiola.clockula.timer.QuickTileTimer +import de.jeanlucmakiola.floret.di.ApplicationScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import java.time.ZonedDateTime +import javax.inject.Inject +import kotlin.time.toJavaInstant + +/** + * The quick-timer Quick Settings tile (#26): the quick-timer widget's twin. + * At rest it shows the duration it starts; a tap starts a transient timer of + * that length. While that timer is active the tile is on and shows when it + * ends (or that it is paused, or done), and a tap resets it — all decided by + * [QuickTileTimer], which this only renders and forwards to. + * + * Works on a locked device, deliberately: starting or stopping a kitchen + * timer is something the timer notification already lets anyone do from the + * lock screen, and asking for an unlock first would make the tile slower + * than the notification it sits above. Long-pressing it opens + * `ui/tile/TilePreferencesActivity.kt`, where its duration is chosen. + * + * Starting the timer brings up its foreground service through the engine, as + * every other start does. Should the platform refuse that start, the timer + * still runs and still rings — the AlarmManager slot is the timekeeper, the + * service only its readout (`timer/android/ServiceTimerHandle.kt`). + */ +@AndroidEntryPoint +class QuickTimerTileService : TileService() { + + @Inject lateinit var quickTile: QuickTileTimer + + @Inject lateinit var zones: ZoneProvider + + /** The tap outlives the binding: the panel may unbind the moment it collapses. */ + @Inject + @ApplicationScope + lateinit var appScope: CoroutineScope + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + + private var listening: Job? = null + + override fun onTileAdded() { + super.onTileAdded() + requestTileUpdate(this, QuickTimerTileService::class.java) + } + + override fun onStartListening() { + super.onStartListening() + listening?.cancel() + listening = scope.launch { quickTile.model.collect(::show) } + } + + override fun onStopListening() { + listening?.cancel() + listening = null + super.onStopListening() + } + + override fun onClick() { + super.onClick() + appScope.launch { quickTile.onTap() } + } + + override fun onDestroy() { + scope.cancel() + super.onDestroy() + } + + private fun show(model: QuickTimerTileModel) { + val tile = qsTile ?: return + val label = getString(R.string.tile_quick_timer_label) + when (model) { + is QuickTimerTileModel.AtRest -> { + val duration = ClockFormat.countdown(model.duration) + render( + tile, + Tile.STATE_INACTIVE, + label, + duration, + R.drawable.tile_ic_timer, + ) + } + is QuickTimerTileModel.Running -> { + val endsAt = ZonedDateTime.ofInstant(model.endsAt.toJavaInstant(), zones.current()).toLocalTime() + val subtitle = getString(R.string.tile_quick_timer_until, tileTime(endsAt)) + render(tile, Tile.STATE_ACTIVE, label, subtitle, R.drawable.tile_ic_timer) + } + is QuickTimerTileModel.Paused -> { + val subtitle = getString(R.string.timer_paused_at, ClockFormat.countdown(model.remaining)) + render(tile, Tile.STATE_ACTIVE, label, subtitle, R.drawable.tile_ic_timer) + } + is QuickTimerTileModel.Expired -> { + val subtitle = getString(R.string.widget_timer_expired) + render(tile, Tile.STATE_ACTIVE, label, subtitle, R.drawable.tile_ic_timer) + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/tile/TileKit.kt b/app/src/main/java/de/jeanlucmakiola/clockula/tile/TileKit.kt new file mode 100644 index 0000000..0934429 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/tile/TileKit.kt @@ -0,0 +1,94 @@ +package de.jeanlucmakiola.clockula.tile + +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.graphics.drawable.Icon +import android.os.Build +import android.service.quicksettings.Tile +import android.service.quicksettings.TileService +import android.text.format.DateFormat +import de.jeanlucmakiola.clockula.MainActivity +import java.time.LocalTime +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +/** + * Opens the app on the shell request [action] names (`ui/shell/ShellNavigation`) + * and collapses the panel. On a locked device the user unlocks first: the app + * is not something to open over the keyguard, and the system would only ask + * for the unlock itself, after the panel had already gone. + * + * API 34 refuses the `Intent` overload of `startActivityAndCollapse` and takes + * a `PendingIntent` instead; everything below it only has the `Intent` one. + */ +internal fun TileService.openApp(action: String) { + val intent = Intent(this, MainActivity::class.java) + .setAction(action) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + val launch = Runnable { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val pending = PendingIntent.getActivity( + this, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + startActivityAndCollapse(pending) + } else { + startIntentAndCollapse(intent) + } + } + if (isLocked) unlockAndRun(launch) else launch.run() +} + +@Suppress("DEPRECATION") +@SuppressLint("StartActivityAndCollapseDeprecated") +private fun TileService.startIntentAndCollapse(intent: Intent) = startActivityAndCollapse(intent) + +/** + * Writes one rendering onto [tile]. The subtitle is API 29, which is + * `minSdk`. TalkBack reads the label, the subtitle and the on/off state the + * panel derives from [state], so there is no separate description to keep in + * step with them. + */ +internal fun TileService.render( + tile: Tile, + state: Int, + label: String, + subtitle: String, + icon: Int, +) { + tile.state = state + tile.label = label + tile.subtitle = subtitle + tile.icon = Icon.createWithResource(this, icon) + tile.updateTile() +} + +/** + * The device's 12/24-hour choice, then the locale's own short form — the same + * two-step the next-alarm widget and the ring notification format with. + */ +internal fun Context.tileTime(time: LocalTime): String { + val formatter = if (DateFormat.is24HourFormat(this)) { + DateTimeFormatter.ofPattern("HH:mm") + } else { + DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) + } + return formatter.withLocale(resources.configuration.locales[0]).format(time) +} + +/** + * Asks the system to bind [service] and let it redraw. Both tiles are + * *active* tiles (`META_DATA_ACTIVE_TILE` in the manifest), so this is the + * only way they are ever refreshed outside a click — which is exactly the + * point: the panel opening costs the app nothing. A tile that is not in the + * panel makes this a no-op; a refusal is harmless for the same reason, so it + * never throws. + */ +internal fun requestTileUpdate(context: Context, service: Class) { + runCatching { TileService.requestListeningState(context, ComponentName(context, service)) } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/tile/TileSync.kt b/app/src/main/java/de/jeanlucmakiola/clockula/tile/TileSync.kt new file mode 100644 index 0000000..4ffdaf2 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/tile/TileSync.kt @@ -0,0 +1,55 @@ +package de.jeanlucmakiola.clockula.tile + +import android.content.Context +import android.service.quicksettings.TileService +import dagger.hilt.android.qualifiers.ApplicationContext +import de.jeanlucmakiola.clockula.alarm.AlarmEngine +import de.jeanlucmakiola.clockula.timer.QuickTileTimer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.milliseconds + +/** + * Keeps the two Quick Settings tiles in step with the app's state (#26) — + * `WidgetSync`'s counterpart, collecting the same read-only flows the tiles + * render. Both are *active* tiles, so nothing else ever redraws them outside + * a click: on every change this asks the system to bind the tile, which then + * collects and draws for itself. The process is alive whenever an engine + * writes, so this sees every change; and every process start emits once, + * which is the redraw after a reboot or a process death. + * + * Started once, from [de.jeanlucmakiola.clockula.ClockulaApp.onCreate], in + * the app's `@ApplicationScope`. + */ +@Singleton +class TileSync @Inject constructor( + @ApplicationContext private val context: Context, + private val alarmEngine: AlarmEngine, + private val quickTile: QuickTileTimer, +) { + + suspend fun run(): Unit = coroutineScope { + sync(alarmEngine.upcoming(), NextAlarmTileService::class.java) + sync(quickTile.model, QuickTimerTileService::class.java) + } + + @OptIn(FlowPreview::class) + private fun CoroutineScope.sync(source: Flow<*>, tile: Class) = launch { + source + .distinctUntilChanged() + // A burst of writes (an import's replace-all) is one redraw. + .debounce(DEBOUNCE) + .collect { requestTileUpdate(context, tile) } + } + + private companion object { + val DEBOUNCE = 150.milliseconds + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/timer/QuickTileTimer.kt b/app/src/main/java/de/jeanlucmakiola/clockula/timer/QuickTileTimer.kt new file mode 100644 index 0000000..fdbb164 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/timer/QuickTileTimer.kt @@ -0,0 +1,74 @@ +package de.jeanlucmakiola.clockula.timer + +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.tile.QuickTimerTileModel +import de.jeanlucmakiola.clockula.domain.tile.QuickTimerTilePresenter +import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock +import de.jeanlucmakiola.clockula.domain.time.WallClock +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The quick-timer Quick Settings tile's state and its one tap (#26), with no + * Android type in sight: `tile/QuickTimerTileService.kt` only renders [model] + * and forwards the click here. + * + * At rest a tap creates a transient timer at the tile's duration and starts + * it — the same two steps as `QuickTimerReceiver` and the Timers screen's + * quick start — and remembers its id. On an active timer a tap resets it, + * which for a transient timer means stop (or stop ringing) and delete, so the + * tile is at rest again: the widget's second tap. Everything goes through the + * [TimerEngine], so the AlarmManager slot and the foreground service follow + * exactly as they do for every other path. + */ +@Singleton +class QuickTileTimer @Inject constructor( + private val timers: TimerRepository, + private val engine: TimerEngine, + private val settings: SettingsPrefs, + private val state: QuickTileStateStore, + private val elapsed: ElapsedRealtimeClock, + private val wall: WallClock, +) { + + /** Re-derived on every write that could change it — never on a tick. */ + val model: Flow = combine( + settings.quickTileDuration, + state.timerId, + timers.timers(), + ) { duration, timerId, all -> + QuickTimerTilePresenter.present(duration, timerId, all, elapsed.elapsedRealtime(), wall.now()) + } + + /** + * Serialised, so a double tap's second half sees the first half's timer + * instead of starting another one the tile could not show. + */ + suspend fun onTap(): Unit = mutex.withLock { + when (val current = model.first()) { + is QuickTimerTileModel.AtRest -> { + val id = timers.create(TimerDraft(duration = current.duration, deleteAfterUse = true)) + engine.start(id) + state.setTimerId(id) + } + is QuickTimerTileModel.Running -> reset(current.timerId) + is QuickTimerTileModel.Paused -> reset(current.timerId) + is QuickTimerTileModel.Expired -> reset(current.timerId) + } + } + + private suspend fun reset(timerId: Long) { + engine.reset(timerId) + state.setTimerId(null) + } + + private val mutex = Mutex() +} 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 new file mode 100644 index 0000000..61c5997 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/tile/TilePreferencesActivity.kt @@ -0,0 +1,99 @@ +package de.jeanlucmakiola.clockula.ui.tile + +import android.content.ComponentName +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.core.content.IntentCompat +import androidx.lifecycle.lifecycleScope +import dagger.hilt.android.AndroidEntryPoint +import de.jeanlucmakiola.clockula.MainActivity +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.ui.theme.ClockulaTheme +import de.jeanlucmakiola.clockula.ui.widget.config.QuickTimerPicker +import de.jeanlucmakiola.floret.prefs.Appearance +import de.jeanlucmakiola.floret.prefs.isDark +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.time.Duration +import javax.inject.Inject + +/** + * What long-pressing one of the Quick Settings tiles opens (#26) — the + * platform's `QS_TILE_PREFERENCES` action, which names the tile in + * `Intent.EXTRA_COMPONENT_NAME`. + * + * The quick-timer tile's one setting is its duration, so for it this is the + * quick-timer widget's own duration picker, saved the moment a row is + * picked, like every other setting in the app. The next-alarm tile has + * nothing to set, so its long-press opens the Alarms tab — what its tap does — + * rather than an empty screen. + */ +@AndroidEntryPoint +class TilePreferencesActivity : ComponentActivity() { + + @Inject lateinit var settingsPrefs: SettingsPrefs + + /** Set once a pick is being written, so the picker's own dismiss can't finish underneath it. */ + private var saving = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // Guarded like `MainActivity.requestFrom`: this activity is exported, + // and reading an extra unparcels the whole bundle. + val tile = runCatching { + IntentCompat.getParcelableExtra(intent, Intent.EXTRA_COMPONENT_NAME, ComponentName::class.java) + }.getOrNull() + if (tile?.className == NextAlarmTileService::class.java.name) { + startActivity( + Intent(this, MainActivity::class.java) + .setAction(AlarmIntents.ACTION_SHOW_ALARMS) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP), + ) + finish() + return + } + + enableEdgeToEdge() + setContent { + val appearance by settingsPrefs.appearance.collectAsState(initial = Appearance()) + // `null` until read, so the picker never flashes the default + // before the stored duration arrives. + val duration by settingsPrefs.quickTileDuration.collectAsState(initial = null) + val darkTheme = appearance.themeMode.isDark(isSystemInDarkTheme()) + ClockulaTheme(darkTheme = darkTheme, dynamicColor = appearance.dynamicColor) { + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {} + val current = duration ?: return@ClockulaTheme + QuickTimerPicker( + selected = current, + onSelect = ::save, + onDismiss = { if (!saving) finish() }, + title = stringResource(R.string.tile_quick_timer_config_title), + ) + } + } + } + + private fun save(duration: Duration) { + if (saving) return + saving = true + lifecycleScope.launch { + withContext(NonCancellable) { settingsPrefs.setQuickTileDuration(duration) } + finish() + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/QuickTimerConfigActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/QuickTimerConfigActivity.kt index 973f179..3817f71 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/QuickTimerConfigActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/QuickTimerConfigActivity.kt @@ -88,15 +88,22 @@ class QuickTimerConfigActivity : WidgetConfigActivity() { * The listed durations, then "Custom…", which opens the timer keypad. A * stored duration that isn't listed marks the Custom row instead, with the * value as its summary, so the picker always shows what the widget starts. - * Picking either closes it. + * Picking either closes it. Also the quick-timer Quick Settings tile's whole + * settings screen (`ui/tile/TilePreferencesActivity.kt`), which shares the + * widget's durations. */ @Composable -private fun QuickTimerPicker(selected: Duration, onSelect: (Duration) -> Unit, onDismiss: () -> Unit) { +internal fun QuickTimerPicker( + selected: Duration, + onSelect: (Duration) -> Unit, + onDismiss: () -> Unit, + title: String = stringResource(R.string.timer_editor_duration), +) { var typing by rememberSaveable { mutableStateOf(false) } val options = QuickTimerWidgetSettings.OPTIONS val count = options.size + 1 - FullScreenPicker(title = stringResource(R.string.timer_editor_duration), onDismiss = onDismiss) { + FullScreenPicker(title = title, onDismiss = onDismiss) { options.forEachIndexed { index, option -> val isSelected = option == selected GroupedRow( diff --git a/app/src/main/res/drawable/tile_ic_timer.xml b/app/src/main/res/drawable/tile_ic_timer.xml new file mode 100644 index 0000000..a1e515c --- /dev/null +++ b/app/src/main/res/drawable/tile_ic_timer.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ad0a66c..0fa19c3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -439,4 +439,14 @@ Custom… Custom duration Between 1 second and 59:59 + + + Next alarm + No alarms + + %1$s %2$s + Quick timer + + Until %1$s + Quick timer tile diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt index fc5bea3..c032fc0 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt @@ -31,7 +31,7 @@ class ManifestRulesTest { } @Test - fun `exactly the launcher and its two icon aliases, the door, the six widget configurators and the six widget receivers are exported`() { + fun `exactly the launcher and its two icon aliases, the door, the widget and the tile surfaces are exported`() { val exported = Regex("<(activity|service|receiver|provider)\\b[\\s\\S]*?(?:/>|)") .findAll(text) .map { it.value } @@ -48,6 +48,8 @@ class ManifestRulesTest { // only the launcher can start an `APPWIDGET_CONFIGURE` activity // cross-package — and guarded to that one action by `the six // widget configurators answer only APPWIDGET_CONFIGURE` below. + // The two Quick Settings tiles and their long-press screen (#26) are + // exported for System UI, and pinned to that below the same way. assertThat(exported).containsExactly( ".MainActivity", ".BloomIconAlias", @@ -65,9 +67,46 @@ class ManifestRulesTest { ".widget.AnalogClockWidgetReceiver", ".widget.StopwatchWidgetReceiver", ".widget.QuickTimerWidgetReceiver", + ".tile.NextAlarmTileService", + ".tile.QuickTimerTileService", + ".ui.tile.TilePreferencesActivity", ) } + /** + * #26: a tile is exported so System UI can bind it, and only System UI + * can: `BIND_QUICK_SETTINGS_TILE` is a signature permission. Without it, + * any app could bind the quick-timer tile and click it. + */ + @Test + fun `the two tiles demand the system's bind permission and answer only QS_TILE`() { + for (name in listOf(".tile.NextAlarmTileService", ".tile.QuickTimerTileService")) { + val block = Regex("|)") + .findAll(text) + .map { it.value } + .first { it.contains("android:name=\"$name\"") } + + val actions = Regex(" { + val state = QuickTileStateStore(store) + return QuickTileTimer(timers, engine, settings, state, elapsed, wallClock) to state + } + + @Test + fun `a tap at rest starts a transient timer at the tile's duration`(@TempDir tempDir: Path) = runTest { + val harness = timerEngineHarness(tempDir) + val (tile, state) = harness.quickTile() + harness.settings.setQuickTileDuration(3.minutes) + + tile.onTap() + + val started = harness.timers.timers().first().single() + assertThat(started.duration).isEqualTo(3.minutes) + assertThat(started.deleteAfterUse).isTrue() + assertThat(started.state).isEqualTo(TimerState.RUNNING) + assertThat(state.currentTimerId()).isEqualTo(started.id) + assertThat(harness.service.active).isTrue() + assertThat(tile.model.first()).isInstanceOf(QuickTimerTileModel.Running::class.java) + } + + @Test + fun `a second tap resets its timer, which deletes it, and the tile is at rest`(@TempDir tempDir: Path) = runTest { + val harness = timerEngineHarness(tempDir) + val (tile, state) = harness.quickTile() + + tile.onTap() + tile.onTap() + + assertThat(harness.timers.timers().first()).isEmpty() + assertThat(state.currentTimerId()).isNull() + assertThat(harness.scheduler.expiryAt).isNull() + assertThat(harness.service.active).isFalse() + assertThat(tile.model.first()).isEqualTo(QuickTimerTileModel.AtRest(5.minutes)) + } + + @Test + fun `a timer deleted elsewhere leaves the tile at rest, and the next tap starts afresh`(@TempDir tempDir: Path) = runTest { + val harness = timerEngineHarness(tempDir) + val (tile, state) = harness.quickTile() + tile.onTap() + val first = state.currentTimerId()!! + + harness.engine.delete(first) + tile.onTap() + + val second = harness.timers.timers().first().single() + assertThat(second.id).isNotEqualTo(first) + assertThat(second.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) + val (tile, _) = harness.quickTile() + + launch { tile.onTap() } + launch { tile.onTap() } + testScheduler.advanceUntilIdle() + + assertThat(harness.timers.timers().first()).isEmpty() + } + + @Test + fun `it never touches a timer it did not start`(@TempDir tempDir: Path) = runTest { + val harness = timerEngineHarness(tempDir) + val (tile, _) = harness.quickTile() + val other = harness.timers.create(TimerDraft(duration = 10.minutes)) + harness.engine.start(other) + + tile.onTap() + tile.onTap() + + assertThat(harness.timers.timers().first().map { it.id to it.state }).containsExactly(other to TimerState.RUNNING) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 97ef4e8..fefa9ef 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -99,9 +99,10 @@ kit module: the kit's roadmap keeps schedulers app-local. | `domain/backup/` | `BackupContent` (M10): the plain Kotlin model between `data/backup`'s JSON codec and the repository/engine; decoupled from the wire DTOs the same way the data layer is decoupled from Room | | `domain/selfcheck/` | `SelfCheck` (M10): the "why might my alarm not ring?" rule table: pure functions over a `DeviceState` snapshot, each producing a `CheckResult` with a status and an optional deep-link `SelfCheckFix` | | `domain/widget/` | the three widget presenters (M10), `NextAlarmWidgetModel`/`TimerWidgetModel`/`ClockWidgetModel`: selection and formatting only, so a widget's content is unit-tested without Glance | +| `domain/tile/` | `QuickTimerTileModel`/`QuickTimerTilePresenter` (#26): what the quick-timer Quick Settings tile shows and what a tap does, over the quick-timer widget's own selection. The next-alarm tile needs no presenter of its own; it reads `NextAlarmWidgetModel` | | `data/db/` | `ClockulaDatabase` (`@Database` v3, `exportSchema = true`) and `Migrations` | | `data/alarms/` | the alarm and ring-state entities, DAOs, mappers and repositories | -| `data/timers/` | `TimerEntity`, `TimerDao`, `TimerMapper`, `TimerRepository(+Impl)`, `TimerRingStateStore` (the ring session's one DataStore record) | +| `data/timers/` | `TimerEntity`, `TimerDao`, `TimerMapper`, `TimerRepository(+Impl)`, `TimerRingStateStore` (the ring session's one DataStore record), `QuickTileStateStore` (which timer the quick-timer tile started, #26) | | `data/worldclocks/` | `WorldClockEntity`, `WorldClockDao`, `WorldClockMapper`, `WorldClockRepository(+Impl)`, unchanged since M2; M8 is the milestone that finally calls all of it | | `data/zones/` | the ICU seam (M8): `ZoneNames`, `IcuZoneNames` (the only `android.icu` file in the app) and `ZoneDirectory`, the locale-keyed catalog cache and the DST-keyed display-name memo | | `data/stopwatch/` | `LapEntity`, `LapDao` (one `COUNT(*)` added in M7), `LapMapper`, `StopwatchStateStore`, `StopwatchRepository(+Impl)` | @@ -116,7 +117,7 @@ kit module: the kit's roadmap keeps schedulers app-local. | `alarm/ring/` | the alarm's ringing foreground service and its notifications. Its audio player and vibrator moved to `ring/` in M6, shared with the timer's ring | | `alarm/di/` | `AlarmModule`: `@Binds` for the four seams | | `ring/` | `RingAudioPlayer` and `RingVibrator`: one `MediaPlayer` wrapper and one vibrator for both ring paths. A build rule fails on a second one | -| `timer/` | `TimerEngine` and the three seams it talks to (`TimerScheduler`, `TimerServiceHandle`, `AlarmRingStatus` + `AlarmStateRingStatus`), plus `TimerIntents` | +| `timer/` | `TimerEngine` and the three seams it talks to (`TimerScheduler`, `TimerServiceHandle`, `AlarmRingStatus` + `AlarmStateRingStatus`), plus `TimerIntents`, and `QuickTileTimer` (#26), the quick-timer tile's model and tap, through the engine and with no Android type | | `timer/android/` | the seams' Android implementations: the one elapsed-realtime AlarmManager slot, the service handle | | `timer/receiver/` | `TimerExpiryReceiver` (the slot arriving), `TimerActionReceiver` (the notification's buttons) | | `timer/service/` | `TimerService` (one foreground service for the countdown and the ring) and `TimerNotifications` | @@ -130,6 +131,8 @@ kit module: the kit's roadmap keeps schedulers app-local. | `system/` | `RebootRepair`, the boot-id gate | | `backup/` | `BackupEngine` (M10), the orchestrator (a ringing alarm dismissed and running timers deleted before a replace, `AlarmEngine.reschedule()`/`TimerEngine.resync()` after it), plus `BackupFiles`, the SAF seam, with its Android implementation in `backup/android/` | | `widget/` | the three `GlanceAppWidget`/`GlanceAppWidgetReceiver` pairs (M10) and `WidgetSync`, the event-driven collector (started from `ClockulaApp.onCreate`) that redraws all three on any change to the state they mirror. `widget/di/` holds the `@EntryPoint` Glance needs, since a `GlanceAppWidgetReceiver` is never `@AndroidEntryPoint`-constructed | +| `tile/` | the two Quick Settings tiles (#26): `NextAlarmTileService` and `QuickTimerTileService`, which only render and forward a click, `TileKit` (opening the app, behind an unlock on a locked device, through the `PendingIntent` overload on API 34+), and `TileSync`, `WidgetSync`'s counterpart: both are *active* tiles, redrawn only when this asks the system to, never on every panel open | +| `ui/tile/` | `TilePreferencesActivity` (#26), what long-pressing a tile opens: the quick-timer tile's duration, through the quick-timer widget's own picker | | `ui/theme/`, `ui/crash/` | the M0/M1 theme and the crash-report surface | | `ui/shell/` | the navigation policy (`ShellNavigation`), the adaptive shell, the live pill and its source/ViewModel, the notification-permission ask. `EmptyTabScreen` is gone: its KDoc said it existed only until each of M5–M8 replaced its own tab, and the world clock was its last caller | | `ui/common/` | `rememberLocalTimeFormatter`, the app's one 12/24-hour formatter, over a `java.time.LocalTime`, with `rememberAlarmTimeFormatter` delegating to it (M8 D17); and, since M6, the ringtone picker (`RingtonePickerState`, `RingtonePickerScreen`), which both editors need | @@ -599,6 +602,7 @@ Four slices: | Stopwatch run record (M2) | `stopwatch_state`, `stopwatch_started_elapsed_millis`, `stopwatch_accumulated_millis`, `stopwatch_last_lap_cumulative_millis` (M7 is the first caller of the last, for the in-progress lap's split) | `StopwatchPrefs`, behind `StopwatchStateStore` | | System facts (M3) | `last_boot_id` | `SystemPrefs`, behind `BootStateStore`, the persisted half of the reboot gate | | Timers (M6) | `timer_presets`, `timer_ring_sounding_since_millis` | `TimerPrefs`: the presets surfaced as `SettingsPrefs.timerPresets`, the ring session's anchor behind `TimerRingStateStore` | +| Quick-timer tile (#26) | `quick_tile_duration_millis`, `quick_tile_timer_id` | `TimerPrefs`: the duration (a backed-up setting) surfaced as `SettingsPrefs.quickTileDuration`, the transient timer the tile started (volatile, not backed up) behind `QuickTileStateStore` | The stopwatch's *run* is a single record, so it lives in DataStore rather than as a one-row table (`PLAN.md` §5); its *laps* are a list, so they live in Room. diff --git a/docs/BACKUP.md b/docs/BACKUP.md index e8536de..23db32f 100644 --- a/docs/BACKUP.md +++ b/docs/BACKUP.md @@ -44,6 +44,10 @@ written here. 24 hours). Room has no clamp of its own, so this is where it happens. - A missing `timer_presets` keeps the built-in presets. An empty list is a user who removed them all, and is imported as such. +- A missing `quick_tile_duration_millis` (a file from before the Quick + Settings tiles), or one outside the quick-timer range of 1 second to 59:59, + keeps the quick-timer tile's default of five minutes — the same reading the + quick-timer widget gives its own stored duration. - An enum field with a name this version does not recognise (a `dismiss_ challenge` or `theme_mode` from a future version, say) degrades to that field's default, the same forgiving-read posture `AlarmMapper` already uses @@ -136,7 +140,8 @@ device or after a reboot (`docs/PLAN.md` §5). Every imported timer lands "default_timer_ringtone_uri": null, "default_dismiss_challenge": "NONE", "home_zone_id": null, - "timer_presets": [60000, 120000, 180000, 300000, 600000, 900000, 1800000, 3600000] + "timer_presets": [60000, 120000, 180000, 300000, 600000, 900000, 1800000, 3600000], + "quick_tile_duration_millis": 300000 } ``` diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index bb7731b..28abcb3 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -610,7 +610,13 @@ Deferred deliberately, in rough priority order: - **Dock / screensaver mode**: a `DreamService` full-screen clock. Cheapest big win, and the surface people look at most. -- **Quick Settings tile**: timer control and next alarm from the shade. +- ✅ **Quick Settings tiles** (#26): next alarm and a quick timer from the + shade. "Next alarm" reads the next-alarm widget's model and opens the Alarms + tab (after an unlock, on a locked device); "Quick timer" is the quick-timer + widget's twin, with its own duration (long-press to choose; in the backup), + and works on a locked device as the timer notification does. Both are active + tiles, redrawn by `TileSync` on a write rather than bound on every panel + open. No new permission. - **Bedtime / sleep schedule**: wind-down and wake, with DND handoff. A feature in its own right, realistically v2. - **`core-notification` in floret-kit**: revisit once Clockula's real From beeca29f747219357e7c1ab5cba942b3e67524be Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 22 Oct 2026 08:19:49 +0200 Subject: [PATCH 2/3] fix(tiles): redraw on clock and zone changes, and tighten the tile surfaces The tiles' refresh now rides WidgetSync's collectors instead of a second copy of them, and SystemEventReceiver asks both tiles to redraw on a time or zone change, where the times they show move without any write. The quick-timer tile remembers its timer before starting it, so a failed start can't leave a timer it cannot see, and shows the timer's stored end rather than one re-derived from two clocks on every read. The tile preferences screen closes on anything but QS_TILE_PREFERENCES, runs in its own task, and asks for a redraw after saving a duration. The tiles and the next-alarm widget share one 12/24-hour time formatter and the widgets' launch intent. --- app/src/main/AndroidManifest.xml | 8 ++- .../de/jeanlucmakiola/clockula/ClockulaApp.kt | 11 +--- .../alarm/receiver/SystemEventReceiver.kt | 5 ++ .../clockula/domain/tile/QuickTimerTile.kt | 5 +- .../clockula/tile/NextAlarmTileService.kt | 3 +- .../jeanlucmakiola/clockula/tile/TileKit.kt | 34 +++++------- .../jeanlucmakiola/clockula/tile/TileSync.kt | 55 ------------------- .../clockula/timer/QuickTileTimer.kt | 12 ++-- .../clockula/ui/common/AlarmTimeFormatter.kt | 24 +++++--- .../ui/tile/TilePreferencesActivity.kt | 19 +++++-- .../ui/widget/NextAlarmWidgetContent.kt | 15 +---- .../clockula/widget/WidgetSync.kt | 29 ++++++++-- docs/ARCHITECTURE.md | 4 +- docs/ROADMAP.md | 2 +- 14 files changed, 99 insertions(+), 127 deletions(-) delete mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/tile/TileSync.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4d16595..fb7b4f9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -444,7 +444,7 @@ for System UI to bind it, and BIND_QUICK_SETTINGS_TILE — a signature permission only System UI holds — keeps anyone else from binding it. Both are active tiles: they are redrawn only - when the app asks (`tile/TileSync.kt`), never on every panel + when the app asks (`widget/WidgetSync.kt`), never on every panel open. --> + because System UI starts it; it answers that one action and + closes on any other. A task of its own, so finishing it returns + to wherever the user was, not into an open Clockula. --> diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt index 2f9c217..d3cffc0 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt @@ -6,7 +6,6 @@ import de.jeanlucmakiola.clockula.alarm.AlarmEngine import de.jeanlucmakiola.clockula.alarm.ring.RingNotifications import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine import de.jeanlucmakiola.clockula.stopwatch.service.StopwatchNotifications -import de.jeanlucmakiola.clockula.tile.TileSync import de.jeanlucmakiola.clockula.timer.TimerEngine import de.jeanlucmakiola.clockula.timer.service.TimerNotifications import de.jeanlucmakiola.clockula.widget.WidgetSync @@ -42,9 +41,6 @@ class ClockulaApp : Application() { @Inject lateinit var widgetSync: WidgetSync - @Inject - lateinit var tileSync: TileSync - @Inject @ApplicationScope lateinit var scope: CoroutineScope @@ -86,10 +82,9 @@ class ClockulaApp : Application() { stopwatchEngine.onBootCompleted() } - // Never completes on its own — it is the widgets' one refresh trigger - // for as long as the process lives (M10, docs/PLAN.md §9). + // Never completes on its own — it is the widgets' and the Quick + // Settings tiles' one refresh trigger for as long as the process + // lives (M10, docs/PLAN.md §9; #26). scope.launch { widgetSync.run() } - // And the Quick Settings tiles' (#26), the same way. - scope.launch { tileSync.run() } } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/receiver/SystemEventReceiver.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/receiver/SystemEventReceiver.kt index a608911..c850387 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/receiver/SystemEventReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/receiver/SystemEventReceiver.kt @@ -6,6 +6,7 @@ import androidx.glance.appwidget.updateAll import dagger.hilt.android.AndroidEntryPoint import de.jeanlucmakiola.clockula.alarm.AlarmEngine import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine +import de.jeanlucmakiola.clockula.tile.requestAllTileUpdates import de.jeanlucmakiola.clockula.timer.TimerEngine import de.jeanlucmakiola.clockula.widget.AnalogClockWidget import de.jeanlucmakiola.clockula.widget.MidnightRefresh @@ -95,6 +96,10 @@ class SystemEventReceiver : HiltBroadcastReceiver() { AnalogClockWidget().updateAll(context) NextAlarmWidget().updateAll(context) MidnightRefresh.schedule(context) + // Both tiles show a wall-clock time in the zone they + // were drawn in, and the instant behind it need not + // have changed, so no write may reach `WidgetSync`. + requestAllTileUpdates(context) } } } finally { 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 900e89c..49d1afb 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 @@ -65,7 +65,10 @@ object QuickTimerTilePresenter { val active = QuickTimerWidgetPresenter.present(duration, timerId, timers, elapsedRealtime, wallClock).active ?: return QuickTimerTileModel.AtRest(duration) return when (active.mode) { - TimerMode.RUNNING -> QuickTimerTileModel.Running(active.id, wallClock + active.remaining) + // The stored end, which the engine re-anchors on a clock change, + // rather than one re-derived per read: two clocks read a moment + // apart would make every emission a "change". + TimerMode.RUNNING -> QuickTimerTileModel.Running(active.id, active.endsAtWallClock ?: (wallClock + active.remaining)) TimerMode.PAUSED -> QuickTimerTileModel.Paused(active.id, active.remaining) TimerMode.EXPIRED -> QuickTimerTileModel.Expired(active.id) // Never active, so never reached; at rest is the honest reading. diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/tile/NextAlarmTileService.kt b/app/src/main/java/de/jeanlucmakiola/clockula/tile/NextAlarmTileService.kt index 0c563f8..f54f0d1 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/tile/NextAlarmTileService.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/tile/NextAlarmTileService.kt @@ -28,7 +28,8 @@ import javax.inject.Inject * It reads the next-alarm widget's own model ([NextAlarmWidgetPresenter]), * so the tile, the widget and the Alarms tab cannot disagree about which * alarm is next. The day is always the weekday's name, never "Today": an - * active tile is redrawn only when an alarm changes (`tile/TileSync.kt`), and + * active tile is redrawn only when an alarm changes (`widget/WidgetSync.kt`) + * or the clock or zone does (`SystemEventReceiver`), and * "Today" would go stale at midnight with nothing to redraw it. */ @AndroidEntryPoint diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/tile/TileKit.kt b/app/src/main/java/de/jeanlucmakiola/clockula/tile/TileKit.kt index 0934429..1e32cab 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/tile/TileKit.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/tile/TileKit.kt @@ -9,11 +9,9 @@ import android.graphics.drawable.Icon import android.os.Build import android.service.quicksettings.Tile import android.service.quicksettings.TileService -import android.text.format.DateFormat -import de.jeanlucmakiola.clockula.MainActivity +import de.jeanlucmakiola.clockula.ui.common.localTimeFormatter +import de.jeanlucmakiola.clockula.ui.widget.openAppIntent import java.time.LocalTime -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle /** * Opens the app on the shell request [action] names (`ui/shell/ShellNavigation`) @@ -25,9 +23,8 @@ import java.time.format.FormatStyle * a `PendingIntent` instead; everything below it only has the `Intent` one. */ internal fun TileService.openApp(action: String) { - val intent = Intent(this, MainActivity::class.java) - .setAction(action) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + // The widgets' own launch intent, so a tile and a widget land the same way. + val intent = openAppIntent(this, action) val launch = Runnable { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { val pending = PendingIntent.getActivity( @@ -68,27 +65,24 @@ internal fun TileService.render( tile.updateTile() } -/** - * The device's 12/24-hour choice, then the locale's own short form — the same - * two-step the next-alarm widget and the ring notification format with. - */ -internal fun Context.tileTime(time: LocalTime): String { - val formatter = if (DateFormat.is24HourFormat(this)) { - DateTimeFormatter.ofPattern("HH:mm") - } else { - DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) - } - return formatter.withLocale(resources.configuration.locales[0]).format(time) -} +/** The app's one 12/24-hour time format, as the next-alarm widget shows it. */ +internal fun Context.tileTime(time: LocalTime): String = localTimeFormatter(this).format(time) /** * Asks the system to bind [service] and let it redraw. Both tiles are * *active* tiles (`META_DATA_ACTIVE_TILE` in the manifest), so this is the * only way they are ever refreshed outside a click — which is exactly the - * point: the panel opening costs the app nothing. A tile that is not in the + * point: the panel opening costs the app nothing. `widget/WidgetSync.kt` + * calls it on every change the tiles mirror. A tile that is not in the * panel makes this a no-op; a refusal is harmless for the same reason, so it * never throws. */ internal fun requestTileUpdate(context: Context, service: Class) { runCatching { TileService.requestListeningState(context, ComponentName(context, service)) } } + +/** Both tiles, for a change that moves what their times mean rather than the state behind them. */ +internal fun requestAllTileUpdates(context: Context) { + requestTileUpdate(context, NextAlarmTileService::class.java) + requestTileUpdate(context, QuickTimerTileService::class.java) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/tile/TileSync.kt b/app/src/main/java/de/jeanlucmakiola/clockula/tile/TileSync.kt deleted file mode 100644 index 4ffdaf2..0000000 --- a/app/src/main/java/de/jeanlucmakiola/clockula/tile/TileSync.kt +++ /dev/null @@ -1,55 +0,0 @@ -package de.jeanlucmakiola.clockula.tile - -import android.content.Context -import android.service.quicksettings.TileService -import dagger.hilt.android.qualifiers.ApplicationContext -import de.jeanlucmakiola.clockula.alarm.AlarmEngine -import de.jeanlucmakiola.clockula.timer.QuickTileTimer -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.launch -import javax.inject.Inject -import javax.inject.Singleton -import kotlin.time.Duration.Companion.milliseconds - -/** - * Keeps the two Quick Settings tiles in step with the app's state (#26) — - * `WidgetSync`'s counterpart, collecting the same read-only flows the tiles - * render. Both are *active* tiles, so nothing else ever redraws them outside - * a click: on every change this asks the system to bind the tile, which then - * collects and draws for itself. The process is alive whenever an engine - * writes, so this sees every change; and every process start emits once, - * which is the redraw after a reboot or a process death. - * - * Started once, from [de.jeanlucmakiola.clockula.ClockulaApp.onCreate], in - * the app's `@ApplicationScope`. - */ -@Singleton -class TileSync @Inject constructor( - @ApplicationContext private val context: Context, - private val alarmEngine: AlarmEngine, - private val quickTile: QuickTileTimer, -) { - - suspend fun run(): Unit = coroutineScope { - sync(alarmEngine.upcoming(), NextAlarmTileService::class.java) - sync(quickTile.model, QuickTimerTileService::class.java) - } - - @OptIn(FlowPreview::class) - private fun CoroutineScope.sync(source: Flow<*>, tile: Class) = launch { - source - .distinctUntilChanged() - // A burst of writes (an import's replace-all) is one redraw. - .debounce(DEBOUNCE) - .collect { requestTileUpdate(context, tile) } - } - - private companion object { - val DEBOUNCE = 150.milliseconds - } -} 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 fdbb164..13e3ed4 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/timer/QuickTileTimer.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/timer/QuickTileTimer.kt @@ -21,9 +21,9 @@ import javax.inject.Singleton * Android type in sight: `tile/QuickTimerTileService.kt` only renders [model] * and forwards the click here. * - * At rest a tap creates a transient timer at the tile's duration and starts - * it — the same two steps as `QuickTimerReceiver` and the Timers screen's - * quick start — and remembers its id. On an active timer a tap resets it, + * At rest a tap creates a transient timer at the tile's duration, remembers + * its id and starts it — the same create-then-start as `QuickTimerReceiver` + * and the Timers screen's quick start. On an active timer a tap resets it, * which for a transient timer means stop (or stop ringing) and delete, so the * tile is at rest again: the widget's second tap. Everything goes through the * [TimerEngine], so the AlarmManager slot and the foreground service follow @@ -56,8 +56,12 @@ class QuickTileTimer @Inject constructor( when (val current = model.first()) { is QuickTimerTileModel.AtRest -> { val id = timers.create(TimerDraft(duration = current.duration, deleteAfterUse = true)) - engine.start(id) + // Remembered before it starts, so a start that throws, or a + // process that dies in between, never leaves a running timer + // the tile can neither see nor stop. It also makes the start + // the last write, the one `WidgetSync` redraws the tile on. state.setTimerId(id) + engine.start(id) } is QuickTimerTileModel.Running -> reset(current.timerId) is QuickTimerTileModel.Paused -> reset(current.timerId) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/AlarmTimeFormatter.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/AlarmTimeFormatter.kt index bf77123..aa0300b 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/AlarmTimeFormatter.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/AlarmTimeFormatter.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.clockula.ui.common +import android.content.Context import android.text.format.DateFormat import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -23,20 +24,25 @@ internal fun rememberLocalTimeFormatter(): (LocalTime) -> String { // The locale can change under a running activity, and the 12/24-hour // choice rides the same configuration, so both are re-read with it. val configuration = LocalConfiguration.current - val formatter = remember(configuration) { - // The device's 12/24-hour choice, then the locale's own short form — - // the same two-step the ring notification already formats with. - if (DateFormat.is24HourFormat(context)) { - DateTimeFormatter.ofPattern("HH:mm") - } else { - DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) - } - } + val formatter = remember(configuration) { localTimeFormatter(context) } return remember(formatter) { { time -> formatter.format(time) } } } +/** + * The device's 12/24-hour choice, then the locale's own short form — the same + * two-step the ring notification already formats with. The non-Composable + * half of [rememberLocalTimeFormatter], for the surfaces outside a + * composition: the next-alarm widget and the Quick Settings tiles. + */ +internal fun localTimeFormatter(context: Context): DateTimeFormatter = + if (DateFormat.is24HourFormat(context)) { + DateTimeFormatter.ofPattern("HH:mm") + } else { + DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) + } + /** Delegates to [rememberLocalTimeFormatter]. Unchanged for every existing caller. */ @Composable internal fun rememberAlarmTimeFormatter(): (TimeOfDay) -> String { 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 61c5997..c23e623 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 @@ -3,6 +3,7 @@ package de.jeanlucmakiola.clockula.ui.tile import android.content.ComponentName import android.content.Intent import android.os.Bundle +import android.service.quicksettings.TileService import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge @@ -17,13 +18,15 @@ import androidx.compose.ui.res.stringResource import androidx.core.content.IntentCompat import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint -import de.jeanlucmakiola.clockula.MainActivity 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 import de.jeanlucmakiola.floret.prefs.Appearance import de.jeanlucmakiola.floret.prefs.isDark import kotlinx.coroutines.NonCancellable @@ -53,17 +56,19 @@ 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. + if (intent?.action != TileService.ACTION_QS_TILE_PREFERENCES) { + finish() + return + } // Guarded like `MainActivity.requestFrom`: this activity is exported, // and reading an extra unparcels the whole bundle. val tile = runCatching { IntentCompat.getParcelableExtra(intent, Intent.EXTRA_COMPONENT_NAME, ComponentName::class.java) }.getOrNull() if (tile?.className == NextAlarmTileService::class.java.name) { - startActivity( - Intent(this, MainActivity::class.java) - .setAction(AlarmIntents.ACTION_SHOW_ALARMS) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP), - ) + startActivity(openAppIntent(this, AlarmIntents.ACTION_SHOW_ALARMS)) finish() return } @@ -93,6 +98,8 @@ 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/ui/widget/NextAlarmWidgetContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/NextAlarmWidgetContent.kt index c379141..447a4fe 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/NextAlarmWidgetContent.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/NextAlarmWidgetContent.kt @@ -2,7 +2,6 @@ package de.jeanlucmakiola.clockula.ui.widget import android.content.Context import android.content.Intent -import android.text.format.DateFormat import androidx.compose.runtime.Composable import androidx.compose.ui.unit.dp import androidx.glance.GlanceModifier @@ -21,10 +20,9 @@ import androidx.glance.text.Text import androidx.glance.text.TextStyle import de.jeanlucmakiola.clockula.R import de.jeanlucmakiola.clockula.alarm.AlarmIntents +import de.jeanlucmakiola.clockula.ui.common.localTimeFormatter import de.jeanlucmakiola.clockula.domain.widget.NextAlarmWidgetModel import java.time.LocalTime -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle import java.time.format.TextStyle as JavaTextStyle import java.util.Locale import de.jeanlucmakiola.clockula.widget.di.WidgetEntryPoint @@ -127,15 +125,8 @@ fun NextAlarmWidgetContent(model: NextAlarmWidgetModel) { private fun showAlarmsIntent(context: Context): Intent = openAppIntent(context, AlarmIntents.ACTION_SHOW_ALARMS) -private fun timeLabel(context: Context, model: NextAlarmWidgetModel): String { - val time = LocalTime.of(model.hour, model.minute) - val formatter = if (DateFormat.is24HourFormat(context)) { - DateTimeFormatter.ofPattern("HH:mm") - } else { - DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) - } - return formatter.format(time) -} +private fun timeLabel(context: Context, model: NextAlarmWidgetModel): String = + localTimeFormatter(context).format(LocalTime.of(model.hour, model.minute)) private fun dayLabel(context: Context, model: NextAlarmWidgetModel): String = when { model.isToday -> context.getString(R.string.widget_next_alarm_today) 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 5362ae1..f3c0e49 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/widget/WidgetSync.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/widget/WidgetSync.kt @@ -1,11 +1,15 @@ package de.jeanlucmakiola.clockula.widget import android.content.Context +import android.service.quicksettings.TileService import dagger.hilt.android.qualifiers.ApplicationContext import de.jeanlucmakiola.clockula.alarm.AlarmEngine import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchRepository import de.jeanlucmakiola.clockula.data.timers.TimerRepository 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 androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.updateAll import kotlinx.coroutines.CoroutineScope @@ -35,9 +39,17 @@ 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. + * * Started once, from [de.jeanlucmakiola.clockula.ClockulaApp.onCreate], in the * app's `@ApplicationScope` — never driven by a `Ticker`, so an idle home - * screen costs nothing between writes. + * screen costs nothing between writes. Every process start emits once, which + * is also the tiles' redraw after a reboot or a process death. */ @Singleton class WidgetSync @Inject constructor( @@ -49,21 +61,28 @@ class WidgetSync @Inject constructor( ) { suspend fun run(): Unit = coroutineScope { - sync(alarmEngine.upcoming(), NextAlarmWidget()) - sync(timers.timers(), TimerWidget(), QuickTimerWidget()) + sync(alarmEngine.upcoming(), NextAlarmWidget(), tiles = listOf(NextAlarmTileService::class.java)) + sync(timers.timers(), TimerWidget(), QuickTimerWidget(), 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()) } @OptIn(FlowPreview::class) - private fun CoroutineScope.sync(source: Flow<*>, vararg widgets: GlanceAppWidget) = launch { + private fun CoroutineScope.sync( + source: Flow<*>, + vararg widgets: GlanceAppWidget, + tiles: List> = emptyList(), + ) = launch { source .distinctUntilChanged() // A burst of writes (an import's replace-all, a reorder) collapses // to one redraw rather than one per row. .debounce(DEBOUNCE) - .collect { widgets.forEach { it.updateAll(context) } } + .collect { + tiles.forEach { requestTileUpdate(context, it) } + widgets.forEach { it.updateAll(context) } + } } private companion object { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fefa9ef..b41a0d7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -130,8 +130,8 @@ kit module: the kit's roadmap keeps schedulers app-local. | `interop/` | the `AlarmClock` boundary's Android half (M9): `AlarmClockActivity` (the one exported, permission-guarded, window-less door), `IntentExtrasReader` (`Bundle` → `IntentExtras`), `AlarmClockHandler` (the orchestrator; no `android.*` import, ever), `InteropIntents` (the internal transport to `MainActivity`) and `InteropLaunch` | | `system/` | `RebootRepair`, the boot-id gate | | `backup/` | `BackupEngine` (M10), the orchestrator (a ringing alarm dismissed and running timers deleted before a replace, `AlarmEngine.reschedule()`/`TimerEngine.resync()` after it), plus `BackupFiles`, the SAF seam, with its Android implementation in `backup/android/` | -| `widget/` | the three `GlanceAppWidget`/`GlanceAppWidgetReceiver` pairs (M10) and `WidgetSync`, the event-driven collector (started from `ClockulaApp.onCreate`) that redraws all three on any change to the state they mirror. `widget/di/` holds the `@EntryPoint` Glance needs, since a `GlanceAppWidgetReceiver` is never `@AndroidEntryPoint`-constructed | -| `tile/` | the two Quick Settings tiles (#26): `NextAlarmTileService` and `QuickTimerTileService`, which only render and forward a click, `TileKit` (opening the app, behind an unlock on a locked device, through the `PendingIntent` overload on API 34+), and `TileSync`, `WidgetSync`'s counterpart: both are *active* tiles, redrawn only when this asks the system to, never on every panel open | +| `widget/` | the three `GlanceAppWidget`/`GlanceAppWidgetReceiver` pairs (M10) and `WidgetSync`, the event-driven collector (started from `ClockulaApp.onCreate`) that redraws all three, and since #26 the two Quick Settings tiles, on any change to the state they mirror. `widget/di/` holds the `@EntryPoint` Glance needs, since a `GlanceAppWidgetReceiver` is never `@AndroidEntryPoint`-constructed | +| `tile/` | the two Quick Settings tiles (#26): `NextAlarmTileService` and `QuickTimerTileService`, which only render and forward a click, and `TileKit` (opening the app, behind an unlock on a locked device, through the `PendingIntent` overload on API 34+; asking for a redraw). Both are *active* tiles, redrawn only when `WidgetSync` (on a change they mirror) or `SystemEventReceiver` (on a clock or zone change) asks the system to, never on every panel open | | `ui/tile/` | `TilePreferencesActivity` (#26), what long-pressing a tile opens: the quick-timer tile's duration, through the quick-timer widget's own picker | | `ui/theme/`, `ui/crash/` | the M0/M1 theme and the crash-report surface | | `ui/shell/` | the navigation policy (`ShellNavigation`), the adaptive shell, the live pill and its source/ViewModel, the notification-permission ask. `EmptyTabScreen` is gone: its KDoc said it existed only until each of M5–M8 replaced its own tab, and the world clock was its last caller | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 28abcb3..e00ddda 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -615,7 +615,7 @@ Deferred deliberately, in rough priority order: tab (after an unlock, on a locked device); "Quick timer" is the quick-timer widget's twin, with its own duration (long-press to choose; in the backup), and works on a locked device as the timer notification does. Both are active - tiles, redrawn by `TileSync` on a write rather than bound on every panel + tiles, redrawn by `WidgetSync` on a write rather than bound on every panel open. No new permission. - **Bedtime / sleep schedule**: wind-down and wake, with DND handoff. A feature in its own right, realistically v2. From 3b488ef6fcbe552b8de99453d1adafd2ab57c5b5 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 22 Oct 2026 08:26:09 +0200 Subject: [PATCH 3/3] fix(tiles): follow the quick-timer tile's whole model, and clear stranded timers WidgetSync now redraws the quick-timer tile on its own model rather than on the timers alone, so a duration restored from a backup reaches it too and no writer has to ask for a redraw by hand. A transient timer the tile created but never got running is deleted on the next tap instead of sitting idle in the Timers list for good. The tile model loses its unused action enum: an Active supertype carries the timer id every reset needs. --- .../clockula/domain/tile/QuickTimerTile.kt | 33 +++++++------------ .../clockula/timer/QuickTileTimer.kt | 18 ++++++++-- .../ui/tile/TilePreferencesActivity.kt | 8 ++--- .../clockula/widget/WidgetSync.kt | 18 ++++++---- .../domain/tile/QuickTimerTileModelTest.kt | 10 ++---- .../clockula/timer/QuickTileTimerTest.kt | 14 ++++++++ 6 files changed, 57 insertions(+), 44 deletions(-) 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)