diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4d16595..fb7b4f9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -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. --> + 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. --> diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt index 2f9c217..d3cffc0 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt @@ -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() } } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/receiver/SystemEventReceiver.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/receiver/SystemEventReceiver.kt index a608911..c850387 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/receiver/SystemEventReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/receiver/SystemEventReceiver.kt @@ -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 { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/tile/QuickTimerTile.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/tile/QuickTimerTile.kt index 900e89c..49d1afb 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/tile/QuickTimerTile.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/tile/QuickTimerTile.kt @@ -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. diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/tile/NextAlarmTileService.kt b/app/src/main/java/de/jeanlucmakiola/clockula/tile/NextAlarmTileService.kt index 0c563f8..f54f0d1 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/tile/NextAlarmTileService.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/tile/NextAlarmTileService.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/tile/TileKit.kt b/app/src/main/java/de/jeanlucmakiola/clockula/tile/TileKit.kt index 0934429..1e32cab 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/tile/TileKit.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/tile/TileKit.kt @@ -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) { runCatching { TileService.requestListeningState(context, ComponentName(context, service)) } } + +/** Both tiles, for a change that moves what their times mean rather than the state behind them. */ +internal fun requestAllTileUpdates(context: Context) { + requestTileUpdate(context, NextAlarmTileService::class.java) + requestTileUpdate(context, QuickTimerTileService::class.java) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/tile/TileSync.kt b/app/src/main/java/de/jeanlucmakiola/clockula/tile/TileSync.kt deleted file mode 100644 index 4ffdaf2..0000000 --- a/app/src/main/java/de/jeanlucmakiola/clockula/tile/TileSync.kt +++ /dev/null @@ -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) = launch { - source - .distinctUntilChanged() - // A burst of writes (an import's replace-all) is one redraw. - .debounce(DEBOUNCE) - .collect { requestTileUpdate(context, tile) } - } - - private companion object { - val DEBOUNCE = 150.milliseconds - } -} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/timer/QuickTileTimer.kt b/app/src/main/java/de/jeanlucmakiola/clockula/timer/QuickTileTimer.kt index fdbb164..13e3ed4 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/timer/QuickTileTimer.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/timer/QuickTileTimer.kt @@ -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) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/AlarmTimeFormatter.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/AlarmTimeFormatter.kt index bf77123..aa0300b 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/AlarmTimeFormatter.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/AlarmTimeFormatter.kt @@ -1,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 { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/tile/TilePreferencesActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/tile/TilePreferencesActivity.kt index 61c5997..c23e623 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/tile/TilePreferencesActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/tile/TilePreferencesActivity.kt @@ -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() } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/NextAlarmWidgetContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/NextAlarmWidgetContent.kt index c379141..447a4fe 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/NextAlarmWidgetContent.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/NextAlarmWidgetContent.kt @@ -2,7 +2,6 @@ package de.jeanlucmakiola.clockula.ui.widget import android.content.Context import android.content.Intent -import android.text.format.DateFormat import androidx.compose.runtime.Composable import androidx.compose.ui.unit.dp import androidx.glance.GlanceModifier @@ -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) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/widget/WidgetSync.kt b/app/src/main/java/de/jeanlucmakiola/clockula/widget/WidgetSync.kt index 5362ae1..f3c0e49 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/widget/WidgetSync.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/widget/WidgetSync.kt @@ -1,11 +1,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> = emptyList(), + ) = launch { source .distinctUntilChanged() // A burst of writes (an import's replace-all, a reorder) collapses // to one redraw rather than one per row. .debounce(DEBOUNCE) - .collect { widgets.forEach { it.updateAll(context) } } + .collect { + tiles.forEach { requestTileUpdate(context, it) } + widgets.forEach { it.updateAll(context) } + } } private companion object { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fefa9ef..b41a0d7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 28abcb3..e00ddda 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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.