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