diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index d80b856..491ed21 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -198,6 +198,7 @@ dependencies {
implementation("de.jeanlucmakiola.floret:core-di")
implementation("de.jeanlucmakiola.floret:identity")
implementation("de.jeanlucmakiola.floret:components")
+ implementation("de.jeanlucmakiola.floret:glance")
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index ab01b72..fa94667 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -233,15 +233,59 @@
android:value="true" />
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -274,6 +318,39 @@
android:name="android.appwidget.provider"
android:resource="@xml/widget_clock_info" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/AnalogClockSettings.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/AnalogClockSettings.kt
new file mode 100644
index 0000000..3e0be17
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/AnalogClockSettings.kt
@@ -0,0 +1,13 @@
+package de.jeanlucmakiola.clockula.domain.widget
+
+/**
+ * The analog faces an [de.jeanlucmakiola.clockula.widget.AnalogClockWidget]
+ * instance can render: a dial shape (one of the M3 Expressive `MaterialShapes`,
+ * as `res/drawable/widget_analog_dial_*`) plus what sits on it. See
+ * `ui/widget/AnalogClockWidgetContent.kt` for the mapping.
+ */
+enum class AnalogClockStyle { ROUND, NUMERAL, BADGE, COOKIE, SUNNY, SCALLOP, CLOVER, SQUARE, PETAL }
+
+/** Anything unreadable, or a name this enum no longer has, falls back to [AnalogClockStyle.ROUND]. */
+fun parseAnalogClockStyle(raw: String?): AnalogClockStyle =
+ raw?.let { runCatching { AnalogClockStyle.valueOf(it) }.getOrNull() } ?: AnalogClockStyle.ROUND
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/ClockWidgetSettings.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/ClockWidgetSettings.kt
new file mode 100644
index 0000000..b1ab4c0
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/ClockWidgetSettings.kt
@@ -0,0 +1,29 @@
+package de.jeanlucmakiola.clockula.domain.widget
+
+/**
+ * The digital faces a [de.jeanlucmakiola.clockula.widget.ClockWidget]
+ * instance can render, in picker order. [REGULAR] is the default, so an
+ * already-placed widget (no stored style) and a "skip configuration"
+ * placement both render the same plain face. Stored by [name], so the
+ * original four keep theirs even where their labels changed (Classic, Thin).
+ */
+enum class DigitalClockStyle { REGULAR, LIGHT, BOLD, PILL, STACKED }
+
+/** Whether [this] face has room for world-clock rows at all — only the card faces do. */
+val DigitalClockStyle.showsWorldClocks: Boolean
+ get() = this == DigitalClockStyle.REGULAR || this == DigitalClockStyle.LIGHT || this == DigitalClockStyle.BOLD
+
+/** Anything unreadable, or a name this enum no longer has, falls back to [DigitalClockStyle.REGULAR]. */
+fun parseDigitalClockStyle(raw: String?): DigitalClockStyle =
+ raw?.let { runCatching { DigitalClockStyle.valueOf(it) }.getOrNull() } ?: DigitalClockStyle.REGULAR
+
+/**
+ * One digital-clock widget instance's own picks, stored per-instance
+ * (`widget/ClockWidgetState.kt`) — never app-wide. [showDate] defaults to
+ * true, [showWorldClocks] to false: world clocks are opt-in.
+ */
+data class DigitalClockSettings(
+ val style: DigitalClockStyle = DigitalClockStyle.REGULAR,
+ val showDate: Boolean = true,
+ val showWorldClocks: Boolean = false,
+)
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/QuickTimerWidgetSettings.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/QuickTimerWidgetSettings.kt
new file mode 100644
index 0000000..b45c8d2
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/QuickTimerWidgetSettings.kt
@@ -0,0 +1,42 @@
+package de.jeanlucmakiola.clockula.domain.widget
+
+import de.jeanlucmakiola.clockula.domain.Timer
+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.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
+ }
+}
+
+/** Anything unreadable, or out of [TimerPresets]' own sane range, 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
+}
+
+/**
+ * What the quick-timer widget shows: its configured [duration], and the
+ * timer it started, while that one is still [active] (running, paused or
+ * expired). `null` means the widget is at rest, offering to start.
+ */
+data class QuickTimerWidgetModel(val duration: Duration, val active: TimerWidgetItem?)
+
+object QuickTimerWidgetPresenter {
+
+ /**
+ * [timerId] is the timer this widget instance last started. It is
+ * transient (deleted on reset/stop), so once it is gone — or back to
+ * idle — the widget is simply at rest again.
+ */
+ fun present(duration: Duration, timerId: Long?, timers: List, elapsedRealtime: Duration, wallClock: Instant): QuickTimerWidgetModel {
+ val own = timers.filter { it.id == timerId }
+ val active = TimerWidgetPresenter.present(own, elapsedRealtime, wallClock).items.firstOrNull()
+ return QuickTimerWidgetModel(duration, active)
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/StopwatchWidgetModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/StopwatchWidgetModel.kt
new file mode 100644
index 0000000..aa3d404
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/StopwatchWidgetModel.kt
@@ -0,0 +1,22 @@
+package de.jeanlucmakiola.clockula.domain.widget
+
+import de.jeanlucmakiola.clockula.domain.StopwatchRun
+import de.jeanlucmakiola.clockula.domain.StopwatchState
+import de.jeanlucmakiola.clockula.domain.stopwatch.StopwatchReadings
+import kotlin.time.Duration
+
+/** What the stopwatch widget shows: the live [mode] and [elapsed], plus how many laps are banked. */
+data class StopwatchWidgetModel(
+ val mode: StopwatchState,
+ val elapsed: Duration,
+ val lapCount: Int,
+)
+
+object StopwatchWidgetPresenter {
+
+ /** Pure; mirrors `StopwatchReadings.of` — the same "now" every other stopwatch surface reads. */
+ fun present(run: StopwatchRun, elapsedRealtime: Duration, lapCount: Int): StopwatchWidgetModel {
+ val reading = StopwatchReadings.of(run, elapsedRealtime)
+ return StopwatchWidgetModel(mode = reading.mode, elapsed = reading.elapsed, lapCount = lapCount)
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/timer/QuickTimerIntents.kt b/app/src/main/java/de/jeanlucmakiola/clockula/timer/QuickTimerIntents.kt
new file mode 100644
index 0000000..7c6a6e6
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/timer/QuickTimerIntents.kt
@@ -0,0 +1,12 @@
+package de.jeanlucmakiola.clockula.timer
+
+/** The quick-timer widget's start action: a fresh, transient timer at its configured duration. */
+object QuickTimerIntents {
+ private const val PREFIX = "de.jeanlucmakiola.clockula."
+
+ const val ACTION_START: String = PREFIX + "action.QUICK_TIMER_START"
+ const val EXTRA_DURATION_MILLIS: String = PREFIX + "extra.QUICK_TIMER_DURATION_MILLIS"
+
+ /** The widget instance that asked, so the timer it started can be shown on it. */
+ const val EXTRA_APP_WIDGET_ID: String = PREFIX + "extra.QUICK_TIMER_APP_WIDGET_ID"
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/timer/receiver/QuickTimerReceiver.kt b/app/src/main/java/de/jeanlucmakiola/clockula/timer/receiver/QuickTimerReceiver.kt
new file mode 100644
index 0000000..73d4b6e
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/timer/receiver/QuickTimerReceiver.kt
@@ -0,0 +1,66 @@
+package de.jeanlucmakiola.clockula.timer.receiver
+
+import android.appwidget.AppWidgetManager
+import android.content.Context
+import android.content.Intent
+import androidx.glance.appwidget.GlanceAppWidgetManager
+import androidx.glance.appwidget.state.updateAppWidgetState
+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.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.launch
+import javax.inject.Inject
+import kotlin.time.Duration.Companion.milliseconds
+
+/**
+ * The quick-timer widget's one tap: creates a fresh timer at the widget's
+ * configured duration and starts it immediately — the same two-step path
+ * `ui/timers/TimersViewModel.kt`'s quick-start button already uses. A
+ * broadcast, not an activity, so tapping the widget never has to open the app
+ * (the same reasoning as `TimerActionReceiver`/`StopwatchActionReceiver`, D16).
+ */
+@AndroidEntryPoint
+class QuickTimerReceiver : HiltBroadcastReceiver() {
+
+ @Inject lateinit var timers: TimerRepository
+
+ @Inject lateinit var engine: TimerEngine
+
+ @Inject
+ @ApplicationScope
+ lateinit var scope: CoroutineScope
+
+ override fun onReceive(context: Context, intent: Intent) {
+ super.onReceive(context, intent)
+
+ if (intent.action != QuickTimerIntents.ACTION_START) return
+ val durationMillis = intent.getLongExtra(QuickTimerIntents.EXTRA_DURATION_MILLIS, 0L)
+ if (durationMillis <= 0L) return
+ val appWidgetId = intent.getIntExtra(QuickTimerIntents.EXTRA_APP_WIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
+ 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)
+ }
+ } finally {
+ pendingResult.finish()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/AnalogClockWidgetContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/AnalogClockWidgetContent.kt
new file mode 100644
index 0000000..28e174e
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/AnalogClockWidgetContent.kt
@@ -0,0 +1,246 @@
+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
+import android.graphics.Typeface
+import android.graphics.drawable.Icon
+import android.os.Build
+import androidx.annotation.DrawableRes
+import android.text.format.DateFormat
+import android.util.TypedValue
+import android.view.View
+import android.widget.RemoteViews
+import androidx.compose.runtime.Composable
+import androidx.datastore.preferences.core.Preferences
+import androidx.glance.GlanceModifier
+import androidx.glance.LocalContext
+import androidx.glance.LocalSize
+import androidx.glance.action.clickable
+import androidx.glance.appwidget.AndroidRemoteViews
+import androidx.glance.appwidget.action.actionStartActivity
+import androidx.glance.currentState
+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]. */
+@Composable
+fun AnalogClockWidgetRoot() {
+ val prefs = currentState()
+ AnalogClockWidgetContent(style = parseAnalogClockStyle(prefs[WIDGET_ANALOG_STYLE_KEY]))
+}
+
+/**
+ * The analog widget's content: [style]'s dial/overlay ([analogClockViews])
+ * floating directly on the home screen, not on a card, the way a platform
+ * clock widget looks. Its colours are the analog palette resources
+ * (`res/values/colors.xml` and its night/v31 variants), so it follows the launcher's day/night and, on
+ * API 31+, the wallpaper by itself.
+ */
+@Composable
+fun AnalogClockWidgetContent(style: AnalogClockStyle) {
+ val context = LocalContext.current
+ Box(
+ modifier = GlanceModifier.fillMaxSize().clickable(actionStartActivity(showWorldClockIntent(context))),
+ contentAlignment = Alignment.Center,
+ ) {
+ AnalogFace(style)
+ }
+}
+
+/**
+ * The `AnalogClock` layouts fill whatever they're given (`match_parent`), so
+ * *this* is what actually sizes the dial: the smaller of the widget's live
+ * placed width/height, read from [LocalSize].
+ */
+@Composable
+private fun AnalogFace(style: AnalogClockStyle) {
+ val context = LocalContext.current
+ val size = LocalSize.current
+ val side = minOf(size.width, size.height)
+ Box(modifier = GlanceModifier.size(side)) {
+ AndroidRemoteViews(
+ remoteViews = analogClockViews(context, style, side.value),
+ modifier = GlanceModifier.fillMaxSize(),
+ )
+ }
+}
+
+/**
+ * What sits on an analog dial besides the hands — picks the layout. [DAY] is
+ * the 12/9/6 numerals plus a day pill at three; [DATE_PILL] is that layout
+ * with the numerals hidden and the full date ("Fri 2") in the pill.
+ */
+private enum class AnalogOverlay { NONE, NUMERALS, DAY, DATE_PILL }
+
+private fun AnalogClockStyle.dial(): Int = when (this) {
+ AnalogClockStyle.ROUND, AnalogClockStyle.NUMERAL, AnalogClockStyle.BADGE -> R.drawable.widget_analog_dial_round
+ AnalogClockStyle.COOKIE -> R.drawable.widget_analog_dial_cookie
+ AnalogClockStyle.CLOVER -> R.drawable.widget_analog_dial_clover
+ AnalogClockStyle.PETAL -> R.drawable.widget_analog_dial_petal
+ AnalogClockStyle.SUNNY -> R.drawable.widget_analog_dial_sunny
+ AnalogClockStyle.SQUARE -> R.drawable.widget_analog_dial_square
+ AnalogClockStyle.SCALLOP -> R.drawable.widget_analog_dial_scallop
+}
+
+/** The hour/minute hand drawables a style wears (`res/drawable/widget_analog_{hour,minute}_*`). */
+private enum class AnalogHands(@DrawableRes val hour: Int, @DrawableRes val minute: Int) {
+ CAPSULE(R.drawable.widget_analog_hour_hand, R.drawable.widget_analog_minute_hand),
+ BOLD(R.drawable.widget_analog_hour_bold, R.drawable.widget_analog_minute_bold),
+ SLIM(R.drawable.widget_analog_hour_slim, R.drawable.widget_analog_minute_slim),
+ LOLLIPOP(R.drawable.widget_analog_hour_lollipop, R.drawable.widget_analog_minute_lollipop),
+}
+
+private fun AnalogClockStyle.hands(): AnalogHands = when (this) {
+ AnalogClockStyle.ROUND, AnalogClockStyle.BADGE, AnalogClockStyle.SCALLOP -> AnalogHands.CAPSULE
+ AnalogClockStyle.COOKIE, AnalogClockStyle.SQUARE -> AnalogHands.BOLD
+ AnalogClockStyle.NUMERAL, AnalogClockStyle.CLOVER -> AnalogHands.SLIM
+ AnalogClockStyle.SUNNY, AnalogClockStyle.PETAL -> AnalogHands.LOLLIPOP
+}
+
+/** What a style's seconds hand is, if it has one at all. */
+private sealed interface AnalogSeconds {
+ data object None : AnalogSeconds
+
+ /** A dot alone; the drawable fixes its size and orbit. */
+ data class Dot(@DrawableRes val drawable: Int) : AnalogSeconds
+
+ /** Google Clock's: a dot plus the date opposite it. */
+ data class DotAndDate(@DrawableRes val dot: Int) : AnalogSeconds
+}
+
+private fun AnalogClockStyle.seconds(): AnalogSeconds = when (this) {
+ AnalogClockStyle.ROUND, AnalogClockStyle.NUMERAL, AnalogClockStyle.BADGE,
+ AnalogClockStyle.CLOVER -> AnalogSeconds.None
+ AnalogClockStyle.SUNNY -> AnalogSeconds.Dot(R.drawable.widget_analog_second_dot)
+ AnalogClockStyle.SQUARE -> AnalogSeconds.Dot(R.drawable.widget_analog_second_dot_bold)
+ AnalogClockStyle.PETAL -> AnalogSeconds.Dot(R.drawable.widget_analog_second_dot_petal)
+ AnalogClockStyle.COOKIE -> AnalogSeconds.DotAndDate(R.drawable.widget_analog_second_dot_dated)
+ AnalogClockStyle.SCALLOP -> AnalogSeconds.DotAndDate(R.drawable.widget_analog_second_dot_scallop)
+}
+
+private fun AnalogClockStyle.overlay(): AnalogOverlay = when (this) {
+ AnalogClockStyle.NUMERAL, AnalogClockStyle.CLOVER -> AnalogOverlay.NUMERALS
+ AnalogClockStyle.BADGE -> AnalogOverlay.DAY
+ AnalogClockStyle.PETAL -> AnalogOverlay.DATE_PILL
+ AnalogClockStyle.ROUND, AnalogClockStyle.COOKIE, AnalogClockStyle.SUNNY,
+ AnalogClockStyle.SCALLOP, AnalogClockStyle.SQUARE -> AnalogOverlay.NONE
+}
+
+/**
+ * The analog face for [style] at a [sideDp]-square size. The single builder
+ * behind both the placed widget and the config picker's previews (which
+ * inflate these same views in-app), so a preview is the real widget.
+ * Numerals, badge and their insets are fractions of [sideDp], so they grow
+ * and shrink with the dial instead of staying at a fixed sp/dp.
+ */
+internal fun analogClockViews(context: Context, style: AnalogClockStyle, sideDp: Float): RemoteViews {
+ val overlay = style.overlay()
+ val layoutRes = when (overlay) {
+ AnalogOverlay.NONE -> R.layout.widget_analog_plain
+ AnalogOverlay.NUMERALS -> R.layout.widget_analog_numeral
+ AnalogOverlay.DAY, AnalogOverlay.DATE_PILL -> R.layout.widget_analog_badge
+ }
+ val density = context.resources.displayMetrics.density
+ fun frac(f: Float): Int = (sideDp * f * density).toInt()
+ return RemoteViews(context.packageName, layoutRes).apply {
+ setImageViewResource(R.id.widget_analog_dial, style.dial())
+ // `AnalogClock`'s hand setters (and its seconds hand at all) only
+ // exist from API 31; before that every style keeps the layout's
+ // capsule hands and no seconds.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ val hands = style.hands()
+ setIcon(R.id.widget_clock_analog, "setHourHand", Icon.createWithResource(context, hands.hour))
+ setIcon(R.id.widget_clock_analog, "setMinuteHand", Icon.createWithResource(context, hands.minute))
+ when (val seconds = style.seconds()) {
+ AnalogSeconds.None -> Unit
+ is AnalogSeconds.Dot ->
+ setIcon(R.id.widget_clock_analog, "setSecondHand", Icon.createWithResource(context, seconds.drawable))
+ is AnalogSeconds.DotAndDate -> {
+ setIcon(R.id.widget_clock_analog, "setSecondHand", Icon.createWithResource(context, seconds.dot))
+ // Only the plain layout has the date clock, and only
+ // plain faces are dated.
+ setViewVisibility(R.id.widget_clock_analog_date, View.VISIBLE)
+ setIcon(R.id.widget_clock_analog_date, "setSecondHand", Icon.createWithBitmap(dateHand(context)))
+ setColorStateList(R.id.widget_clock_analog_date, "setSecondHandTintList", R.color.widget_analog_date)
+ }
+ }
+ }
+ if (overlay == AnalogOverlay.NONE) return@apply
+ val inset = frac(0.07f)
+ val numerals = if (overlay == AnalogOverlay.DATE_PILL) emptyList() else buildList {
+ add(R.id.widget_analog_numeral_12 to intArrayOf(0, inset, 0, 0))
+ add(R.id.widget_analog_numeral_6 to intArrayOf(0, 0, 0, inset))
+ add(R.id.widget_analog_numeral_9 to intArrayOf(inset, 0, 0, 0))
+ if (overlay == AnalogOverlay.NUMERALS) add(R.id.widget_analog_numeral_3 to intArrayOf(0, 0, inset, 0))
+ }
+ numerals.forEach { (id, p) ->
+ setTextViewTextSize(id, TypedValue.COMPLEX_UNIT_DIP, sideDp * 0.15f)
+ setViewPadding(id, p[0], p[1], p[2], p[3])
+ }
+ if (overlay == AnalogOverlay.DATE_PILL) {
+ listOf(R.id.widget_analog_numeral_12, R.id.widget_analog_numeral_6, R.id.widget_analog_numeral_9)
+ .forEach { setViewVisibility(it, View.GONE) }
+ }
+ if (overlay == AnalogOverlay.DAY || overlay == AnalogOverlay.DATE_PILL) {
+ val skeleton = if (overlay == AnalogOverlay.DAY) "EEE" else "EEEd"
+ val pattern = DateFormat.getBestDateTimePattern(currentLocale(context), skeleton)
+ setCharSequence(R.id.widget_analog_badge_day, "setFormat12Hour", pattern)
+ setCharSequence(R.id.widget_analog_badge_day, "setFormat24Hour", pattern)
+ setTextViewTextSize(R.id.widget_analog_badge_day, TypedValue.COMPLEX_UNIT_DIP, sideDp * 0.075f)
+ setViewPadding(R.id.widget_analog_badge_day, frac(0.045f), frac(0.02f), frac(0.045f), frac(0.02f))
+ // Petal's rim dips in between its lobes, so its pill sits further in.
+ val edgeInset = if (overlay == AnalogOverlay.DAY) 0.06f else 0.1f
+ setViewPadding(R.id.widget_analog_badge_frame, 0, 0, frac(edgeInset), 0)
+ }
+ }
+}
+
+/**
+ * The date half of Google Clock's seconds hand: today's date ("Fri 2")
+ * 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.
+ */
+private fun dateHand(context: Context): Bitmap {
+ val px = 360
+ val unit = px / 100f
+ val bitmap = Bitmap.createBitmap(px, px, Bitmap.Config.ALPHA_8)
+ // 360px at 120dpi reads as 480dp — the intrinsic size every other hand
+ // and the dial share, so `AnalogClock` scales them all alike. Kept to
+ // 360px (and alpha-only) so the bitmap stays small in the RemoteViews.
+ bitmap.density = 120
+ val canvas = Canvas(bitmap)
+ val paint = Paint(Paint.ANTI_ALIAS_FLAG)
+ paint.textSize = 9.5f * unit
+ paint.typeface = Typeface.create(Typeface.DEFAULT, 600, false)
+ paint.textAlign = Paint.Align.CENTER
+ val pattern = DateFormat.getBestDateTimePattern(currentLocale(context), "EEEd")
+ val date = DateFormat.format(pattern, System.currentTimeMillis()).toString()
+ val metrics = paint.fontMetrics
+ // Centred at radius 33, so the text stays inside the dated faces'
+ // notches (41+) even at its corners.
+ val dateY = 83 * unit
+ // Drawn upside down here, below the pivot, so it reads upright when the
+ // hand carries it over the top of the dial — where it is read most.
+ canvas.rotate(180f, 50 * unit, dateY)
+ canvas.drawText(date, 50 * unit, dateY - (metrics.ascent + metrics.descent) / 2, paint)
+ return bitmap
+}
+
+private fun showWorldClockIntent(context: Context): Intent =
+ Intent(context, MainActivity::class.java)
+ .setAction(ShellNavigation.ACTION_SHOW_WORLD_CLOCK)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/ClockWidgetContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/ClockWidgetContent.kt
index 9d3e764..41f0d70 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/ClockWidgetContent.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/ClockWidgetContent.kt
@@ -2,88 +2,86 @@ package de.jeanlucmakiola.clockula.ui.widget
import android.content.Context
import android.content.Intent
-import android.widget.RemoteViews
import androidx.compose.runtime.Composable
-import androidx.compose.ui.unit.Dp
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.produceState
+import androidx.datastore.preferences.core.Preferences
import androidx.glance.GlanceModifier
-import androidx.glance.GlanceTheme
import androidx.glance.LocalContext
import androidx.glance.LocalSize
-import androidx.glance.text.Text
import androidx.glance.action.clickable
import androidx.glance.appwidget.AndroidRemoteViews
import androidx.glance.appwidget.action.actionStartActivity
-import androidx.glance.appwidget.cornerRadius
-import androidx.glance.background
-import androidx.glance.layout.Alignment
+import androidx.glance.currentState
import androidx.glance.layout.Box
-import androidx.glance.layout.Column
import androidx.glance.layout.fillMaxSize
-import androidx.glance.layout.padding
-import androidx.glance.text.TextStyle
import de.jeanlucmakiola.clockula.MainActivity
-import de.jeanlucmakiola.clockula.R
+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.first
-/** One resolved world clock for the clock 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)
+/** The world clocks the digital widget shows, resolved to city names — shared by the widget and its config preview. */
+internal suspend fun widgetCities(worldClocks: WorldClockRepository, zoneNames: ZoneNames): List =
+ ClockWidgetPresenter.present(worldClocks.worldClocks().first()).map { clock ->
+ ClockWidgetCity(
+ zoneId = clock.zoneId,
+ cityName = clock.label?.takeIf(String::isNotBlank) ?: zoneNames.cityOf(clock.zoneId) ?: clock.zoneId,
+ )
+ }
+
/**
- * The clock widget's content. Responsive by size (`LocalSize.current`), read
- * here rather than via `SizeMode.Responsive` so the `GlanceAppWidget` subclass
- * in `widget/` never needs a Compose-namespace `DpSize` import
- * (`ArchitectureRulesTest`'s "Compose stays inside ui/" rule).
+ * 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].
*/
@Composable
-fun ClockWidgetContent(cities: List) {
+fun ClockWidgetRoot(entryPoint: WidgetEntryPoint) {
+ val prefs = currentState()
+ val settings = parseDigitalClockSettings(
+ style = prefs[WIDGET_DIGITAL_STYLE_KEY],
+ showDate = prefs[WIDGET_SHOW_DATE_KEY],
+ showWorldClocks = prefs[WIDGET_SHOW_WORLD_CLOCKS_KEY],
+ )
+ val showWorldClocks = settings.showWorldClocks
+ val cities by produceState(initialValue = emptyList(), showWorldClocks) {
+ value = if (showWorldClocks) widgetCities(entryPoint.worldClockRepository(), entryPoint.zoneNames()) else emptyList()
+ }
+
+ ClockWidgetContent(cities = cities, settings = settings)
+}
+
+/**
+ * The digital widget's content: [settings]' face ([digitalClockViews]) at
+ * the widget's live size. Like the analog widget, the face draws its own
+ * container (a card, pills, or a shape) and takes its colours from the
+ * `widget_*` palette resources, so it follows the launcher's day/night and
+ * the wallpaper by itself.
+ */
+@Composable
+fun ClockWidgetContent(cities: List, settings: DigitalClockSettings) {
val context = LocalContext.current
val size = LocalSize.current
- val showDate = size.width >= MEDIUM_WIDTH
- val showCities = size.width >= LARGE_WIDTH && cities.isNotEmpty()
-
- GlanceTheme(WidgetDefaults.Colors) {
- Box(
- modifier = GlanceModifier
- .fillMaxSize()
- .background(GlanceTheme.colors.widgetBackground)
- .cornerRadius(WidgetDefaults.CornerRadius)
- .padding(WidgetDefaults.ContentPadding)
- .clickable(actionStartActivity(showWorldClockIntent(context))),
- contentAlignment = Alignment.CenterStart,
- ) {
- Column {
- TextClockRow(context, zoneId = null)
- if (showDate) {
- Text(todayLabel(), style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = WidgetDefaults.BodyTextSize))
- }
- if (showCities) {
- cities.forEach { city ->
- Text(city.cityName, style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = WidgetDefaults.CaptionTextSize))
- TextClockRow(context, zoneId = city.zoneId)
- }
- }
- }
- }
+ Box(modifier = GlanceModifier.fillMaxSize().clickable(actionStartActivity(showWorldClockIntent(context)))) {
+ AndroidRemoteViews(
+ remoteViews = digitalClockViews(context, settings, cities, size.width.value, size.height.value),
+ modifier = GlanceModifier.fillMaxSize(),
+ )
}
}
-/** [zoneId] null means the device's own zone — a plain `TextClock` with no timezone override. */
-@Composable
-private fun TextClockRow(context: Context, zoneId: String?) {
- val views = RemoteViews(context.packageName, R.layout.widget_text_clock).apply {
- if (zoneId != null) setString(R.id.widget_text_clock, "setTimeZone", zoneId)
- }
- AndroidRemoteViews(remoteViews = views)
-}
-
-/** A plain `java.time` date string — no `android.icu`, this is not a localised zone name (`data/zones/` owns that). */
-private fun todayLabel(): String =
- java.time.LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("EEE, MMM d"))
-
private fun showWorldClockIntent(context: Context): Intent =
Intent(context, MainActivity::class.java)
.setAction(ShellNavigation.ACTION_SHOW_WORLD_CLOCK)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
-
-private val MEDIUM_WIDTH = Dp(180f)
-private val LARGE_WIDTH = Dp(250f)
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/DigitalFaces.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/DigitalFaces.kt
new file mode 100644
index 0000000..666fa82
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/DigitalFaces.kt
@@ -0,0 +1,158 @@
+package de.jeanlucmakiola.clockula.ui.widget
+
+import android.content.Context
+import android.text.format.DateFormat
+import android.util.TypedValue
+import android.view.View
+import android.widget.RemoteViews
+import de.jeanlucmakiola.clockula.R
+import de.jeanlucmakiola.clockula.domain.widget.DigitalClockSettings
+import de.jeanlucmakiola.clockula.domain.widget.DigitalClockStyle
+import de.jeanlucmakiola.clockula.domain.widget.showsWorldClocks
+
+/** Below this the time is too small to share the card with world-clock rows, so they drop first. */
+private const val MIN_TIME_WITH_CITIES = 30f
+
+/** Below this the time drops the date line too. */
+private const val MIN_TIME_WITH_DATE = 22f
+
+/**
+ * The digital face for [settings] at [widthDp] x [heightDp]. The single
+ * builder behind both the placed widget and the config picker's previews
+ * (which inflate these same views in-app), so a preview is the real widget —
+ * as `analogClockViews` is for the analog one. Every text size and spacing
+ * is a fraction of the size, so the face fills the widget at any size
+ * instead of sitting at a fixed sp in the middle of it; on a small widget,
+ * world clocks and then the date drop out rather than squeezing the time.
+ *
+ * The time and date are `TextClock`s ticking in the launcher; the time drops
+ * the 12-hour AM/PM marker, as the platform's own clock widgets do.
+ */
+internal fun digitalClockViews(
+ context: Context,
+ settings: DigitalClockSettings,
+ cities: List,
+ widthDp: Float,
+ heightDp: Float,
+): RemoteViews = when (settings.style) {
+ DigitalClockStyle.REGULAR, DigitalClockStyle.LIGHT, DigitalClockStyle.BOLD ->
+ cardViews(context, settings, cities, widthDp, heightDp)
+ DigitalClockStyle.PILL -> pillViews(context, settings.showDate, widthDp, heightDp)
+ DigitalClockStyle.STACKED -> stackedViews(context, settings.showDate, minOf(widthDp, heightDp))
+}
+
+/** The card faces: time, date, then up to three world-clock rows. */
+private fun cardViews(
+ context: Context,
+ settings: DigitalClockSettings,
+ cities: List,
+ w: Float,
+ h: Float,
+): RemoteViews {
+ val (layout, cityLayout, emsWide) = when (settings.style) {
+ DigitalClockStyle.LIGHT -> Triple(R.layout.widget_digital_thin, R.layout.widget_digital_city, 2.6f)
+ DigitalClockStyle.BOLD -> Triple(R.layout.widget_digital_bold, R.layout.widget_digital_city_on_primary, 2.95f)
+ else -> Triple(R.layout.widget_digital_classic, R.layout.widget_digital_city, 2.75f)
+ }
+ val pad = (minOf(w, h) * 0.1f).coerceIn(10f, 22f)
+ val innerW = w - 2 * pad
+ val innerH = h - 2 * pad
+
+ // Heights in ems of the time: its own line, the date's (0.3em text), and
+ // each city row's (0.3em text) after a small gap.
+ fun timeSize(cityCount: Int, date: Boolean): Float {
+ val ems = 1.12f + (if (date) 0.42f else 0f) + (if (cityCount > 0) 0.12f + cityCount * 0.44f else 0f)
+ return minOf(innerW / emsWide, innerH / ems)
+ }
+ var cityCount = if (settings.showWorldClocks && settings.style.showsWorldClocks) cities.size else 0
+ var showDate = settings.showDate
+ while (cityCount > 0 && timeSize(cityCount, showDate) < MIN_TIME_WITH_CITIES) cityCount--
+ if (showDate && timeSize(cityCount, true) < MIN_TIME_WITH_DATE) showDate = false
+ val time = timeSize(cityCount, showDate)
+ val small = (time * 0.3f).coerceAtLeast(10f)
+ val px = pxOf(context)
+
+ return RemoteViews(context.packageName, layout).apply {
+ setViewPadding(R.id.widget_digital_root, px(pad), px(pad), px(pad), px(pad))
+ setTime(context, R.id.widget_digital_time, time)
+ setDate(context, R.id.widget_digital_date, showDate, small, "EEEMMMd")
+ if (cityCount > 0) {
+ setViewVisibility(R.id.widget_digital_cities, View.VISIBLE)
+ setViewPadding(R.id.widget_digital_cities, 0, px(time * 0.12f), 0, 0)
+ val cityPattern = if (DateFormat.is24HourFormat(context)) "HH:mm" else "h:mm a"
+ cities.take(cityCount).forEachIndexed { index, city ->
+ val row = RemoteViews(context.packageName, cityLayout).apply {
+ setTextViewText(R.id.widget_digital_city_name, city.cityName)
+ setTextViewTextSize(R.id.widget_digital_city_name, TypedValue.COMPLEX_UNIT_DIP, small)
+ setString(R.id.widget_digital_city_time, "setTimeZone", city.zoneId)
+ setCharSequence(R.id.widget_digital_city_time, "setFormat12Hour", cityPattern)
+ setCharSequence(R.id.widget_digital_city_time, "setFormat24Hour", cityPattern)
+ setTextViewTextSize(R.id.widget_digital_city_time, TypedValue.COMPLEX_UNIT_DIP, small)
+ // Each row after the first sits a little below the one before.
+ if (index > 0) setViewPadding(R.id.widget_digital_city_name, 0, px(small * 0.4f), 0, 0)
+ if (index > 0) setViewPadding(R.id.widget_digital_city_time, 0, px(small * 0.4f), 0, 0)
+ }
+ addView(R.id.widget_digital_cities, row)
+ }
+ }
+ }
+}
+
+/** The time in a primary pill, the date in a smaller tertiary pill under it. */
+private fun pillViews(context: Context, showDate: Boolean, w: Float, h: Float): RemoteViews {
+ // The pill is 2.85em of digits plus 0.5em padding either side, 1.4em tall;
+ // the date adds a 0.14em gap and a 0.34em-text pill about 0.6em tall.
+ val time = minOf(w * 0.94f / 3.85f, h * 0.94f / (if (showDate) 2.15f else 1.4f))
+ val date = time * 0.34f
+ val px = pxOf(context)
+ return RemoteViews(context.packageName, R.layout.widget_digital_pill).apply {
+ setTime(context, R.id.widget_digital_time, time)
+ setViewPadding(R.id.widget_digital_time, px(time * 0.5f), px(time * 0.14f), px(time * 0.5f), px(time * 0.14f))
+ setDate(context, R.id.widget_digital_date, showDate, date, "EEEMMMd")
+ if (showDate) {
+ setViewPadding(R.id.widget_digital_date_frame, 0, px(time * 0.14f), 0, 0)
+ setViewPadding(R.id.widget_digital_date, px(date * 0.8f), px(date * 0.3f), px(date * 0.8f), px(date * 0.3f))
+ } else {
+ setViewVisibility(R.id.widget_digital_date_frame, View.GONE)
+ }
+ }
+}
+
+/** Hours over minutes on a Square of [side], sized so both fit inside its rounded corners. */
+private fun stackedViews(context: Context, showDate: Boolean, side: Float): RemoteViews {
+ val digits = side * (if (showDate) 0.3f else 0.34f)
+ val is24 = DateFormat.is24HourFormat(context)
+ return RemoteViews(context.packageName, R.layout.widget_digital_stacked).apply {
+ setClock(R.id.widget_digital_hours, if (is24) "HH" else "hh", digits)
+ setClock(R.id.widget_digital_minutes, "mm", digits)
+ setDate(context, R.id.widget_digital_date, showDate, side * 0.075f, "EEEd")
+ }
+}
+
+/** The main time: `h:mm` / `HH:mm` — the 12-hour form without AM/PM. */
+private fun RemoteViews.setTime(context: Context, viewId: Int, sizeDp: Float) =
+ setClock(viewId, if (DateFormat.is24HourFormat(context)) "HH:mm" else "h:mm", sizeDp)
+
+/** One `TextClock` showing [pattern] whatever the 12/24-hour setting — the caller has already picked by it. */
+private fun RemoteViews.setClock(viewId: Int, pattern: String, sizeDp: Float) {
+ setCharSequence(viewId, "setFormat12Hour", pattern)
+ setCharSequence(viewId, "setFormat24Hour", pattern)
+ setTextViewTextSize(viewId, TypedValue.COMPLEX_UNIT_DIP, sizeDp)
+}
+
+/**
+ * A date-only `TextClock` — no time tokens — so it reads as a date line but
+ * still re-renders itself at midnight with no app-side ticking.
+ */
+private fun RemoteViews.setDate(context: Context, viewId: Int, show: Boolean, sizeDp: Float, skeleton: String) {
+ if (!show) {
+ setViewVisibility(viewId, View.GONE)
+ return
+ }
+ setClock(viewId, DateFormat.getBestDateTimePattern(currentLocale(context), skeleton), sizeDp)
+}
+
+private fun pxOf(context: Context): (Float) -> Int {
+ val density = context.resources.displayMetrics.density
+ return { dp -> (dp * density).toInt() }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/NextAlarmWidgetContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/NextAlarmWidgetContent.kt
index dde3dc7..bca4546 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/NextAlarmWidgetContent.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/NextAlarmWidgetContent.kt
@@ -4,22 +4,21 @@ import android.content.Context
import android.content.Intent
import android.text.format.DateFormat
import androidx.compose.runtime.Composable
+import androidx.compose.ui.unit.dp
import androidx.glance.GlanceModifier
-import androidx.glance.LocalContext
-import androidx.glance.text.Text
import androidx.glance.GlanceTheme
-import androidx.glance.action.clickable
+import androidx.glance.LocalContext
+import androidx.glance.LocalSize
import androidx.glance.appwidget.action.actionStartActivity
-import androidx.glance.appwidget.cornerRadius
-import androidx.glance.background
import androidx.glance.layout.Alignment
-import androidx.glance.layout.Box
import androidx.glance.layout.Column
-import androidx.glance.layout.fillMaxSize
+import androidx.glance.layout.Row
+import androidx.glance.layout.Spacer
import androidx.glance.layout.padding
+import androidx.glance.layout.width
import androidx.glance.text.FontWeight
+import androidx.glance.text.Text
import androidx.glance.text.TextStyle
-import androidx.glance.unit.ColorProvider
import de.jeanlucmakiola.clockula.MainActivity
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
@@ -29,32 +28,97 @@ import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
+import de.jeanlucmakiola.clockula.widget.di.WidgetEntryPoint
+import de.jeanlucmakiola.clockula.domain.widget.NextAlarmWidgetPresenter
+import de.jeanlucmakiola.clockula.domain.alarm.UpcomingAlarm
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.collectAsState
-/** The next-alarm widget's content, given an already-resolved [model]. */
+/**
+ * Reads the upcoming alarms reactively, then renders [NextAlarmWidgetContent].
+ * Collected *inside* the composition: `update()` on a still-live session (one
+ * lives ~45s) only recomposes, so a value read once before `provideContent`
+ * would keep showing what was true when the session started — the
+ * `initial` value only makes the first frame right.
+ */
+@Composable
+fun NextAlarmWidgetRoot(entryPoint: WidgetEntryPoint, initial: List) {
+ val upcoming by remember { entryPoint.alarmEngine().upcoming() }.collectAsState(initial = initial)
+ val model = remember(upcoming) {
+ NextAlarmWidgetPresenter.present(upcoming = upcoming, now = entryPoint.wallClock().now(), zone = entryPoint.zoneProvider().current())
+ }
+ NextAlarmWidgetContent(model)
+}
+
+/**
+ * The next-alarm widget's content, given an already-resolved [model]: a
+ * Cookie badge carrying the alarm glyph, then the day, the time — the
+ * largest thing on it — and the label, all sized from the widget's live size.
+ */
@Composable
fun NextAlarmWidgetContent(model: NextAlarmWidgetModel) {
val context = LocalContext.current
- GlanceTheme(WidgetDefaults.Colors) {
- Box(
- modifier = GlanceModifier
- .fillMaxSize()
- .background(GlanceTheme.colors.widgetBackground)
- .cornerRadius(WidgetDefaults.CornerRadius)
- .padding(WidgetDefaults.ContentPadding)
- .clickable(actionStartActivity(showAlarmsIntent(context))),
- contentAlignment = Alignment.CenterStart,
- ) {
+ val size = LocalSize.current
+ val w = size.width.value
+ val h = size.height.value
+ val pad = (minOf(w, h) * 0.14f).coerceIn(10f, 22f)
+ val innerH = h - 2 * pad
+ val innerW = w - 2 * pad
+ // The badge takes the card's height, but never more than a third of
+ // its width, so a narrow widget keeps room for the time.
+ val badge = minOf(innerH, innerW * 0.34f).coerceAtMost(96f)
+ val gap = badge * 0.22f
+ val textW = innerW - badge - gap
+
+ WidgetCard(onClick = actionStartActivity(showAlarmsIntent(context)), contentAlignment = Alignment.CenterStart) {
+ Row(modifier = GlanceModifier.padding(pad.dp), verticalAlignment = Alignment.CenterVertically) {
+ ShapedIcon(
+ icon = if (model.hasAlarm) R.drawable.widget_ic_alarm else R.drawable.widget_ic_alarm_off,
+ contentDescription = null,
+ width = badge.dp,
+ height = badge.dp,
+ container = if (model.hasAlarm) GlanceTheme.colors.primaryContainer else GlanceTheme.colors.secondaryContainer,
+ content = if (model.hasAlarm) GlanceTheme.colors.onPrimaryContainer else GlanceTheme.colors.onSecondaryContainer,
+ shape = R.drawable.widget_shape_cookie,
+ iconFraction = 0.48f,
+ )
+ Spacer(GlanceModifier.width(gap.dp))
if (!model.hasAlarm) {
Text(
text = context.getString(R.string.widget_next_alarm_empty),
- style = bodyStyle(GlanceTheme.colors.onSurfaceVariant),
+ style = TextStyle(
+ color = GlanceTheme.colors.onSurfaceVariant,
+ fontSize = fittedSp(context, minOf(innerH * 0.26f, textW / 7f).coerceIn(11f, 20f)),
+ fontWeight = FontWeight.Medium,
+ ),
+ maxLines = 2,
)
} else {
+ val time = timeLabel(context, model)
+ val hasLabel = model.label.isNotBlank()
+ // Caption + time (+ label) stacked in the badge's height;
+ // the time's width is about 0.62em per character.
+ val lines = if (hasLabel) 2.1f else 1.65f
+ val timeSize = minOf(innerH / lines, textW / (time.length * 0.62f)).coerceAtLeast(14f)
+ val caption = (timeSize * 0.36f).coerceIn(11f, 18f)
Column {
- Text(dayLabel(context, model), style = captionStyle(GlanceTheme.colors.onSurfaceVariant))
- Text(timeLabel(context, model), style = timeStyle(GlanceTheme.colors.onSurface))
- if (model.label.isNotBlank()) {
- Text(model.label, style = bodyStyle(GlanceTheme.colors.onSurfaceVariant))
+ Text(
+ text = dayLabel(context, model),
+ style = TextStyle(color = GlanceTheme.colors.primary, fontSize = fittedSp(context, caption), fontWeight = FontWeight.Medium),
+ maxLines = 1,
+ )
+ Text(
+ text = time,
+ style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = fittedSp(context, timeSize), fontWeight = FontWeight.Medium),
+ maxLines = 1,
+ )
+ if (hasLabel) {
+ Text(
+ text = model.label,
+ style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = fittedSp(context, caption)),
+ maxLines = 1,
+ )
}
}
}
@@ -82,10 +146,3 @@ private fun dayLabel(context: Context, model: NextAlarmWidgetModel): String = wh
model.dayOfWeek != null -> model.dayOfWeek.getDisplayName(JavaTextStyle.FULL, Locale.getDefault())
else -> ""
}
-
-private fun timeStyle(color: ColorProvider) =
- TextStyle(color = color, fontSize = WidgetDefaults.PrimaryTextSize, fontWeight = FontWeight.Medium)
-
-private fun bodyStyle(color: ColorProvider) = TextStyle(color = color, fontSize = WidgetDefaults.BodyTextSize)
-
-private fun captionStyle(color: ColorProvider) = TextStyle(color = color, fontSize = WidgetDefaults.CaptionTextSize)
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/QuickTimerWidgetContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/QuickTimerWidgetContent.kt
new file mode 100644
index 0000000..e59130f
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/QuickTimerWidgetContent.kt
@@ -0,0 +1,174 @@
+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.remember
+import androidx.compose.ui.unit.dp
+import androidx.datastore.preferences.core.Preferences
+import androidx.glance.ColorFilter
+import androidx.glance.GlanceModifier
+import androidx.glance.GlanceTheme
+import androidx.glance.Image
+import androidx.glance.ImageProvider
+import androidx.glance.LocalContext
+import androidx.glance.LocalGlanceId
+import androidx.glance.LocalSize
+import androidx.glance.appwidget.GlanceAppWidgetManager
+import androidx.glance.appwidget.action.actionSendBroadcast
+import androidx.glance.background
+import androidx.glance.currentState
+import androidx.glance.layout.Alignment
+import androidx.glance.layout.Column
+import androidx.glance.layout.fillMaxSize
+import androidx.glance.layout.size
+import androidx.glance.semantics.contentDescription
+import androidx.glance.semantics.semantics
+import de.jeanlucmakiola.clockula.R
+import de.jeanlucmakiola.clockula.domain.Timer
+import de.jeanlucmakiola.clockula.domain.format.ClockFormat
+import de.jeanlucmakiola.clockula.domain.timer.TimerMode
+import de.jeanlucmakiola.clockula.domain.widget.QuickTimerWidgetModel
+import de.jeanlucmakiola.clockula.domain.widget.QuickTimerWidgetPresenter
+import de.jeanlucmakiola.clockula.domain.widget.TimerWidgetItem
+import de.jeanlucmakiola.clockula.domain.widget.parseQuickTimerDuration
+import de.jeanlucmakiola.clockula.timer.QuickTimerIntents
+import de.jeanlucmakiola.clockula.timer.TimerIntents
+import de.jeanlucmakiola.clockula.timer.receiver.QuickTimerReceiver
+import de.jeanlucmakiola.clockula.widget.WIDGET_QUICK_TIMER_DURATION_KEY
+import de.jeanlucmakiola.clockula.widget.WIDGET_QUICK_TIMER_ID_KEY
+import de.jeanlucmakiola.clockula.widget.di.WidgetEntryPoint
+
+/**
+ * Reads this instance's duration and started-timer id, and the timers
+ * themselves, reactively, then renders [QuickTimerWidgetContent]. Collected
+ * inside the composition for the reason `TimerWidgetRoot` gives.
+ */
+@Composable
+fun QuickTimerWidgetRoot(entryPoint: WidgetEntryPoint, initial: List) {
+ val prefs = currentState()
+ val duration = parseQuickTimerDuration(prefs[WIDGET_QUICK_TIMER_DURATION_KEY])
+ val timerId = prefs[WIDGET_QUICK_TIMER_ID_KEY]
+ val timers by remember { entryPoint.timerRepository().timers() }.collectAsState(initial = initial)
+ val elapsedRealtime = remember(timers, timerId) { entryPoint.elapsedRealtimeClock().elapsedRealtime() }
+ val model = remember(duration, timerId, timers) {
+ QuickTimerWidgetPresenter.present(duration, timerId, timers, elapsedRealtime, entryPoint.wallClock().now())
+ }
+ QuickTimerWidgetContent(model, elapsedRealtime.inWholeMilliseconds)
+}
+
+/**
+ * The quick-timer widget: a Scallop floating on the home screen, like the
+ * analog faces, that is a timer of its own. At rest it is a play glyph over
+ * its duration, and tapping it starts a transient timer of that length
+ * (`timer/receiver/QuickTimerReceiver.kt`). While that timer is active the
+ * Scallop shows its countdown in the same layout, and tapping it again
+ * resets (or, once it rings, stops) it — which deletes the timer, so the
+ * widget is at rest again. [elapsedRealtimeNow] is the
+ * `SystemClock.elapsedRealtime()` millis the model was resolved at, the
+ * countdown chronometer's time base.
+ */
+@Composable
+fun QuickTimerWidgetContent(model: QuickTimerWidgetModel, elapsedRealtimeNow: Long) {
+ val context = LocalContext.current
+ val size = LocalSize.current
+ val side = minOf(size.width, size.height)
+ val s = side.value
+ val active = model.active
+ val onClick = if (active == null) {
+ val appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(LocalGlanceId.current)
+ actionSendBroadcast(startIntent(context, model, appWidgetId))
+ } else {
+ // Reset is a transient timer's dismissal: it stops (or stops ringing)
+ // and is deleted, so the widget is at rest again.
+ actionSendBroadcast(timerActionIntent(context, TimerIntents.ACTION_RESET, active.id))
+ }
+
+ CenteredSquare(side = side, onClick = onClick) {
+ val shape = GlanceModifier
+ .fillMaxSize()
+ .background(ImageProvider(R.drawable.widget_shape_scallop), colorFilter = ColorFilter.tint(GlanceTheme.colors.primaryContainer))
+ if (active == null) {
+ AtRest(context, model, s, shape)
+ } else {
+ Running(context, active, elapsedRealtimeNow, s, shape)
+ }
+ }
+}
+
+/** At rest: a play glyph over the duration it starts. */
+@Composable
+private fun AtRest(context: Context, model: QuickTimerWidgetModel, s: Float, shape: GlanceModifier) {
+ val label = ClockFormat.countdown(model.duration)
+ GlyphOverTime(
+ icon = R.drawable.widget_ic_play,
+ description = context.getString(R.string.widget_quick_timer_start, label),
+ s = s,
+ shape = shape,
+ ) { size -> WidgetReadout(label, size, R.color.widget_on_primary_container) }
+}
+
+/**
+ * Its timer active: the same layout as [AtRest] — the glyph says what a tap
+ * does now (reset, or stop once it rings), over the live countdown.
+ */
+@Composable
+private fun Running(context: Context, item: TimerWidgetItem, elapsedRealtimeNow: Long, s: Float, shape: GlanceModifier) {
+ val expired = item.mode == TimerMode.EXPIRED
+ GlyphOverTime(
+ icon = if (expired) R.drawable.widget_ic_stop else R.drawable.widget_ic_replay,
+ description = context.getString(if (expired) R.string.timer_stop else R.string.timer_reset),
+ s = s,
+ shape = shape,
+ ) { size ->
+ if (item.mode == TimerMode.RUNNING) {
+ WidgetChronometer(
+ layoutRes = R.layout.widget_chronometer_on_primary,
+ viewId = R.id.widget_chronometer_on_primary,
+ base = elapsedRealtimeNow + item.remaining.inWholeMilliseconds,
+ countDown = true,
+ textSizeDp = size,
+ )
+ } else {
+ val text = if (expired) context.getString(R.string.widget_timer_expired) else ClockFormat.countdown(item.remaining)
+ // Shrunk to the same width should the word be longer than a time.
+ WidgetReadout(text, size * minOf(1f, 5f / text.length), R.color.widget_on_primary_container)
+ }
+ }
+}
+
+/**
+ * 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%.
+ */
+@Composable
+private fun GlyphOverTime(
+ icon: Int,
+ description: String,
+ s: Float,
+ shape: GlanceModifier,
+ readout: @Composable (sizeDp: Float) -> Unit,
+) {
+ Column(
+ modifier = shape.semantics { contentDescription = description },
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Image(
+ provider = ImageProvider(icon),
+ contentDescription = null,
+ modifier = GlanceModifier.size((s * 0.26f).dp),
+ colorFilter = ColorFilter.tint(GlanceTheme.colors.onPrimaryContainer),
+ )
+ readout(minOf(s * 0.2f, s * 0.6f / (5 * 0.6f)))
+ }
+}
+
+private fun startIntent(context: Context, model: QuickTimerWidgetModel, appWidgetId: Int): Intent =
+ Intent(context, QuickTimerReceiver::class.java)
+ .setAction(QuickTimerIntents.ACTION_START)
+ .putExtra(QuickTimerIntents.EXTRA_DURATION_MILLIS, model.duration.inWholeMilliseconds)
+ .putExtra(QuickTimerIntents.EXTRA_APP_WIDGET_ID, appWidgetId)
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/StopwatchWidgetContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/StopwatchWidgetContent.kt
new file mode 100644
index 0000000..fd80ab0
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/StopwatchWidgetContent.kt
@@ -0,0 +1,188 @@
+package de.jeanlucmakiola.clockula.ui.widget
+
+import android.content.Context
+import android.content.Intent
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.unit.dp
+import androidx.glance.GlanceModifier
+import androidx.glance.GlanceTheme
+import androidx.glance.LocalContext
+import androidx.glance.LocalSize
+import androidx.glance.appwidget.action.actionSendBroadcast
+import androidx.glance.appwidget.action.actionStartActivity
+import androidx.glance.layout.Alignment
+import androidx.glance.layout.Column
+import androidx.glance.layout.Row
+import androidx.glance.layout.Spacer
+import androidx.glance.layout.fillMaxSize
+import androidx.glance.layout.height
+import androidx.glance.layout.padding
+import androidx.glance.layout.width
+import androidx.glance.text.FontWeight
+import androidx.glance.text.Text
+import androidx.glance.text.TextStyle
+import de.jeanlucmakiola.clockula.MainActivity
+import de.jeanlucmakiola.clockula.R
+import de.jeanlucmakiola.clockula.domain.StopwatchState
+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
+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].
+ * Collected *inside* the composition: `update()` on a still-live session (one
+ * lives ~45s) only recomposes, so a value read once before `provideContent`
+ * would keep showing what was true when the session started — the
+ * `initial` value only makes the first frame right.
+ */
+@Composable
+fun StopwatchWidgetRoot(entryPoint: WidgetEntryPoint, initialRun: StopwatchRun, initialLapCount: Int) {
+ val repository = entryPoint.stopwatchRepository()
+ val run by remember { repository.run() }.collectAsState(initial = initialRun)
+ 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)
+}
+
+/**
+ * The stopwatch widget's content, given an already-resolved [model]: the
+ * readout as large as the widget allows, a wide pill for start/pause and a
+ * round lap/reset beside it. Below [WIDE_BELOW_HEIGHT] the controls move
+ * beside the readout instead of under it. No 1s ticker — a running readout
+ * is the platform chronometer, based at [elapsedRealtimeNow] (the
+ * `SystemClock.elapsedRealtime()` millis the model was resolved at).
+ */
+@Composable
+fun StopwatchWidgetContent(model: StopwatchWidgetModel, elapsedRealtimeNow: Long) {
+ val context = LocalContext.current
+ val size = LocalSize.current
+ val w = size.width.value
+ val h = size.height.value
+ val pad = (minOf(w, h) * 0.1f).coerceIn(10f, 18f)
+ val innerW = w - 2 * pad
+ val innerH = h - 2 * pad
+ // The chronometer reads "00:00" (or "H:MM:SS"), never shorter.
+ val readoutChars = maxOf(5, ClockFormat.elapsed(model.elapsed).length)
+
+ WidgetCard(onClick = actionStartActivity(showStopwatchIntent(context))) {
+ if (h < WIDE_BELOW_HEIGHT) {
+ val button = (innerH * 0.7f).coerceIn(32f, 60f)
+ val controlsW = button * 1.6f + button * 1.18f
+ val timeSize = minOf(innerH * 0.62f, (innerW - controlsW) / (readoutChars * 0.6f)).coerceAtLeast(14f)
+ Row(modifier = GlanceModifier.fillMaxSize().padding(pad.dp), verticalAlignment = Alignment.CenterVertically) {
+ Column(modifier = GlanceModifier.defaultWeight()) {
+ Readout(context, model, elapsedRealtimeNow, timeSize)
+ }
+ Controls(context, model, button)
+ }
+ } else {
+ val button = (innerH * 0.3f).coerceIn(32f, 64f)
+ val gap = button * 0.3f
+ val caption = (button * 0.3f).coerceIn(11f, 16f)
+ // A Glance `Text` line, font padding included, is ~1.5x its size;
+ // the readout (no padding) ~1.2x. A little slack keeps rounding
+ // from clipping the buttons.
+ val captionH = if (model.lapCount > 0) caption * 1.5f else 0f
+ val timeSize = minOf((innerH * 0.94f - button - gap - captionH) / 1.2f, innerW / (readoutChars * 0.6f)).coerceAtLeast(14f)
+ Column(
+ modifier = GlanceModifier.fillMaxSize().padding(pad.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Readout(context, model, elapsedRealtimeNow, timeSize)
+ if (model.lapCount > 0) {
+ Text(
+ text = context.getString(R.string.widget_stopwatch_laps, model.lapCount),
+ style = TextStyle(color = GlanceTheme.colors.primary, fontSize = fittedSp(context, caption), fontWeight = FontWeight.Medium),
+ maxLines = 1,
+ )
+ }
+ Spacer(GlanceModifier.height(gap.dp))
+ Controls(context, model, button)
+ }
+ }
+ }
+}
+
+/** Under this height (one launcher row, give or take) the controls sit beside the readout. */
+private const val WIDE_BELOW_HEIGHT = 120f
+
+@Composable
+private fun Readout(context: Context, model: StopwatchWidgetModel, elapsedRealtimeNow: Long, sizeDp: Float) {
+ if (model.mode == StopwatchState.RUNNING) {
+ WidgetChronometer(
+ layoutRes = R.layout.widget_chronometer,
+ viewId = R.id.widget_chronometer,
+ base = elapsedRealtimeNow - model.elapsed.inWholeMilliseconds,
+ countDown = false,
+ textSizeDp = sizeDp,
+ )
+ } else {
+ WidgetReadout(
+ text = ClockFormat.elapsed(model.elapsed),
+ textSizeDp = sizeDp,
+ colorRes = if (model.mode == StopwatchState.IDLE) R.color.widget_on_surface_variant else R.color.widget_on_surface,
+ )
+ }
+}
+
+/** A wide primary pill (start/pause) and, once the stopwatch has run, a round lap/reset beside it. */
+@Composable
+private fun Controls(context: Context, model: StopwatchWidgetModel, button: Float) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ val running = model.mode == StopwatchState.RUNNING
+ ShapedIcon(
+ icon = if (running) R.drawable.widget_ic_pause else R.drawable.widget_ic_play,
+ contentDescription = context.getString(
+ when (model.mode) {
+ StopwatchState.IDLE -> R.string.stopwatch_start
+ StopwatchState.RUNNING -> R.string.stopwatch_pause
+ StopwatchState.PAUSED -> R.string.stopwatch_resume
+ },
+ ),
+ width = (button * 1.6f).dp,
+ height = button.dp,
+ container = GlanceTheme.colors.primary,
+ content = GlanceTheme.colors.onPrimary,
+ shape = R.drawable.widget_bg_pill,
+ onClick = actionSendBroadcast(
+ actionIntent(context, if (running) StopwatchIntents.ACTION_PAUSE else StopwatchIntents.ACTION_RESUME),
+ ),
+ )
+ if (model.mode != StopwatchState.IDLE) {
+ Spacer(GlanceModifier.width((button * 0.18f).dp))
+ ShapedIcon(
+ icon = if (running) R.drawable.widget_ic_flag else R.drawable.widget_ic_replay,
+ contentDescription = context.getString(if (running) R.string.stopwatch_lap else R.string.stopwatch_reset),
+ width = button.dp,
+ height = button.dp,
+ container = GlanceTheme.colors.secondaryContainer,
+ content = GlanceTheme.colors.onSecondaryContainer,
+ onClick = actionSendBroadcast(
+ actionIntent(context, if (running) StopwatchIntents.ACTION_LAP else StopwatchIntents.ACTION_RESET),
+ ),
+ )
+ }
+ }
+}
+
+private fun actionIntent(context: Context, action: String): Intent =
+ Intent(context, StopwatchActionReceiver::class.java).setAction(action)
+
+private fun showStopwatchIntent(context: Context): Intent =
+ Intent(context, MainActivity::class.java)
+ .setAction(StopwatchIntents.ACTION_SHOW_STOPWATCH)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/TimerWidgetContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/TimerWidgetContent.kt
index 15be7ec..b8f70fe 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/TimerWidgetContent.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/TimerWidgetContent.kt
@@ -2,31 +2,27 @@ package de.jeanlucmakiola.clockula.ui.widget
import android.content.Context
import android.content.Intent
-import android.widget.RemoteViews
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.LocalContext
-import androidx.glance.text.Text
-import androidx.glance.action.clickable
-import androidx.glance.appwidget.AndroidRemoteViews
+import androidx.glance.LocalSize
import androidx.glance.appwidget.action.actionSendBroadcast
import androidx.glance.appwidget.action.actionStartActivity
-import androidx.glance.appwidget.cornerRadius
-import androidx.glance.background
import androidx.glance.layout.Alignment
-import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.Row
+import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth
-import androidx.glance.layout.padding
-import androidx.glance.layout.Spacer
import androidx.glance.layout.height
+import androidx.glance.layout.padding
+import androidx.glance.layout.width
import androidx.glance.text.FontWeight
+import androidx.glance.text.Text
import androidx.glance.text.TextStyle
-import androidx.glance.unit.ColorProvider
+import de.jeanlucmakiola.clockula.MainActivity
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.format.ClockFormat
import de.jeanlucmakiola.clockula.domain.timer.TimerMode
@@ -35,107 +31,234 @@ import de.jeanlucmakiola.clockula.domain.widget.TimerWidgetModel
import de.jeanlucmakiola.clockula.interop.InteropIntents
import de.jeanlucmakiola.clockula.timer.TimerIntents
import de.jeanlucmakiola.clockula.timer.receiver.TimerActionReceiver
-import de.jeanlucmakiola.clockula.MainActivity
+import de.jeanlucmakiola.clockula.widget.di.WidgetEntryPoint
+import de.jeanlucmakiola.clockula.domain.widget.TimerWidgetPresenter
+import de.jeanlucmakiola.clockula.domain.Timer
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.collectAsState
-/** The timer widget's content, given an already-resolved [model]. No 1s ticker — a running row's countdown is the platform chronometer (as `TimerNotifications.kt`). */
+/**
+ * Reads the timers reactively, then renders [TimerWidgetContent].
+ * Collected *inside* the composition: `update()` on a still-live session (one
+ * lives ~45s) only recomposes, so a value read once before `provideContent`
+ * would keep showing what was true when the session started — the
+ * `initial` value only makes the first frame right.
+ */
@Composable
-fun TimerWidgetContent(model: TimerWidgetModel) {
+fun TimerWidgetRoot(entryPoint: WidgetEntryPoint, initial: List) {
+ val timers by remember { entryPoint.timerRepository().timers() }.collectAsState(initial = initial)
+ val elapsedRealtime = remember(timers) { entryPoint.elapsedRealtimeClock().elapsedRealtime() }
+ val model = remember(timers) {
+ TimerWidgetPresenter.present(timers = timers, elapsedRealtime = elapsedRealtime, wallClock = entryPoint.wallClock().now())
+ }
+ TimerWidgetContent(model, elapsedRealtime.inWholeMilliseconds)
+}
+
+/** Below this a row's readout and buttons get too small to read or hit, so fewer rows show. */
+private const val MIN_ROW_HEIGHT = 56f
+
+/**
+ * The timer widget's content, given an already-resolved [model]. No 1s
+ * ticker — a running row's countdown is the platform chronometer (as
+ * `TimerNotifications.kt`), based at [elapsedRealtimeNow], the
+ * `SystemClock.elapsedRealtime()` millis the model was resolved at.
+ */
+@Composable
+fun TimerWidgetContent(model: TimerWidgetModel, elapsedRealtimeNow: Long) {
val context = LocalContext.current
- GlanceTheme(WidgetDefaults.Colors) {
- Box(
- modifier = GlanceModifier
- .fillMaxSize()
- .background(GlanceTheme.colors.widgetBackground)
- .cornerRadius(WidgetDefaults.CornerRadius)
- .padding(WidgetDefaults.ContentPadding)
- .clickable(actionStartActivity(showTimersOrComposeIntent(context, model))),
- ) {
- if (model.items.isEmpty()) {
- Box(modifier = GlanceModifier.fillMaxSize(), contentAlignment = Alignment.Center) {
- Text(
- text = context.getString(R.string.widget_timer_new),
- style = TextStyle(color = GlanceTheme.colors.onSurface, fontWeight = FontWeight.Medium),
- )
- }
- } else {
- Column(modifier = GlanceModifier.fillMaxSize()) {
- model.items.forEachIndexed { index, item ->
- if (index > 0) Spacer(modifier = GlanceModifier.height(WidgetDefaults.ContentPadding))
- TimerRow(context, item)
- }
+ val size = LocalSize.current
+ val w = size.width.value
+ val h = size.height.value
+ val pad = (minOf(w, h) * 0.1f).coerceIn(10f, 18f)
+ val innerW = w - 2 * pad
+ val innerH = h - 2 * pad
+
+ WidgetCard(onClick = actionStartActivity(showTimersOrComposeIntent(context, model))) {
+ if (model.items.isEmpty()) {
+ NewTimer(context, innerW, innerH)
+ } else if (model.items.size == 1 && h >= STACK_FROM_HEIGHT) {
+ SingleTimer(context, model.items.single(), elapsedRealtimeNow, innerW, innerH, pad)
+ } else {
+ val visible = model.items.take((innerH / MIN_ROW_HEIGHT).toInt().coerceAtLeast(1))
+ val rowGap = pad * 0.5f
+ val rowH = (innerH - rowGap * (visible.size - 1)) / visible.size
+ Column(modifier = GlanceModifier.fillMaxSize().padding(pad.dp), verticalAlignment = Alignment.CenterVertically) {
+ visible.forEachIndexed { index, item ->
+ if (index > 0) Spacer(GlanceModifier.height(rowGap.dp))
+ TimerRow(context, item, elapsedRealtimeNow, innerW, rowH)
}
}
}
}
}
+/** From this height a lone timer gets the stopwatch's layout — a big readout over its buttons — instead of a row. */
+private const val STACK_FROM_HEIGHT = 120f
+
+/** One timer on a tall card: label, a readout as large as fits, and its buttons under it. */
@Composable
-private fun TimerRow(context: Context, item: TimerWidgetItem) {
+private fun SingleTimer(context: Context, item: TimerWidgetItem, elapsedRealtimeNow: Long, innerW: Float, innerH: Float, pad: Float) {
+ val button = (innerH * 0.3f).coerceIn(32f, 64f)
+ val gap = button * 0.3f
+ val hasLabel = item.label.isNotBlank()
+ val label = (button * 0.3f).coerceIn(11f, 16f)
+ val labelH = if (hasLabel) label * 1.5f else 0f
+ val readout = staticReadout(context, item)
+ val chars = if (item.mode == TimerMode.RUNNING) maxOf(5, readout.length) else readout.length
+ val timeSize = minOf((innerH * 0.94f - button - gap - labelH) / 1.2f, innerW / (chars * 0.6f)).coerceAtLeast(14f)
+ Column(
+ modifier = GlanceModifier.fillMaxSize().padding(pad.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (hasLabel) {
+ Text(
+ text = item.label,
+ style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = fittedSp(context, label), fontWeight = FontWeight.Medium),
+ maxLines = 1,
+ )
+ }
+ Readout(context, item, elapsedRealtimeNow, timeSize)
+ Spacer(GlanceModifier.height(gap.dp))
+ Row(verticalAlignment = Alignment.CenterVertically) { Buttons(context, item, button) }
+ }
+}
+
+/** The idle state: a Cookie "+" that, like the whole card, opens a new timer. */
+@Composable
+private fun NewTimer(context: Context, innerW: Float, innerH: Float) {
+ val wide = innerW >= innerH * 1.6f
+ val badge = (if (wide) innerH else minOf(innerW, innerH) * 0.62f).coerceAtMost(96f)
+ val text = context.getString(R.string.widget_timer_new)
+ val textStyle = TextStyle(
+ color = GlanceTheme.colors.onSurface,
+ fontSize = fittedSp(context, (badge * 0.26f).coerceIn(12f, 22f)),
+ fontWeight = FontWeight.Medium,
+ )
+ val icon = @Composable {
+ ShapedIcon(
+ icon = R.drawable.widget_ic_add,
+ contentDescription = null,
+ width = badge.dp,
+ height = badge.dp,
+ container = GlanceTheme.colors.primaryContainer,
+ content = GlanceTheme.colors.onPrimaryContainer,
+ shape = R.drawable.widget_shape_cookie,
+ )
+ }
+ if (wide) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ icon()
+ Spacer(GlanceModifier.width((badge * 0.22f).dp))
+ Text(text, style = textStyle, maxLines = 1)
+ }
+ } else {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ icon()
+ Spacer(GlanceModifier.height((badge * 0.12f).dp))
+ Text(text, style = textStyle, maxLines = 1)
+ }
+ }
+}
+
+@Composable
+private fun TimerRow(context: Context, item: TimerWidgetItem, elapsedRealtimeNow: Long, rowW: Float, rowH: Float) {
+ val button = minOf(rowH * 0.8f, rowW * 0.2f, 60f).coerceAtLeast(28f)
+ val buttonGap = button * 0.18f
+ val buttons = if (item.mode == TimerMode.EXPIRED) 1 else 2
+ val textW = rowW - buttons * button - (buttons - 1) * buttonGap - button * 0.25f
+ val hasLabel = item.label.isNotBlank()
+ val readout = staticReadout(context, item)
+ // A running countdown's chronometer reads "01:02", never shorter.
+ val chars = if (item.mode == TimerMode.RUNNING) maxOf(5, readout.length) else readout.length
+ val timeSize = minOf(rowH * (if (hasLabel) 0.5f else 0.62f), textW / (chars * 0.6f)).coerceAtLeast(14f)
+ val labelSize = (timeSize * 0.42f).coerceIn(10f, 16f)
+
Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(modifier = GlanceModifier.defaultWeight()) {
- if (item.label.isNotBlank()) {
- Text(item.label, style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = WidgetDefaults.BodyTextSize))
+ if (hasLabel) {
+ Text(
+ text = item.label,
+ style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = fittedSp(context, labelSize), fontWeight = FontWeight.Medium),
+ maxLines = 1,
+ )
}
- TimerReadout(context, item)
+ Readout(context, item, elapsedRealtimeNow, timeSize)
}
- TimerActions(context, item)
+ Spacer(GlanceModifier.width((button * 0.25f).dp))
+ Buttons(context, item, button)
}
}
@Composable
-private fun TimerReadout(context: Context, item: TimerWidgetItem) {
- val endsAt = item.endsAtWallClock
- if (item.mode == TimerMode.RUNNING && endsAt != null) {
- val views = RemoteViews(context.packageName, R.layout.widget_chronometer).apply {
- setChronometer(R.id.widget_chronometer, endsAt.toEpochMilliseconds(), null, true)
- setChronometerCountDown(R.id.widget_chronometer, true)
- }
- AndroidRemoteViews(remoteViews = views)
+private fun Readout(context: Context, item: TimerWidgetItem, elapsedRealtimeNow: Long, timeSize: Float) {
+ if (item.mode == TimerMode.RUNNING) {
+ WidgetChronometer(
+ layoutRes = R.layout.widget_chronometer,
+ viewId = R.id.widget_chronometer,
+ base = elapsedRealtimeNow + item.remaining.inWholeMilliseconds,
+ countDown = true,
+ textSizeDp = timeSize,
+ )
} else {
- Text(text = staticReadout(context, item), style = timeStyle(GlanceTheme.colors.onSurface))
+ WidgetReadout(
+ text = staticReadout(context, item),
+ textSizeDp = timeSize,
+ colorRes = if (item.mode == TimerMode.EXPIRED) R.color.widget_tertiary else R.color.widget_on_surface,
+ )
}
}
+/** The primary action (pause / play / stop) and, unless expired, reset. Laid out by the caller's `Row`. */
+@Composable
+private fun Buttons(context: Context, item: TimerWidgetItem, button: Float) {
+ val buttonGap = button * 0.18f
+ when (item.mode) {
+ TimerMode.RUNNING -> PrimaryButton(R.drawable.widget_ic_pause, context.getString(R.string.timer_pause), pauseIntent(context, item.id), button, R.drawable.widget_bg_circle)
+ // Expressive shape change: a paused timer's play button is a
+ // rounded square, a running one's pause button a circle.
+ TimerMode.PAUSED -> PrimaryButton(R.drawable.widget_ic_play, context.getString(R.string.timer_resume), resumeIntent(context, item.id), button, R.drawable.widget_shape_square)
+ else -> PrimaryButton(R.drawable.widget_ic_stop, context.getString(R.string.timer_stop), resetIntent(context, item.id), button, R.drawable.widget_shape_square)
+ }
+ if (item.mode != TimerMode.EXPIRED) {
+ Spacer(GlanceModifier.width(buttonGap.dp))
+ ShapedIcon(
+ icon = R.drawable.widget_ic_replay,
+ contentDescription = context.getString(R.string.timer_reset),
+ width = button.dp,
+ height = button.dp,
+ container = GlanceTheme.colors.secondaryContainer,
+ content = GlanceTheme.colors.onSecondaryContainer,
+ onClick = actionSendBroadcast(resetIntent(context, item.id)),
+ )
+ }
+}
+
+@Composable
+private fun PrimaryButton(icon: Int, description: String, intent: Intent, size: Float, shape: Int) {
+ ShapedIcon(
+ icon = icon,
+ contentDescription = description,
+ width = size.dp,
+ height = size.dp,
+ container = GlanceTheme.colors.primary,
+ content = GlanceTheme.colors.onPrimary,
+ shape = shape,
+ onClick = actionSendBroadcast(intent),
+ )
+}
+
private fun staticReadout(context: Context, item: TimerWidgetItem): String = when (item.mode) {
TimerMode.EXPIRED -> context.getString(R.string.widget_timer_expired)
else -> ClockFormat.countdown(item.remaining)
}
-@Composable
-private fun TimerActions(context: Context, item: TimerWidgetItem) {
- Row {
- when (item.mode) {
- TimerMode.RUNNING -> ActionButton(context.getString(R.string.timer_pause), pauseIntent(context, item.id))
- TimerMode.PAUSED -> ActionButton(context.getString(R.string.timer_resume), resumeIntent(context, item.id))
- else -> Unit
- }
- ActionButton(
- text = if (item.mode == TimerMode.EXPIRED) {
- context.getString(R.string.timer_stop)
- } else {
- context.getString(R.string.timer_reset)
- },
- intent = resetIntent(context, item.id),
- )
- }
-}
-
-@Composable
-private fun ActionButton(text: String, intent: Intent) {
- Text(
- text = text,
- style = TextStyle(color = GlanceTheme.colors.primary, fontSize = WidgetDefaults.BodyTextSize),
- modifier = GlanceModifier
- .padding(horizontal = 8.dp, vertical = 4.dp)
- .clickable(actionSendBroadcast(intent)),
- )
-}
-
private fun pauseIntent(context: Context, timerId: Long) = timerActionIntent(context, TimerIntents.ACTION_PAUSE, timerId)
private fun resumeIntent(context: Context, timerId: Long) = timerActionIntent(context, TimerIntents.ACTION_RESUME, timerId)
private fun resetIntent(context: Context, timerId: Long) = timerActionIntent(context, TimerIntents.ACTION_RESET, timerId)
-private fun timerActionIntent(context: Context, action: String, timerId: Long): Intent =
+internal fun timerActionIntent(context: Context, action: String, timerId: Long): Intent =
Intent(context, TimerActionReceiver::class.java)
.setAction(action)
.putExtra(TimerIntents.EXTRA_TIMER_ID, timerId)
@@ -146,6 +269,3 @@ private fun showTimersOrComposeIntent(context: Context, model: TimerWidgetModel)
.setAction(action)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
-
-private fun timeStyle(color: ColorProvider) =
- TextStyle(color = color, fontSize = WidgetDefaults.PrimaryTextSize, fontWeight = FontWeight.Medium)
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/WidgetDefaults.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/WidgetDefaults.kt
deleted file mode 100644
index 25bebb2..0000000
--- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/WidgetDefaults.kt
+++ /dev/null
@@ -1,29 +0,0 @@
-package de.jeanlucmakiola.clockula.ui.widget
-
-import androidx.compose.ui.unit.Dp
-import androidx.compose.ui.unit.TextUnit
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import androidx.glance.color.ColorProviders
-import androidx.glance.material3.ColorProviders as glanceM3ColorProviders
-import de.jeanlucmakiola.clockula.ui.theme.ClockulaDarkFallback
-import de.jeanlucmakiola.clockula.ui.theme.ClockulaLightFallback
-
-/** Shared sizing for the three widgets' content, kept in one place like the app's own theme. */
-object WidgetDefaults {
- val CornerRadius: Dp = 24.dp
- val ContentPadding: Dp = 16.dp
- val PrimaryTextSize: TextUnit = 28.sp
- val BodyTextSize: TextUnit = 14.sp
- val CaptionTextSize: TextUnit = 12.sp
-
- /**
- * [GlanceTheme]'s own M3 bridge: dynamic colour on API 31+, [ClockulaLightFallback]/
- * [ClockulaDarkFallback] otherwise — the same fallback schemes `ui/theme/Theme.kt`
- * uses for ordinary Compose (`docs/PLAN.md` §9).
- */
- val Colors: ColorProviders = glanceM3ColorProviders(
- light = ClockulaLightFallback,
- dark = ClockulaDarkFallback,
- )
-}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/WidgetKit.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/WidgetKit.kt
new file mode 100644
index 0000000..d4894f6
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/WidgetKit.kt
@@ -0,0 +1,185 @@
+package de.jeanlucmakiola.clockula.ui.widget
+
+import android.content.Context
+import android.os.Build
+import android.util.TypedValue
+import android.widget.RemoteViews
+import androidx.annotation.ColorRes
+import androidx.annotation.DrawableRes
+import androidx.annotation.LayoutRes
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.TextUnit
+import androidx.compose.ui.unit.sp
+import androidx.core.os.ConfigurationCompat
+import androidx.glance.ColorFilter
+import androidx.glance.GlanceModifier
+import androidx.glance.GlanceTheme
+import androidx.glance.Image
+import androidx.glance.ImageProvider
+import androidx.glance.LocalContext
+import androidx.glance.action.Action
+import androidx.glance.action.clickable
+import androidx.glance.appwidget.AndroidRemoteViews
+import androidx.glance.appwidget.appWidgetBackground
+import androidx.glance.appwidget.cornerRadius
+import androidx.glance.background
+import androidx.glance.layout.Alignment
+import androidx.glance.layout.Box
+import androidx.glance.layout.fillMaxSize
+import androidx.glance.layout.size
+import androidx.glance.layout.wrapContentSize
+import androidx.glance.unit.ColorProvider
+import de.jeanlucmakiola.clockula.R
+import de.jeanlucmakiola.clockula.ui.theme.ClockulaDarkFallback
+import de.jeanlucmakiola.clockula.ui.theme.ClockulaLightFallback
+import de.jeanlucmakiola.floret.glance.FloretGlanceTheme
+import de.jeanlucmakiola.floret.glance.WidgetThemeMode
+import java.util.Locale
+
+/*
+ * The pieces every widget but the analog one is built from — the same
+ * patterns `AnalogClockWidgetContent.kt` set:
+ * - colour follows the launcher's day/night and, on API 31+, the wallpaper
+ * by itself ([WidgetTheme]) — no app-side theme pinning;
+ * - containers are M3 shapes (`widget_bg_*`, `widget_shape_*`), white
+ * drawables tinted here;
+ * - every size is a fraction of the widget's live `LocalSize` (`SizeMode.Exact`
+ * on each widget), so text and controls grow and shrink with the widget
+ * instead of sitting at a fixed sp in a corner of it.
+ */
+
+/**
+ * The widgets' colours: on API 31+ Glance's own dynamic scheme — resource
+ * colours the launcher resolves against its day/night and the wallpaper, the
+ * same system tones as the `widget_*` palette the plain-`RemoteViews` faces
+ * use — and the app's own fallback schemes below that.
+ */
+@Composable
+internal fun WidgetTheme(content: @Composable () -> Unit) {
+ FloretGlanceTheme(
+ lightScheme = ClockulaLightFallback,
+ darkScheme = ClockulaDarkFallback,
+ dynamicColor = true,
+ mode = WidgetThemeMode.SYSTEM,
+ content = content,
+ )
+}
+
+/**
+ * A widget's card: the shared surface colour inside the launcher's own corner
+ * radius, and [onClick] for the whole of it.
+ */
+@Composable
+internal fun WidgetCard(
+ onClick: Action,
+ contentAlignment: Alignment = Alignment.Center,
+ content: @Composable () -> Unit,
+) {
+ WidgetTheme {
+ val base = GlanceModifier
+ .fillMaxSize()
+ .appWidgetBackground()
+ .background(ImageProvider(R.drawable.widget_bg_card), colorFilter = ColorFilter.tint(GlanceTheme.colors.widgetBackground))
+ // The drawable already draws the corner; this clips the launcher's own
+ // press ripple to it, where the platform has a radius to clip to.
+ val clipped = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ base.cornerRadius(android.R.dimen.system_app_widget_background_radius)
+ } else {
+ base
+ }
+ Box(modifier = clipped.clickable(onClick), contentAlignment = contentAlignment, content = content)
+ }
+}
+
+/** [icon] centred on a tinted [shape] — an M3 shape for a badge, a circle or pill for a button. */
+@Composable
+internal fun ShapedIcon(
+ @DrawableRes icon: Int,
+ contentDescription: String?,
+ width: Dp,
+ height: Dp,
+ container: ColorProvider,
+ content: ColorProvider,
+ @DrawableRes shape: Int = R.drawable.widget_bg_circle,
+ iconFraction: Float = 0.5f,
+ onClick: Action? = null,
+) {
+ val modifier = GlanceModifier
+ .size(width, height)
+ .background(ImageProvider(shape), colorFilter = ColorFilter.tint(container))
+ Box(
+ modifier = if (onClick != null) modifier.clickable(onClick) else modifier,
+ contentAlignment = Alignment.Center,
+ ) {
+ Image(
+ provider = ImageProvider(icon),
+ contentDescription = contentDescription,
+ modifier = GlanceModifier.size(minOf(width, height) * iconFraction),
+ colorFilter = ColorFilter.tint(content),
+ )
+ }
+}
+
+/**
+ * A platform `Chronometer` ([layoutRes], ticking in the launcher, not the
+ * app) at a proportional [textSizeDp]. [base] is in
+ * `SystemClock.elapsedRealtime()` time, as `RemoteViews.setChronometer` wants.
+ */
+@Composable
+internal fun WidgetChronometer(@LayoutRes layoutRes: Int, viewId: Int, base: Long, countDown: Boolean, textSizeDp: Float) {
+ val context = LocalContext.current
+ val views = RemoteViews(context.packageName, layoutRes).apply {
+ setChronometer(viewId, base, null, true)
+ setChronometerCountDown(viewId, countDown)
+ setTextViewTextSize(viewId, TypedValue.COMPLEX_UNIT_DIP, textSizeDp)
+ }
+ // Without an explicit wrap, Glance lets embedded views fill their
+ // parent, and the readout pushes everything after it out of the widget.
+ AndroidRemoteViews(remoteViews = views, modifier = GlanceModifier.wrapContentSize())
+}
+
+/**
+ * A stopped readout ([text] at [textSizeDp], in palette colour [colorRes]):
+ * the same padding-free view as [WidgetChronometer]'s, unlike a Glance
+ * `Text`, so a readout takes the same height whether it is ticking or not.
+ */
+@Composable
+internal fun WidgetReadout(text: String, textSizeDp: Float, @ColorRes colorRes: Int) {
+ val context = LocalContext.current
+ val views = RemoteViews(context.packageName, R.layout.widget_readout).apply {
+ setTextViewText(R.id.widget_readout, text)
+ setTextViewTextSize(R.id.widget_readout, TypedValue.COMPLEX_UNIT_DIP, textSizeDp)
+ // From API 31 the launcher resolves the colour against its own
+ // day/night; before that it can only be resolved here.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ setColorStateList(R.id.widget_readout, "setTextColor", colorRes)
+ } else {
+ setTextColor(R.id.widget_readout, context.getColor(colorRes))
+ }
+ }
+ AndroidRemoteViews(remoteViews = views, modifier = GlanceModifier.wrapContentSize())
+}
+
+/**
+ * [dp] as a Glance text size that stays [dp] whatever the user's font scale:
+ * sizes here are already fitted to the widget, and scaling them again would
+ * overflow it.
+ */
+internal fun fittedSp(context: Context, dp: Float): TextUnit = (dp / context.resources.configuration.fontScale).sp
+
+/** A square [Box] of [side], centred in the widget — for faces that are a shape rather than a card. */
+@Composable
+internal fun CenteredSquare(side: Dp, onClick: Action, content: @Composable () -> Unit) {
+ Box(
+ modifier = GlanceModifier.fillMaxSize().clickable(onClick),
+ contentAlignment = Alignment.Center,
+ ) {
+ WidgetTheme {
+ Box(modifier = GlanceModifier.size(side), contentAlignment = Alignment.Center, content = content)
+ }
+ }
+}
+
+internal fun currentLocale(context: Context): Locale =
+ ConfigurationCompat.getLocales(context.resources.configuration).get(0) ?: Locale.getDefault()
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/AnalogClockConfigActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/AnalogClockConfigActivity.kt
new file mode 100644
index 0000000..4a73f4a
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/AnalogClockConfigActivity.kt
@@ -0,0 +1,142 @@
+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
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+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
+ * activity *is* [WidgetStylePicker]; tapping an option saves and finishes
+ * straight away, as the reference picker does (no separate "Save" step).
+ */
+@AndroidEntryPoint
+class AnalogClockConfigActivity : ComponentActivity() {
+
+ @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() },
+ )
+ }
+ }
+ }
+
+ private fun resultIntent(appWidgetId: Int) =
+ Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
+}
+
+internal fun analogStyleLabel(style: AnalogClockStyle): Int = when (style) {
+ AnalogClockStyle.ROUND -> R.string.analog_style_round
+ AnalogClockStyle.NUMERAL -> R.string.analog_style_numeral
+ AnalogClockStyle.BADGE -> R.string.analog_style_badge
+ AnalogClockStyle.COOKIE -> R.string.analog_style_cookie
+ AnalogClockStyle.CLOVER -> R.string.analog_style_clover
+ AnalogClockStyle.PETAL -> R.string.analog_style_petal
+ AnalogClockStyle.SUNNY -> R.string.analog_style_sunny
+ AnalogClockStyle.SQUARE -> R.string.analog_style_square
+ AnalogClockStyle.SCALLOP -> R.string.analog_style_scallop
+}
+
+/**
+ * The real widget face, not a redrawing: inflates [analogClockViews] — the
+ * same `RemoteViews` the placed widget renders — in-app, so the `AnalogClock`
+ * ticks and the dial, numerals and colours match exactly.
+ */
+@Composable
+private fun AnalogStylePreview(style: AnalogClockStyle) {
+ val context = LocalContext.current
+ val views = remember(style) { analogClockViews(context, style, PreviewSize.value) }
+ AndroidView(
+ factory = { FrameLayout(it) },
+ update = { frame ->
+ frame.removeAllViews()
+ frame.addView(views.apply(frame.context, frame))
+ },
+ modifier = Modifier.size(PreviewSize),
+ )
+}
+
+private val PreviewSize = 180.dp
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/ClockWidgetConfigActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/ClockWidgetConfigActivity.kt
new file mode 100644
index 0000000..3876160
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/ClockWidgetConfigActivity.kt
@@ -0,0 +1,232 @@
+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
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+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
+import de.jeanlucmakiola.clockula.widget.ClockWidget
+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.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".
+ */
+@AndroidEntryPoint
+class ClockWidgetConfigActivity : ComponentActivity() {
+
+ @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
+ }
+ // A stale id (the widget removed meanwhile) or one that isn't ours
+ // throws here; there is nothing to configure, so just close.
+ val glanceId = runCatching { GlanceAppWidgetManager(this).getGlanceIdBy(appWidgetId) }.getOrNull()
+ if (glanceId == null) {
+ finish()
+ return
+ }
+ enableEdgeToEdge()
+
+ setContent {
+ val appearance by settingsPrefs.appearance.collectAsState(initial = Appearance())
+ var settings by remember { mutableStateOf(DigitalClockSettings()) }
+ val cities by produceState(initialValue = emptyList()) {
+ value = widgetCities(worldClockRepository, zoneNames)
+ }
+ LaunchedEffect(glanceId) {
+ val state = getAppWidgetState(this@ClockWidgetConfigActivity, PreferencesGlanceStateDefinition, glanceId)
+ settings = parseDigitalClockSettings(
+ style = state[WIDGET_DIGITAL_STYLE_KEY],
+ showDate = state[WIDGET_SHOW_DATE_KEY],
+ showWorldClocks = state[WIDGET_SHOW_WORLD_CLOCKS_KEY],
+ )
+ }
+ val darkTheme = appearance.themeMode.isDark(isSystemInDarkTheme())
+ ClockulaTheme(darkTheme = darkTheme, dynamicColor = appearance.dynamicColor) {
+ Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {}
+ ClockWidgetConfigScreen(
+ settings = settings,
+ cities = cities,
+ onSettingsChange = { settings = it },
+ onSave = {
+ lifecycleScope.launch {
+ updateAppWidgetState(this@ClockWidgetConfigActivity, glanceId) { prefs ->
+ prefs[WIDGET_DIGITAL_STYLE_KEY] = settings.style.name
+ prefs[WIDGET_SHOW_DATE_KEY] = settings.showDate
+ prefs[WIDGET_SHOW_WORLD_CLOCKS_KEY] = settings.showWorldClocks
+ }
+ ClockWidget().update(this@ClockWidgetConfigActivity, glanceId)
+ setResult(RESULT_OK, resultIntent(appWidgetId))
+ finish()
+ }
+ },
+ onDismiss = { finish() },
+ )
+ }
+ }
+ }
+
+ private fun resultIntent(appWidgetId: Int) =
+ Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
+}
+
+@Composable
+private fun ClockWidgetConfigScreen(
+ settings: DigitalClockSettings,
+ cities: List,
+ onSettingsChange: (DigitalClockSettings) -> Unit,
+ onSave: () -> Unit,
+ onDismiss: () -> Unit,
+) {
+ var showStylePicker by remember { mutableStateOf(false) }
+ val showsWorldClocks = settings.style.showsWorldClocks
+
+ FullScreenPicker(
+ title = stringResource(R.string.widget_config_title),
+ onDismiss = onDismiss,
+ actions = {
+ TextButton(onClick = onSave) { Text(stringResource(android.R.string.ok)) }
+ },
+ ) {
+ // The widget as it will look, live, above the settings that change it.
+ Box(modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp), contentAlignment = Alignment.Center) {
+ DigitalStylePreview(settings, cities)
+ }
+ GroupedRow(
+ title = stringResource(R.string.widget_config_style),
+ summary = stringResource(digitalStyleLabel(settings.style)),
+ position = Position.Top,
+ onClick = { showStylePicker = true },
+ )
+ GroupedRow(
+ title = stringResource(R.string.widget_config_show_date),
+ position = if (showsWorldClocks) Position.Middle else Position.Bottom,
+ trailing = { Switch(checked = settings.showDate, onCheckedChange = { onSettingsChange(settings.copy(showDate = it)) }) },
+ onClick = { onSettingsChange(settings.copy(showDate = !settings.showDate)) },
+ )
+ // Only the card faces have room for world clocks.
+ if (showsWorldClocks) {
+ GroupedRow(
+ title = stringResource(R.string.widget_config_show_world_clocks),
+ summary = stringResource(R.string.widget_config_show_world_clocks_hint),
+ position = Position.Bottom,
+ trailing = { Switch(checked = settings.showWorldClocks, onCheckedChange = { onSettingsChange(settings.copy(showWorldClocks = it)) }) },
+ onClick = { onSettingsChange(settings.copy(showWorldClocks = !settings.showWorldClocks)) },
+ )
+ }
+ Spacer(Modifier.height(24.dp))
+ }
+
+ if (showStylePicker) {
+ WidgetStylePicker(
+ title = stringResource(R.string.widget_config_style),
+ options = DigitalClockStyle.entries,
+ selected = settings.style,
+ label = { stringResource(digitalStyleLabel(it)) },
+ preview = { style -> DigitalStylePreview(settings.copy(style = style), cities) },
+ onSelect = { onSettingsChange(settings.copy(style = it)) },
+ onDismiss = { showStylePicker = false },
+ )
+ }
+}
+
+internal fun digitalStyleLabel(style: DigitalClockStyle): Int = when (style) {
+ DigitalClockStyle.REGULAR -> R.string.clock_face_regular
+ DigitalClockStyle.LIGHT -> R.string.clock_face_light
+ DigitalClockStyle.BOLD -> R.string.clock_face_bold
+ DigitalClockStyle.PILL -> R.string.clock_face_pill
+ DigitalClockStyle.STACKED -> R.string.clock_face_stacked
+}
+
+/**
+ * The real widget face, not a redrawing: inflates [digitalClockViews] — the
+ * same `RemoteViews` the placed widget renders — in-app, so the `TextClock`s
+ * tick and the layout, sizes and colours match exactly.
+ */
+@Composable
+private fun DigitalStylePreview(settings: DigitalClockSettings, cities: List) {
+ val context = LocalContext.current
+ val views = remember(settings, cities) {
+ digitalClockViews(context, settings, cities, PreviewWidth.value, PreviewHeight.value)
+ }
+ AndroidView(
+ factory = { FrameLayout(it) },
+ update = { frame ->
+ frame.removeAllViews()
+ frame.addView(views.apply(frame.context, frame))
+ },
+ modifier = Modifier.size(PreviewWidth, PreviewHeight),
+ )
+}
+
+/** About a 3x2 widget: wide enough for the card faces, tall enough for a city or two. */
+private val PreviewWidth = 260.dp
+private val PreviewHeight = 160.dp
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/QuickTimerConfigActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/QuickTimerConfigActivity.kt
new file mode 100644
index 0000000..8940ff5
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/QuickTimerConfigActivity.kt
@@ -0,0 +1,105 @@
+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.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.
+ */
+@AndroidEntryPoint
+class QuickTimerConfigActivity : ComponentActivity() {
+
+ @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() },
+ )
+ }
+ }
+ }
+
+ private fun resultIntent(appWidgetId: Int) =
+ Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/WidgetStylePicker.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/WidgetStylePicker.kt
new file mode 100644
index 0000000..ab972ab
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/WidgetStylePicker.kt
@@ -0,0 +1,66 @@
+package de.jeanlucmakiola.clockula.ui.widget.config
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import de.jeanlucmakiola.floret.components.FullScreenPicker
+import de.jeanlucmakiola.floret.components.GroupedListInset
+
+/**
+ * A full-bleed style picker: one large, live preview per option with its name
+ * centred below — the layout a visual choice needs (`FullScreenPicker`'s third,
+ * richest rung), rather than a row-plus-summary list a reader has to imagine.
+ * The selected option carries a tonal highlight; tapping any option selects it
+ * and closes the picker immediately, same as [de.jeanlucmakiola.floret.components.OptionPicker].
+ */
+@Composable
+fun WidgetStylePicker(
+ title: String,
+ options: List,
+ selected: T,
+ label: @Composable (T) -> String,
+ preview: @Composable (T) -> Unit,
+ onSelect: (T) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ FullScreenPicker(title = title, onDismiss = onDismiss) {
+ options.forEach { option ->
+ val isSelected = option == selected
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = GroupedListInset, vertical = 10.dp)
+ .clip(RoundedCornerShape(28.dp))
+ .let { if (isSelected) it.background(MaterialTheme.colorScheme.secondaryContainer) else it }
+ .clickable {
+ onSelect(option)
+ onDismiss()
+ }
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ preview(option)
+ Spacer(Modifier.height(10.dp))
+ Text(
+ text = label(option),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
+ color = if (isSelected) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/widget/AnalogClockWidget.kt b/app/src/main/java/de/jeanlucmakiola/clockula/widget/AnalogClockWidget.kt
new file mode 100644
index 0000000..8599610
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/widget/AnalogClockWidget.kt
@@ -0,0 +1,37 @@
+package de.jeanlucmakiola.clockula.widget
+
+import android.content.Context
+import androidx.glance.GlanceId
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.GlanceAppWidgetReceiver
+import androidx.glance.appwidget.SizeMode
+import androidx.glance.appwidget.provideContent
+import de.jeanlucmakiola.clockula.ui.widget.AnalogClockWidgetRoot
+
+/**
+ * The analog clock widget. Per-instance style is read reactively inside
+ * [AnalogClockWidgetRoot] from this instance's own Glance state
+ * (`widget/ClockWidgetState.kt`), written by `AnalogClockConfigActivity` —
+ * see `widget/ClockWidget.kt`'s doc comment for why that read happens inside
+ * the composition rather than once up front here.
+ *
+ * [SizeMode.Exact]: the default [SizeMode.Single] gives every session a
+ * fixed, one-time `LocalSize` no matter how the widget is later resized —
+ * `ui/widget/AnalogClockWidgetContent.kt` reads `LocalSize` to size the dial
+ * to the widget's actual placed size, so without this a resize would never
+ * change what's drawn.
+ */
+class AnalogClockWidget : GlanceAppWidget() {
+
+ override val sizeMode: SizeMode = SizeMode.Exact
+
+ override suspend fun provideGlance(context: Context, id: GlanceId) {
+ provideContent {
+ AnalogClockWidgetRoot()
+ }
+ }
+}
+
+class AnalogClockWidgetReceiver : GlanceAppWidgetReceiver() {
+ override val glanceAppWidget: GlanceAppWidget = AnalogClockWidget()
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/widget/ClockWidget.kt b/app/src/main/java/de/jeanlucmakiola/clockula/widget/ClockWidget.kt
index af373b6..f27ce70 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/widget/ClockWidget.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/widget/ClockWidget.kt
@@ -4,27 +4,38 @@ import android.content.Context
import androidx.glance.GlanceId
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
+import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.provideContent
-import de.jeanlucmakiola.clockula.domain.widget.ClockWidgetPresenter
-import de.jeanlucmakiola.clockula.ui.widget.ClockWidgetCity
-import de.jeanlucmakiola.clockula.ui.widget.ClockWidgetContent
+import de.jeanlucmakiola.clockula.ui.widget.ClockWidgetRoot
import de.jeanlucmakiola.clockula.widget.di.widgetEntryPoint
-import kotlinx.coroutines.flow.first
-/** The clock widget. City names are resolved through the ICU seam (`ZoneNames`) here, before composing — `ui/widget/` never names `android.icu` itself. */
+/**
+ * The digital clock widget. Per-instance face and settings are
+ * 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]
+ * resolves to the defaults that reproduce the pre-M11 layout.
+ *
+ * Reading that state via `currentState()` *inside* the composition — rather
+ * than once up front here — matters: `GlanceAppWidget.update()` reuses a
+ * still-live session (one lives ~45s) by recomposing in place rather than
+ * re-running [provideGlance], so a `val` read here before `provideContent`
+ * would keep showing whatever was true when the session started, no matter
+ * how many times the config screen saves afterwards.
+ */
class ClockWidget : GlanceAppWidget() {
+ // The default SizeMode.Single would give every session a fixed, one-time
+ // LocalSize, so `DigitalFaces.kt`'s sizing would never react to an
+ // actual resize.
+ override val sizeMode: SizeMode = SizeMode.Exact
+
override suspend fun provideGlance(context: Context, id: GlanceId) {
val entryPoint = widgetEntryPoint(context)
- val selected = ClockWidgetPresenter.present(entryPoint.worldClockRepository().worldClocks().first())
- val zoneNames = entryPoint.zoneNames()
- val cities = selected.map { clock ->
- ClockWidgetCity(
- zoneId = clock.zoneId,
- cityName = clock.label?.takeIf(String::isNotBlank) ?: zoneNames.cityOf(clock.zoneId) ?: clock.zoneId,
- )
+ provideContent {
+ ClockWidgetRoot(entryPoint)
}
- provideContent { ClockWidgetContent(cities) }
}
}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/widget/ClockWidgetState.kt b/app/src/main/java/de/jeanlucmakiola/clockula/widget/ClockWidgetState.kt
new file mode 100644
index 0000000..5cf313c
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/widget/ClockWidgetState.kt
@@ -0,0 +1,43 @@
+package de.jeanlucmakiola.clockula.widget
+
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.longPreferencesKey
+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")
+
+/** Per-instance Glance state key: show the date line below the time. */
+internal val WIDGET_SHOW_DATE_KEY = booleanPreferencesKey("show_date")
+
+/** Per-instance Glance state key: show up to three world-clock cities below the face. */
+internal val WIDGET_SHOW_WORLD_CLOCKS_KEY = booleanPreferencesKey("show_world_clocks")
+
+/** Per-instance Glance state key holding the analog widget's [AnalogClockStyle] ([name]). */
+internal val WIDGET_ANALOG_STYLE_KEY = stringPreferencesKey("analog_style")
+
+/** Per-instance Glance state key holding the quick-timer widget's configured duration, in millis. */
+internal val WIDGET_QUICK_TIMER_DURATION_KEY = longPreferencesKey("quick_timer_duration_millis")
+
+/** Per-instance Glance state key: the id of the timer this quick-timer widget last started (`timer/receiver/QuickTimerReceiver.kt`). */
+internal val WIDGET_QUICK_TIMER_ID_KEY = longPreferencesKey("quick_timer_timer_id")
+
+internal fun parseDigitalClockSettings(style: String?, showDate: Boolean?, showWorldClocks: Boolean?): DigitalClockSettings =
+ DigitalClockSettings(
+ style = parseDigitalClockStyle(style),
+ showDate = showDate ?: true,
+ showWorldClocks = showWorldClocks ?: false,
+ )
+
+internal fun quickTimerSettings(durationMillis: Long?): QuickTimerWidgetSettings =
+ QuickTimerWidgetSettings(duration = parseQuickTimerDuration(durationMillis))
+
+internal val QuickTimerWidgetSettings.durationMillis: Long
+ get() = duration.inWholeMilliseconds
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/widget/NextAlarmWidget.kt b/app/src/main/java/de/jeanlucmakiola/clockula/widget/NextAlarmWidget.kt
index 18f75c7..e54c690 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/widget/NextAlarmWidget.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/widget/NextAlarmWidget.kt
@@ -4,24 +4,23 @@ import android.content.Context
import androidx.glance.GlanceId
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
+import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.provideContent
-import de.jeanlucmakiola.clockula.domain.widget.NextAlarmWidgetPresenter
-import de.jeanlucmakiola.clockula.ui.widget.NextAlarmWidgetContent
+import de.jeanlucmakiola.clockula.ui.widget.NextAlarmWidgetRoot
import de.jeanlucmakiola.clockula.widget.di.widgetEntryPoint
import kotlinx.coroutines.flow.first
/** The next-alarm widget. No `updatePeriodMillis` — [WidgetSync] refreshes it whenever an alarm changes. */
class NextAlarmWidget : GlanceAppWidget() {
+ // Content is sized from the live `LocalSize` (`ui/widget/WidgetKit.kt`);
+ // the default SizeMode.Single would freeze it at the placed size.
+ override val sizeMode: SizeMode = SizeMode.Exact
+
override suspend fun provideGlance(context: Context, id: GlanceId) {
val entryPoint = widgetEntryPoint(context)
- val upcoming = entryPoint.alarmEngine().upcoming().first()
- val model = NextAlarmWidgetPresenter.present(
- upcoming = upcoming,
- now = entryPoint.wallClock().now(),
- zone = entryPoint.zoneProvider().current(),
- )
- provideContent { NextAlarmWidgetContent(model) }
+ val initial = entryPoint.alarmEngine().upcoming().first()
+ provideContent { NextAlarmWidgetRoot(entryPoint, initial) }
}
}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/widget/QuickTimerWidget.kt b/app/src/main/java/de/jeanlucmakiola/clockula/widget/QuickTimerWidget.kt
new file mode 100644
index 0000000..fc5a5ae
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/widget/QuickTimerWidget.kt
@@ -0,0 +1,36 @@
+package de.jeanlucmakiola.clockula.widget
+
+import android.content.Context
+import androidx.glance.GlanceId
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.GlanceAppWidgetReceiver
+import androidx.glance.appwidget.SizeMode
+import androidx.glance.appwidget.provideContent
+import de.jeanlucmakiola.clockula.ui.widget.QuickTimerWidgetRoot
+import de.jeanlucmakiola.clockula.widget.di.widgetEntryPoint
+import kotlinx.coroutines.flow.first
+
+/**
+ * The quick-timer widget: a one-duration timer of its own. Its configured
+ * duration (written by `QuickTimerConfigActivity`) and the id of the timer it
+ * last started (written by `QuickTimerReceiver`) are this instance's Glance
+ * state (`widget/ClockWidgetState.kt`), read reactively inside
+ * `QuickTimerWidgetRoot` along with the timers themselves — see
+ * `widget/ClockWidget.kt`'s doc comment for why.
+ */
+class QuickTimerWidget : GlanceAppWidget() {
+
+ // Content is sized from the live `LocalSize` (`ui/widget/WidgetKit.kt`);
+ // the default SizeMode.Single would freeze it at the placed size.
+ override val sizeMode: SizeMode = SizeMode.Exact
+
+ override suspend fun provideGlance(context: Context, id: GlanceId) {
+ val entryPoint = widgetEntryPoint(context)
+ val initial = entryPoint.timerRepository().timers().first()
+ provideContent { QuickTimerWidgetRoot(entryPoint, initial) }
+ }
+}
+
+class QuickTimerWidgetReceiver : GlanceAppWidgetReceiver() {
+ override val glanceAppWidget: GlanceAppWidget = QuickTimerWidget()
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/widget/StopwatchWidget.kt b/app/src/main/java/de/jeanlucmakiola/clockula/widget/StopwatchWidget.kt
new file mode 100644
index 0000000..1731573
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/widget/StopwatchWidget.kt
@@ -0,0 +1,31 @@
+package de.jeanlucmakiola.clockula.widget
+
+import android.content.Context
+import androidx.glance.GlanceId
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.GlanceAppWidgetReceiver
+import androidx.glance.appwidget.SizeMode
+import androidx.glance.appwidget.provideContent
+import de.jeanlucmakiola.clockula.ui.widget.StopwatchWidgetRoot
+import de.jeanlucmakiola.clockula.widget.di.widgetEntryPoint
+import kotlinx.coroutines.flow.first
+
+/** The stopwatch widget. No `updatePeriodMillis` and no per-second tick of its own — a running readout is the platform chronometer. [WidgetSync] refreshes it on every stopwatch write. */
+class StopwatchWidget : GlanceAppWidget() {
+
+ // Content is sized from the live `LocalSize` (`ui/widget/WidgetKit.kt`);
+ // the default SizeMode.Single would freeze it at the placed size.
+ override val sizeMode: SizeMode = SizeMode.Exact
+
+ override suspend fun provideGlance(context: Context, id: GlanceId) {
+ val entryPoint = widgetEntryPoint(context)
+ val repository = entryPoint.stopwatchRepository()
+ val initialRun = repository.run().first()
+ val initialLaps = repository.laps().first()
+ provideContent { StopwatchWidgetRoot(entryPoint, initialRun, initialLaps.size) }
+ }
+}
+
+class StopwatchWidgetReceiver : GlanceAppWidgetReceiver() {
+ override val glanceAppWidget: GlanceAppWidget = StopwatchWidget()
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/widget/TimerWidget.kt b/app/src/main/java/de/jeanlucmakiola/clockula/widget/TimerWidget.kt
index b9a5e99..a4d7cb8 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/widget/TimerWidget.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/widget/TimerWidget.kt
@@ -4,24 +4,24 @@ import android.content.Context
import androidx.glance.GlanceId
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
+import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.provideContent
-import de.jeanlucmakiola.clockula.domain.widget.TimerWidgetPresenter
-import de.jeanlucmakiola.clockula.ui.widget.TimerWidgetContent
+import de.jeanlucmakiola.clockula.ui.widget.TimerWidgetRoot
import de.jeanlucmakiola.clockula.widget.di.widgetEntryPoint
import kotlinx.coroutines.flow.first
/** The running-timer widget. No `updatePeriodMillis`, and no per-second tick of its own — a running row's countdown is the platform chronometer. [WidgetSync] refreshes it on every timer write. */
class TimerWidget : GlanceAppWidget() {
+ // Content is sized from the live `LocalSize` (`ui/widget/WidgetKit.kt`);
+ // the default SizeMode.Single would freeze it at the placed size.
+ override val sizeMode: SizeMode = SizeMode.Exact
+
override suspend fun provideGlance(context: Context, id: GlanceId) {
val entryPoint = widgetEntryPoint(context)
- val timers = entryPoint.timerRepository().timers().first()
- val model = TimerWidgetPresenter.present(
- timers = timers,
- elapsedRealtime = entryPoint.elapsedRealtimeClock().elapsedRealtime(),
- wallClock = entryPoint.wallClock().now(),
- )
- provideContent { TimerWidgetContent(model) }
+ val repository = entryPoint.timerRepository()
+ val initial = repository.timers().first()
+ provideContent { TimerWidgetRoot(entryPoint, initial) }
}
}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/widget/WidgetSync.kt b/app/src/main/java/de/jeanlucmakiola/clockula/widget/WidgetSync.kt
index da216c2..a557bc1 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/widget/WidgetSync.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/widget/WidgetSync.kt
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.clockula.widget
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
+import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchRepository
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository
import androidx.glance.appwidget.updateAll
@@ -15,12 +16,16 @@ import javax.inject.Singleton
import kotlin.time.Duration.Companion.milliseconds
/**
- * Keeps the three widgets in step with the app's own state, event-driven —
+ * Keeps the widgets in step with the app's own state, event-driven —
* `updatePeriodMillis = 0` on every widget — by collecting the same read-only
* flows the shell already reads (`AlarmEngine.upcoming()`, `TimerRepository.
- * timers()`, `WorldClockRepository.worldClocks()`). This covers every engine
- * write and every `SystemEventReceiver` path, because the process is alive
- * whenever those run — it touches no engine itself, only re-renders.
+ * timers()`, `StopwatchRepository.run()`, `WorldClockRepository.worldClocks()`).
+ * This covers every engine write and every `SystemEventReceiver` path,
+ * 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`).
*
* Started once, from [de.jeanlucmakiola.clockula.ClockulaApp.onCreate], in the
* app's `@ApplicationScope` — never driven by a `Ticker`, so an idle home
@@ -31,6 +36,7 @@ class WidgetSync @Inject constructor(
@ApplicationContext private val context: Context,
private val alarmEngine: AlarmEngine,
private val timers: TimerRepository,
+ private val stopwatch: StopwatchRepository,
private val worldClocks: WorldClockRepository,
) {
@@ -39,8 +45,12 @@ class WidgetSync @Inject constructor(
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, clocks -> Triple(upcoming, timerRows, clocks) }
+ ) { upcoming, timerRows, stopwatchRun, clocks ->
+ WidgetSyncState(upcoming, timerRows, stopwatchRun, clocks)
+ }
.distinctUntilChanged()
// A burst of writes (an import's replace-all, a reorder) collapses
// to one redraw rather than one per row.
@@ -51,10 +61,16 @@ class WidgetSync @Inject constructor(
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 {
val DEBOUNCE = 150.milliseconds
}
}
+
+/** `combine` has no tuple wider than a `Triple`; this is the four-flow one. */
+private data class WidgetSyncState(val a: A, val b: B, val c: C, val d: D)
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/widget/di/WidgetEntryPoint.kt b/app/src/main/java/de/jeanlucmakiola/clockula/widget/di/WidgetEntryPoint.kt
index df831b4..58b4fba 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/widget/di/WidgetEntryPoint.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/widget/di/WidgetEntryPoint.kt
@@ -6,6 +6,7 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
+import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchRepository
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository
import de.jeanlucmakiola.clockula.data.zones.ZoneNames
@@ -25,6 +26,7 @@ import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
interface WidgetEntryPoint {
fun alarmEngine(): AlarmEngine
fun timerRepository(): TimerRepository
+ fun stopwatchRepository(): StopwatchRepository
fun worldClockRepository(): WorldClockRepository
fun zoneNames(): ZoneNames
fun wallClock(): WallClock
diff --git a/app/src/main/res/drawable/widget_analog_dial_clover.xml b/app/src/main/res/drawable/widget_analog_dial_clover.xml
new file mode 100644
index 0000000..f94c451
--- /dev/null
+++ b/app/src/main/res/drawable/widget_analog_dial_clover.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_analog_dial_cookie.xml b/app/src/main/res/drawable/widget_analog_dial_cookie.xml
new file mode 100644
index 0000000..ea22c29
--- /dev/null
+++ b/app/src/main/res/drawable/widget_analog_dial_cookie.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_analog_dial_none.xml b/app/src/main/res/drawable/widget_analog_dial_none.xml
new file mode 100644
index 0000000..c1b4290
--- /dev/null
+++ b/app/src/main/res/drawable/widget_analog_dial_none.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_analog_dial_petal.xml b/app/src/main/res/drawable/widget_analog_dial_petal.xml
new file mode 100644
index 0000000..58a25ef
--- /dev/null
+++ b/app/src/main/res/drawable/widget_analog_dial_petal.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_analog_dial_round.xml b/app/src/main/res/drawable/widget_analog_dial_round.xml
new file mode 100644
index 0000000..970ec6c
--- /dev/null
+++ b/app/src/main/res/drawable/widget_analog_dial_round.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_analog_dial_scallop.xml b/app/src/main/res/drawable/widget_analog_dial_scallop.xml
new file mode 100644
index 0000000..4b602a0
--- /dev/null
+++ b/app/src/main/res/drawable/widget_analog_dial_scallop.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_analog_dial_square.xml b/app/src/main/res/drawable/widget_analog_dial_square.xml
new file mode 100644
index 0000000..12f75c1
--- /dev/null
+++ b/app/src/main/res/drawable/widget_analog_dial_square.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_analog_dial_sunny.xml b/app/src/main/res/drawable/widget_analog_dial_sunny.xml
new file mode 100644
index 0000000..ef4efa0
--- /dev/null
+++ b/app/src/main/res/drawable/widget_analog_dial_sunny.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_analog_hour_bold.xml b/app/src/main/res/drawable/widget_analog_hour_bold.xml
new file mode 100644
index 0000000..46aca5c
--- /dev/null
+++ b/app/src/main/res/drawable/widget_analog_hour_bold.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_analog_hour_hand.xml b/app/src/main/res/drawable/widget_analog_hour_hand.xml
new file mode 100644
index 0000000..caf3e7e
--- /dev/null
+++ b/app/src/main/res/drawable/widget_analog_hour_hand.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_analog_hour_lollipop.xml b/app/src/main/res/drawable/widget_analog_hour_lollipop.xml
new file mode 100644
index 0000000..a8f6196
--- /dev/null
+++ b/app/src/main/res/drawable/widget_analog_hour_lollipop.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_analog_hour_slim.xml b/app/src/main/res/drawable/widget_analog_hour_slim.xml
new file mode 100644
index 0000000..afc499d
--- /dev/null
+++ b/app/src/main/res/drawable/widget_analog_hour_slim.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_analog_minute_bold.xml b/app/src/main/res/drawable/widget_analog_minute_bold.xml
new file mode 100644
index 0000000..58414c8
--- /dev/null
+++ b/app/src/main/res/drawable/widget_analog_minute_bold.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_analog_minute_hand.xml b/app/src/main/res/drawable/widget_analog_minute_hand.xml
new file mode 100644
index 0000000..c1c5571
--- /dev/null
+++ b/app/src/main/res/drawable/widget_analog_minute_hand.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_analog_minute_lollipop.xml b/app/src/main/res/drawable/widget_analog_minute_lollipop.xml
new file mode 100644
index 0000000..f7ebd65
--- /dev/null
+++ b/app/src/main/res/drawable/widget_analog_minute_lollipop.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_analog_minute_slim.xml b/app/src/main/res/drawable/widget_analog_minute_slim.xml
new file mode 100644
index 0000000..e6c3726
--- /dev/null
+++ b/app/src/main/res/drawable/widget_analog_minute_slim.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_analog_second_dot.xml b/app/src/main/res/drawable/widget_analog_second_dot.xml
new file mode 100644
index 0000000..95e7214
--- /dev/null
+++ b/app/src/main/res/drawable/widget_analog_second_dot.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_analog_second_dot_bold.xml b/app/src/main/res/drawable/widget_analog_second_dot_bold.xml
new file mode 100644
index 0000000..1a20e38
--- /dev/null
+++ b/app/src/main/res/drawable/widget_analog_second_dot_bold.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_analog_second_dot_dated.xml b/app/src/main/res/drawable/widget_analog_second_dot_dated.xml
new file mode 100644
index 0000000..18466a9
--- /dev/null
+++ b/app/src/main/res/drawable/widget_analog_second_dot_dated.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_analog_second_dot_petal.xml b/app/src/main/res/drawable/widget_analog_second_dot_petal.xml
new file mode 100644
index 0000000..9f9108b
--- /dev/null
+++ b/app/src/main/res/drawable/widget_analog_second_dot_petal.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_analog_second_dot_scallop.xml b/app/src/main/res/drawable/widget_analog_second_dot_scallop.xml
new file mode 100644
index 0000000..ddcac6d
--- /dev/null
+++ b/app/src/main/res/drawable/widget_analog_second_dot_scallop.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_badge_pill_background.xml b/app/src/main/res/drawable/widget_badge_pill_background.xml
new file mode 100644
index 0000000..5d90345
--- /dev/null
+++ b/app/src/main/res/drawable/widget_badge_pill_background.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_bg_card.xml b/app/src/main/res/drawable/widget_bg_card.xml
new file mode 100644
index 0000000..f32dbe3
--- /dev/null
+++ b/app/src/main/res/drawable/widget_bg_card.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_bg_circle.xml b/app/src/main/res/drawable/widget_bg_circle.xml
new file mode 100644
index 0000000..2549282
--- /dev/null
+++ b/app/src/main/res/drawable/widget_bg_circle.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_bg_pill.xml b/app/src/main/res/drawable/widget_bg_pill.xml
new file mode 100644
index 0000000..3c915a0
--- /dev/null
+++ b/app/src/main/res/drawable/widget_bg_pill.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_digital_card_primary.xml b/app/src/main/res/drawable/widget_digital_card_primary.xml
new file mode 100644
index 0000000..5ef232d
--- /dev/null
+++ b/app/src/main/res/drawable/widget_digital_card_primary.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_digital_card_surface.xml b/app/src/main/res/drawable/widget_digital_card_surface.xml
new file mode 100644
index 0000000..5cdc1a8
--- /dev/null
+++ b/app/src/main/res/drawable/widget_digital_card_surface.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_digital_pill_primary.xml b/app/src/main/res/drawable/widget_digital_pill_primary.xml
new file mode 100644
index 0000000..610ad4e
--- /dev/null
+++ b/app/src/main/res/drawable/widget_digital_pill_primary.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_digital_pill_tertiary.xml b/app/src/main/res/drawable/widget_digital_pill_tertiary.xml
new file mode 100644
index 0000000..4b755a8
--- /dev/null
+++ b/app/src/main/res/drawable/widget_digital_pill_tertiary.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_digital_shape_square.xml b/app/src/main/res/drawable/widget_digital_shape_square.xml
new file mode 100644
index 0000000..0b5bfa9
--- /dev/null
+++ b/app/src/main/res/drawable/widget_digital_shape_square.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_ic_add.xml b/app/src/main/res/drawable/widget_ic_add.xml
new file mode 100644
index 0000000..34ca22a
--- /dev/null
+++ b/app/src/main/res/drawable/widget_ic_add.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_ic_alarm.xml b/app/src/main/res/drawable/widget_ic_alarm.xml
new file mode 100644
index 0000000..04f7d3d
--- /dev/null
+++ b/app/src/main/res/drawable/widget_ic_alarm.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_ic_alarm_off.xml b/app/src/main/res/drawable/widget_ic_alarm_off.xml
new file mode 100644
index 0000000..9ad38fb
--- /dev/null
+++ b/app/src/main/res/drawable/widget_ic_alarm_off.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_ic_flag.xml b/app/src/main/res/drawable/widget_ic_flag.xml
new file mode 100644
index 0000000..b1db2f4
--- /dev/null
+++ b/app/src/main/res/drawable/widget_ic_flag.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_ic_pause.xml b/app/src/main/res/drawable/widget_ic_pause.xml
new file mode 100644
index 0000000..8cdfe4c
--- /dev/null
+++ b/app/src/main/res/drawable/widget_ic_pause.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_ic_play.xml b/app/src/main/res/drawable/widget_ic_play.xml
new file mode 100644
index 0000000..663467b
--- /dev/null
+++ b/app/src/main/res/drawable/widget_ic_play.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_ic_replay.xml b/app/src/main/res/drawable/widget_ic_replay.xml
new file mode 100644
index 0000000..f9d61cd
--- /dev/null
+++ b/app/src/main/res/drawable/widget_ic_replay.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_ic_stop.xml b/app/src/main/res/drawable/widget_ic_stop.xml
new file mode 100644
index 0000000..9167dcd
--- /dev/null
+++ b/app/src/main/res/drawable/widget_ic_stop.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_shape_cookie.xml b/app/src/main/res/drawable/widget_shape_cookie.xml
new file mode 100644
index 0000000..b2f6b38
--- /dev/null
+++ b/app/src/main/res/drawable/widget_shape_cookie.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_shape_scallop.xml b/app/src/main/res/drawable/widget_shape_scallop.xml
new file mode 100644
index 0000000..b6aa477
--- /dev/null
+++ b/app/src/main/res/drawable/widget_shape_scallop.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_shape_square.xml b/app/src/main/res/drawable/widget_shape_square.xml
new file mode 100644
index 0000000..517c732
--- /dev/null
+++ b/app/src/main/res/drawable/widget_shape_square.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_analog_badge.xml b/app/src/main/res/layout/widget_analog_badge.xml
new file mode 100644
index 0000000..8831b93
--- /dev/null
+++ b/app/src/main/res/layout/widget_analog_badge.xml
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_analog_numeral.xml b/app/src/main/res/layout/widget_analog_numeral.xml
new file mode 100644
index 0000000..223293d
--- /dev/null
+++ b/app/src/main/res/layout/widget_analog_numeral.xml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_analog_plain.xml b/app/src/main/res/layout/widget_analog_plain.xml
new file mode 100644
index 0000000..45d4202
--- /dev/null
+++ b/app/src/main/res/layout/widget_analog_plain.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_chronometer.xml b/app/src/main/res/layout/widget_chronometer.xml
index afdd1cf..e9730c5 100644
--- a/app/src/main/res/layout/widget_chronometer.xml
+++ b/app/src/main/res/layout/widget_chronometer.xml
@@ -1,13 +1,17 @@
diff --git a/app/src/main/res/layout/widget_chronometer_on_primary.xml b/app/src/main/res/layout/widget_chronometer_on_primary.xml
new file mode 100644
index 0000000..d9eb85b
--- /dev/null
+++ b/app/src/main/res/layout/widget_chronometer_on_primary.xml
@@ -0,0 +1,15 @@
+
+
+
diff --git a/app/src/main/res/layout/widget_digital_bold.xml b/app/src/main/res/layout/widget_digital_bold.xml
new file mode 100644
index 0000000..b042c0c
--- /dev/null
+++ b/app/src/main/res/layout/widget_digital_bold.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_digital_city.xml b/app/src/main/res/layout/widget_digital_city.xml
new file mode 100644
index 0000000..a4f86d8
--- /dev/null
+++ b/app/src/main/res/layout/widget_digital_city.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_digital_city_on_primary.xml b/app/src/main/res/layout/widget_digital_city_on_primary.xml
new file mode 100644
index 0000000..60aa659
--- /dev/null
+++ b/app/src/main/res/layout/widget_digital_city_on_primary.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_digital_classic.xml b/app/src/main/res/layout/widget_digital_classic.xml
new file mode 100644
index 0000000..47632d9
--- /dev/null
+++ b/app/src/main/res/layout/widget_digital_classic.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_digital_pill.xml b/app/src/main/res/layout/widget_digital_pill.xml
new file mode 100644
index 0000000..9509308
--- /dev/null
+++ b/app/src/main/res/layout/widget_digital_pill.xml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_digital_stacked.xml b/app/src/main/res/layout/widget_digital_stacked.xml
new file mode 100644
index 0000000..88bb9ae
--- /dev/null
+++ b/app/src/main/res/layout/widget_digital_stacked.xml
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_digital_thin.xml b/app/src/main/res/layout/widget_digital_thin.xml
new file mode 100644
index 0000000..82ee4c3
--- /dev/null
+++ b/app/src/main/res/layout/widget_digital_thin.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_readout.xml b/app/src/main/res/layout/widget_readout.xml
new file mode 100644
index 0000000..d5f9866
--- /dev/null
+++ b/app/src/main/res/layout/widget_readout.xml
@@ -0,0 +1,17 @@
+
+
+
diff --git a/app/src/main/res/layout/widget_text_clock.xml b/app/src/main/res/layout/widget_text_clock.xml
deleted file mode 100644
index 1869452..0000000
--- a/app/src/main/res/layout/widget_text_clock.xml
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
diff --git a/app/src/main/res/values-night-v31/colors.xml b/app/src/main/res/values-night-v31/colors.xml
new file mode 100644
index 0000000..a524639
--- /dev/null
+++ b/app/src/main/res/values-night-v31/colors.xml
@@ -0,0 +1,24 @@
+
+
+ @android:color/system_accent2_800
+ @android:color/system_accent1_800
+ @android:color/system_accent3_800
+ @android:color/system_accent1_100
+ @android:color/system_accent1_300
+ @android:color/system_accent1_200
+ @android:color/system_accent3_300
+ @android:color/system_neutral1_50
+ @android:color/system_accent3_300
+ @android:color/system_accent3_900
+
+
+ @android:color/system_accent2_800
+ @android:color/system_neutral1_50
+ @android:color/system_neutral2_200
+ @android:color/system_accent1_200
+ @android:color/system_accent1_700
+ @android:color/system_accent1_100
+ @android:color/system_accent3_200
+ @android:color/system_accent3_700
+ @android:color/system_accent3_100
+
diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml
new file mode 100644
index 0000000..203ba07
--- /dev/null
+++ b/app/src/main/res/values-night/colors.xml
@@ -0,0 +1,26 @@
+
+
+ #FF2B2F26
+ #FF3E4C32
+ #FF334B47
+ #FFE4E9E0
+ #FFB4C7A4
+ #FFC9D9B9
+ #FFA5CEC7
+ #FFE3E3DC
+ #FFB4C7A4
+ #FF22261C
+
+
+ #FF2B2F26
+ #FFE4E9E0
+ #FFC4C8BA
+ #FFCEE2BA
+ #FF4A583B
+ #FFE8F6D9
+ #FFA5CEC7
+ #FF244D48
+ #FFC1ECE4
+
diff --git a/app/src/main/res/values-v31/colors.xml b/app/src/main/res/values-v31/colors.xml
new file mode 100644
index 0000000..dbb32fa
--- /dev/null
+++ b/app/src/main/res/values-v31/colors.xml
@@ -0,0 +1,28 @@
+
+
+ @android:color/system_accent2_50
+ @android:color/system_accent1_100
+ @android:color/system_accent3_100
+ @android:color/system_accent1_700
+ @android:color/system_accent1_400
+ @android:color/system_accent1_600
+ @android:color/system_accent3_500
+ @android:color/system_neutral1_900
+ @android:color/system_accent3_200
+ @android:color/system_accent3_900
+
+
+ @android:color/system_accent2_50
+ @android:color/system_neutral1_900
+ @android:color/system_neutral2_700
+ @android:color/system_accent1_600
+ @android:color/system_accent1_100
+ @android:color/system_accent1_900
+ @android:color/system_accent3_600
+ @android:color/system_accent3_100
+ @android:color/system_accent3_900
+
diff --git a/app/src/main/res/values-v31/dimens.xml b/app/src/main/res/values-v31/dimens.xml
new file mode 100644
index 0000000..ea9ae34
--- /dev/null
+++ b/app/src/main/res/values-v31/dimens.xml
@@ -0,0 +1,4 @@
+
+
+ @android:dimen/system_app_widget_background_radius
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index 080f756..ba4d168 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -4,4 +4,37 @@
#FF6B7A5C
#FF6B7A5C
+
+
+ #FFE8EAE1
+ #FFD7E8C6
+ #FFCCE8E3
+ #FF44483E
+ #FF6B7A5C
+ #FF4E5C40
+ #FF3D6660
+ #FF1A1C18
+ #FF6B7A5C
+ #FFFFFFFF
+
+
+ #FFE8EAE1
+ #FF1C1F1A
+ #FF44483E
+ #FF5C6A4E
+ #FFD7E8C6
+ #FF1D2A10
+ #FF3D6660
+ #FFCCE8E3
+ #FF05201C
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
new file mode 100644
index 0000000..a031b9e
--- /dev/null
+++ b/app/src/main/res/values/dimens.xml
@@ -0,0 +1,4 @@
+
+
+ 20dp
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 8c8db25..d0e3de9 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -348,6 +348,40 @@
Today
Timer
New timer
- Time\'s up
+ Done
Clock
+
+
+ Digital clock
+ Style
+ Classic
+ Bold
+ Thin
+ Pill
+ Stacked
+ Show date
+ World clocks
+ Up to three cities from your World clock tab
+
+
+ Analog clock
+ Analog clock
+ Round
+ Numeral
+ Day
+ Cookie
+ Clover
+ Petal
+ Sunny
+ Square
+ Scallop
+
+
+ Stopwatch
+ Laps: %1$d
+
+
+ Quick timer
+ Quick timer
+ Start %1$s
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 5aa70ef..9230f70 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -8,4 +8,16 @@
+
+
+
diff --git a/app/src/main/res/values/widget_shapes.xml b/app/src/main/res/values/widget_shapes.xml
new file mode 100644
index 0000000..914d782
--- /dev/null
+++ b/app/src/main/res/values/widget_shapes.xml
@@ -0,0 +1,20 @@
+
+
+
+
+ M50.00,2.49 C52.46,2.49 54.92,3.48 56.72,5.46 C59.11,8.09 62.82,9.08 66.20,8.00 C71.30,6.38 76.70,9.49 77.84,14.72 C78.60,18.19 81.31,20.90 84.78,21.66 C90.01,22.81 93.13,28.20 91.50,33.30 C90.42,36.69 91.42,40.39 94.04,42.78 C98.00,46.39 98.00,52.62 94.04,56.23 C91.42,58.62 90.42,62.32 91.50,65.71 C93.13,70.81 90.01,76.20 84.78,77.35 C81.31,78.11 78.60,80.82 77.84,84.29 C76.70,89.52 71.30,92.63 66.20,91.01 C62.82,89.93 59.11,90.92 56.72,93.55 C53.11,97.51 46.89,97.51 43.28,93.55 C40.89,90.92 37.18,89.93 33.80,91.01 C28.70,92.63 23.30,89.52 22.16,84.29 C21.40,80.82 18.69,78.11 15.22,77.35 C9.99,76.20 6.87,70.81 8.50,65.71 C9.58,62.32 8.58,58.62 5.96,56.23 C2.00,52.62 2.00,46.39 5.96,42.78 C8.58,40.39 9.58,36.69 8.50,33.30 C6.87,28.20 9.99,22.81 15.22,21.66 C18.69,20.90 21.40,18.19 22.16,14.72 C23.30,9.49 28.70,6.38 33.80,8.00 C37.18,9.08 40.89,8.09 43.28,5.46 C45.08,3.48 47.54,2.49 50.00,2.49 Z
+
+ M97.70,50.00 C97.70,51.72 97.11,53.45 95.92,54.84 C93.49,57.69 91.06,60.54 88.63,63.39 C87.61,64.59 87.00,66.07 86.87,67.64 C86.58,71.37 86.28,75.10 85.98,78.84 C85.69,82.49 82.79,85.39 79.14,85.68 C75.40,85.98 71.67,86.28 67.94,86.58 C66.37,86.70 64.88,87.32 63.69,88.34 C60.84,90.76 57.99,93.19 55.14,95.62 C52.35,98.00 48.25,98.00 45.46,95.62 C42.61,93.19 39.76,90.76 36.91,88.34 C35.71,87.32 34.22,86.70 32.66,86.58 C28.93,86.28 25.19,85.98 21.46,85.68 C17.81,85.39 14.91,82.49 14.61,78.84 C14.32,75.10 14.02,71.37 13.72,67.64 C13.60,66.07 12.98,64.59 11.96,63.39 C9.53,60.54 7.10,57.69 4.67,54.84 C2.30,52.05 2.30,47.95 4.67,45.16 C7.10,42.31 9.53,39.46 11.96,36.61 C12.98,35.41 13.60,33.93 13.72,32.36 C14.02,28.63 14.32,24.90 14.61,21.16 C14.91,17.51 17.81,14.61 21.46,14.32 C25.19,14.02 28.93,13.72 32.66,13.42 C34.22,13.30 35.71,12.68 36.91,11.66 C39.76,9.24 42.61,6.81 45.46,4.38 C48.25,2.00 52.35,2.00 55.14,4.38 C57.99,6.81 60.84,9.24 63.69,11.66 C64.88,12.68 66.37,13.30 67.94,13.42 C71.67,13.72 75.40,14.02 79.14,14.32 C82.79,14.61 85.69,17.51 85.98,21.16 C86.28,24.90 86.58,28.63 86.87,32.36 C87.00,33.93 87.61,35.41 88.63,36.61 C91.06,39.46 93.49,42.31 95.92,45.16 C97.11,46.55 97.70,48.28 97.70,50.00 Z
+
+ M70.79,85.80 C69.17,86.73 67.66,87.86 66.30,89.17 C66.26,89.22 66.21,89.26 66.16,89.31 C57.15,98.00 42.87,97.99 33.87,89.28 C31.16,86.66 27.85,84.75 24.23,83.70 C24.17,83.69 24.10,83.67 24.04,83.65 C12.00,80.19 4.87,67.82 7.92,55.67 C8.83,52.01 8.83,48.19 7.93,44.53 C7.91,44.47 7.89,44.40 7.88,44.34 C4.86,32.19 12.01,19.83 24.05,16.39 C27.67,15.35 30.98,13.44 33.70,10.83 C33.74,10.78 33.79,10.74 33.84,10.69 C42.85,2.00 57.13,2.01 66.13,10.72 C68.84,13.34 72.15,15.25 75.77,16.30 C75.83,16.31 75.90,16.33 75.96,16.35 C88.00,19.81 95.13,32.18 92.08,44.33 C91.17,47.99 91.17,51.81 92.07,55.47 C92.09,55.53 92.11,55.60 92.12,55.66 C95.14,67.81 87.99,80.17 75.95,83.61 C74.14,84.13 72.41,84.86 70.79,85.80 Z
+
+ M89.56,89.56 C84.35,94.78 77.15,98.00 69.20,98.00 C56.40,98.00 43.60,98.00 30.80,98.00 C14.89,98.00 2.00,85.11 2.00,69.20 C2.00,56.40 2.00,43.60 2.00,30.80 C2.00,14.89 14.89,2.00 30.80,2.00 C43.60,2.00 56.40,2.00 69.20,2.00 C85.11,2.00 98.00,14.89 98.00,30.80 C98.00,43.60 98.00,56.40 98.00,69.20 C98.00,77.15 94.78,84.35 89.56,89.56 Z
+
+ M50.00,8.84 C50.70,8.47 51.39,8.10 52.09,7.73 C62.69,2.11 75.76,7.87 78.74,19.49 C78.86,19.96 78.98,20.43 79.10,20.90 C79.86,21.13 80.61,21.36 81.37,21.59 C92.84,25.11 98.00,38.42 91.89,48.75 C91.65,49.17 91.40,49.58 91.16,50.00 C91.53,50.70 91.90,51.39 92.27,52.09 C97.89,62.69 92.13,75.76 80.51,78.74 C80.04,78.86 79.57,78.98 79.10,79.10 C78.87,79.86 78.64,80.61 78.41,81.37 C74.89,92.84 61.58,98.00 51.25,91.89 C50.83,91.65 50.42,91.40 50.00,91.16 C49.30,91.53 48.61,91.90 47.91,92.27 C37.31,97.89 24.24,92.13 21.26,80.51 C21.14,80.04 21.02,79.57 20.90,79.10 C20.14,78.87 19.39,78.64 18.63,78.41 C7.16,74.89 2.00,61.58 8.11,51.25 C8.35,50.83 8.60,50.42 8.84,50.00 C8.47,49.30 8.10,48.61 7.73,47.91 C2.11,37.31 7.87,24.24 19.49,21.26 C19.96,21.14 20.43,21.02 20.90,20.90 C21.13,20.14 21.36,19.39 21.59,18.63 C25.11,7.16 38.42,2.00 48.75,8.11 C49.17,8.35 49.58,8.60 50.00,8.84 Z
+
+ M50.00,11.42 C50.46,11.06 50.93,10.71 51.39,10.35 C62.25,2.00 77.62,3.00 87.31,12.69 C97.00,22.38 98.00,37.75 89.65,48.61 C89.29,49.07 88.94,49.54 88.58,50.00 C88.94,50.46 89.29,50.93 89.65,51.39 C98.00,62.25 97.00,77.62 87.31,87.31 C77.62,97.00 62.25,98.00 51.39,89.65 C50.93,89.29 50.46,88.94 50.00,88.58 C49.54,88.94 49.07,89.29 48.61,89.65 C37.75,98.00 22.38,97.00 12.69,87.31 C3.00,77.62 2.00,62.25 10.35,51.39 C10.71,50.93 11.06,50.46 11.42,50.00 C11.06,49.54 10.71,49.07 10.35,48.61 C2.00,37.75 3.00,22.38 12.69,12.69 C22.38,3.00 37.75,2.00 48.61,10.35 C49.07,10.71 49.54,11.06 50.00,11.42 Z
+
diff --git a/app/src/main/res/xml/widget_analog_clock_info.xml b/app/src/main/res/xml/widget_analog_clock_info.xml
new file mode 100644
index 0000000..ebbf703
--- /dev/null
+++ b/app/src/main/res/xml/widget_analog_clock_info.xml
@@ -0,0 +1,15 @@
+
+
diff --git a/app/src/main/res/xml/widget_clock_info.xml b/app/src/main/res/xml/widget_clock_info.xml
index c0bbdd6..1e4f328 100644
--- a/app/src/main/res/xml/widget_clock_info.xml
+++ b/app/src/main/res/xml/widget_clock_info.xml
@@ -9,5 +9,7 @@
android:updatePeriodMillis="0"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
- android:previewImage="@mipmap/ic_launcher"
- android:description="@string/widget_clock_description" />
+ android:description="@string/widget_clock_description"
+ android:configure="de.jeanlucmakiola.clockula.ui.widget.config.ClockWidgetConfigActivity"
+ android:widgetFeatures="reconfigurable|configuration_optional"
+ android:previewLayout="@layout/widget_digital_classic" />
diff --git a/app/src/main/res/xml/widget_quick_timer_info.xml b/app/src/main/res/xml/widget_quick_timer_info.xml
new file mode 100644
index 0000000..65e8970
--- /dev/null
+++ b/app/src/main/res/xml/widget_quick_timer_info.xml
@@ -0,0 +1,15 @@
+
+
diff --git a/app/src/main/res/xml/widget_stopwatch_info.xml b/app/src/main/res/xml/widget_stopwatch_info.xml
new file mode 100644
index 0000000..13f2f68
--- /dev/null
+++ b/app/src/main/res/xml/widget_stopwatch_info.xml
@@ -0,0 +1,13 @@
+
+
diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt
index 9ce0ca7..d7905a9 100644
--- a/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt
@@ -31,7 +31,7 @@ class ManifestRulesTest {
}
@Test
- fun `exactly the launcher, the door and the three widget receivers are exported`() {
+ fun `exactly the launcher, the door, the three widget configurators and the six widget receivers are exported`() {
val exported = Regex("<(activity|service|receiver|provider)\\b[\\s\\S]*?(?:/>|\\1>)")
.findAll(text)
.map { it.value }
@@ -39,20 +39,53 @@ class ManifestRulesTest {
.map { Regex("android:name=\"([^\"]+)\"").find(it)!!.groupValues[1] }
.toList()
- // M10 adds exactly three: a GlanceAppWidgetReceiver must be exported
- // for the platform to pick it as a widget provider at all, and
- // `the three widget receivers answer only APPWIDGET_UPDATE` pins that
- // each one is guarded to that one platform-delivered broadcast and
- // nothing wider.
+ // A GlanceAppWidgetReceiver must be exported for the platform to pick
+ // it as a widget provider at all, and `the six widget receivers
+ // answer only APPWIDGET_UPDATE` pins that each one is guarded to
+ // that one platform-delivered broadcast and nothing wider. M11 adds
+ // three widget config activities: exported for the same reason —
+ // only the launcher can start an `APPWIDGET_CONFIGURE` activity
+ // cross-package — and guarded to that one action by `the three
+ // widget configurators answer only APPWIDGET_CONFIGURE` below.
assertThat(exported).containsExactly(
".MainActivity",
".interop.AlarmClockActivity",
+ ".ui.widget.config.ClockWidgetConfigActivity",
+ ".ui.widget.config.AnalogClockConfigActivity",
+ ".ui.widget.config.QuickTimerConfigActivity",
".widget.NextAlarmWidgetReceiver",
".widget.TimerWidgetReceiver",
".widget.ClockWidgetReceiver",
+ ".widget.AnalogClockWidgetReceiver",
+ ".widget.StopwatchWidgetReceiver",
+ ".widget.QuickTimerWidgetReceiver",
)
}
+ /**
+ * M11: the mirror of `the six widget receivers answer only
+ * APPWIDGET_UPDATE` for the three widget config activities — being
+ * exported cannot be widened into a second, wider surface by accident.
+ */
+ @Test
+ fun `the three widget configurators answer only APPWIDGET_CONFIGURE`() {
+ val configurators = listOf(
+ ".ui.widget.config.ClockWidgetConfigActivity",
+ ".ui.widget.config.AnalogClockConfigActivity",
+ ".ui.widget.config.QuickTimerConfigActivity",
+ )
+ for (name in configurators) {
+ val block = activityBlock(name)
+
+ val actions = Regex("|)")
.findAll(text)
@@ -113,17 +149,20 @@ class ManifestRulesTest {
}
/**
- * M10: a `GlanceAppWidgetReceiver` must be exported for the platform to
- * offer it as a widget provider, but that is the *only* door it may open —
- * each one answers `APPWIDGET_UPDATE` and nothing else, so being exported
+ * A `GlanceAppWidgetReceiver` must be exported for the platform to offer
+ * it as a widget provider, but that is the *only* door it may open — each
+ * one answers `APPWIDGET_UPDATE` and nothing else, so being exported
* cannot be widened into a second, wider surface by accident.
*/
@Test
- fun `the three widget receivers answer only APPWIDGET_UPDATE`() {
+ fun `the six widget receivers answer only APPWIDGET_UPDATE`() {
val widgetReceivers = listOf(
".widget.NextAlarmWidgetReceiver",
".widget.TimerWidgetReceiver",
".widget.ClockWidgetReceiver",
+ ".widget.AnalogClockWidgetReceiver",
+ ".widget.StopwatchWidgetReceiver",
+ ".widget.QuickTimerWidgetReceiver",
)
for (name in widgetReceivers) {
val block = Regex("|)")
diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/ClockWidgetSettingsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/ClockWidgetSettingsTest.kt
new file mode 100644
index 0000000..3279aec
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/ClockWidgetSettingsTest.kt
@@ -0,0 +1,65 @@
+package de.jeanlucmakiola.clockula.domain.widget
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.jupiter.api.Test
+
+class ClockWidgetSettingsTest {
+
+ @Test
+ fun `null parses to Regular`() {
+ assertThat(parseDigitalClockStyle(null)).isEqualTo(DigitalClockStyle.REGULAR)
+ }
+
+ @Test
+ fun `an unknown name parses to Regular`() {
+ assertThat(parseDigitalClockStyle("Nonexistent")).isEqualTo(DigitalClockStyle.REGULAR)
+ }
+
+ @Test
+ fun `each digital style's own name round-trips`() {
+ DigitalClockStyle.entries.forEach { style ->
+ assertThat(parseDigitalClockStyle(style.name)).isEqualTo(style)
+ }
+ }
+
+ @Test
+ fun `digital settings default to Regular with the date on and world clocks off`() {
+ val settings = DigitalClockSettings()
+
+ assertThat(settings.style).isEqualTo(DigitalClockStyle.REGULAR)
+ assertThat(settings.showDate).isTrue()
+ assertThat(settings.showWorldClocks).isFalse()
+ }
+
+ @Test
+ fun `null parses to Round`() {
+ assertThat(parseAnalogClockStyle(null)).isEqualTo(AnalogClockStyle.ROUND)
+ }
+
+ @Test
+ fun `an unknown analog name parses to Round`() {
+ assertThat(parseAnalogClockStyle("Nonexistent")).isEqualTo(AnalogClockStyle.ROUND)
+ }
+
+ @Test
+ fun `each analog style's own name round-trips`() {
+ AnalogClockStyle.entries.forEach { style ->
+ assertThat(parseAnalogClockStyle(style.name)).isEqualTo(style)
+ }
+ }
+
+ @Test
+ fun `null quick-timer duration parses to the default`() {
+ assertThat(parseQuickTimerDuration(null)).isEqualTo(QuickTimerWidgetSettings.DEFAULT)
+ }
+
+ @Test
+ fun `an out-of-range quick-timer duration parses to the default`() {
+ assertThat(parseQuickTimerDuration(0L)).isEqualTo(QuickTimerWidgetSettings.DEFAULT)
+ }
+
+ @Test
+ fun `an in-range quick-timer duration round-trips`() {
+ assertThat(parseQuickTimerDuration(600_000L).inWholeMilliseconds).isEqualTo(600_000L)
+ }
+}
diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/QuickTimerWidgetModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/QuickTimerWidgetModelTest.kt
new file mode 100644
index 0000000..a9c686c
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/QuickTimerWidgetModelTest.kt
@@ -0,0 +1,47 @@
+package de.jeanlucmakiola.clockula.domain.widget
+
+import com.google.common.truth.Truth.assertThat
+import de.jeanlucmakiola.clockula.domain.timer.TimerMode
+import de.jeanlucmakiola.clockula.testing.T0
+import de.jeanlucmakiola.clockula.testing.expiredTimer
+import de.jeanlucmakiola.clockula.testing.idleTimer
+import de.jeanlucmakiola.clockula.testing.pausedTimer
+import de.jeanlucmakiola.clockula.testing.runningTimerWith
+import org.junit.jupiter.api.Test
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.minutes
+
+class QuickTimerWidgetModelTest {
+
+ @Test
+ fun `no started timer is at rest`() {
+ val model = QuickTimerWidgetPresenter.present(5.minutes, null, listOf(runningTimerWith(id = 1, left = 1.minutes)), Duration.ZERO, T0)
+
+ assertThat(model.active).isNull()
+ assertThat(model.duration).isEqualTo(5.minutes)
+ }
+
+ @Test
+ fun `its own running timer is active`() {
+ val timers = listOf(runningTimerWith(id = 1, left = 1.minutes), runningTimerWith(id = 2, left = 3.minutes))
+
+ val model = QuickTimerWidgetPresenter.present(5.minutes, 2L, timers, Duration.ZERO, T0)
+
+ assertThat(model.active?.id).isEqualTo(2L)
+ assertThat(model.active?.mode).isEqualTo(TimerMode.RUNNING)
+ }
+
+ @Test
+ fun `paused and expired stay active`() {
+ assertThat(QuickTimerWidgetPresenter.present(5.minutes, 1L, listOf(pausedTimer(id = 1)), Duration.ZERO, T0).active?.mode)
+ .isEqualTo(TimerMode.PAUSED)
+ assertThat(QuickTimerWidgetPresenter.present(5.minutes, 1L, listOf(expiredTimer(id = 1)), Duration.ZERO, T0).active?.mode)
+ .isEqualTo(TimerMode.EXPIRED)
+ }
+
+ @Test
+ fun `a deleted or idle timer is back at rest`() {
+ assertThat(QuickTimerWidgetPresenter.present(5.minutes, 1L, emptyList(), Duration.ZERO, T0).active).isNull()
+ assertThat(QuickTimerWidgetPresenter.present(5.minutes, 1L, listOf(idleTimer(id = 1)), Duration.ZERO, T0).active).isNull()
+ }
+}
diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/StopwatchWidgetModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/StopwatchWidgetModelTest.kt
new file mode 100644
index 0000000..94fdc46
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/StopwatchWidgetModelTest.kt
@@ -0,0 +1,51 @@
+package de.jeanlucmakiola.clockula.domain.widget
+
+import com.google.common.truth.Truth.assertThat
+import de.jeanlucmakiola.clockula.domain.StopwatchRun
+import de.jeanlucmakiola.clockula.domain.StopwatchState
+import org.junit.jupiter.api.Test
+import kotlin.time.Duration.Companion.seconds
+
+class StopwatchWidgetModelTest {
+
+ @Test
+ fun `idle reads zero with no laps`() {
+ val model = StopwatchWidgetPresenter.present(StopwatchRun(), elapsedRealtime = 10.seconds, lapCount = 0)
+
+ assertThat(model.mode).isEqualTo(StopwatchState.IDLE)
+ assertThat(model.elapsed).isEqualTo(kotlin.time.Duration.ZERO)
+ assertThat(model.lapCount).isEqualTo(0)
+ }
+
+ @Test
+ fun `running reads the live elapsed value`() {
+ val run = StopwatchRun(state = StopwatchState.RUNNING, startedAtElapsedRealtime = 5.seconds)
+
+ val model = StopwatchWidgetPresenter.present(run, elapsedRealtime = 17.seconds, lapCount = 2)
+
+ assertThat(model.mode).isEqualTo(StopwatchState.RUNNING)
+ assertThat(model.elapsed).isEqualTo(12.seconds)
+ assertThat(model.lapCount).isEqualTo(2)
+ }
+
+ @Test
+ fun `paused reads the banked accumulated value regardless of elapsed realtime`() {
+ val run = StopwatchRun(state = StopwatchState.PAUSED, accumulated = 90.seconds)
+
+ val model = StopwatchWidgetPresenter.present(run, elapsedRealtime = 500.seconds, lapCount = 3)
+
+ assertThat(model.mode).isEqualTo(StopwatchState.PAUSED)
+ assertThat(model.elapsed).isEqualTo(90.seconds)
+ assertThat(model.lapCount).isEqualTo(3)
+ }
+
+ @Test
+ fun `a stale running anchor reads paused at the banked value`() {
+ val run = StopwatchRun(state = StopwatchState.RUNNING, accumulated = 30.seconds, startedAtElapsedRealtime = null)
+
+ val model = StopwatchWidgetPresenter.present(run, elapsedRealtime = 40.seconds, lapCount = 0)
+
+ assertThat(model.mode).isEqualTo(StopwatchState.PAUSED)
+ assertThat(model.elapsed).isEqualTo(30.seconds)
+ }
+}
diff --git a/floret-kit b/floret-kit
index 18a7977..6ef77bd 160000
--- a/floret-kit
+++ b/floret-kit
@@ -1 +1 @@
-Subproject commit 18a797747b6f27032bf674415af857a1223b6975
+Subproject commit 6ef77bd5b247024dfb997553cdcfc8fb27c84a01