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