From 0f5cf4f1eb503e97a92e597eebff16308ea09ef2 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 2 Oct 2026 18:18:04 +0200 Subject: [PATCH] fix(widgets): config saves, live world clocks, quick-timer guards - Config screens share WidgetConfigActivity: a picker that dismisses right after selecting no longer finishes with RESULT_CANCELED (which removed a newly placed widget on API 29/30), and the save runs to completion. - Digital config shows only once stored settings are read, so an early OK or a late load can't write defaults over them. - Digital widget collects world clocks inside the composition, so edits reach a live session. - Quick timer: a second tap while its timer is active starts nothing; durations are capped at 59:59 (1 h preset dropped) to fit the readout. - Analog date hand is redrawn at local midnight and on time/zone changes (AnalogDateRefresh); updatePeriodMillis back to 0. - WidgetSync routes each flow only to the widgets that read it. - Stopwatch widget uses the ElapsedRealtimeClock seam and has a two-row minimum height. - Remove dead quick-timer helpers; share showWorldClockIntent. --- app/src/main/AndroidManifest.xml | 5 +- .../alarm/receiver/SystemEventReceiver.kt | 7 ++ .../domain/widget/QuickTimerWidgetSettings.kt | 22 ++-- .../timer/receiver/QuickTimerReceiver.kt | 46 +++++-- .../ui/widget/AnalogClockWidgetContent.kt | 12 +- .../clockula/ui/widget/ClockWidgetContent.kt | 53 ++++---- .../ui/widget/QuickTimerWidgetContent.kt | 4 +- .../ui/widget/StopwatchWidgetContent.kt | 10 +- .../clockula/ui/widget/WidgetKit.kt | 9 ++ .../config/AnalogClockConfigActivity.kt | 97 ++++----------- .../config/ClockWidgetConfigActivity.kt | 116 +++++------------- .../widget/config/QuickTimerConfigActivity.kt | 106 +++++----------- .../ui/widget/config/WidgetConfigActivity.kt | 104 ++++++++++++++++ .../clockula/widget/AnalogClockWidget.kt | 22 ++++ .../clockula/widget/AnalogDateRefresh.kt | 59 +++++++++ .../clockula/widget/ClockWidget.kt | 6 +- .../clockula/widget/ClockWidgetState.kt | 9 -- .../clockula/widget/WidgetSync.kt | 46 ++++--- .../main/res/xml/widget_analog_clock_info.xml | 2 +- .../main/res/xml/widget_stopwatch_info.xml | 4 +- .../domain/widget/ClockWidgetSettingsTest.kt | 11 ++ 21 files changed, 415 insertions(+), 335 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/WidgetConfigActivity.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/widget/AnalogDateRefresh.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fa94667..01acaaa 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -284,8 +284,9 @@ app's widget: APPWIDGET_UPDATE is a platform-delivered broadcast, never a door another app can open by naming the action itself. updatePeriodMillis is 0 in every provider-info XML — WidgetSync - is the one refresh trigger (the quick-timer widget has no live - state, so it is refreshed only by its own config save). --> + is the one refresh trigger for the data widgets, and the analog + widget's date is redrawn at midnight and on a clock or zone + change (widget/AnalogDateRefresh.kt). --> 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 ec6b8ae..7d82353 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 @@ -2,10 +2,13 @@ package de.jeanlucmakiola.clockula.alarm.receiver import android.content.Context import android.content.Intent +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.timer.TimerEngine +import de.jeanlucmakiola.clockula.widget.AnalogClockWidget +import de.jeanlucmakiola.clockula.widget.AnalogDateRefresh import de.jeanlucmakiola.floret.di.ApplicationScope import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -79,6 +82,10 @@ class SystemEventReceiver : HiltBroadcastReceiver() { // the notification re-posts with a freshly derived // chronometer base (M7 D4, D17). stopwatchEngine.onSystemTimeChanged() + // The analog faces' date is drawn for the old day or + // zone, and the midnight redraw armed for the old one. + AnalogClockWidget().updateAll(context) + AnalogDateRefresh.schedule(context) } } } finally { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/QuickTimerWidgetSettings.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/QuickTimerWidgetSettings.kt index b45c8d2..bede8c3 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/QuickTimerWidgetSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/QuickTimerWidgetSettings.kt @@ -5,19 +5,27 @@ import de.jeanlucmakiola.clockula.domain.timer.TimerPresets import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant -/** One quick-timer widget instance's own pick: the duration it starts on tap. */ -data class QuickTimerWidgetSettings(val duration: Duration = DEFAULT) { - companion object { - val DEFAULT: Duration = 5.minutes - } +/** The quick-timer widget's one setting: the duration it starts on tap. */ +object QuickTimerWidgetSettings { + val DEFAULT: Duration = 5.minutes + + /** + * 59:59 — the Scallop's readout is sized for "MM:SS", so the widget never + * offers (or reads back) anything that would format as "H:MM:SS". + */ + val MAX: Duration = 59.minutes + 59.seconds + + /** What the config screen offers: the Timers screen's own built-in quick starts, up to [MAX]. */ + val OPTIONS: List = TimerPresets.BUILT_IN.filter { it <= MAX } } -/** Anything unreadable, or out of [TimerPresets]' own sane range, falls back to [QuickTimerWidgetSettings.DEFAULT]. */ +/** Anything unreadable, or outside [TimerPresets.MIN]..[QuickTimerWidgetSettings.MAX], falls back to [QuickTimerWidgetSettings.DEFAULT]. */ fun parseQuickTimerDuration(raw: Long?): Duration { val duration = raw?.milliseconds - return duration?.takeIf { it in TimerPresets.MIN..TimerPresets.MAX } ?: QuickTimerWidgetSettings.DEFAULT + return duration?.takeIf { it in TimerPresets.MIN..QuickTimerWidgetSettings.MAX } ?: QuickTimerWidgetSettings.DEFAULT } /** diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/timer/receiver/QuickTimerReceiver.kt b/app/src/main/java/de/jeanlucmakiola/clockula/timer/receiver/QuickTimerReceiver.kt index 73d4b6e..197a890 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/timer/receiver/QuickTimerReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/timer/receiver/QuickTimerReceiver.kt @@ -3,19 +3,26 @@ package de.jeanlucmakiola.clockula.timer.receiver import android.appwidget.AppWidgetManager import android.content.Context import android.content.Intent +import androidx.glance.GlanceId import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.state.getAppWidgetState import androidx.glance.appwidget.state.updateAppWidgetState +import androidx.glance.state.PreferencesGlanceStateDefinition import dagger.hilt.android.AndroidEntryPoint import de.jeanlucmakiola.clockula.alarm.receiver.HiltBroadcastReceiver import de.jeanlucmakiola.clockula.data.timers.TimerRepository import de.jeanlucmakiola.clockula.domain.TimerDraft +import de.jeanlucmakiola.clockula.domain.TimerState import de.jeanlucmakiola.clockula.timer.QuickTimerIntents import de.jeanlucmakiola.clockula.timer.TimerEngine import de.jeanlucmakiola.clockula.widget.QuickTimerWidget import de.jeanlucmakiola.clockula.widget.WIDGET_QUICK_TIMER_ID_KEY import de.jeanlucmakiola.floret.di.ApplicationScope import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds @@ -47,20 +54,39 @@ class QuickTimerReceiver : HiltBroadcastReceiver() { val pendingResult = goAsync() scope.launch { try { - // Transient: the widget's own reset/stop deletes it rather - // than leaving a stray idle timer in the Timers list. - val id = timers.create(TimerDraft(duration = durationMillis.milliseconds, deleteAfterUse = true)) - engine.start(id) - // Remember which timer this widget started, so it shows that - // one's countdown and controls (`ui/widget/QuickTimerWidgetContent.kt`). - val glanceId = runCatching { GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId) }.getOrNull() - if (glanceId != null) { - updateAppWidgetState(context, glanceId) { prefs -> prefs[WIDGET_QUICK_TIMER_ID_KEY] = id } - QuickTimerWidget().update(context, glanceId) + // Serialised, so a double tap's second start sees the first + // one's timer instead of racing it. + startMutex.withLock { + val glanceId = runCatching { GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId) }.getOrNull() + // A second tap before the widget re-rendered into its + // running state: this widget already has a live timer, + // so leave it be rather than start one it can't control. + if (glanceId != null && hasActiveTimer(context, glanceId)) return@withLock + // Transient: the widget's own reset/stop deletes it rather + // than leaving a stray idle timer in the Timers list. + val id = timers.create(TimerDraft(duration = durationMillis.milliseconds, deleteAfterUse = true)) + engine.start(id) + // Remember which timer this widget started, so it shows that + // one's countdown and controls (`ui/widget/QuickTimerWidgetContent.kt`). + if (glanceId != null) { + updateAppWidgetState(context, glanceId) { prefs -> prefs[WIDGET_QUICK_TIMER_ID_KEY] = id } + QuickTimerWidget().update(context, glanceId) + } } } finally { pendingResult.finish() } } } + + /** Whether the timer this widget last started still exists and isn't idle — the same test the widget's at-rest state uses. */ + private suspend fun hasActiveTimer(context: Context, glanceId: GlanceId): Boolean { + val state = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId) + val id = state[WIDGET_QUICK_TIMER_ID_KEY] ?: return false + return timers.timers().first().any { it.id == id && it.state != TimerState.IDLE } + } + + private companion object { + val startMutex = Mutex() + } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/AnalogClockWidgetContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/AnalogClockWidgetContent.kt index 28e174e..b392335 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/AnalogClockWidgetContent.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/AnalogClockWidgetContent.kt @@ -1,7 +1,6 @@ package de.jeanlucmakiola.clockula.ui.widget import android.content.Context -import android.content.Intent import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Paint @@ -26,11 +25,9 @@ import androidx.glance.layout.Alignment import androidx.glance.layout.Box import androidx.glance.layout.fillMaxSize import androidx.glance.layout.size -import de.jeanlucmakiola.clockula.MainActivity import de.jeanlucmakiola.clockula.R import de.jeanlucmakiola.clockula.domain.widget.AnalogClockStyle import de.jeanlucmakiola.clockula.domain.widget.parseAnalogClockStyle -import de.jeanlucmakiola.clockula.ui.shell.ShellNavigation import de.jeanlucmakiola.clockula.widget.WIDGET_ANALOG_STYLE_KEY /** Reads this instance's own [AnalogClockStyle] reactively, then renders [AnalogClockWidgetContent]. */ @@ -211,8 +208,8 @@ internal fun analogClockViews(context: Context, style: AnalogClockStyle, sideDp: * opposite the seconds dot, on the layout's separate date clock. Text cannot * live in a vector drawable, so this hand is a bitmap — an alpha mask, tinted by * `setSecondHandTintList` in the launcher so it still follows day/night. - * The date goes stale at midnight until the next update; the provider's 30 - * minute `updatePeriodMillis` bounds that. + * It is redrawn at midnight and on a clock or zone change + * (`widget/AnalogDateRefresh.kt`). */ private fun dateHand(context: Context): Bitmap { val px = 360 @@ -239,8 +236,3 @@ private fun dateHand(context: Context): Bitmap { canvas.drawText(date, 50 * unit, dateY - (metrics.ascent + metrics.descent) / 2, paint) return bitmap } - -private fun showWorldClockIntent(context: Context): Intent = - Intent(context, MainActivity::class.java) - .setAction(ShellNavigation.ACTION_SHOW_WORLD_CLOCK) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/ClockWidgetContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/ClockWidgetContent.kt index 41f0d70..9bc9f79 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/ClockWidgetContent.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/ClockWidgetContent.kt @@ -1,10 +1,9 @@ package de.jeanlucmakiola.clockula.ui.widget -import android.content.Context -import android.content.Intent import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember import androidx.datastore.preferences.core.Preferences import androidx.glance.GlanceModifier import androidx.glance.LocalContext @@ -15,51 +14,56 @@ import androidx.glance.appwidget.action.actionStartActivity import androidx.glance.currentState import androidx.glance.layout.Box import androidx.glance.layout.fillMaxSize -import de.jeanlucmakiola.clockula.MainActivity import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository import de.jeanlucmakiola.clockula.data.zones.ZoneNames import de.jeanlucmakiola.clockula.domain.widget.ClockWidgetPresenter import de.jeanlucmakiola.clockula.domain.widget.DigitalClockSettings -import de.jeanlucmakiola.clockula.ui.shell.ShellNavigation import de.jeanlucmakiola.clockula.widget.WIDGET_DIGITAL_STYLE_KEY import de.jeanlucmakiola.clockula.widget.WIDGET_SHOW_DATE_KEY import de.jeanlucmakiola.clockula.widget.WIDGET_SHOW_WORLD_CLOCKS_KEY import de.jeanlucmakiola.clockula.widget.di.WidgetEntryPoint import de.jeanlucmakiola.clockula.widget.parseDigitalClockSettings +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map /** One resolved world clock for the digital widget: the zone id a `TextClock` ticks against, with its already-resolved city name. */ data class ClockWidgetCity(val zoneId: String, val cityName: String) -/** The world clocks the digital widget shows, resolved to city names — shared by the widget and its config preview. */ -internal suspend fun widgetCities(worldClocks: WorldClockRepository, zoneNames: ZoneNames): List = - ClockWidgetPresenter.present(worldClocks.worldClocks().first()).map { clock -> - ClockWidgetCity( - zoneId = clock.zoneId, - cityName = clock.label?.takeIf(String::isNotBlank) ?: zoneNames.cityOf(clock.zoneId) ?: clock.zoneId, - ) +/** The world clocks the digital widget shows, resolved to city names, as they change. */ +internal fun widgetCitiesFlow(worldClocks: WorldClockRepository, zoneNames: ZoneNames): Flow> = + worldClocks.worldClocks().map { clocks -> + ClockWidgetPresenter.present(clocks).map { clock -> + ClockWidgetCity( + zoneId = clock.zoneId, + cityName = clock.label?.takeIf(String::isNotBlank) ?: zoneNames.cityOf(clock.zoneId) ?: clock.zoneId, + ) + } } +/** [widgetCitiesFlow]'s current value — for the config preview and a session's first frame. */ +internal suspend fun widgetCities(worldClocks: WorldClockRepository, zoneNames: ZoneNames): List = + widgetCitiesFlow(worldClocks, zoneNames).first() + /** - * Reads this widget instance's own state — reactively, so a config-screen - * save that lands on a still-live Glance session (see `widget/ClockWidget.kt`) - * recomposes with the new values instead of the ones the session started - * with — then renders [ClockWidgetContent]. + * Reads this widget instance's own state and the world clocks — both + * reactively, so a config-screen save or a world-clock edit that lands on a + * still-live Glance session (see `widget/ClockWidget.kt`) recomposes with the + * new values instead of the ones the session started with — then renders + * [ClockWidgetContent]. [initialCities] only makes the first frame right. */ @Composable -fun ClockWidgetRoot(entryPoint: WidgetEntryPoint) { +fun ClockWidgetRoot(entryPoint: WidgetEntryPoint, initialCities: List) { val prefs = currentState() val settings = parseDigitalClockSettings( style = prefs[WIDGET_DIGITAL_STYLE_KEY], showDate = prefs[WIDGET_SHOW_DATE_KEY], showWorldClocks = prefs[WIDGET_SHOW_WORLD_CLOCKS_KEY], ) - val showWorldClocks = settings.showWorldClocks - val cities by produceState(initialValue = emptyList(), showWorldClocks) { - value = if (showWorldClocks) widgetCities(entryPoint.worldClockRepository(), entryPoint.zoneNames()) else emptyList() - } + val cities by remember { widgetCitiesFlow(entryPoint.worldClockRepository(), entryPoint.zoneNames()) } + .collectAsState(initial = initialCities) - ClockWidgetContent(cities = cities, settings = settings) + ClockWidgetContent(cities = if (settings.showWorldClocks) cities else emptyList(), settings = settings) } /** @@ -80,8 +84,3 @@ fun ClockWidgetContent(cities: List, settings: DigitalClockSett ) } } - -private fun showWorldClockIntent(context: Context): Intent = - Intent(context, MainActivity::class.java) - .setAction(ShellNavigation.ACTION_SHOW_WORLD_CLOCK) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/QuickTimerWidgetContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/QuickTimerWidgetContent.kt index e59130f..b2217d1 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/QuickTimerWidgetContent.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/QuickTimerWidgetContent.kt @@ -142,7 +142,9 @@ private fun Running(context: Context, item: TimerWidgetItem, elapsedRealtimeNow: /** * The one layout both states share: [icon] over a time readout, sized for * five characters ("01:02", what a running chronometer shows) so the readout - * keeps its size from rest to running and fits the shape's inner ~60%. + * keeps its size from rest to running and fits the shape's inner ~60%. The + * duration is capped at 59:59 (`QuickTimerWidgetSettings.MAX`), so a time + * never needs more. */ @Composable private fun GlyphOverTime( diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/StopwatchWidgetContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/StopwatchWidgetContent.kt index fd80ab0..b986c3a 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/StopwatchWidgetContent.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/StopwatchWidgetContent.kt @@ -28,7 +28,6 @@ import de.jeanlucmakiola.clockula.domain.format.ClockFormat import de.jeanlucmakiola.clockula.domain.widget.StopwatchWidgetModel import de.jeanlucmakiola.clockula.stopwatch.StopwatchIntents import de.jeanlucmakiola.clockula.stopwatch.receiver.StopwatchActionReceiver -import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.flow.map import de.jeanlucmakiola.clockula.widget.di.WidgetEntryPoint import de.jeanlucmakiola.clockula.domain.widget.StopwatchWidgetPresenter @@ -36,7 +35,6 @@ import de.jeanlucmakiola.clockula.domain.StopwatchRun import androidx.compose.runtime.remember import androidx.compose.runtime.getValue import androidx.compose.runtime.collectAsState -import android.os.SystemClock /** * Reads the stopwatch run and its laps reactively, then renders [StopwatchWidgetContent]. @@ -52,9 +50,9 @@ fun StopwatchWidgetRoot(entryPoint: WidgetEntryPoint, initialRun: StopwatchRun, val lapCount by remember { repository.laps().map { it.size } }.collectAsState(initial = initialLapCount) // The same instant the model is resolved at, so the chronometer's base // is exactly `now - elapsed`. - val elapsedRealtime = remember(run, lapCount) { SystemClock.elapsedRealtime() } - val model = remember(run, lapCount) { StopwatchWidgetPresenter.present(run, elapsedRealtime.milliseconds, lapCount) } - StopwatchWidgetContent(model, elapsedRealtime) + val elapsedRealtime = remember(run, lapCount) { entryPoint.elapsedRealtimeClock().elapsedRealtime() } + val model = remember(run, lapCount) { StopwatchWidgetPresenter.present(run, elapsedRealtime, lapCount) } + StopwatchWidgetContent(model, elapsedRealtime.inWholeMilliseconds) } /** @@ -117,7 +115,7 @@ fun StopwatchWidgetContent(model: StopwatchWidgetModel, elapsedRealtimeNow: Long } } -/** Under this height (one launcher row, give or take) the controls sit beside the readout. */ +/** Under this height the controls sit beside the readout. */ private const val WIDE_BELOW_HEIGHT = 120f @Composable diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/WidgetKit.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/WidgetKit.kt index d4894f6..0ef13b3 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/WidgetKit.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/WidgetKit.kt @@ -1,6 +1,7 @@ package de.jeanlucmakiola.clockula.ui.widget import android.content.Context +import android.content.Intent import android.os.Build import android.util.TypedValue import android.widget.RemoteViews @@ -30,9 +31,11 @@ import androidx.glance.layout.fillMaxSize import androidx.glance.layout.size import androidx.glance.layout.wrapContentSize import androidx.glance.unit.ColorProvider +import de.jeanlucmakiola.clockula.MainActivity import de.jeanlucmakiola.clockula.R import de.jeanlucmakiola.clockula.ui.theme.ClockulaDarkFallback import de.jeanlucmakiola.clockula.ui.theme.ClockulaLightFallback +import de.jeanlucmakiola.clockula.ui.shell.ShellNavigation import de.jeanlucmakiola.floret.glance.FloretGlanceTheme import de.jeanlucmakiola.floret.glance.WidgetThemeMode import java.util.Locale @@ -181,5 +184,11 @@ internal fun CenteredSquare(side: Dp, onClick: Action, content: @Composable () - } } +/** Opens the app on World clock — where both clock widgets lead. */ +internal fun showWorldClockIntent(context: Context): Intent = + Intent(context, MainActivity::class.java) + .setAction(ShellNavigation.ACTION_SHOW_WORLD_CLOCK) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + internal fun currentLocale(context: Context): Locale = ConfigurationCompat.getLocales(context.resources.configuration).get(0) ?: Locale.getDefault() diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/AnalogClockConfigActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/AnalogClockConfigActivity.kt index 4a73f4a..2d9b6cf 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/AnalogClockConfigActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/AnalogClockConfigActivity.kt @@ -1,20 +1,9 @@ package de.jeanlucmakiola.clockula.ui.widget.config -import android.appwidget.AppWidgetManager -import android.content.Intent -import android.os.Bundle import android.widget.FrameLayout -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.foundation.layout.size -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -24,24 +13,16 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView -import androidx.glance.appwidget.GlanceAppWidgetManager import androidx.glance.appwidget.state.getAppWidgetState import androidx.glance.appwidget.state.updateAppWidgetState import androidx.glance.state.PreferencesGlanceStateDefinition -import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint import de.jeanlucmakiola.clockula.R -import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs import de.jeanlucmakiola.clockula.domain.widget.AnalogClockStyle import de.jeanlucmakiola.clockula.domain.widget.parseAnalogClockStyle -import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme import de.jeanlucmakiola.clockula.ui.widget.analogClockViews import de.jeanlucmakiola.clockula.widget.AnalogClockWidget import de.jeanlucmakiola.clockula.widget.WIDGET_ANALOG_STYLE_KEY -import de.jeanlucmakiola.floret.prefs.Appearance -import de.jeanlucmakiola.floret.prefs.isDark -import kotlinx.coroutines.launch -import javax.inject.Inject /** * The analog widget's entire configuration: a style pick, nothing else — this @@ -49,63 +30,33 @@ import javax.inject.Inject * straight away, as the reference picker does (no separate "Save" step). */ @AndroidEntryPoint -class AnalogClockConfigActivity : ComponentActivity() { +class AnalogClockConfigActivity : WidgetConfigActivity() { - @Inject lateinit var settingsPrefs: SettingsPrefs - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val appWidgetId = intent?.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID) - ?: AppWidgetManager.INVALID_APPWIDGET_ID - setResult(RESULT_CANCELED, resultIntent(appWidgetId)) - if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { - finish() - return - } - // A stale id (the widget removed meanwhile) or one that isn't ours - // throws here; there is nothing to configure, so just close. - val glanceId = runCatching { GlanceAppWidgetManager(this).getGlanceIdBy(appWidgetId) }.getOrNull() - if (glanceId == null) { - finish() - return - } - enableEdgeToEdge() - - setContent { - val appearance by settingsPrefs.appearance.collectAsState(initial = Appearance()) - var style by remember { mutableStateOf(AnalogClockStyle.ROUND) } - LaunchedEffect(glanceId) { - val state = getAppWidgetState(this@AnalogClockConfigActivity, PreferencesGlanceStateDefinition, glanceId) - style = parseAnalogClockStyle(state[WIDGET_ANALOG_STYLE_KEY]) - } - val darkTheme = appearance.themeMode.isDark(isSystemInDarkTheme()) - ClockulaTheme(darkTheme = darkTheme, dynamicColor = appearance.dynamicColor) { - Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {} - WidgetStylePicker( - title = stringResource(R.string.widget_analog_config_title), - options = AnalogClockStyle.entries, - selected = style, - label = { stringResource(analogStyleLabel(it)) }, - preview = { AnalogStylePreview(it) }, - onSelect = { picked -> - style = picked - lifecycleScope.launch { - updateAppWidgetState(this@AnalogClockConfigActivity, glanceId) { prefs -> - prefs[WIDGET_ANALOG_STYLE_KEY] = picked.name - } - AnalogClockWidget().update(this@AnalogClockConfigActivity, glanceId) - setResult(RESULT_OK, resultIntent(appWidgetId)) - finish() - } - }, - onDismiss = { finish() }, - ) - } + @Composable + override fun Content() { + var style by remember { mutableStateOf(AnalogClockStyle.ROUND) } + LaunchedEffect(glanceId) { + val state = getAppWidgetState(this@AnalogClockConfigActivity, PreferencesGlanceStateDefinition, glanceId) + style = parseAnalogClockStyle(state[WIDGET_ANALOG_STYLE_KEY]) } + WidgetStylePicker( + title = stringResource(R.string.widget_analog_config_title), + options = AnalogClockStyle.entries, + selected = style, + label = { stringResource(analogStyleLabel(it)) }, + preview = { AnalogStylePreview(it) }, + onSelect = { picked -> + style = picked + save { + updateAppWidgetState(this@AnalogClockConfigActivity, glanceId) { prefs -> + prefs[WIDGET_ANALOG_STYLE_KEY] = picked.name + } + AnalogClockWidget().update(this@AnalogClockConfigActivity, glanceId) + } + }, + onDismiss = ::dismiss, + ) } - - private fun resultIntent(appWidgetId: Int) = - Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) } internal fun analogStyleLabel(style: AnalogClockStyle): Int = when (style) { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/ClockWidgetConfigActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/ClockWidgetConfigActivity.kt index 3876160..153f79c 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/ClockWidgetConfigActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/ClockWidgetConfigActivity.kt @@ -1,28 +1,17 @@ package de.jeanlucmakiola.clockula.ui.widget.config -import android.appwidget.AppWidgetManager -import android.content.Intent -import android.os.Bundle import android.widget.FrameLayout -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState @@ -34,20 +23,16 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView -import androidx.glance.appwidget.GlanceAppWidgetManager import androidx.glance.appwidget.state.getAppWidgetState import androidx.glance.appwidget.state.updateAppWidgetState import androidx.glance.state.PreferencesGlanceStateDefinition -import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint import de.jeanlucmakiola.clockula.R -import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository import de.jeanlucmakiola.clockula.data.zones.ZoneNames import de.jeanlucmakiola.clockula.domain.widget.DigitalClockSettings import de.jeanlucmakiola.clockula.domain.widget.DigitalClockStyle import de.jeanlucmakiola.clockula.domain.widget.showsWorldClocks -import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme import de.jeanlucmakiola.clockula.ui.widget.ClockWidgetCity import de.jeanlucmakiola.clockula.ui.widget.digitalClockViews import de.jeanlucmakiola.clockula.ui.widget.widgetCities @@ -59,83 +44,50 @@ import de.jeanlucmakiola.clockula.widget.parseDigitalClockSettings import de.jeanlucmakiola.floret.components.FullScreenPicker import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.Position -import de.jeanlucmakiola.floret.prefs.Appearance -import de.jeanlucmakiola.floret.prefs.isDark -import kotlinx.coroutines.launch import javax.inject.Inject -/** - * Picks the digital widget instance's style and the two switches. Runs when - * the widget is placed (below API 31, or whenever the launcher doesn't honour - * `configuration_optional`) and again from "Edit widget". - */ +/** Picks the digital widget instance's style and the two switches. */ @AndroidEntryPoint -class ClockWidgetConfigActivity : ComponentActivity() { +class ClockWidgetConfigActivity : WidgetConfigActivity() { - @Inject lateinit var settingsPrefs: SettingsPrefs @Inject lateinit var worldClockRepository: WorldClockRepository @Inject lateinit var zoneNames: ZoneNames - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val appWidgetId = intent?.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID) - ?: AppWidgetManager.INVALID_APPWIDGET_ID - // Backing out cancels the placement, as the framework expects. - setResult(RESULT_CANCELED, resultIntent(appWidgetId)) - if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { - finish() - return + @Composable + override fun Content() { + // `null` until this instance's stored settings are read: the screen + // (and its OK) only appears then, so neither an early save nor a + // late load can write defaults over what the user has. + var settings by remember { mutableStateOf(null) } + val cities by produceState(initialValue = emptyList()) { + value = widgetCities(worldClockRepository, zoneNames) } - // A stale id (the widget removed meanwhile) or one that isn't ours - // throws here; there is nothing to configure, so just close. - val glanceId = runCatching { GlanceAppWidgetManager(this).getGlanceIdBy(appWidgetId) }.getOrNull() - if (glanceId == null) { - finish() - return - } - enableEdgeToEdge() - - setContent { - val appearance by settingsPrefs.appearance.collectAsState(initial = Appearance()) - var settings by remember { mutableStateOf(DigitalClockSettings()) } - val cities by produceState(initialValue = emptyList()) { - value = widgetCities(worldClockRepository, zoneNames) - } - LaunchedEffect(glanceId) { - val state = getAppWidgetState(this@ClockWidgetConfigActivity, PreferencesGlanceStateDefinition, glanceId) - settings = parseDigitalClockSettings( - style = state[WIDGET_DIGITAL_STYLE_KEY], - showDate = state[WIDGET_SHOW_DATE_KEY], - showWorldClocks = state[WIDGET_SHOW_WORLD_CLOCKS_KEY], - ) - } - val darkTheme = appearance.themeMode.isDark(isSystemInDarkTheme()) - ClockulaTheme(darkTheme = darkTheme, dynamicColor = appearance.dynamicColor) { - Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {} - ClockWidgetConfigScreen( - settings = settings, - cities = cities, - onSettingsChange = { settings = it }, - onSave = { - lifecycleScope.launch { - updateAppWidgetState(this@ClockWidgetConfigActivity, glanceId) { prefs -> - prefs[WIDGET_DIGITAL_STYLE_KEY] = settings.style.name - prefs[WIDGET_SHOW_DATE_KEY] = settings.showDate - prefs[WIDGET_SHOW_WORLD_CLOCKS_KEY] = settings.showWorldClocks - } - ClockWidget().update(this@ClockWidgetConfigActivity, glanceId) - setResult(RESULT_OK, resultIntent(appWidgetId)) - finish() - } - }, - onDismiss = { finish() }, - ) - } + LaunchedEffect(glanceId) { + val state = getAppWidgetState(this@ClockWidgetConfigActivity, PreferencesGlanceStateDefinition, glanceId) + settings = parseDigitalClockSettings( + style = state[WIDGET_DIGITAL_STYLE_KEY], + showDate = state[WIDGET_SHOW_DATE_KEY], + showWorldClocks = state[WIDGET_SHOW_WORLD_CLOCKS_KEY], + ) } + val current = settings ?: return + ClockWidgetConfigScreen( + settings = current, + cities = cities, + onSettingsChange = { settings = it }, + onSave = { + save { + updateAppWidgetState(this@ClockWidgetConfigActivity, glanceId) { prefs -> + prefs[WIDGET_DIGITAL_STYLE_KEY] = current.style.name + prefs[WIDGET_SHOW_DATE_KEY] = current.showDate + prefs[WIDGET_SHOW_WORLD_CLOCKS_KEY] = current.showWorldClocks + } + ClockWidget().update(this@ClockWidgetConfigActivity, glanceId) + } + }, + onDismiss = ::dismiss, + ) } - - private fun resultIntent(appWidgetId: Int) = - Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) } @Composable diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/QuickTimerConfigActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/QuickTimerConfigActivity.kt index 8940ff5..4bc0238 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/QuickTimerConfigActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/QuickTimerConfigActivity.kt @@ -1,105 +1,55 @@ package de.jeanlucmakiola.clockula.ui.widget.config -import android.appwidget.AppWidgetManager -import android.content.Intent -import android.os.Bundle -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.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.glance.appwidget.GlanceAppWidgetManager import androidx.glance.appwidget.state.getAppWidgetState import androidx.glance.appwidget.state.updateAppWidgetState import androidx.glance.state.PreferencesGlanceStateDefinition -import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint import de.jeanlucmakiola.clockula.R -import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs import de.jeanlucmakiola.clockula.domain.format.ClockFormat -import de.jeanlucmakiola.clockula.domain.timer.TimerPresets import de.jeanlucmakiola.clockula.domain.widget.QuickTimerWidgetSettings import de.jeanlucmakiola.clockula.domain.widget.parseQuickTimerDuration -import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme import de.jeanlucmakiola.clockula.widget.QuickTimerWidget import de.jeanlucmakiola.clockula.widget.WIDGET_QUICK_TIMER_DURATION_KEY import de.jeanlucmakiola.floret.components.OptionPicker -import de.jeanlucmakiola.floret.prefs.Appearance -import de.jeanlucmakiola.floret.prefs.isDark -import kotlinx.coroutines.launch -import javax.inject.Inject /** - * The quick-timer widget's entire configuration: which built-in duration it - * starts on tap — reuses `domain/timer/TimerPresets.BUILT_IN`, the same list - * the Timers screen's own quick-start row offers, so this picker never - * invents a second set of durations. Like [AnalogClockConfigActivity], tapping - * an option saves and finishes immediately. + * The quick-timer widget's entire configuration: which duration it starts on + * tap — [QuickTimerWidgetSettings.OPTIONS], the Timers screen's own built-in + * quick-start durations up to the widget's 59:59 cap. Like + * [AnalogClockConfigActivity], tapping an option saves and finishes + * immediately. */ @AndroidEntryPoint -class QuickTimerConfigActivity : ComponentActivity() { +class QuickTimerConfigActivity : WidgetConfigActivity() { - @Inject lateinit var settingsPrefs: SettingsPrefs - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val appWidgetId = intent?.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID) - ?: AppWidgetManager.INVALID_APPWIDGET_ID - setResult(RESULT_CANCELED, resultIntent(appWidgetId)) - if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { - finish() - return - } - // A stale id (the widget removed meanwhile) or one that isn't ours - // throws here; there is nothing to configure, so just close. - val glanceId = runCatching { GlanceAppWidgetManager(this).getGlanceIdBy(appWidgetId) }.getOrNull() - if (glanceId == null) { - finish() - return - } - enableEdgeToEdge() - - setContent { - val appearance by settingsPrefs.appearance.collectAsState(initial = Appearance()) - var duration by remember { mutableStateOf(QuickTimerWidgetSettings.DEFAULT) } - LaunchedEffect(glanceId) { - val state = getAppWidgetState(this@QuickTimerConfigActivity, PreferencesGlanceStateDefinition, glanceId) - duration = parseQuickTimerDuration(state[WIDGET_QUICK_TIMER_DURATION_KEY]) - } - val darkTheme = appearance.themeMode.isDark(isSystemInDarkTheme()) - ClockulaTheme(darkTheme = darkTheme, dynamicColor = appearance.dynamicColor) { - Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {} - OptionPicker( - title = stringResource(R.string.widget_quick_timer_config_title), - options = TimerPresets.BUILT_IN, - selected = duration, - label = { ClockFormat.countdown(it) }, - onSelect = { picked -> - lifecycleScope.launch { - updateAppWidgetState(this@QuickTimerConfigActivity, glanceId) { prefs -> - prefs[WIDGET_QUICK_TIMER_DURATION_KEY] = picked.inWholeMilliseconds - } - QuickTimerWidget().update(this@QuickTimerConfigActivity, glanceId) - setResult(RESULT_OK, resultIntent(appWidgetId)) - finish() - } - }, - onDismiss = { finish() }, - ) - } + @Composable + override fun Content() { + var duration by remember { mutableStateOf(QuickTimerWidgetSettings.DEFAULT) } + LaunchedEffect(glanceId) { + val state = getAppWidgetState(this@QuickTimerConfigActivity, PreferencesGlanceStateDefinition, glanceId) + duration = parseQuickTimerDuration(state[WIDGET_QUICK_TIMER_DURATION_KEY]) } + OptionPicker( + title = stringResource(R.string.widget_quick_timer_config_title), + options = QuickTimerWidgetSettings.OPTIONS, + selected = duration, + label = { ClockFormat.countdown(it) }, + onSelect = { picked -> + save { + updateAppWidgetState(this@QuickTimerConfigActivity, glanceId) { prefs -> + prefs[WIDGET_QUICK_TIMER_DURATION_KEY] = picked.inWholeMilliseconds + } + QuickTimerWidget().update(this@QuickTimerConfigActivity, glanceId) + } + }, + onDismiss = ::dismiss, + ) } - - private fun resultIntent(appWidgetId: Int) = - Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/WidgetConfigActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/WidgetConfigActivity.kt new file mode 100644 index 0000000..7c689e5 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/WidgetConfigActivity.kt @@ -0,0 +1,104 @@ +package de.jeanlucmakiola.clockula.ui.widget.config + +import android.appwidget.AppWidgetManager +import android.content.Intent +import android.os.Bundle +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.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.glance.GlanceId +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.lifecycle.lifecycleScope +import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme +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 javax.inject.Inject + +/** + * What every widget config screen shares: resolving the instance it was + * launched for, the configure-result contract, and the app's theme. + * + * Runs when a widget is placed (below API 31, or whenever the launcher doesn't + * honour `configuration_optional`) and again from "Edit widget". The result + * starts as `RESULT_CANCELED`, so backing out cancels a placement, as the + * framework expects; only [save] turns it into `RESULT_OK`. + */ +abstract class WidgetConfigActivity : ComponentActivity() { + + @Inject lateinit var settingsPrefs: SettingsPrefs + + protected lateinit var glanceId: GlanceId + private set + + private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID + + /** Set once [save] starts, so the picker's own dismiss can't finish (and cancel) underneath it. */ + private var saving = false + + /** The screen itself, inside [ConfigTheme]; [glanceId] is resolved by the time it runs. */ + @Composable + protected abstract fun Content() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + appWidgetId = intent?.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID) + ?: AppWidgetManager.INVALID_APPWIDGET_ID + setResult(RESULT_CANCELED, resultIntent()) + // A missing or stale id (the widget removed meanwhile), or one that + // isn't ours, throws here; there is nothing to configure, so just close. + val id = appWidgetId.takeIf { it != AppWidgetManager.INVALID_APPWIDGET_ID } + ?.let { runCatching { GlanceAppWidgetManager(this).getGlanceIdBy(it) }.getOrNull() } + if (id == null) { + finish() + return + } + glanceId = id + enableEdgeToEdge() + setContent { ConfigTheme { Content() } } + } + + /** + * Writes the instance's state via [write], then confirms and closes. + * A picker that dismisses itself right after selecting is fine: [dismiss] + * is a no-op from here on, and the write runs to completion even if the + * activity is torn down meanwhile. + */ + protected fun save(write: suspend () -> Unit) { + if (saving) return + saving = true + lifecycleScope.launch { + withContext(NonCancellable) { write() } + setResult(RESULT_OK, resultIntent()) + finish() + } + } + + /** Back out without saving — unless a [save] is already under way. */ + protected fun dismiss() { + if (!saving) finish() + } + + @Composable + private fun ConfigTheme(content: @Composable () -> Unit) { + val appearance by settingsPrefs.appearance.collectAsState(initial = Appearance()) + val darkTheme = appearance.themeMode.isDark(isSystemInDarkTheme()) + ClockulaTheme(darkTheme = darkTheme, dynamicColor = appearance.dynamicColor) { + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {} + content() + } + } + + private fun resultIntent() = Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/widget/AnalogClockWidget.kt b/app/src/main/java/de/jeanlucmakiola/clockula/widget/AnalogClockWidget.kt index 8599610..6bd4ca0 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/widget/AnalogClockWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/widget/AnalogClockWidget.kt @@ -1,6 +1,8 @@ package de.jeanlucmakiola.clockula.widget +import android.appwidget.AppWidgetManager import android.content.Context +import android.content.Intent import androidx.glance.GlanceId import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidgetReceiver @@ -34,4 +36,24 @@ class AnalogClockWidget : GlanceAppWidget() { class AnalogClockWidgetReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = AnalogClockWidget() + + override fun onReceive(context: Context, intent: Intent) { + // Midnight: redraw every analog widget, as the platform's own update + // would, which re-arms the next one in `onUpdate`. + if (intent.action == AnalogDateRefresh.ACTION_REFRESH) { + onUpdate(context, AppWidgetManager.getInstance(context), AnalogDateRefresh.placedIds(context)) + return + } + super.onReceive(context, intent) + } + + override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { + super.onUpdate(context, appWidgetManager, appWidgetIds) + AnalogDateRefresh.schedule(context) + } + + override fun onDisabled(context: Context) { + super.onDisabled(context) + AnalogDateRefresh.cancel(context) + } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/widget/AnalogDateRefresh.kt b/app/src/main/java/de/jeanlucmakiola/clockula/widget/AnalogDateRefresh.kt new file mode 100644 index 0000000..cf96478 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/widget/AnalogDateRefresh.kt @@ -0,0 +1,59 @@ +package de.jeanlucmakiola.clockula.widget + +import android.app.AlarmManager +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import de.jeanlucmakiola.clockula.widget.di.widgetEntryPoint +import java.time.Instant +import java.time.ZonedDateTime + +/** + * Keeps the analog faces' date current. The dated faces draw today's date + * into a bitmap hand (`ui/widget/AnalogClockWidgetContent.kt`), which only + * changes when the widget is re-rendered — and nothing the app writes + * re-renders it. So the analog widget is redrawn at each local midnight (a + * non-waking, windowed alarm: a dark screen has no date to show, and a + * minute's slack lets the platform batch it) and on a clock or zone change + * (`SystemEventReceiver`). + */ +object AnalogDateRefresh { + + const val ACTION_REFRESH = "de.jeanlucmakiola.clockula.widget.action.REFRESH_ANALOG_DATE" + + /** Arms the next midnight redraw while any analog widget is placed, and disarms it otherwise. */ + fun schedule(context: Context) { + val manager = context.getSystemService(AlarmManager::class.java) ?: return + val pendingIntent = pendingIntent(context) + if (placedIds(context).isEmpty()) { + manager.cancel(pendingIntent) + return + } + val entryPoint = widgetEntryPoint(context) + val zone = entryPoint.zoneProvider().current() + val now = ZonedDateTime.ofInstant(Instant.ofEpochMilli(entryPoint.wallClock().now().toEpochMilliseconds()), zone) + val midnight = now.toLocalDate().plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli() + manager.setWindow(AlarmManager.RTC, midnight, WINDOW_MILLIS, pendingIntent) + } + + fun cancel(context: Context) { + context.getSystemService(AlarmManager::class.java)?.cancel(pendingIntent(context)) + } + + /** The placed analog widgets' ids — what an [ACTION_REFRESH] hands to `onUpdate`. */ + fun placedIds(context: Context): IntArray = + AppWidgetManager.getInstance(context) + .getAppWidgetIds(ComponentName(context, AnalogClockWidgetReceiver::class.java)) + + private fun pendingIntent(context: Context): PendingIntent = + PendingIntent.getBroadcast( + context, + 0, + Intent(context, AnalogClockWidgetReceiver::class.java).setAction(ACTION_REFRESH), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + private const val WINDOW_MILLIS = 60_000L +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/widget/ClockWidget.kt b/app/src/main/java/de/jeanlucmakiola/clockula/widget/ClockWidget.kt index f27ce70..fa7b029 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/widget/ClockWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/widget/ClockWidget.kt @@ -7,6 +7,7 @@ import androidx.glance.appwidget.GlanceAppWidgetReceiver import androidx.glance.appwidget.SizeMode import androidx.glance.appwidget.provideContent import de.jeanlucmakiola.clockula.ui.widget.ClockWidgetRoot +import de.jeanlucmakiola.clockula.ui.widget.widgetCities import de.jeanlucmakiola.clockula.widget.di.widgetEntryPoint /** @@ -14,7 +15,7 @@ import de.jeanlucmakiola.clockula.widget.di.widgetEntryPoint * read reactively inside [ClockWidgetRoot] (`ui/widget/ClockWidgetContent.kt`) * from this instance's own Glance state (`widget/ClockWidgetState.kt`), * written by `ClockWidgetConfigActivity`; an instance placed with - * configuration skipped has none set, which [parseStoredClockWidgetState] + * configuration skipped has none set, which `parseDigitalClockSettings` * resolves to the defaults that reproduce the pre-M11 layout. * * Reading that state via `currentState()` *inside* the composition — rather @@ -33,8 +34,9 @@ class ClockWidget : GlanceAppWidget() { override suspend fun provideGlance(context: Context, id: GlanceId) { val entryPoint = widgetEntryPoint(context) + val initialCities = widgetCities(entryPoint.worldClockRepository(), entryPoint.zoneNames()) provideContent { - ClockWidgetRoot(entryPoint) + ClockWidgetRoot(entryPoint, initialCities) } } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/widget/ClockWidgetState.kt b/app/src/main/java/de/jeanlucmakiola/clockula/widget/ClockWidgetState.kt index 5cf313c..5f90ca4 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/widget/ClockWidgetState.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/widget/ClockWidgetState.kt @@ -6,10 +6,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey import de.jeanlucmakiola.clockula.domain.widget.AnalogClockStyle import de.jeanlucmakiola.clockula.domain.widget.DigitalClockSettings import de.jeanlucmakiola.clockula.domain.widget.DigitalClockStyle -import de.jeanlucmakiola.clockula.domain.widget.QuickTimerWidgetSettings -import de.jeanlucmakiola.clockula.domain.widget.parseAnalogClockStyle import de.jeanlucmakiola.clockula.domain.widget.parseDigitalClockStyle -import de.jeanlucmakiola.clockula.domain.widget.parseQuickTimerDuration /** Per-instance Glance state key holding the digital widget's [DigitalClockStyle] ([name]). */ internal val WIDGET_DIGITAL_STYLE_KEY = stringPreferencesKey("digital_style") @@ -35,9 +32,3 @@ internal fun parseDigitalClockSettings(style: String?, showDate: Boolean?, showW showDate = showDate ?: true, showWorldClocks = showWorldClocks ?: false, ) - -internal fun quickTimerSettings(durationMillis: Long?): QuickTimerWidgetSettings = - QuickTimerWidgetSettings(duration = parseQuickTimerDuration(durationMillis)) - -internal val QuickTimerWidgetSettings.durationMillis: Long - get() = duration.inWholeMilliseconds 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 a557bc1..2d7c34f 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/widget/WidgetSync.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/widget/WidgetSync.kt @@ -6,10 +6,15 @@ 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 androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.updateAll +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.launch import kotlinx.coroutines.flow.distinctUntilChanged import javax.inject.Inject import javax.inject.Singleton @@ -24,8 +29,11 @@ import kotlin.time.Duration.Companion.milliseconds * because the process is alive whenever those run — it touches no engine * itself, only re-renders. * - * The quick-timer widget follows the timers too: it shows the countdown of - * the timer it started (`widget/QuickTimerWidget.kt`). + * Each flow re-renders only the widgets that read it: a stopwatch lap redraws + * the stopwatch, not the clocks. The quick-timer widget follows the timers, + * since it shows the countdown of the timer it started + * (`widget/QuickTimerWidget.kt`). The analog clock reads none of them; its + * date is kept current by [AnalogDateRefresh]. * * Started once, from [de.jeanlucmakiola.clockula.ClockulaApp.onCreate], in the * app's `@ApplicationScope` — never driven by a `Ticker`, so an idle home @@ -40,37 +48,25 @@ class WidgetSync @Inject constructor( private val worldClocks: WorldClockRepository, ) { + suspend fun run(): Unit = coroutineScope { + sync(alarmEngine.upcoming(), NextAlarmWidget()) + sync(timers.timers(), TimerWidget(), QuickTimerWidget()) + // 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) - suspend fun run() { - combine( - alarmEngine.upcoming(), - timers.timers(), - // A lap leaves the run untouched, so the lap count is watched too. - combine(stopwatch.run(), stopwatch.laps()) { run, laps -> run to laps.size }, - worldClocks.worldClocks(), - ) { upcoming, timerRows, stopwatchRun, clocks -> - WidgetSyncState(upcoming, timerRows, stopwatchRun, clocks) - } + private fun CoroutineScope.sync(source: Flow<*>, vararg widgets: GlanceAppWidget) = 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 { updateAll() } - } - - private suspend fun updateAll() { - NextAlarmWidget().updateAll(context) - TimerWidget().updateAll(context) - StopwatchWidget().updateAll(context) - ClockWidget().updateAll(context) - AnalogClockWidget().updateAll(context) - QuickTimerWidget().updateAll(context) + .collect { widgets.forEach { it.updateAll(context) } } } private companion object { val DEBOUNCE = 150.milliseconds } } - -/** `combine` has no tuple wider than a `Triple`; this is the four-flow one. */ -private data class WidgetSyncState(val a: A, val b: B, val c: C, val d: D) diff --git a/app/src/main/res/xml/widget_analog_clock_info.xml b/app/src/main/res/xml/widget_analog_clock_info.xml index ebbf703..3e41e2d 100644 --- a/app/src/main/res/xml/widget_analog_clock_info.xml +++ b/app/src/main/res/xml/widget_analog_clock_info.xml @@ -6,7 +6,7 @@ android:minResizeHeight="110dp" android:targetCellWidth="2" android:targetCellHeight="2" - android:updatePeriodMillis="1800000" + android:updatePeriodMillis="0" android:resizeMode="horizontal|vertical" android:widgetCategory="home_screen" android:description="@string/widget_analog_description" diff --git a/app/src/main/res/xml/widget_stopwatch_info.xml b/app/src/main/res/xml/widget_stopwatch_info.xml index 13f2f68..15a650e 100644 --- a/app/src/main/res/xml/widget_stopwatch_info.xml +++ b/app/src/main/res/xml/widget_stopwatch_info.xml @@ -1,9 +1,9 @@