fix(tiles): redraw on clock and zone changes, and tighten the tile surfaces

The tiles' refresh now rides WidgetSync's collectors instead of a second
copy of them, and SystemEventReceiver asks both tiles to redraw on a time
or zone change, where the times they show move without any write.

The quick-timer tile remembers its timer before starting it, so a failed
start can't leave a timer it cannot see, and shows the timer's stored end
rather than one re-derived from two clocks on every read.

The tile preferences screen closes on anything but QS_TILE_PREFERENCES,
runs in its own task, and asks for a redraw after saving a duration. The
tiles and the next-alarm widget share one 12/24-hour time formatter and
the widgets' launch intent.
This commit is contained in:
makiolaj committed 2026-10-22 08:19:49 +02:00
1 parent da9854abf8
commit beeca29f74
14 files changed
+99 -127

No files matched your search

+5 -3
View File
@@ -444,7 +444,7 @@
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 (`tile/TileSync.kt`), never on every panel
when the app asks (`widget/WidgetSync.kt`), never on every panel
open. -->
<service
android:name=".tile.NextAlarmTileService"
@@ -478,12 +478,14 @@
<!-- 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, and guarded to that one action, as
the widget configurators are to theirs. -->
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" />
@@ -6,7 +6,6 @@ import de.jeanlucmakiola.clockula.alarm.AlarmEngine
import de.jeanlucmakiola.clockula.alarm.ring.RingNotifications
import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine
import de.jeanlucmakiola.clockula.stopwatch.service.StopwatchNotifications
import de.jeanlucmakiola.clockula.tile.TileSync
import de.jeanlucmakiola.clockula.timer.TimerEngine
import de.jeanlucmakiola.clockula.timer.service.TimerNotifications
import de.jeanlucmakiola.clockula.widget.WidgetSync
@@ -42,9 +41,6 @@ class ClockulaApp : Application() {
@Inject
lateinit var widgetSync: WidgetSync
@Inject
lateinit var tileSync: TileSync
@Inject
@ApplicationScope
lateinit var scope: CoroutineScope
@@ -86,10 +82,9 @@ class ClockulaApp : Application() {
stopwatchEngine.onBootCompleted()
}
// 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() }
// And the Quick Settings tiles' (#26), the same way.
scope.launch { tileSync.run() }
}
}
@@ -6,6 +6,7 @@ import androidx.glance.appwidget.updateAll
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
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
@@ -95,6 +96,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 {
@@ -65,7 +65,10 @@ object QuickTimerTilePresenter {
val active = QuickTimerWidgetPresenter.present(duration, timerId, timers, elapsedRealtime, wallClock).active
?: return QuickTimerTileModel.AtRest(duration)
return when (active.mode) {
TimerMode.RUNNING -> QuickTimerTileModel.Running(active.id, wallClock + active.remaining)
// 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.
@@ -28,7 +28,8 @@ import javax.inject.Inject
* It reads the next-alarm widget's own model ([NextAlarmWidgetPresenter]),
* so the tile, the widget and the Alarms tab cannot disagree about which
* alarm is next. The day is always the weekday's name, never "Today": an
* active tile is redrawn only when an alarm changes (`tile/TileSync.kt`), and
* 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
@@ -9,11 +9,9 @@ import android.graphics.drawable.Icon
import android.os.Build
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import android.text.format.DateFormat
import de.jeanlucmakiola.clockula.MainActivity
import de.jeanlucmakiola.clockula.ui.common.localTimeFormatter
import de.jeanlucmakiola.clockula.ui.widget.openAppIntent
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
/**
* Opens the app on the shell request [action] names (`ui/shell/ShellNavigation`)
@@ -25,9 +23,8 @@ import java.time.format.FormatStyle
* a `PendingIntent` instead; everything below it only has the `Intent` one.
*/
internal fun TileService.openApp(action: String) {
val intent = Intent(this, MainActivity::class.java)
.setAction(action)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
// 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(
@@ -68,27 +65,24 @@ internal fun TileService.render(
tile.updateTile()
}
/**
* The device's 12/24-hour choice, then the locale's own short form — the same
* two-step the next-alarm widget and the ring notification format with.
*/
internal fun Context.tileTime(time: LocalTime): String {
val formatter = if (DateFormat.is24HourFormat(this)) {
DateTimeFormatter.ofPattern("HH:mm")
} else {
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
}
return formatter.withLocale(resources.configuration.locales[0]).format(time)
}
/** 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. A tile that is not in 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)
}
@@ -1,55 +0,0 @@
package de.jeanlucmakiola.clockula.tile
import android.content.Context
import android.service.quicksettings.TileService
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
import de.jeanlucmakiola.clockula.timer.QuickTileTimer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.milliseconds
/**
* Keeps the two Quick Settings tiles in step with the app's state (#26) —
* `WidgetSync`'s counterpart, collecting the same read-only flows the tiles
* render. Both are *active* tiles, so nothing else ever redraws them outside
* a click: on every change this asks the system to bind the tile, which then
* collects and draws for itself. The process is alive whenever an engine
* writes, so this sees every change; and every process start emits once,
* which is the redraw after a reboot or a process death.
*
* Started once, from [de.jeanlucmakiola.clockula.ClockulaApp.onCreate], in
* the app's `@ApplicationScope`.
*/
@Singleton
class TileSync @Inject constructor(
@ApplicationContext private val context: Context,
private val alarmEngine: AlarmEngine,
private val quickTile: QuickTileTimer,
) {
suspend fun run(): Unit = coroutineScope {
sync(alarmEngine.upcoming(), NextAlarmTileService::class.java)
sync(quickTile.model, QuickTimerTileService::class.java)
}
@OptIn(FlowPreview::class)
private fun CoroutineScope.sync(source: Flow<*>, tile: Class<out TileService>) = launch {
source
.distinctUntilChanged()
// A burst of writes (an import's replace-all) is one redraw.
.debounce(DEBOUNCE)
.collect { requestTileUpdate(context, tile) }
}
private companion object {
val DEBOUNCE = 150.milliseconds
}
}
@@ -21,9 +21,9 @@ import javax.inject.Singleton
* Android type in sight: `tile/QuickTimerTileService.kt` only renders [model]
* and forwards the click here.
*
* At rest a tap creates a transient timer at the tile's duration and starts
* it — the same two steps as `QuickTimerReceiver` and the Timers screen's
* quick start — and remembers its id. On an active timer a tap resets it,
* 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
@@ -56,8 +56,12 @@ class QuickTileTimer @Inject constructor(
when (val current = model.first()) {
is QuickTimerTileModel.AtRest -> {
val id = timers.create(TimerDraft(duration = current.duration, deleteAfterUse = true))
engine.start(id)
// 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.Running -> reset(current.timerId)
is QuickTimerTileModel.Paused -> reset(current.timerId)
@@ -1,5 +1,6 @@
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
@@ -23,20 +24,25 @@ internal fun rememberLocalTimeFormatter(): (LocalTime) -> String {
// The locale can change under a running activity, and the 12/24-hour
// choice rides the same configuration, so both are re-read with it.
val configuration = LocalConfiguration.current
val formatter = remember(configuration) {
// The device's 12/24-hour choice, then the locale's own short form —
// the same two-step the ring notification already formats with.
if (DateFormat.is24HourFormat(context)) {
DateTimeFormatter.ofPattern("HH:mm")
} else {
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
}
}
val formatter = remember(configuration) { localTimeFormatter(context) }
return remember(formatter) {
{ time -> formatter.format(time) }
}
}
/**
* The device's 12/24-hour choice, then the locale's own short form — the same
* two-step the ring notification already formats with. 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): DateTimeFormatter =
if (DateFormat.is24HourFormat(context)) {
DateTimeFormatter.ofPattern("HH:mm")
} else {
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
}
/** Delegates to [rememberLocalTimeFormatter]. Unchanged for every existing caller. */
@Composable
internal fun rememberAlarmTimeFormatter(): (TimeOfDay) -> String {
@@ -3,6 +3,7 @@ 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
@@ -17,13 +18,15 @@ import androidx.compose.ui.res.stringResource
import androidx.core.content.IntentCompat
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.MainActivity
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.tile.NextAlarmTileService
import de.jeanlucmakiola.clockula.tile.QuickTimerTileService
import de.jeanlucmakiola.clockula.tile.requestTileUpdate
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
@@ -53,17 +56,19 @@ class TilePreferencesActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Exported for System UI, so anything else that starts it explicitly
// is turned away rather than shown a settings screen.
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(
Intent(this, MainActivity::class.java)
.setAction(AlarmIntents.ACTION_SHOW_ALARMS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP),
)
startActivity(openAppIntent(this, AlarmIntents.ACTION_SHOW_ALARMS))
finish()
return
}
@@ -93,6 +98,8 @@ class TilePreferencesActivity : ComponentActivity() {
saving = true
lifecycleScope.launch {
withContext(NonCancellable) { settingsPrefs.setQuickTileDuration(duration) }
// The one write to the tile's state that `WidgetSync` doesn't watch.
requestTileUpdate(this@TilePreferencesActivity, QuickTimerTileService::class.java)
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
@@ -21,10 +20,9 @@ import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
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
@@ -127,15 +125,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)
@@ -1,11 +1,15 @@
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 androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.updateAll
import kotlinx.coroutines.CoroutineScope
@@ -35,9 +39,17 @@ 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) ride the same collectors: 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 quick-timer
* tile follows the timers, as the quick-timer widget does; its duration is
* written only by `ui/tile/TilePreferencesActivity.kt`, which asks for its
* own redraw.
*
* 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(
@@ -49,21 +61,28 @@ class WidgetSync @Inject constructor(
) {
suspend fun run(): Unit = coroutineScope {
sync(alarmEngine.upcoming(), NextAlarmWidget())
sync(timers.timers(), TimerWidget(), QuickTimerWidget())
sync(alarmEngine.upcoming(), NextAlarmWidget(), tiles = listOf(NextAlarmTileService::class.java))
sync(timers.timers(), TimerWidget(), QuickTimerWidget(), 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 {
+2 -2
View File
@@ -130,8 +130,8 @@ kit module: the kit's roadmap keeps schedulers app-local.
| `interop/` | the `AlarmClock` boundary's Android half (M9): `AlarmClockActivity` (the one exported, permission-guarded, window-less door), `IntentExtrasReader` (`Bundle` → `IntentExtras`), `AlarmClockHandler` (the orchestrator; no `android.*` import, ever), `InteropIntents` (the internal transport to `MainActivity`) and `InteropLaunch` |
| `system/` | `RebootRepair`, the boot-id gate |
| `backup/` | `BackupEngine` (M10), the orchestrator (a ringing alarm dismissed and running timers deleted before a replace, `AlarmEngine.reschedule()`/`TimerEngine.resync()` after it), plus `BackupFiles`, the SAF seam, with its Android implementation in `backup/android/` |
| `widget/` | the three `GlanceAppWidget`/`GlanceAppWidgetReceiver` pairs (M10) and `WidgetSync`, the event-driven collector (started from `ClockulaApp.onCreate`) that redraws all three on any change to the state they mirror. `widget/di/` holds the `@EntryPoint` Glance needs, since a `GlanceAppWidgetReceiver` is never `@AndroidEntryPoint`-constructed |
| `tile/` | the two Quick Settings tiles (#26): `NextAlarmTileService` and `QuickTimerTileService`, which only render and forward a click, `TileKit` (opening the app, behind an unlock on a locked device, through the `PendingIntent` overload on API 34+), and `TileSync`, `WidgetSync`'s counterpart: both are *active* tiles, redrawn only when this asks the system to, never on every panel open |
| `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 |
+1 -1
View File
@@ -615,7 +615,7 @@ Deferred deliberately, in rough priority order:
tab (after an unlock, on a locked device); "Quick timer" is the quick-timer
widget's twin, with its own duration (long-press to choose; in the backup),
and works on a locked device as the timer notification does. Both are active
tiles, redrawn by `TileSync` on a write rather than bound on every panel
tiles, redrawn by `WidgetSync` on a write rather than bound on every panel
open. No new permission.
- **Bedtime / sleep schedule**: wind-down and wake, with DND handoff. A feature
in its own right, realistically v2.