diff --git a/CHANGELOG.md b/CHANGELOG.md
index 832aa80..02ae0ba 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -109,3 +109,7 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock.
nothing burns into the screen. Pick it in Android's screen saver settings,
or open it any time from the menu on each tab. Its settings are in Settings
→ Clock and are included in backups.
+- 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 15f4649..4952c72 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -496,6 +496,58 @@
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 22c64d3..34cf08b 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt
@@ -91,8 +91,9 @@ 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).
+ // Never completes on its own — it is the widgets' and the Quick
+ // Settings tiles' one refresh trigger for as long as the process
+ // lives (M10, docs/PLAN.md §9; #26).
scope.launch { widgetSync.run() }
scope.launch { settleTimerVibrate() }
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/receiver/SystemEventReceiver.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/receiver/SystemEventReceiver.kt
index 05469f6..cd0bb18 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/receiver/SystemEventReceiver.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/receiver/SystemEventReceiver.kt
@@ -7,6 +7,7 @@ import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
import de.jeanlucmakiola.clockula.bedtime.BedtimeEngine
import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine
+import de.jeanlucmakiola.clockula.tile.requestAllTileUpdates
import de.jeanlucmakiola.clockula.timer.TimerEngine
import de.jeanlucmakiola.clockula.widget.AnalogClockWidget
import de.jeanlucmakiola.clockula.widget.MidnightRefresh
@@ -99,6 +100,10 @@ class SystemEventReceiver : HiltBroadcastReceiver() {
AnalogClockWidget().updateAll(context)
NextAlarmWidget().updateAll(context)
MidnightRefresh.schedule(context)
+ // Both tiles show a wall-clock time in the zone they
+ // were drawn in, and the instant behind it need not
+ // have changed, so no write may reach `WidgetSync`.
+ requestAllTileUpdates(context)
}
}
} finally {
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt b/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt
index afc04a5..18aba23 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt
@@ -117,6 +117,7 @@ class BackupEngine @Inject constructor(
timerPresets = settings.timerPresets.first(),
worldClockSeconds = settings.worldClockSeconds.first(),
screenSaver = settings.screenSaver.first(),
+ quickTileDuration = settings.quickTileDuration.first(),
bedtime = settings.currentBedtime(),
)
}
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 8f44ce0..f4231de 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
@@ -20,6 +20,7 @@ import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle
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
@@ -151,6 +152,7 @@ object BackupCodec {
bedtime = bedtime?.toDto(),
screenSaverStyle = screenSaver.style.name,
screenSaverNightMode = screenSaver.nightMode,
+ quickTileDurationMillis = quickTileDuration.inWholeMilliseconds,
)
private fun BedtimeSettings.toDto() = BedtimeDto(
@@ -313,6 +315,7 @@ object BackupCodec {
style = settingsDto.screenSaverStyle.toEnum(ScreenSaverStyle.DIGITAL),
nightMode = settingsDto.screenSaverNightMode,
),
+ quickTileDuration = parseQuickTimerDuration(settingsDto.quickTileDurationMillis),
bedtime = settingsDto.bedtime?.toBedtime(),
)
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 60c6df5..1e6a3e4 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
@@ -108,6 +108,8 @@ data class SettingsDto(
/** Absent in files from before the screen saver; they restore its defaults. */
@SerialName("screen_saver_style") val screenSaverStyle: String = "DIGITAL",
@SerialName("screen_saver_night_mode") val screenSaverNightMode: Boolean = false,
+ /** 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 1555002..3778830 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
@@ -133,6 +133,7 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
backup.bedtime?.let { prefs.putBedtime(it) }
prefs[ScreenSaverPrefs.style] = backup.screenSaver.style
prefs[ScreenSaverPrefs.nightMode] = backup.screenSaver.nightMode
+ prefs[TimerPrefs.quickTileDuration] = backup.quickTileDuration
}
}
@@ -288,6 +289,13 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
suspend fun setScreenSaverNightMode(enabled: Boolean) = store.set(ScreenSaverPrefs.nightMode, enabled)
+ // --- 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 0d48ba7..37a8f73 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
@@ -7,6 +7,7 @@ import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings
+import de.jeanlucmakiola.clockula.domain.widget.QuickTimerWidgetSettings
import de.jeanlucmakiola.floret.prefs.ThemeMode
import java.time.LocalDate
import kotlin.time.Duration
@@ -65,6 +66,8 @@ data class BackupSettings(
/** The world clock's "Display time with seconds" (#23). */
val worldClockSeconds: Boolean,
val screenSaver: ScreenSaverSettings,
+ /** What the quick-timer Quick Settings tile starts (#26). */
+ val quickTileDuration: Duration = QuickTimerWidgetSettings.DEFAULT,
/** Null for a file written before bedtime existed: restoring it keeps the current schedule. */
val bedtime: BedtimeSettings? = null,
)
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..6700f8c
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/tile/QuickTimerTile.kt
@@ -0,0 +1,69 @@
+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 {
+
+ /** Offering to start a timer of [duration]; a tap starts one. */
+ data class AtRest(val duration: Duration) : QuickTimerTileModel
+
+ /**
+ * Its timer [timerId] active. A tap resets it — which, for a transient
+ * timer, stops it (or its ring) and deletes it, as the widget's second
+ * tap does.
+ */
+ sealed interface Active : QuickTimerTileModel {
+ val timerId: Long
+ }
+
+ /** Its timer counting down, due at [endsAt]. */
+ data class Running(override val timerId: Long, val endsAt: Instant) : Active
+
+ /** Its timer paused with [remaining] left. */
+ data class Paused(override val timerId: Long, val remaining: Duration) : Active
+
+ /** Its timer ringing. */
+ data class Expired(override val timerId: Long) : Active
+}
+
+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) {
+ // The stored end, which the engine re-anchors on a clock change,
+ // rather than one re-derived per read: two clocks read a moment
+ // apart would make every emission a "change".
+ TimerMode.RUNNING -> QuickTimerTileModel.Running(active.id, active.endsAtWallClock ?: (wallClock + active.remaining))
+ TimerMode.PAUSED -> QuickTimerTileModel.Paused(active.id, active.remaining)
+ TimerMode.EXPIRED -> QuickTimerTileModel.Expired(active.id)
+ // Never active, so never reached; at rest is the honest reading.
+ 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..f54f0d1
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/tile/NextAlarmTileService.kt
@@ -0,0 +1,95 @@
+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 (`widget/WidgetSync.kt`)
+ * or the clock or zone does (`SystemEventReceiver`), 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..1e32cab
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/tile/TileKit.kt
@@ -0,0 +1,88 @@
+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 de.jeanlucmakiola.clockula.ui.common.localTimeFormatter
+import de.jeanlucmakiola.clockula.ui.widget.openAppIntent
+import java.time.LocalTime
+
+/**
+ * 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) {
+ // The widgets' own launch intent, so a tile and a widget land the same way.
+ val intent = openAppIntent(this, action)
+ val launch = Runnable {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ val pending = PendingIntent.getActivity(
+ 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 app's one 12/24-hour time format, as the next-alarm widget shows it. */
+internal fun Context.tileTime(time: LocalTime): String = localTimeFormatter(this).format(time)
+
+/**
+ * Asks the system to bind [service] and let it redraw. Both tiles are
+ * *active* tiles (`META_DATA_ACTIVE_TILE` in the manifest), so this is the
+ * only way they are ever refreshed outside a click — which is exactly the
+ * point: the panel opening costs the app nothing. `widget/WidgetSync.kt`
+ * calls it on every change the tiles mirror. A tile that is not in the
+ * panel makes this a no-op; a refusal is harmless for the same reason, so it
+ * never throws.
+ */
+internal fun requestTileUpdate(context: Context, service: Class) {
+ runCatching { TileService.requestListeningState(context, ComponentName(context, service)) }
+}
+
+/** Both tiles, for a change that moves what their times mean rather than the state behind them. */
+internal fun requestAllTileUpdates(context: Context) {
+ requestTileUpdate(context, NextAlarmTileService::class.java)
+ requestTileUpdate(context, QuickTimerTileService::class.java)
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/timer/QuickTileTimer.kt b/app/src/main/java/de/jeanlucmakiola/clockula/timer/QuickTileTimer.kt
new file mode 100644
index 0000000..c4c0d24
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/timer/QuickTileTimer.kt
@@ -0,0 +1,90 @@
+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.TimerState
+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, remembers
+ * its id and starts it — the same create-then-start as `QuickTimerReceiver`
+ * and the Timers screen's quick start. On an active timer a tap resets it,
+ * which for a transient timer means stop (or stop ringing) and delete, so the
+ * tile is at rest again: the widget's second tap. Everything goes through the
+ * [TimerEngine], so the AlarmManager slot and the foreground service follow
+ * 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 -> {
+ dropStrandedTimer()
+ val id = timers.create(TimerDraft(duration = current.duration, deleteAfterUse = true))
+ // Remembered before it starts, so a start that throws, or a
+ // process that dies in between, never leaves a running timer
+ // the tile can neither see nor stop. It also makes the start
+ // the last write, the one `WidgetSync` redraws the tile on.
+ state.setTimerId(id)
+ engine.start(id)
+ }
+ is QuickTimerTileModel.Active -> reset(current.timerId)
+ }
+ }
+
+ /**
+ * A transient timer the tile created but never got running — a start that
+ * threw, or a process that died in between — would sit idle in the Timers
+ * list for good, since only a reset deletes one and a reset of an idle
+ * timer does nothing. The next tap clears it before making another.
+ */
+ private suspend fun dropStrandedTimer() {
+ val id = state.currentTimerId() ?: return
+ val timer = timers.find(id) ?: return
+ if (timer.deleteAfterUse && timer.state == TimerState.IDLE) engine.delete(id)
+ }
+
+ private suspend fun reset(timerId: Long) {
+ engine.reset(timerId)
+ state.setTimerId(null)
+ }
+
+ private val mutex = Mutex()
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/AlarmTimeFormatter.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/AlarmTimeFormatter.kt
index 4646e90..f0423e6 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/AlarmTimeFormatter.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/AlarmTimeFormatter.kt
@@ -1,11 +1,13 @@
package de.jeanlucmakiola.clockula.ui.common
+import android.content.Context
import android.text.format.DateFormat
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import de.jeanlucmakiola.clockula.domain.TimeOfDay
+import de.jeanlucmakiola.clockula.ui.widget.currentLocale as contextLocale
import de.jeanlucmakiola.floret.locale.currentLocale
import java.time.LocalTime
import java.time.chrono.IsoChronology
@@ -54,6 +56,16 @@ internal fun localTimePattern(is24Hour: Boolean, locale: Locale, withSeconds: Bo
DateTimeFormatterBuilder.getLocalizedDateTimePattern(null, style, IsoChronology.INSTANCE, locale)
}
+/**
+ * The same rule as a formatter, the non-Composable half of
+ * [rememberLocalTimeFormatter], for the surfaces outside a composition: the
+ * next-alarm widget and the Quick Settings tiles.
+ */
+internal fun localTimeFormatter(context: Context, withSeconds: Boolean = false): DateTimeFormatter {
+ val locale = contextLocale(context)
+ return DateTimeFormatter.ofPattern(localTimePattern(DateFormat.is24HourFormat(context), locale, withSeconds), locale)
+ }
+
/** Delegates to [rememberLocalTimeFormatter]. Unchanged for every existing caller. */
@Composable
internal fun rememberAlarmTimeFormatter(): (TimeOfDay) -> String {
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/tile/TilePreferencesActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/tile/TilePreferencesActivity.kt
new file mode 100644
index 0000000..fa5bed1
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/tile/TilePreferencesActivity.kt
@@ -0,0 +1,102 @@
+package de.jeanlucmakiola.clockula.ui.tile
+
+import android.content.ComponentName
+import android.content.Intent
+import android.os.Bundle
+import android.service.quicksettings.TileService
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+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.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.clockula.ui.widget.openAppIntent
+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)
+ // Answers only the action System UI sends. Not access control —
+ // nothing behind it needs any: it is one setting, changed by a tap.
+ if (intent?.action != TileService.ACTION_QS_TILE_PREFERENCES) {
+ finish()
+ return
+ }
+ // 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(openAppIntent(this, AlarmIntents.ACTION_SHOW_ALARMS))
+ 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/NextAlarmWidgetContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/NextAlarmWidgetContent.kt
index fb9eb69..08defdc 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/NextAlarmWidgetContent.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/NextAlarmWidgetContent.kt
@@ -2,7 +2,6 @@ package de.jeanlucmakiola.clockula.ui.widget
import android.content.Context
import android.content.Intent
-import android.text.format.DateFormat
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.glance.GlanceModifier
@@ -22,10 +21,9 @@ import androidx.glance.text.TextStyle
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
import de.jeanlucmakiola.clockula.ui.alarms.AlarmDateText
+import de.jeanlucmakiola.clockula.ui.common.localTimeFormatter
import de.jeanlucmakiola.clockula.domain.widget.NextAlarmWidgetModel
import java.time.LocalTime
-import java.time.format.DateTimeFormatter
-import java.time.format.FormatStyle
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
import de.jeanlucmakiola.clockula.widget.di.WidgetEntryPoint
@@ -128,15 +126,8 @@ fun NextAlarmWidgetContent(model: NextAlarmWidgetModel) {
private fun showAlarmsIntent(context: Context): Intent = openAppIntent(context, AlarmIntents.ACTION_SHOW_ALARMS)
-private fun timeLabel(context: Context, model: NextAlarmWidgetModel): String {
- val time = LocalTime.of(model.hour, model.minute)
- val formatter = if (DateFormat.is24HourFormat(context)) {
- DateTimeFormatter.ofPattern("HH:mm")
- } else {
- DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
- }
- return formatter.format(time)
-}
+private fun timeLabel(context: Context, model: NextAlarmWidgetModel): String =
+ localTimeFormatter(context).format(LocalTime.of(model.hour, model.minute))
private fun dayLabel(context: Context, model: NextAlarmWidgetModel): String = when {
model.isToday -> context.getString(R.string.widget_next_alarm_today)
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/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/java/de/jeanlucmakiola/clockula/widget/WidgetSync.kt b/app/src/main/java/de/jeanlucmakiola/clockula/widget/WidgetSync.kt
index 5362ae1..f32cd1a 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/widget/WidgetSync.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/widget/WidgetSync.kt
@@ -1,11 +1,16 @@
package de.jeanlucmakiola.clockula.widget
import android.content.Context
+import android.service.quicksettings.TileService
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchRepository
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository
+import de.jeanlucmakiola.clockula.tile.NextAlarmTileService
+import de.jeanlucmakiola.clockula.tile.QuickTimerTileService
+import de.jeanlucmakiola.clockula.tile.requestTileUpdate
+import de.jeanlucmakiola.clockula.timer.QuickTileTimer
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.updateAll
import kotlinx.coroutines.CoroutineScope
@@ -35,9 +40,16 @@ import kotlin.time.Duration.Companion.milliseconds
* (`widget/QuickTimerWidget.kt`). The analog clock reads none of them; its
* date is kept current by [MidnightRefresh].
*
+ * The two Quick Settings tiles (#26) are kept here too: both are *active*
+ * tiles, so nothing redraws them outside a click unless the app asks the
+ * system to bind them, which is what a change here does. The next-alarm tile
+ * rides the next-alarm widget's flow; the quick-timer tile follows its own
+ * model, which covers its timer, its remembered id and its duration.
+ *
* Started once, from [de.jeanlucmakiola.clockula.ClockulaApp.onCreate], in the
* app's `@ApplicationScope` — never driven by a `Ticker`, so an idle home
- * screen costs nothing between writes.
+ * screen costs nothing between writes. Every process start emits once, which
+ * is also the tiles' redraw after a reboot or a process death.
*/
@Singleton
class WidgetSync @Inject constructor(
@@ -46,24 +58,35 @@ class WidgetSync @Inject constructor(
private val timers: TimerRepository,
private val stopwatch: StopwatchRepository,
private val worldClocks: WorldClockRepository,
+ private val quickTile: QuickTileTimer,
) {
suspend fun run(): Unit = coroutineScope {
- sync(alarmEngine.upcoming(), NextAlarmWidget())
+ sync(alarmEngine.upcoming(), NextAlarmWidget(), tiles = listOf(NextAlarmTileService::class.java))
sync(timers.timers(), TimerWidget(), QuickTimerWidget())
+ // The tile's own model rather than the timers alone: its duration
+ // (a pick, or a restored backup) and its timer id are part of it too.
+ sync(quickTile.model, tiles = listOf(QuickTimerTileService::class.java))
// A lap leaves the run untouched, so the lap count is watched too.
sync(combine(stopwatch.run(), stopwatch.laps()) { run, laps -> run to laps.size }, StopwatchWidget())
sync(worldClocks.worldClocks(), ClockWidget())
}
@OptIn(FlowPreview::class)
- private fun CoroutineScope.sync(source: Flow<*>, vararg widgets: GlanceAppWidget) = launch {
+ private fun CoroutineScope.sync(
+ source: Flow<*>,
+ vararg widgets: GlanceAppWidget,
+ tiles: List> = emptyList(),
+ ) = launch {
source
.distinctUntilChanged()
// A burst of writes (an import's replace-all, a reorder) collapses
// to one redraw rather than one per row.
.debounce(DEBOUNCE)
- .collect { widgets.forEach { it.updateAll(context) } }
+ .collect {
+ tiles.forEach { requestTileUpdate(context, it) }
+ widgets.forEach { it.updateAll(context) }
+ }
}
private companion object {
diff --git a/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 0374d0f..749ce8c 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -552,4 +552,14 @@
- Bedtime in %1$d minutes
Bedtime mode starts at %1$s.
+
+
+ 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 ca7d825..1bde42d 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, the six widget receivers and the screen saver are exported`() {
+ fun `exactly the launcher and its two icon aliases, the door, the six widget configurators, the six widget receivers, the screen saver and the tile surfaces are exported`() {
val exported = Regex("<(activity|service|receiver|provider)\\b[\\s\\S]*?(?:/>|\\1>)")
.findAll(text)
.map { it.value }
@@ -51,6 +51,8 @@ class ManifestRulesTest {
// screen saver's dream is bound by the system's dream manager and its
// settings screen started by the Settings app, both from another
// process; `the screen saver is bound only by the system` guards them.
+ // 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",
@@ -70,6 +72,9 @@ class ManifestRulesTest {
".widget.QuickTimerWidgetReceiver",
".ui.screensaver.ClockulaDreamService",
".ui.screensaver.ScreenSaverSettingsActivity",
+ ".tile.NextAlarmTileService",
+ ".tile.QuickTimerTileService",
+ ".ui.tile.TilePreferencesActivity",
)
}
@@ -93,6 +98,40 @@ class ManifestRulesTest {
assertThat(activityBlock(".ui.screensaver.ScreenSaverActivity")).contains("android:exported=\"false\"")
}
+ /**
+ * #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 `a transient timer it created but never started is cleared by the next tap`(@TempDir tempDir: Path) = runTest {
+ val harness = timerEngineHarness(tempDir)
+ val (tile, state) = harness.quickTile()
+ val stranded = harness.timers.create(TimerDraft(duration = 5.minutes, deleteAfterUse = true))
+ state.setTimerId(stranded)
+
+ tile.onTap()
+
+ val left = harness.timers.timers().first().single()
+ assertThat(left.id).isNotEqualTo(stranded)
+ assertThat(left.state).isEqualTo(TimerState.RUNNING)
+ }
+
+ @Test
+ fun `two taps at once start one timer and reset it, never two`(@TempDir tempDir: Path) = runTest {
+ val harness = timerEngineHarness(tempDir)
+ 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 f1c7531..9ad3ba3 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -101,9 +101,10 @@ kit module: the kit's roadmap keeps schedulers app-local.
| `domain/bedtime/` | the bedtime schedule's pure half (issue #1): `BedtimeSettings`/`BedtimeEffects`/`BedtimeLimits` and `BedtimeSchedule` (the window a night resolves to, the active window, the due reminder, the next event). §18 |
| `domain/screensaver/` | the screen saver's pure half (issue #25): `ScreenSaverSettings` (style and night mode), `ScreenSaverDrift`, the once-a-minute burn-in position as fractions of the free space, derived from the minute alone, and `ScreenSaverAlarmDay`, which dates a next alarm a week or more away rather than naming its weekday alone |
| `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` v4, `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)` |
@@ -118,7 +119,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` |
@@ -132,7 +133,9 @@ kit module: the kit's roadmap keeps schedulers app-local.
| `bedtime/` | `BedtimeEngine` (issue #1) and its three seams, `BedtimeScheduler`, `BedtimeMode` and `BedtimeNotifier`, plus `BedtimeIntents`. `bedtime/android/` holds their implementations (the one wall-clock slot, the Do Not Disturb rule, the reminder), `bedtime/receiver/` the `BedtimeReceiver`, `bedtime/di/` the `BedtimeModule`. §18 |
| `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 |
+| `widget/` | the three `GlanceAppWidget`/`GlanceAppWidgetReceiver` pairs (M10) and `WidgetSync`, the event-driven collector (started from `ClockulaApp.onCreate`) that redraws all three, and since #26 the two Quick Settings tiles, on any change to the state they mirror. `widget/di/` holds the `@EntryPoint` Glance needs, since a `GlanceAppWidgetReceiver` is never `@AndroidEntryPoint`-constructed |
+| `tile/` | the two Quick Settings tiles (#26): `NextAlarmTileService` and `QuickTimerTileService`, which only render and forward a click, and `TileKit` (opening the app, behind an unlock on a locked device, through the `PendingIntent` overload on API 34+; asking for a redraw). Both are *active* tiles, redrawn only when `WidgetSync` (on a change they mirror) or `SystemEventReceiver` (on a clock or zone change) asks the system to, never on every panel open |
+| `ui/tile/` | `TilePreferencesActivity` (#26), what long-pressing a tile opens: the quick-timer tile's duration, through the quick-timer widget's own picker |
| `ui/theme/`, `ui/crash/` | the M0/M1 theme and the crash-report surface |
| `ui/shell/` | the navigation policy (`ShellNavigation`), the adaptive shell, the live pill and its source/ViewModel, the notification-permission ask. `EmptyTabScreen` is gone: its KDoc said it existed only until each of M5–M8 replaced its own tab, and the world clock was its last caller |
| `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 |
@@ -621,6 +624,7 @@ Six slices:
| Timers (M6) | `timer_presets`, `timer_ring_sounding_since_millis` | `TimerPrefs`: the presets surfaced as `SettingsPrefs.timerPresets`, the ring session's anchor behind `TimerRingStateStore` |
| Bedtime (issue #1) | `bedtime_enabled`, `bedtime_start_minutes`, `bedtime_wake_minutes`, `bedtime_days`, `bedtime_reminder_minutes`, `bedtime_effect_grayscale`, `bedtime_effect_dim_wallpaper`, `bedtime_effect_dark_theme`, `bedtime_effect_hide_aod`, `bedtime_applied_active`, `bedtime_rule_stale`, `bedtime_state_owed` | `BedtimePrefs`, surfaced as `SettingsPrefs.bedtime: Flow` and written as one record by `BedtimeEngine` only. Times clamp into a day, the reminder into 0–120 minutes, the days through `RepeatDays.fromMask`. The system rule's id is **not** stored (§18) |
| Screen saver (issue #25) | `screen_saver_style`, `screen_saver_night_mode` | `ScreenSaverPrefs`, surfaced as `SettingsPrefs.screenSaver: Flow` and carried in the backup |
+| 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 3a003eb..6268ede 100644
--- a/docs/BACKUP.md
+++ b/docs/BACKUP.md
@@ -54,6 +54,10 @@ written here.
`default_timer_volume_ramp_seconds` restores 0, the ring timers had then.
- 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
@@ -174,7 +178,8 @@ device or after a reboot (`docs/PLAN.md` §5). Every imported timer lands
"hide_always_on_display": false
},
"screen_saver_style": "DIGITAL",
- "screen_saver_night_mode": false
+ "screen_saver_night_mode": false,
+ "quick_tile_duration_millis": 300000
}
```
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 62714d7..de854a8 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -616,7 +616,13 @@ Deferred deliberately, in rough priority order:
`ClockulaDreamService` plus `ScreenSaverActivity` for the overflow menu's
"Screen saver", digital or analog, with a dim night mode and a once-a-minute
drift against burn-in (`ARCHITECTURE.md` §3, §6).
-- **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 `WidgetSync` on a write rather than bound on every panel
+ open. No new permission.
- 🚧 **Bedtime / sleep schedule** (issue #1): a first version exists as an
opt-in screen under Settings, not a fifth tab. Clockula's own Do Not Disturb
mode (a real mode in Settings › Modes on Android 15+, with grayscale, dimmed