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]*?(?:/>|\\1>)")
.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