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, app's widget: APPWIDGET_UPDATE is a platform-delivered broadcast,
never a door another app can open by naming the action itself. never a door another app can open by naming the action itself.
updatePeriodMillis is 0 in every provider-info XML — WidgetSync updatePeriodMillis is 0 in every provider-info XML — WidgetSync
is the one refresh trigger (the quick-timer widget has no live is the one refresh trigger for the data widgets, and the analog
state, so it is refreshed only by its own config save). --> widget's date is redrawn at midnight and on a clock or zone
change (widget/AnalogDateRefresh.kt). -->
<receiver <receiver
android:name=".widget.NextAlarmWidgetReceiver" android:name=".widget.NextAlarmWidgetReceiver"
android:exported="true"> android:exported="true">
@@ -2,10 +2,13 @@ package de.jeanlucmakiola.clockula.alarm.receiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.glance.appwidget.updateAll
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.alarm.AlarmEngine import de.jeanlucmakiola.clockula.alarm.AlarmEngine
import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine
import de.jeanlucmakiola.clockula.timer.TimerEngine 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 de.jeanlucmakiola.floret.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -79,6 +82,10 @@ class SystemEventReceiver : HiltBroadcastReceiver() {
// the notification re-posts with a freshly derived // the notification re-posts with a freshly derived
// chronometer base (M7 D4, D17). // chronometer base (M7 D4, D17).
stopwatchEngine.onSystemTimeChanged() 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 { } finally {
@@ -5,19 +5,27 @@ import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant import kotlin.time.Instant
/** One quick-timer widget instance's own pick: the duration it starts on tap. */ /** The quick-timer widget's one setting: the duration it starts on tap. */
data class QuickTimerWidgetSettings(val duration: Duration = DEFAULT) { object QuickTimerWidgetSettings {
companion object { val DEFAULT: Duration = 5.minutes
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 { fun parseQuickTimerDuration(raw: Long?): Duration {
val duration = raw?.milliseconds 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.appwidget.AppWidgetManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.glance.GlanceId
import androidx.glance.appwidget.GlanceAppWidgetManager import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.appwidget.state.updateAppWidgetState import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.state.PreferencesGlanceStateDefinition
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.alarm.receiver.HiltBroadcastReceiver import de.jeanlucmakiola.clockula.alarm.receiver.HiltBroadcastReceiver
import de.jeanlucmakiola.clockula.data.timers.TimerRepository import de.jeanlucmakiola.clockula.data.timers.TimerRepository
import de.jeanlucmakiola.clockula.domain.TimerDraft import de.jeanlucmakiola.clockula.domain.TimerDraft
import de.jeanlucmakiola.clockula.domain.TimerState
import de.jeanlucmakiola.clockula.timer.QuickTimerIntents import de.jeanlucmakiola.clockula.timer.QuickTimerIntents
import de.jeanlucmakiola.clockula.timer.TimerEngine import de.jeanlucmakiola.clockula.timer.TimerEngine
import de.jeanlucmakiola.clockula.widget.QuickTimerWidget import de.jeanlucmakiola.clockula.widget.QuickTimerWidget
import de.jeanlucmakiola.clockula.widget.WIDGET_QUICK_TIMER_ID_KEY import de.jeanlucmakiola.clockula.widget.WIDGET_QUICK_TIMER_ID_KEY
import de.jeanlucmakiola.floret.di.ApplicationScope import de.jeanlucmakiola.floret.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@@ -47,20 +54,39 @@ class QuickTimerReceiver : HiltBroadcastReceiver() {
val pendingResult = goAsync() val pendingResult = goAsync()
scope.launch { scope.launch {
try { try {
// Transient: the widget's own reset/stop deletes it rather // Serialised, so a double tap's second start sees the first
// than leaving a stray idle timer in the Timers list. // one's timer instead of racing it.
val id = timers.create(TimerDraft(duration = durationMillis.milliseconds, deleteAfterUse = true)) startMutex.withLock {
engine.start(id) val glanceId = runCatching { GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId) }.getOrNull()
// Remember which timer this widget started, so it shows that // A second tap before the widget re-rendered into its
// one's countdown and controls (`ui/widget/QuickTimerWidgetContent.kt`). // running state: this widget already has a live timer,
val glanceId = runCatching { GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId) }.getOrNull() // so leave it be rather than start one it can't control.
if (glanceId != null) { if (glanceId != null && hasActiveTimer(context, glanceId)) return@withLock
updateAppWidgetState(context, glanceId) { prefs -> prefs[WIDGET_QUICK_TIMER_ID_KEY] = id } // Transient: the widget's own reset/stop deletes it rather
QuickTimerWidget().update(context, glanceId) // 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 { } finally {
pendingResult.finish() 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 package de.jeanlucmakiola.clockula.ui.widget
import android.content.Context import android.content.Context
import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Paint import android.graphics.Paint
@@ -26,11 +25,9 @@ import androidx.glance.layout.Alignment
import androidx.glance.layout.Box import androidx.glance.layout.Box
import androidx.glance.layout.fillMaxSize import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.size import androidx.glance.layout.size
import de.jeanlucmakiola.clockula.MainActivity
import de.jeanlucmakiola.clockula.R import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.widget.AnalogClockStyle import de.jeanlucmakiola.clockula.domain.widget.AnalogClockStyle
import de.jeanlucmakiola.clockula.domain.widget.parseAnalogClockStyle import de.jeanlucmakiola.clockula.domain.widget.parseAnalogClockStyle
import de.jeanlucmakiola.clockula.ui.shell.ShellNavigation
import de.jeanlucmakiola.clockula.widget.WIDGET_ANALOG_STYLE_KEY import de.jeanlucmakiola.clockula.widget.WIDGET_ANALOG_STYLE_KEY
/** Reads this instance's own [AnalogClockStyle] reactively, then renders [AnalogClockWidgetContent]. */ /** 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 * 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 * 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. * `setSecondHandTintList` in the launcher so it still follows day/night.
* The date goes stale at midnight until the next update; the provider's 30 * It is redrawn at midnight and on a clock or zone change
* minute `updatePeriodMillis` bounds that. * (`widget/AnalogDateRefresh.kt`).
*/ */
private fun dateHand(context: Context): Bitmap { private fun dateHand(context: Context): Bitmap {
val px = 360 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) canvas.drawText(date, 50 * unit, dateY - (metrics.ascent + metrics.descent) / 2, paint)
return bitmap 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 package de.jeanlucmakiola.clockula.ui.widget
import android.content.Context
import android.content.Intent
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState import androidx.compose.runtime.remember
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.glance.GlanceModifier import androidx.glance.GlanceModifier
import androidx.glance.LocalContext import androidx.glance.LocalContext
@@ -15,51 +14,56 @@ import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.currentState import androidx.glance.currentState
import androidx.glance.layout.Box import androidx.glance.layout.Box
import androidx.glance.layout.fillMaxSize import androidx.glance.layout.fillMaxSize
import de.jeanlucmakiola.clockula.MainActivity
import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository
import de.jeanlucmakiola.clockula.data.zones.ZoneNames import de.jeanlucmakiola.clockula.data.zones.ZoneNames
import de.jeanlucmakiola.clockula.domain.widget.ClockWidgetPresenter import de.jeanlucmakiola.clockula.domain.widget.ClockWidgetPresenter
import de.jeanlucmakiola.clockula.domain.widget.DigitalClockSettings 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_DIGITAL_STYLE_KEY
import de.jeanlucmakiola.clockula.widget.WIDGET_SHOW_DATE_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.WIDGET_SHOW_WORLD_CLOCKS_KEY
import de.jeanlucmakiola.clockula.widget.di.WidgetEntryPoint import de.jeanlucmakiola.clockula.widget.di.WidgetEntryPoint
import de.jeanlucmakiola.clockula.widget.parseDigitalClockSettings import de.jeanlucmakiola.clockula.widget.parseDigitalClockSettings
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first 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. */ /** 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) 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. */ /** The world clocks the digital widget shows, resolved to city names, as they change. */
internal suspend fun widgetCities(worldClocks: WorldClockRepository, zoneNames: ZoneNames): List<ClockWidgetCity> = internal fun widgetCitiesFlow(worldClocks: WorldClockRepository, zoneNames: ZoneNames): Flow<List<ClockWidgetCity>> =
ClockWidgetPresenter.present(worldClocks.worldClocks().first()).map { clock -> worldClocks.worldClocks().map { clocks ->
ClockWidgetCity( ClockWidgetPresenter.present(clocks).map { clock ->
zoneId = clock.zoneId, ClockWidgetCity(
cityName = clock.label?.takeIf(String::isNotBlank) ?: zoneNames.cityOf(clock.zoneId) ?: clock.zoneId, 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 * Reads this widget instance's own state and the world clocks — both
* save that lands on a still-live Glance session (see `widget/ClockWidget.kt`) * reactively, so a config-screen save or a world-clock edit that lands on a
* recomposes with the new values instead of the ones the session started * still-live Glance session (see `widget/ClockWidget.kt`) recomposes with the
* with — then renders [ClockWidgetContent]. * new values instead of the ones the session started with — then renders
* [ClockWidgetContent]. [initialCities] only makes the first frame right.
*/ */
@Composable @Composable
fun ClockWidgetRoot(entryPoint: WidgetEntryPoint) { fun ClockWidgetRoot(entryPoint: WidgetEntryPoint, initialCities: List<ClockWidgetCity>) {
val prefs = currentState<Preferences>() val prefs = currentState<Preferences>()
val settings = parseDigitalClockSettings( val settings = parseDigitalClockSettings(
style = prefs[WIDGET_DIGITAL_STYLE_KEY], style = prefs[WIDGET_DIGITAL_STYLE_KEY],
showDate = prefs[WIDGET_SHOW_DATE_KEY], showDate = prefs[WIDGET_SHOW_DATE_KEY],
showWorldClocks = prefs[WIDGET_SHOW_WORLD_CLOCKS_KEY], showWorldClocks = prefs[WIDGET_SHOW_WORLD_CLOCKS_KEY],
) )
val showWorldClocks = settings.showWorldClocks val cities by remember { widgetCitiesFlow(entryPoint.worldClockRepository(), entryPoint.zoneNames()) }
val cities by produceState(initialValue = emptyList<ClockWidgetCity>(), showWorldClocks) { .collectAsState(initial = initialCities)
value = if (showWorldClocks) widgetCities(entryPoint.worldClockRepository(), entryPoint.zoneNames()) else emptyList()
}
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 * 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 * 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 @Composable
private fun GlyphOverTime( 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.domain.widget.StopwatchWidgetModel
import de.jeanlucmakiola.clockula.stopwatch.StopwatchIntents import de.jeanlucmakiola.clockula.stopwatch.StopwatchIntents
import de.jeanlucmakiola.clockula.stopwatch.receiver.StopwatchActionReceiver import de.jeanlucmakiola.clockula.stopwatch.receiver.StopwatchActionReceiver
import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import de.jeanlucmakiola.clockula.widget.di.WidgetEntryPoint import de.jeanlucmakiola.clockula.widget.di.WidgetEntryPoint
import de.jeanlucmakiola.clockula.domain.widget.StopwatchWidgetPresenter 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.remember
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import android.os.SystemClock
/** /**
* Reads the stopwatch run and its laps reactively, then renders [StopwatchWidgetContent]. * 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) 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 // The same instant the model is resolved at, so the chronometer's base
// is exactly `now - elapsed`. // is exactly `now - elapsed`.
val elapsedRealtime = remember(run, lapCount) { SystemClock.elapsedRealtime() } val elapsedRealtime = remember(run, lapCount) { entryPoint.elapsedRealtimeClock().elapsedRealtime() }
val model = remember(run, lapCount) { StopwatchWidgetPresenter.present(run, elapsedRealtime.milliseconds, lapCount) } val model = remember(run, lapCount) { StopwatchWidgetPresenter.present(run, elapsedRealtime, lapCount) }
StopwatchWidgetContent(model, elapsedRealtime) 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 private const val WIDE_BELOW_HEIGHT = 120f
@Composable @Composable
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.clockula.ui.widget package de.jeanlucmakiola.clockula.ui.widget
import android.content.Context import android.content.Context
import android.content.Intent
import android.os.Build import android.os.Build
import android.util.TypedValue import android.util.TypedValue
import android.widget.RemoteViews import android.widget.RemoteViews
@@ -30,9 +31,11 @@ import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.size import androidx.glance.layout.size
import androidx.glance.layout.wrapContentSize import androidx.glance.layout.wrapContentSize
import androidx.glance.unit.ColorProvider import androidx.glance.unit.ColorProvider
import de.jeanlucmakiola.clockula.MainActivity
import de.jeanlucmakiola.clockula.R import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.ui.theme.ClockulaDarkFallback import de.jeanlucmakiola.clockula.ui.theme.ClockulaDarkFallback
import de.jeanlucmakiola.clockula.ui.theme.ClockulaLightFallback 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.FloretGlanceTheme
import de.jeanlucmakiola.floret.glance.WidgetThemeMode import de.jeanlucmakiola.floret.glance.WidgetThemeMode
import java.util.Locale 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 = internal fun currentLocale(context: Context): Locale =
ConfigurationCompat.getLocales(context.resources.configuration).get(0) ?: Locale.getDefault() ConfigurationCompat.getLocales(context.resources.configuration).get(0) ?: Locale.getDefault()
@@ -1,20 +1,9 @@
package de.jeanlucmakiola.clockula.ui.widget.config package de.jeanlucmakiola.clockula.ui.widget.config
import android.appwidget.AppWidgetManager
import android.content.Intent
import android.os.Bundle
import android.widget.FrameLayout 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.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.getAppWidgetState import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.appwidget.state.updateAppWidgetState import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.state.PreferencesGlanceStateDefinition import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.R 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.AnalogClockStyle
import de.jeanlucmakiola.clockula.domain.widget.parseAnalogClockStyle 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.ui.widget.analogClockViews
import de.jeanlucmakiola.clockula.widget.AnalogClockWidget import de.jeanlucmakiola.clockula.widget.AnalogClockWidget
import de.jeanlucmakiola.clockula.widget.WIDGET_ANALOG_STYLE_KEY 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 * 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). * straight away, as the reference picker does (no separate "Save" step).
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class AnalogClockConfigActivity : ComponentActivity() { class AnalogClockConfigActivity : WidgetConfigActivity() {
@Inject lateinit var settingsPrefs: SettingsPrefs @Composable
override fun Content() {
override fun onCreate(savedInstanceState: Bundle?) { var style by remember { mutableStateOf(AnalogClockStyle.ROUND) }
super.onCreate(savedInstanceState) LaunchedEffect(glanceId) {
val appWidgetId = intent?.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID) val state = getAppWidgetState(this@AnalogClockConfigActivity, PreferencesGlanceStateDefinition, glanceId)
?: AppWidgetManager.INVALID_APPWIDGET_ID style = parseAnalogClockStyle(state[WIDGET_ANALOG_STYLE_KEY])
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() },
)
}
} }
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) { internal fun analogStyleLabel(style: AnalogClockStyle): Int = when (style) {
@@ -1,28 +1,17 @@
package de.jeanlucmakiola.clockula.ui.widget.config package de.jeanlucmakiola.clockula.ui.widget.config
import android.appwidget.AppWidgetManager
import android.content.Intent
import android.os.Bundle
import android.widget.FrameLayout 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.Box
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.padding 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.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState 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.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.getAppWidgetState import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.appwidget.state.updateAppWidgetState import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.state.PreferencesGlanceStateDefinition import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.R import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository
import de.jeanlucmakiola.clockula.data.zones.ZoneNames import de.jeanlucmakiola.clockula.data.zones.ZoneNames
import de.jeanlucmakiola.clockula.domain.widget.DigitalClockSettings import de.jeanlucmakiola.clockula.domain.widget.DigitalClockSettings
import de.jeanlucmakiola.clockula.domain.widget.DigitalClockStyle import de.jeanlucmakiola.clockula.domain.widget.DigitalClockStyle
import de.jeanlucmakiola.clockula.domain.widget.showsWorldClocks 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.ClockWidgetCity
import de.jeanlucmakiola.clockula.ui.widget.digitalClockViews import de.jeanlucmakiola.clockula.ui.widget.digitalClockViews
import de.jeanlucmakiola.clockula.ui.widget.widgetCities 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.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position 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 import javax.inject.Inject
/** /** Picks the digital widget instance's style and the two switches. */
* 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".
*/
@AndroidEntryPoint @AndroidEntryPoint
class ClockWidgetConfigActivity : ComponentActivity() { class ClockWidgetConfigActivity : WidgetConfigActivity() {
@Inject lateinit var settingsPrefs: SettingsPrefs
@Inject lateinit var worldClockRepository: WorldClockRepository @Inject lateinit var worldClockRepository: WorldClockRepository
@Inject lateinit var zoneNames: ZoneNames @Inject lateinit var zoneNames: ZoneNames
override fun onCreate(savedInstanceState: Bundle?) { @Composable
super.onCreate(savedInstanceState) override fun Content() {
val appWidgetId = intent?.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID) // `null` until this instance's stored settings are read: the screen
?: AppWidgetManager.INVALID_APPWIDGET_ID // (and its OK) only appears then, so neither an early save nor a
// Backing out cancels the placement, as the framework expects. // late load can write defaults over what the user has.
setResult(RESULT_CANCELED, resultIntent(appWidgetId)) var settings by remember { mutableStateOf<DigitalClockSettings?>(null) }
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { val cities by produceState(initialValue = emptyList<ClockWidgetCity>()) {
finish() value = widgetCities(worldClockRepository, zoneNames)
return
} }
// A stale id (the widget removed meanwhile) or one that isn't ours LaunchedEffect(glanceId) {
// throws here; there is nothing to configure, so just close. val state = getAppWidgetState(this@ClockWidgetConfigActivity, PreferencesGlanceStateDefinition, glanceId)
val glanceId = runCatching { GlanceAppWidgetManager(this).getGlanceIdBy(appWidgetId) }.getOrNull() settings = parseDigitalClockSettings(
if (glanceId == null) { style = state[WIDGET_DIGITAL_STYLE_KEY],
finish() showDate = state[WIDGET_SHOW_DATE_KEY],
return showWorldClocks = state[WIDGET_SHOW_WORLD_CLOCKS_KEY],
} )
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() },
)
}
} }
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 @Composable
@@ -1,105 +1,55 @@
package de.jeanlucmakiola.clockula.ui.widget.config package de.jeanlucmakiola.clockula.ui.widget.config
import android.appwidget.AppWidgetManager import androidx.compose.runtime.Composable
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.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.getAppWidgetState import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.appwidget.state.updateAppWidgetState import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.state.PreferencesGlanceStateDefinition import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.R import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.domain.format.ClockFormat 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.QuickTimerWidgetSettings
import de.jeanlucmakiola.clockula.domain.widget.parseQuickTimerDuration 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.QuickTimerWidget
import de.jeanlucmakiola.clockula.widget.WIDGET_QUICK_TIMER_DURATION_KEY import de.jeanlucmakiola.clockula.widget.WIDGET_QUICK_TIMER_DURATION_KEY
import de.jeanlucmakiola.floret.components.OptionPicker 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 * The quick-timer widget's entire configuration: which duration it starts on
* starts on tap — reuses `domain/timer/TimerPresets.BUILT_IN`, the same list * tap — [QuickTimerWidgetSettings.OPTIONS], the Timers screen's own built-in
* the Timers screen's own quick-start row offers, so this picker never * quick-start durations up to the widget's 59:59 cap. Like
* invents a second set of durations. Like [AnalogClockConfigActivity], tapping * [AnalogClockConfigActivity], tapping an option saves and finishes
* an option saves and finishes immediately. * immediately.
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class QuickTimerConfigActivity : ComponentActivity() { class QuickTimerConfigActivity : WidgetConfigActivity() {
@Inject lateinit var settingsPrefs: SettingsPrefs @Composable
override fun Content() {
override fun onCreate(savedInstanceState: Bundle?) { var duration by remember { mutableStateOf(QuickTimerWidgetSettings.DEFAULT) }
super.onCreate(savedInstanceState) LaunchedEffect(glanceId) {
val appWidgetId = intent?.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID) val state = getAppWidgetState(this@QuickTimerConfigActivity, PreferencesGlanceStateDefinition, glanceId)
?: AppWidgetManager.INVALID_APPWIDGET_ID duration = parseQuickTimerDuration(state[WIDGET_QUICK_TIMER_DURATION_KEY])
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() },
)
}
} }
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 package de.jeanlucmakiola.clockula.widget
import android.appwidget.AppWidgetManager
import android.content.Context import android.content.Context
import android.content.Intent
import androidx.glance.GlanceId import androidx.glance.GlanceId
import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver import androidx.glance.appwidget.GlanceAppWidgetReceiver
@@ -34,4 +36,24 @@ class AnalogClockWidget : GlanceAppWidget() {
class AnalogClockWidgetReceiver : GlanceAppWidgetReceiver() { class AnalogClockWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = AnalogClockWidget() 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.SizeMode
import androidx.glance.appwidget.provideContent import androidx.glance.appwidget.provideContent
import de.jeanlucmakiola.clockula.ui.widget.ClockWidgetRoot import de.jeanlucmakiola.clockula.ui.widget.ClockWidgetRoot
import de.jeanlucmakiola.clockula.ui.widget.widgetCities
import de.jeanlucmakiola.clockula.widget.di.widgetEntryPoint 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`) * read reactively inside [ClockWidgetRoot] (`ui/widget/ClockWidgetContent.kt`)
* from this instance's own Glance state (`widget/ClockWidgetState.kt`), * from this instance's own Glance state (`widget/ClockWidgetState.kt`),
* written by `ClockWidgetConfigActivity`; an instance placed with * 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. * resolves to the defaults that reproduce the pre-M11 layout.
* *
* Reading that state via `currentState()` *inside* the composition — rather * Reading that state via `currentState()` *inside* the composition — rather
@@ -33,8 +34,9 @@ class ClockWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) { override suspend fun provideGlance(context: Context, id: GlanceId) {
val entryPoint = widgetEntryPoint(context) val entryPoint = widgetEntryPoint(context)
val initialCities = widgetCities(entryPoint.worldClockRepository(), entryPoint.zoneNames())
provideContent { 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.AnalogClockStyle
import de.jeanlucmakiola.clockula.domain.widget.DigitalClockSettings import de.jeanlucmakiola.clockula.domain.widget.DigitalClockSettings
import de.jeanlucmakiola.clockula.domain.widget.DigitalClockStyle 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.parseDigitalClockStyle
import de.jeanlucmakiola.clockula.domain.widget.parseQuickTimerDuration
/** Per-instance Glance state key holding the digital widget's [DigitalClockStyle] ([name]). */ /** Per-instance Glance state key holding the digital widget's [DigitalClockStyle] ([name]). */
internal val WIDGET_DIGITAL_STYLE_KEY = stringPreferencesKey("digital_style") internal val WIDGET_DIGITAL_STYLE_KEY = stringPreferencesKey("digital_style")
@@ -35,9 +32,3 @@ internal fun parseDigitalClockSettings(style: String?, showDate: Boolean?, showW
showDate = showDate ?: true, showDate = showDate ?: true,
showWorldClocks = showWorldClocks ?: false, 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.stopwatch.StopwatchRepository
import de.jeanlucmakiola.clockula.data.timers.TimerRepository import de.jeanlucmakiola.clockula.data.timers.TimerRepository
import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.updateAll import androidx.glance.appwidget.updateAll
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton 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 * because the process is alive whenever those run — it touches no engine
* itself, only re-renders. * itself, only re-renders.
* *
* The quick-timer widget follows the timers too: it shows the countdown of * Each flow re-renders only the widgets that read it: a stopwatch lap redraws
* the timer it started (`widget/QuickTimerWidget.kt`). * 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 * Started once, from [de.jeanlucmakiola.clockula.ClockulaApp.onCreate], in the
* app's `@ApplicationScope` — never driven by a `Ticker`, so an idle home * app's `@ApplicationScope` — never driven by a `Ticker`, so an idle home
@@ -40,37 +48,25 @@ class WidgetSync @Inject constructor(
private val worldClocks: WorldClockRepository, 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) @OptIn(FlowPreview::class)
suspend fun run() { private fun CoroutineScope.sync(source: Flow<*>, vararg widgets: GlanceAppWidget) = launch {
combine( source
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)
}
.distinctUntilChanged() .distinctUntilChanged()
// A burst of writes (an import's replace-all, a reorder) collapses // A burst of writes (an import's replace-all, a reorder) collapses
// to one redraw rather than one per row. // to one redraw rather than one per row.
.debounce(DEBOUNCE) .debounce(DEBOUNCE)
.collect { updateAll() } .collect { widgets.forEach { it.updateAll(context) } }
}
private suspend fun updateAll() {
NextAlarmWidget().updateAll(context)
TimerWidget().updateAll(context)
StopwatchWidget().updateAll(context)
ClockWidget().updateAll(context)
AnalogClockWidget().updateAll(context)
QuickTimerWidget().updateAll(context)
} }
private companion object { private companion object {
val DEBOUNCE = 150.milliseconds 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:minResizeHeight="110dp"
android:targetCellWidth="2" android:targetCellWidth="2"
android:targetCellHeight="2" android:targetCellHeight="2"
android:updatePeriodMillis="1800000" android:updatePeriodMillis="0"
android:resizeMode="horizontal|vertical" android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen" android:widgetCategory="home_screen"
android:description="@string/widget_analog_description" android:description="@string/widget_analog_description"
@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android" <appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="140dp" android:minWidth="140dp"
android:minHeight="80dp" android:minHeight="110dp"
android:minResizeWidth="110dp" android:minResizeWidth="110dp"
android:minResizeHeight="60dp" android:minResizeHeight="110dp"
android:targetCellWidth="3" android:targetCellWidth="3"
android:targetCellHeight="2" android:targetCellHeight="2"
android:updatePeriodMillis="0" android:updatePeriodMillis="0"
@@ -58,6 +58,17 @@ class ClockWidgetSettingsTest {
assertThat(parseQuickTimerDuration(0L)).isEqualTo(QuickTimerWidgetSettings.DEFAULT) 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 @Test
fun `an in-range quick-timer duration round-trips`() { fun `an in-range quick-timer duration round-trips`() {
assertThat(parseQuickTimerDuration(600_000L).inWholeMilliseconds).isEqualTo(600_000L) assertThat(parseQuickTimerDuration(600_000L).inWholeMilliseconds).isEqualTo(600_000L)