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.
This commit is contained in:
2026-10-02 18:18:04 +02:00
parent 82fff19d3d
commit 0f5cf4f1eb
21 changed files with 415 additions and 335 deletions
+3 -2
View File
@@ -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). -->
<receiver
android:name=".widget.NextAlarmWidgetReceiver"
android:exported="true">
@@ -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 {
@@ -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<Duration> = 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
}
/**
@@ -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()
}
}
@@ -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)
@@ -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<ClockWidgetCity> =
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<List<ClockWidgetCity>> =
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<ClockWidgetCity> =
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<ClockWidgetCity>) {
val prefs = currentState<Preferences>()
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<ClockWidgetCity>(), 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<ClockWidgetCity>, 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)
@@ -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(
@@ -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
@@ -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()
@@ -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) {
@@ -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<DigitalClockSettings?>(null) }
val cities by produceState(initialValue = emptyList<ClockWidgetCity>()) {
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<ClockWidgetCity>()) {
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
@@ -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)
}
@@ -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)
}
@@ -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)
}
}
@@ -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
}
@@ -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)
}
}
}
@@ -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
@@ -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<A, B, C, D>(val a: A, val b: B, val c: C, val d: D)
@@ -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"
@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="140dp"
android:minHeight="80dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="60dp"
android:minResizeHeight="110dp"
android:targetCellWidth="3"
android:targetCellHeight="2"
android:updatePeriodMillis="0"
@@ -58,6 +58,17 @@ class ClockWidgetSettingsTest {
assertThat(parseQuickTimerDuration(0L)).isEqualTo(QuickTimerWidgetSettings.DEFAULT)
}
@Test
fun `a quick-timer duration past 59 59 parses to the default`() {
assertThat(parseQuickTimerDuration(3_600_000L)).isEqualTo(QuickTimerWidgetSettings.DEFAULT)
}
@Test
fun `the quick-timer options stop at 59 59`() {
assertThat(QuickTimerWidgetSettings.OPTIONS).isNotEmpty()
assertThat(QuickTimerWidgetSettings.OPTIONS.all { it <= QuickTimerWidgetSettings.MAX }).isTrue()
}
@Test
fun `an in-range quick-timer duration round-trips`() {
assertThat(parseQuickTimerDuration(600_000L).inWholeMilliseconds).isEqualTo(600_000L)