From fb81bfbf2ed9f0cd9aea7d6e2ab779b50af71a94 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 1 Oct 2026 22:32:07 +0200 Subject: [PATCH] feat(widgets): next alarm, timer and clock home-screen widgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three Glance widgets, mirroring state the app already computes rather than owning any of their own: the next alarm (AlarmEngine.upcoming()), the live pill's subject timer (same selection logic, no ticker of its own — a running countdown is the platform chronometer, exactly like the ring notification), and the local time growing to world clocks at larger sizes. WidgetSync collects the same read-only flows the shell already reads and redraws on change, event-driven (updatePeriodMillis = 0) — it touches no engine, only re-renders, and covers every write path including SystemEventReceiver's since the process is already alive whenever those run. Tapping a widget reuses the same internal navigation actions the AlarmClock contract's door already produces; the timer buttons send the same unexported broadcasts its notification does. A widget is just another caller of machinery that already exists. ManifestRulesTest is updated deliberately: three GlanceAppWidgetReceivers join the exported set, each pinned to exactly the platform-required APPWIDGET_UPDATE filter and no permission — no new permission is requested anywhere in this change. --- app/src/main/AndroidManifest.xml | 42 +++++ .../de/jeanlucmakiola/clockula/ClockulaApp.kt | 8 + .../domain/widget/ClockWidgetModel.kt | 19 +++ .../domain/widget/NextAlarmWidgetModel.kt | 41 +++++ .../domain/widget/TimerWidgetModel.kt | 46 ++++++ .../clockula/ui/shell/ShellNavigation.kt | 10 ++ .../clockula/ui/widget/ClockWidgetContent.kt | 89 +++++++++++ .../ui/widget/NextAlarmWidgetContent.kt | 91 +++++++++++ .../clockula/ui/widget/TimerWidgetContent.kt | 151 ++++++++++++++++++ .../clockula/ui/widget/WidgetDefaults.kt | 29 ++++ .../clockula/widget/ClockWidget.kt | 33 ++++ .../clockula/widget/NextAlarmWidget.kt | 30 ++++ .../clockula/widget/TimerWidget.kt | 30 ++++ .../clockula/widget/WidgetSync.kt | 60 +++++++ .../clockula/widget/di/WidgetEntryPoint.kt | 36 +++++ .../main/res/layout/widget_chronometer.xml | 13 ++ app/src/main/res/layout/widget_text_clock.xml | 13 ++ app/src/main/res/values/strings.xml | 9 ++ app/src/main/res/xml/widget_clock_info.xml | 13 ++ .../main/res/xml/widget_next_alarm_info.xml | 13 ++ app/src/main/res/xml/widget_timer_info.xml | 13 ++ .../clockula/ManifestRulesTest.kt | 53 +++++- .../domain/widget/ClockWidgetModelTest.kt | 42 +++++ .../domain/widget/NextAlarmWidgetModelTest.kt | 83 ++++++++++ .../domain/widget/TimerWidgetModelTest.kt | 76 +++++++++ 25 files changed, 1039 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/ClockWidgetModel.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/NextAlarmWidgetModel.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/TimerWidgetModel.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/ClockWidgetContent.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/NextAlarmWidgetContent.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/TimerWidgetContent.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/WidgetDefaults.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/widget/ClockWidget.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/widget/NextAlarmWidget.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/widget/TimerWidget.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/widget/WidgetSync.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/widget/di/WidgetEntryPoint.kt create mode 100644 app/src/main/res/layout/widget_chronometer.xml create mode 100644 app/src/main/res/layout/widget_text_clock.xml create mode 100644 app/src/main/res/xml/widget_clock_info.xml create mode 100644 app/src/main/res/xml/widget_next_alarm_info.xml create mode 100644 app/src/main/res/xml/widget_timer_info.xml create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/ClockWidgetModelTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/NextAlarmWidgetModelTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/TimerWidgetModelTest.kt 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]*?(?:/>|)") .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) + } +}