diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 72e9b8a..ab01b72 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -232,6 +232,48 @@
android:name="autoStoreLocales"
android:value="true" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt
index 8dd09c6..4b7e227 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine
import de.jeanlucmakiola.clockula.stopwatch.service.StopwatchNotifications
import de.jeanlucmakiola.clockula.timer.TimerEngine
import de.jeanlucmakiola.clockula.timer.service.TimerNotifications
+import de.jeanlucmakiola.clockula.widget.WidgetSync
import de.jeanlucmakiola.floret.crash.CrashConfig
import de.jeanlucmakiola.floret.crash.CrashReporter
import de.jeanlucmakiola.floret.di.ApplicationScope
@@ -37,6 +38,9 @@ class ClockulaApp : Application() {
@Inject
lateinit var stopwatchEngine: StopwatchEngine
+ @Inject
+ lateinit var widgetSync: WidgetSync
+
@Inject
@ApplicationScope
lateinit var scope: CoroutineScope
@@ -77,5 +81,9 @@ class ClockulaApp : Application() {
// run, leaving it paused at what it banked (M7 D17).
stopwatchEngine.onBootCompleted()
}
+
+ // Never completes on its own — it is the widgets' one refresh trigger
+ // for as long as the process lives (M10, docs/PLAN.md §9).
+ scope.launch { widgetSync.run() }
}
}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/ClockWidgetModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/ClockWidgetModel.kt
new file mode 100644
index 0000000..1dc49dc
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/ClockWidgetModel.kt
@@ -0,0 +1,19 @@
+package de.jeanlucmakiola.clockula.domain.widget
+
+import de.jeanlucmakiola.clockula.domain.WorldClock
+import de.jeanlucmakiola.clockula.domain.isKnownZone
+
+object ClockWidgetPresenter {
+
+ /** The widget shows at most this many cities, even on a large size. */
+ const val MAX_CITIES = 3
+
+ /**
+ * Up to [MAX_CITIES] world clocks, in stored order, dropping a zone id the
+ * device no longer knows (`WorldClock.isKnownZone`) — city-name resolution
+ * itself is left to the ICU seam (`data/zones/`), which this pure selection
+ * does not touch.
+ */
+ fun present(worldClocks: List): List =
+ worldClocks.filter { it.isKnownZone }.take(MAX_CITIES)
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/NextAlarmWidgetModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/NextAlarmWidgetModel.kt
new file mode 100644
index 0000000..85f3276
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/NextAlarmWidgetModel.kt
@@ -0,0 +1,41 @@
+package de.jeanlucmakiola.clockula.domain.widget
+
+import de.jeanlucmakiola.clockula.domain.alarm.UpcomingAlarm
+import java.time.DayOfWeek
+import java.time.ZoneId
+import java.time.ZonedDateTime
+import kotlin.time.Instant
+import kotlin.time.toJavaInstant
+
+/**
+ * What the next-alarm widget shows. [hour]/[minute]/[dayOfWeek] are absolute —
+ * never a relative "in 3h" — so the widget cannot go stale between the writes
+ * that refresh it (`docs/PLAN.md` §9).
+ */
+data class NextAlarmWidgetModel(
+ val hasAlarm: Boolean,
+ val hour: Int = 0,
+ val minute: Int = 0,
+ val dayOfWeek: DayOfWeek? = null,
+ val isToday: Boolean = false,
+ val label: String = "",
+)
+
+object NextAlarmWidgetPresenter {
+
+ /** The first [UpcomingAlarm] with a resolved fire time — `AlarmEngine.upcoming()`'s own sort already puts it first. */
+ fun present(upcoming: List, now: Instant, zone: ZoneId): NextAlarmWidgetModel {
+ val next = upcoming.firstOrNull { it.nextFire != null } ?: return NextAlarmWidgetModel(hasAlarm = false)
+ val fireAt = next.nextFire!!
+ val zonedFire = ZonedDateTime.ofInstant(fireAt.toJavaInstant(), zone)
+ val zonedNow = ZonedDateTime.ofInstant(now.toJavaInstant(), zone)
+ return NextAlarmWidgetModel(
+ hasAlarm = true,
+ hour = zonedFire.hour,
+ minute = zonedFire.minute,
+ dayOfWeek = zonedFire.dayOfWeek,
+ isToday = zonedFire.toLocalDate() == zonedNow.toLocalDate(),
+ label = next.alarm.label,
+ )
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/TimerWidgetModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/TimerWidgetModel.kt
new file mode 100644
index 0000000..2f2c19c
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/TimerWidgetModel.kt
@@ -0,0 +1,46 @@
+package de.jeanlucmakiola.clockula.domain.widget
+
+import de.jeanlucmakiola.clockula.domain.Timer
+import de.jeanlucmakiola.clockula.domain.timer.TimerMode
+import de.jeanlucmakiola.clockula.domain.timer.TimerReadings
+import kotlin.time.Duration
+import kotlin.time.Instant
+
+data class TimerWidgetItem(
+ val id: Long,
+ val label: String,
+ val mode: TimerMode,
+ val remaining: Duration,
+ val duration: Duration,
+ val endsAtWallClock: Instant?,
+)
+
+/** Empty means idle: no active timer to show, the widget's "New timer" state. */
+data class TimerWidgetModel(val items: List)
+
+object TimerWidgetPresenter {
+
+ /** The widget shows at most this many rows, even on a large size. */
+ const val MAX_ITEMS = 3
+
+ /**
+ * Up to [MAX_ITEMS] active timers, in the app's one subject order
+ * (`TimerReadings.active` — expired, then running by least remaining, then
+ * paused): the same precedence the live pill and the notification already
+ * use, so the widget never disagrees with them about which timer matters.
+ */
+ fun present(timers: List, elapsedRealtime: Duration, wallClock: Instant): TimerWidgetModel {
+ val active = TimerReadings.active(timers, elapsedRealtime, wallClock)
+ val items = active.take(MAX_ITEMS).map { reading ->
+ TimerWidgetItem(
+ id = reading.timer.id,
+ label = reading.timer.label,
+ mode = reading.mode,
+ remaining = reading.remaining,
+ duration = reading.timer.duration,
+ endsAtWallClock = reading.timer.endsAtWallClock,
+ )
+ }
+ return TimerWidgetModel(items)
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt
index 5c2e21d..22d834c 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt
@@ -38,6 +38,15 @@ sealed interface ShellBackAction {
/** The shell's navigation policy, as pure functions over routes. */
object ShellNavigation {
+ /**
+ * Internal only, like [de.jeanlucmakiola.clockula.interop.InteropIntents]'
+ * actions: nothing outside the app sends this. It exists because the clock
+ * widget (M10) needs to open the World clock tab the same way a
+ * notification opens its tab, and no `AlarmClock`-contract action covers a
+ * world clock.
+ */
+ const val ACTION_SHOW_WORLD_CLOCK: String = "de.jeanlucmakiola.clockula.action.SHOW_WORLD_CLOCK"
+
val start: ClockulaDestination = ClockulaDestination.ALARMS
val topLevel: List = ClockulaDestination.entries.toList()
@@ -90,6 +99,7 @@ object ShellNavigation {
AlarmIntents.ACTION_SHOW_ALARMS -> ClockulaDestination.ALARMS
TimerIntents.ACTION_SHOW_TIMERS -> ClockulaDestination.TIMERS
StopwatchIntents.ACTION_SHOW_STOPWATCH -> ClockulaDestination.STOPWATCH
+ ACTION_SHOW_WORLD_CLOCK -> ClockulaDestination.WORLD_CLOCK
else -> null
}
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
new file mode 100644
index 0000000..9d3e764
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/ClockWidgetContent.kt
@@ -0,0 +1,89 @@
+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.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.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.ui.shell.ShellNavigation
+
+/** One resolved world clock for the clock widget: the zone id a `TextClock` ticks against, with its already-resolved city name. */
+data class ClockWidgetCity(val zoneId: String, val cityName: String)
+
+/**
+ * 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).
+ */
+@Composable
+fun ClockWidgetContent(cities: List) {
+ 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)
+ }
+ }
+ }
+ }
+ }
+}
+
+/** [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/NextAlarmWidgetContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/NextAlarmWidgetContent.kt
new file mode 100644
index 0000000..dde3dc7
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/NextAlarmWidgetContent.kt
@@ -0,0 +1,91 @@
+package de.jeanlucmakiola.clockula.ui.widget
+
+import android.content.Context
+import android.content.Intent
+import android.text.format.DateFormat
+import androidx.compose.runtime.Composable
+import androidx.glance.GlanceModifier
+import androidx.glance.LocalContext
+import androidx.glance.text.Text
+import androidx.glance.GlanceTheme
+import androidx.glance.action.clickable
+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.padding
+import androidx.glance.text.FontWeight
+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
+import de.jeanlucmakiola.clockula.domain.widget.NextAlarmWidgetModel
+import java.time.LocalTime
+import java.time.format.DateTimeFormatter
+import java.time.format.FormatStyle
+import java.time.format.TextStyle as JavaTextStyle
+import java.util.Locale
+
+/** The next-alarm widget's content, given an already-resolved [model]. */
+@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,
+ ) {
+ if (!model.hasAlarm) {
+ Text(
+ text = context.getString(R.string.widget_next_alarm_empty),
+ style = bodyStyle(GlanceTheme.colors.onSurfaceVariant),
+ )
+ } else {
+ 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))
+ }
+ }
+ }
+ }
+ }
+}
+
+private fun showAlarmsIntent(context: Context): Intent =
+ Intent(context, MainActivity::class.java)
+ .setAction(AlarmIntents.ACTION_SHOW_ALARMS)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+
+private fun timeLabel(context: Context, model: NextAlarmWidgetModel): String {
+ val time = LocalTime.of(model.hour, model.minute)
+ val formatter = if (DateFormat.is24HourFormat(context)) {
+ DateTimeFormatter.ofPattern("HH:mm")
+ } else {
+ DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
+ }
+ return formatter.format(time)
+}
+
+private fun dayLabel(context: Context, model: NextAlarmWidgetModel): String = when {
+ model.isToday -> context.getString(R.string.widget_next_alarm_today)
+ 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/TimerWidgetContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/TimerWidgetContent.kt
new file mode 100644
index 0000000..15be7ec
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/TimerWidgetContent.kt
@@ -0,0 +1,151 @@
+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.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.fillMaxSize
+import androidx.glance.layout.fillMaxWidth
+import androidx.glance.layout.padding
+import androidx.glance.layout.Spacer
+import androidx.glance.layout.height
+import androidx.glance.text.FontWeight
+import androidx.glance.text.TextStyle
+import androidx.glance.unit.ColorProvider
+import de.jeanlucmakiola.clockula.R
+import de.jeanlucmakiola.clockula.domain.format.ClockFormat
+import de.jeanlucmakiola.clockula.domain.timer.TimerMode
+import de.jeanlucmakiola.clockula.domain.widget.TimerWidgetItem
+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
+
+/** 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`). */
+@Composable
+fun TimerWidgetContent(model: TimerWidgetModel) {
+ 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)
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun TimerRow(context: Context, item: TimerWidgetItem) {
+ 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))
+ }
+ TimerReadout(context, item)
+ }
+ TimerActions(context, item)
+ }
+}
+
+@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)
+ } else {
+ Text(text = staticReadout(context, item), style = timeStyle(GlanceTheme.colors.onSurface))
+ }
+}
+
+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 =
+ Intent(context, TimerActionReceiver::class.java)
+ .setAction(action)
+ .putExtra(TimerIntents.EXTRA_TIMER_ID, timerId)
+
+private fun showTimersOrComposeIntent(context: Context, model: TimerWidgetModel): Intent {
+ val action = if (model.items.isEmpty()) InteropIntents.ACTION_COMPOSE_TIMER else TimerIntents.ACTION_SHOW_TIMERS
+ return Intent(context, MainActivity::class.java)
+ .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
new file mode 100644
index 0000000..25bebb2
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/WidgetDefaults.kt
@@ -0,0 +1,29 @@
+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/widget/ClockWidget.kt b/app/src/main/java/de/jeanlucmakiola/clockula/widget/ClockWidget.kt
new file mode 100644
index 0000000..af373b6
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/widget/ClockWidget.kt
@@ -0,0 +1,33 @@
+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.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.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. */
+class ClockWidget : GlanceAppWidget() {
+
+ 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 { ClockWidgetContent(cities) }
+ }
+}
+
+class ClockWidgetReceiver : GlanceAppWidgetReceiver() {
+ override val glanceAppWidget: GlanceAppWidget = ClockWidget()
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/widget/NextAlarmWidget.kt b/app/src/main/java/de/jeanlucmakiola/clockula/widget/NextAlarmWidget.kt
new file mode 100644
index 0000000..18f75c7
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/widget/NextAlarmWidget.kt
@@ -0,0 +1,30 @@
+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.provideContent
+import de.jeanlucmakiola.clockula.domain.widget.NextAlarmWidgetPresenter
+import de.jeanlucmakiola.clockula.ui.widget.NextAlarmWidgetContent
+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() {
+
+ 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) }
+ }
+}
+
+class NextAlarmWidgetReceiver : GlanceAppWidgetReceiver() {
+ override val glanceAppWidget: GlanceAppWidget = NextAlarmWidget()
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/widget/TimerWidget.kt b/app/src/main/java/de/jeanlucmakiola/clockula/widget/TimerWidget.kt
new file mode 100644
index 0000000..b9a5e99
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/widget/TimerWidget.kt
@@ -0,0 +1,30 @@
+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.provideContent
+import de.jeanlucmakiola.clockula.domain.widget.TimerWidgetPresenter
+import de.jeanlucmakiola.clockula.ui.widget.TimerWidgetContent
+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() {
+
+ 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) }
+ }
+}
+
+class TimerWidgetReceiver : GlanceAppWidgetReceiver() {
+ override val glanceAppWidget: GlanceAppWidget = TimerWidget()
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/widget/WidgetSync.kt b/app/src/main/java/de/jeanlucmakiola/clockula/widget/WidgetSync.kt
new file mode 100644
index 0000000..da216c2
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/widget/WidgetSync.kt
@@ -0,0 +1,60 @@
+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.timers.TimerRepository
+import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository
+import androidx.glance.appwidget.updateAll
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.distinctUntilChanged
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.time.Duration.Companion.milliseconds
+
+/**
+ * Keeps the three 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.
+ *
+ * Started once, from [de.jeanlucmakiola.clockula.ClockulaApp.onCreate], in the
+ * app's `@ApplicationScope` — never driven by a `Ticker`, so an idle home
+ * screen costs nothing between writes.
+ */
+@Singleton
+class WidgetSync @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val alarmEngine: AlarmEngine,
+ private val timers: TimerRepository,
+ private val worldClocks: WorldClockRepository,
+) {
+
+ @OptIn(FlowPreview::class)
+ suspend fun run() {
+ combine(
+ alarmEngine.upcoming(),
+ timers.timers(),
+ worldClocks.worldClocks(),
+ ) { upcoming, timerRows, clocks -> Triple(upcoming, timerRows, clocks) }
+ .distinctUntilChanged()
+ // A burst of writes (an import's replace-all, a reorder) collapses
+ // to one redraw rather than one per row.
+ .debounce(DEBOUNCE)
+ .collect { updateAll() }
+ }
+
+ private suspend fun updateAll() {
+ NextAlarmWidget().updateAll(context)
+ TimerWidget().updateAll(context)
+ ClockWidget().updateAll(context)
+ }
+
+ private companion object {
+ val DEBOUNCE = 150.milliseconds
+ }
+}
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
new file mode 100644
index 0000000..df831b4
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/widget/di/WidgetEntryPoint.kt
@@ -0,0 +1,36 @@
+package de.jeanlucmakiola.clockula.widget.di
+
+import android.content.Context
+import dagger.hilt.EntryPoint
+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.timers.TimerRepository
+import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository
+import de.jeanlucmakiola.clockula.data.zones.ZoneNames
+import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock
+import de.jeanlucmakiola.clockula.domain.time.WallClock
+import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
+
+/**
+ * A `GlanceAppWidget` is not Hilt-constructed — it is instantiated by
+ * `GlanceAppWidgetReceiver`/Glance's own session machinery, never through
+ * `@AndroidEntryPoint` — so this is the seam the three widgets reach the app's
+ * Hilt singletons through, the standard pattern for a non-Hilt-constructable
+ * class (`EntryPointAccessors.fromApplication`).
+ */
+@EntryPoint
+@InstallIn(SingletonComponent::class)
+interface WidgetEntryPoint {
+ fun alarmEngine(): AlarmEngine
+ fun timerRepository(): TimerRepository
+ fun worldClockRepository(): WorldClockRepository
+ fun zoneNames(): ZoneNames
+ fun wallClock(): WallClock
+ fun elapsedRealtimeClock(): ElapsedRealtimeClock
+ fun zoneProvider(): ZoneProvider
+}
+
+fun widgetEntryPoint(context: Context): WidgetEntryPoint =
+ EntryPointAccessors.fromApplication(context.applicationContext, WidgetEntryPoint::class.java)
diff --git a/app/src/main/res/layout/widget_chronometer.xml b/app/src/main/res/layout/widget_chronometer.xml
new file mode 100644
index 0000000..afdd1cf
--- /dev/null
+++ b/app/src/main/res/layout/widget_chronometer.xml
@@ -0,0 +1,13 @@
+
+
+
diff --git a/app/src/main/res/layout/widget_text_clock.xml b/app/src/main/res/layout/widget_text_clock.xml
new file mode 100644
index 0000000..1869452
--- /dev/null
+++ b/app/src/main/res/layout/widget_text_clock.xml
@@ -0,0 +1,13 @@
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 1468b8d..8c8db25 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -341,4 +341,13 @@
Next alarm, as the system sees it
Matches what Clockula scheduled
Doesn\'t match what Clockula scheduled — open the app to re-check
+
+
+ Next alarm
+ No alarms set
+ Today
+ Timer
+ New timer
+ Time\'s up
+ Clock
diff --git a/app/src/main/res/xml/widget_clock_info.xml b/app/src/main/res/xml/widget_clock_info.xml
new file mode 100644
index 0000000..c0bbdd6
--- /dev/null
+++ b/app/src/main/res/xml/widget_clock_info.xml
@@ -0,0 +1,13 @@
+
+
diff --git a/app/src/main/res/xml/widget_next_alarm_info.xml b/app/src/main/res/xml/widget_next_alarm_info.xml
new file mode 100644
index 0000000..cff3cb4
--- /dev/null
+++ b/app/src/main/res/xml/widget_next_alarm_info.xml
@@ -0,0 +1,13 @@
+
+
diff --git a/app/src/main/res/xml/widget_timer_info.xml b/app/src/main/res/xml/widget_timer_info.xml
new file mode 100644
index 0000000..e198daf
--- /dev/null
+++ b/app/src/main/res/xml/widget_timer_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 c398d0f..9ce0ca7 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 two components are exported`() {
+ fun `exactly the launcher, the door and the three widget receivers are exported`() {
val exported = Regex("<(activity|service|receiver|provider)\\b[\\s\\S]*?(?:/>|\\1>)")
.findAll(text)
.map { it.value }
@@ -39,7 +39,18 @@ class ManifestRulesTest {
.map { Regex("android:name=\"([^\"]+)\"").find(it)!!.groupValues[1] }
.toList()
- assertThat(exported).containsExactly(".MainActivity", ".interop.AlarmClockActivity")
+ // 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.
+ assertThat(exported).containsExactly(
+ ".MainActivity",
+ ".interop.AlarmClockActivity",
+ ".widget.NextAlarmWidgetReceiver",
+ ".widget.TimerWidgetReceiver",
+ ".widget.ClockWidgetReceiver",
+ )
}
@Test
@@ -85,14 +96,48 @@ class ManifestRulesTest {
}
@Test
- fun `every receiver is still unexported`() {
+ fun `every receiver but the three widget providers is still unexported`() {
+ val widgetReceivers = setOf(
+ ".widget.NextAlarmWidgetReceiver",
+ ".widget.TimerWidgetReceiver",
+ ".widget.ClockWidgetReceiver",
+ )
val exportedReceivers = Regex("|)")
.findAll(text)
.map { it.value }
.filterNot { it.contains("android:exported=\"false\"") }
+ .map { Regex("android:name=\"([^\"]+)\"").find(it)!!.groupValues[1] }
.toList()
- assertThat(exportedReceivers).isEmpty()
+ assertThat(exportedReceivers).containsExactlyElementsIn(widgetReceivers)
+ }
+
+ /**
+ * 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
+ * cannot be widened into a second, wider surface by accident.
+ */
+ @Test
+ fun `the three widget receivers answer only APPWIDGET_UPDATE`() {
+ val widgetReceivers = listOf(
+ ".widget.NextAlarmWidgetReceiver",
+ ".widget.TimerWidgetReceiver",
+ ".widget.ClockWidgetReceiver",
+ )
+ for (name in widgetReceivers) {
+ val block = Regex("|)")
+ .findAll(text)
+ .map { it.value }
+ .first { it.contains("android:name=\"$name\"") }
+
+ val actions = Regex(" expiredTimer(id = id) }
+
+ val model = TimerWidgetPresenter.present(timers, Duration.ZERO, T0)
+
+ assertThat(model.items).hasSize(TimerWidgetPresenter.MAX_ITEMS)
+ }
+
+ @Test
+ fun `a paused timer keeps its remaining and endsAtWallClock is null`() {
+ val timers = listOf(pausedTimer(id = 1, remaining = 90.minutes))
+
+ val model = TimerWidgetPresenter.present(timers, Duration.ZERO, T0)
+
+ val item = model.items.single()
+ assertThat(item.mode).isEqualTo(TimerMode.PAUSED)
+ assertThat(item.remaining).isEqualTo(90.minutes)
+ assertThat(item.endsAtWallClock).isNull()
+ }
+
+ @Test
+ fun `a running timer carries its wall-clock end, for the widget's chronometer`() {
+ val timers = listOf(runningTimerWith(id = 1, left = 10.minutes, wallNow = T0))
+
+ val model = TimerWidgetPresenter.present(timers, Duration.ZERO, T0)
+
+ val item = model.items.single()
+ assertThat(item.mode).isEqualTo(TimerMode.RUNNING)
+ assertThat(item.endsAtWallClock).isEqualTo(T0 + 10.minutes)
+ }
+}