Merge branch 'feat/26-quick-settings-tiles' into release/v1.0.0
# Conflicts: # CHANGELOG.md # app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt # app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt # app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt # app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt # app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt # app/src/main/java/de/jeanlucmakiola/clockula/ui/common/AlarmTimeFormatter.kt # app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/NextAlarmWidgetContent.kt # app/src/main/res/values/strings.xml # app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt # app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt # app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt # docs/ARCHITECTURE.md # docs/BACKUP.md # docs/ROADMAP.md
This commit is contained in:
commit
df8be5e158
31 files changed
+998
-27
No files matched your search
@@ -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.
|
||||
@@ -496,6 +496,58 @@
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/widget_quick_timer_info" />
|
||||
</receiver>
|
||||
|
||||
<!-- #26: the two Quick Settings tiles. A TileService must be exported
|
||||
for System UI to bind it, and BIND_QUICK_SETTINGS_TILE — a
|
||||
signature permission only System UI holds — keeps anyone else
|
||||
from binding it. Both are active tiles: they are redrawn only
|
||||
when the app asks (`widget/WidgetSync.kt`), never on every panel
|
||||
open. -->
|
||||
<service
|
||||
android:name=".tile.NextAlarmTileService"
|
||||
android:exported="true"
|
||||
android:icon="@drawable/widget_ic_alarm"
|
||||
android:label="@string/tile_next_alarm_label"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.service.quicksettings.ACTIVE_TILE"
|
||||
android:value="true" />
|
||||
</service>
|
||||
<service
|
||||
android:name=".tile.QuickTimerTileService"
|
||||
android:exported="true"
|
||||
android:icon="@drawable/tile_ic_timer"
|
||||
android:label="@string/tile_quick_timer_label"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.service.quicksettings.ACTIVE_TILE"
|
||||
android:value="true" />
|
||||
<meta-data
|
||||
android:name="android.service.quicksettings.TOGGLEABLE_TILE"
|
||||
android:value="true" />
|
||||
</service>
|
||||
|
||||
<!-- What long-pressing either tile opens: the quick-timer tile's
|
||||
duration, or the Alarms tab for the next-alarm one. Exported
|
||||
because System UI starts it; it answers that one action and
|
||||
closes on any other. A task of its own, so finishing it returns
|
||||
to wherever the user was, not into an open Clockula. -->
|
||||
<activity
|
||||
android:name=".ui.tile.TilePreferencesActivity"
|
||||
android:exported="true"
|
||||
android:excludeFromRecents="true"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/Theme.Clockula">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -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() }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<Duration> = store.flow(TimerPrefs.quickTileDuration)
|
||||
|
||||
suspend fun setQuickTileDuration(value: Duration) = store.set(TimerPrefs.quickTileDuration, value)
|
||||
|
||||
// --- first-launch bookkeeping (M4) ---
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<Duration> = 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<Long?> = nullableLongPref("quick_tile_timer_id")
|
||||
}
|
||||
@@ -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<Long?> = store.flow(TimerPrefs.quickTileTimerId)
|
||||
|
||||
suspend fun currentTimerId(): Long? = store.get(TimerPrefs.quickTileTimerId)
|
||||
|
||||
suspend fun setTimerId(id: Long?) = store.set(TimerPrefs.quickTileTimerId, id)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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<Timer>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<out TileService>) {
|
||||
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)
|
||||
}
|
||||
@@ -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<QuickTimerTileModel> = 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()
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+10
-3
@@ -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(
|
||||
|
||||
@@ -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<Class<out TileService>> = 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 {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Material Symbols "timer", white: the Quick Settings panel tints it by the tile's state. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M15,1L9,1v2h6L15,1zM11,14h2L13,8h-2v6zM19.03,7.39l1.42,-1.42c-0.43,-0.51 -0.9,-0.99 -1.41,-1.41l-1.42,1.42C16.07,4.74 14.12,4 12,4c-4.97,0 -9,4.03 -9,9s4.02,9 9,9 9,-4.03 9,-9c0,-2.12 -0.74,-4.07 -1.97,-5.61zM12,20c-3.87,0 -7,-3.13 -7,-7s3.13,-7 7,-7 7,3.13 7,7 -3.13,7 -7,7z"
|
||||
android:fillColor="#FFFFFFFF" />
|
||||
</vector>
|
||||
@@ -552,4 +552,14 @@
|
||||
<item quantity="other">Bedtime in %1$d minutes</item>
|
||||
</plurals>
|
||||
<string name="bedtime_reminder_text">Bedtime mode starts at %1$s.</string>
|
||||
|
||||
<!-- #26: Quick Settings tiles -->
|
||||
<string name="tile_next_alarm_label">Next alarm</string>
|
||||
<string name="tile_next_alarm_empty">No alarms</string>
|
||||
<!-- The next alarm's day and time, e.g. "Tue 07:00". %1$s: the abbreviated weekday, %2$s: the time. -->
|
||||
<string name="tile_next_alarm_subtitle">%1$s %2$s</string>
|
||||
<string name="tile_quick_timer_label">Quick timer</string>
|
||||
<!-- A running quick timer's end time, e.g. "Until 14:32". -->
|
||||
<string name="tile_quick_timer_until">Until %1$s</string>
|
||||
<string name="tile_quick_timer_config_title">Quick timer tile</string>
|
||||
</resources>
|
||||
@@ -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("<service\\b[\\s\\S]*?(?:/>|</service>)")
|
||||
.findAll(text)
|
||||
.map { it.value }
|
||||
.first { it.contains("android:name=\"$name\"") }
|
||||
|
||||
val actions = Regex("<action\\s+android:name=\"([^\"]+)\"").findAll(block)
|
||||
.map { it.groupValues[1] }
|
||||
.toList()
|
||||
|
||||
assertThat(actions).containsExactly("android.service.quicksettings.action.QS_TILE")
|
||||
assertThat(block).contains("android:permission=\"android.permission.BIND_QUICK_SETTINGS_TILE\"")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the tile preferences screen answers only QS_TILE_PREFERENCES`() {
|
||||
val block = activityBlock(".ui.tile.TilePreferencesActivity")
|
||||
|
||||
val actions = Regex("<action\\s+android:name=\"([^\"]+)\"").findAll(block)
|
||||
.map { it.groupValues[1] }
|
||||
.toList()
|
||||
|
||||
assertThat(actions).containsExactly("android.service.quicksettings.action.QS_TILE_PREFERENCES")
|
||||
assertThat(block).doesNotContain("android:permission")
|
||||
}
|
||||
|
||||
/**
|
||||
* M11: the mirror of `the six widget receivers answer only
|
||||
* APPWIDGET_UPDATE` for the six widget config activities — being
|
||||
|
||||
@@ -18,6 +18,7 @@ import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter
|
||||
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.QuickTimerWidgetSettings
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.LocalDate
|
||||
@@ -78,6 +79,7 @@ class BackupCodecTest {
|
||||
),
|
||||
timerPresets = listOf(1.minutes, 2.minutes, 10.minutes),
|
||||
worldClockSeconds = true,
|
||||
quickTileDuration = 3.minutes,
|
||||
screenSaver = ScreenSaverSettings(style = ScreenSaverStyle.ANALOG, nightMode = true),
|
||||
bedtime = BedtimeSettings(
|
||||
enabled = true,
|
||||
@@ -508,6 +510,19 @@ class BackupCodecTest {
|
||||
assertThat(settings.screenSaver).isEqualTo(ScreenSaverSettings(ScreenSaverStyle.DIGITAL, nightMode = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a file without the quick tile's duration keeps its default`() {
|
||||
val settings = (BackupCodec.decode(minimalDocument()) as BackupResult.Success).content.settings
|
||||
assertThat(settings.quickTileDuration).isEqualTo(QuickTimerWidgetSettings.DEFAULT)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a quick tile duration the tile cannot start falls back to its default`() {
|
||||
val json = minimalDocument().replace("\"settings\": {}", "\"settings\": {\"quick_tile_duration_millis\": 7200000}")
|
||||
val settings = (BackupCodec.decode(json) as BackupResult.Success).content.settings
|
||||
assertThat(settings.quickTileDuration).isEqualTo(QuickTimerWidgetSettings.DEFAULT)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty preset list is honoured as all removed`() {
|
||||
val json = minimalDocument().replace("\"settings\": {}", "\"settings\": {\"timer_presets\": []}")
|
||||
|
||||
@@ -97,6 +97,7 @@ class SettingsPrefsTest {
|
||||
),
|
||||
timerPresets = listOf(5.minutes, 1.minutes, 5.minutes),
|
||||
worldClockSeconds = false,
|
||||
quickTileDuration = 2.minutes,
|
||||
screenSaver = ScreenSaverSettings(ScreenSaverStyle.ANALOG, nightMode = true),
|
||||
),
|
||||
)
|
||||
@@ -113,6 +114,7 @@ class SettingsPrefsTest {
|
||||
assertThat(prefs.currentDefaults().timerVolumeRampSeconds).isEqualTo(60)
|
||||
assertThat(prefs.currentDefaults().automaticLocalClock).isFalse()
|
||||
assertThat(prefs.timerPresets.first()).containsExactly(1.minutes, 5.minutes).inOrder()
|
||||
assertThat(prefs.quickTileDuration.first()).isEqualTo(2.minutes)
|
||||
assertThat(prefs.screenSaver.first()).isEqualTo(ScreenSaverSettings(ScreenSaverStyle.ANALOG, nightMode = true))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package de.jeanlucmakiola.clockula.domain.tile
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.testing.T0
|
||||
import de.jeanlucmakiola.clockula.testing.expiredTimer
|
||||
import de.jeanlucmakiola.clockula.testing.idleTimer
|
||||
import de.jeanlucmakiola.clockula.testing.pausedTimer
|
||||
import de.jeanlucmakiola.clockula.testing.runningTimerWith
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class QuickTimerTileModelTest {
|
||||
|
||||
@Test
|
||||
fun `nothing started is at rest on its duration`() {
|
||||
val model = QuickTimerTilePresenter.present(5.minutes, null, listOf(runningTimerWith(id = 1, left = 1.minutes)), Duration.ZERO, T0)
|
||||
|
||||
assertThat(model).isEqualTo(QuickTimerTileModel.AtRest(5.minutes))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `its own running timer shows when it ends, on the wall clock`() {
|
||||
val timers = listOf(runningTimerWith(id = 1, left = 1.minutes), runningTimerWith(id = 2, left = 90.seconds))
|
||||
|
||||
val model = QuickTimerTilePresenter.present(5.minutes, 2L, timers, Duration.ZERO, T0)
|
||||
|
||||
assertThat(model).isEqualTo(QuickTimerTileModel.Running(2L, T0 + 90.seconds))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `paused shows what is left`() {
|
||||
val model = QuickTimerTilePresenter.present(5.minutes, 1L, listOf(pausedTimer(id = 1, remaining = 2.minutes)), Duration.ZERO, T0)
|
||||
|
||||
assertThat(model).isEqualTo(QuickTimerTileModel.Paused(1L, 2.minutes))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `expired is done`() {
|
||||
val model = QuickTimerTilePresenter.present(5.minutes, 1L, listOf(expiredTimer(id = 1)), Duration.ZERO, T0)
|
||||
|
||||
assertThat(model).isEqualTo(QuickTimerTileModel.Expired(1L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a deleted or idle timer is back at rest`() {
|
||||
assertThat(QuickTimerTilePresenter.present(3.minutes, 1L, emptyList(), Duration.ZERO, T0))
|
||||
.isEqualTo(QuickTimerTileModel.AtRest(3.minutes))
|
||||
assertThat(QuickTimerTilePresenter.present(3.minutes, 1L, listOf(idleTimer(id = 1)), Duration.ZERO, T0))
|
||||
.isEqualTo(QuickTimerTileModel.AtRest(3.minutes))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package de.jeanlucmakiola.clockula.timer
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.data.timers.QuickTileStateStore
|
||||
import de.jeanlucmakiola.clockula.domain.TimerDraft
|
||||
import de.jeanlucmakiola.clockula.domain.TimerState
|
||||
import de.jeanlucmakiola.clockula.domain.tile.QuickTimerTileModel
|
||||
import de.jeanlucmakiola.clockula.testing.TimerEngineHarness
|
||||
import de.jeanlucmakiola.clockula.testing.timerEngineHarness
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
/**
|
||||
* The quick-timer tile's tap (#26) over the **real** [TimerEngine] and a real
|
||||
* DataStore: what it starts, what it resets, and that the engine — so the
|
||||
* slot and the service — is what does both.
|
||||
*/
|
||||
class QuickTileTimerTest {
|
||||
|
||||
private fun TimerEngineHarness.quickTile(): Pair<QuickTileTimer, QuickTileStateStore> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<BedtimeSettings>` 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<ScreenSaverSettings>` 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.
|
||||
|
||||
+6
-1
@@ -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
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
+7
-1
@@ -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
|
||||
|
||||
Reference in new issue
Block a user