feat(widgets): next alarm, timer and clock home-screen widgets

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.
This commit is contained in:
2026-10-01 22:32:07 +02:00
parent a11396b058
commit fb81bfbf2e
25 changed files with 1039 additions and 4 deletions
+42
View File
@@ -232,6 +232,48 @@
android:name="autoStoreLocales"
android:value="true" />
</service>
<!-- The three M10 home-screen widgets (docs/PLAN.md §9). Each is a
GlanceAppWidgetReceiver, and each must be exported with only the
APPWIDGET_UPDATE filter — that is how the launcher delivers a
widget update, and the platform requires exported="true" for a
widget provider to be pickable at all. No permission guards it,
same as any other app's widget: APPWIDGET_UPDATE is a
platform-delivered broadcast, never a door another app can open by
naming the action itself. updatePeriodMillis is 0 in every
provider-info XML — WidgetSync is the one refresh trigger. -->
<receiver
android:name=".widget.NextAlarmWidgetReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_next_alarm_info" />
</receiver>
<receiver
android:name=".widget.TimerWidgetReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_timer_info" />
</receiver>
<receiver
android:name=".widget.ClockWidgetReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_clock_info" />
</receiver>
</application>
</manifest>
@@ -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() }
}
}
@@ -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<WorldClock>): List<WorldClock> =
worldClocks.filter { it.isKnownZone }.take(MAX_CITIES)
}
@@ -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<UpcomingAlarm>, 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,
)
}
}
@@ -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<TimerWidgetItem>)
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<Timer>, 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)
}
}
@@ -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> = 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
}
@@ -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<ClockWidgetCity>) {
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)
@@ -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)
@@ -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)
@@ -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,
)
}
@@ -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()
}
@@ -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()
}
@@ -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()
}
@@ -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
}
}
@@ -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)
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Embedded inside a Glance layout via AndroidRemoteViews (widget/TimerWidget.kt):
Glance has no chronometer composable, and RemoteViews.setChronometer +
setChronometerCountDown is exactly the mechanism TimerNotifications.kt
already uses for the same reason — the platform ticks it, not the app.
-->
<Chronometer xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_chronometer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="?android:attr/textColorPrimary"
android:textSize="28sp" />
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Embedded inside a Glance layout via AndroidRemoteViews (widget/ClockWidget.kt):
a self-updating platform clock that honours the device's 12/24-hour
setting on its own, with no per-second app code. The timezone is set at
render time via RemoteViews.setString(id, "setTimeZone", zoneId).
-->
<TextClock xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_text_clock"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="?android:attr/textColorPrimary"
android:textSize="28sp" />
+9
View File
@@ -341,4 +341,13 @@
<string name="selfcheck_next_alarm_title">Next alarm, as the system sees it</string>
<string name="selfcheck_next_alarm_ok">Matches what Clockula scheduled</string>
<string name="selfcheck_next_alarm_problem">Doesn\'t match what Clockula scheduled — open the app to re-check</string>
<!-- M10: widgets -->
<string name="widget_next_alarm_description">Next alarm</string>
<string name="widget_next_alarm_empty">No alarms set</string>
<string name="widget_next_alarm_today">Today</string>
<string name="widget_timer_description">Timer</string>
<string name="widget_timer_new">New timer</string>
<string name="widget_timer_expired">Time\'s up</string>
<string name="widget_clock_description">Clock</string>
</resources>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="90dp"
android:minHeight="40dp"
android:minResizeWidth="90dp"
android:minResizeHeight="40dp"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:updatePeriodMillis="0"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:previewImage="@mipmap/ic_launcher"
android:description="@string/widget_clock_description" />
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="40dp"
android:minResizeWidth="110dp"
android:minResizeHeight="40dp"
android:targetCellWidth="2"
android:targetCellHeight="1"
android:updatePeriodMillis="0"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:previewImage="@mipmap/ic_launcher"
android:description="@string/widget_next_alarm_description" />
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="60dp"
android:minResizeWidth="110dp"
android:minResizeHeight="40dp"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:updatePeriodMillis="0"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:previewImage="@mipmap/ic_launcher"
android:description="@string/widget_timer_description" />
@@ -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("<receiver\\b[\\s\\S]*?(?:/>|</receiver>)")
.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("<receiver\\b[\\s\\S]*?(?:/>|</receiver>)")
.findAll(text)
.map { it.value }
.first { it.contains("android:name=\"$name\"") }
val actions = Regex("<action\\s+android:name=\"([^\"]+)\"").findAll(block)
.map { it.groupValues[1] }
.toList()
assertThat(actions).containsExactly("android.appwidget.action.APPWIDGET_UPDATE")
assertThat(block).doesNotContain("android:permission")
}
}
@Test
@@ -0,0 +1,42 @@
package de.jeanlucmakiola.clockula.domain.widget
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.WorldClock
import org.junit.jupiter.api.Test
class ClockWidgetModelTest {
private fun clock(id: Long, zoneId: String, sortOrder: Int = 0) =
WorldClock(id = id, zoneId = zoneId, label = null, sortOrder = sortOrder)
@Test
fun `no world clocks is empty`() {
assertThat(ClockWidgetPresenter.present(emptyList())).isEmpty()
}
@Test
fun `a non-IANA zone id is dropped`() {
val clocks = listOf(clock(1, "Not/AZone"), clock(2, "Europe/Berlin"))
val selected = ClockWidgetPresenter.present(clocks)
assertThat(selected.map { it.zoneId }).containsExactly("Europe/Berlin")
}
@Test
fun `at most three cities are shown, in stored order`() {
val clocks = listOf(
clock(1, "Europe/Berlin", sortOrder = 0),
clock(2, "America/New_York", sortOrder = 1),
clock(3, "Asia/Tokyo", sortOrder = 2),
clock(4, "Australia/Sydney", sortOrder = 3),
)
val selected = ClockWidgetPresenter.present(clocks)
assertThat(selected).hasSize(ClockWidgetPresenter.MAX_CITIES)
assertThat(selected.map { it.zoneId }).containsExactly(
"Europe/Berlin", "America/New_York", "Asia/Tokyo",
).inOrder()
}
}
@@ -0,0 +1,83 @@
package de.jeanlucmakiola.clockula.domain.widget
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.testing.T0
import de.jeanlucmakiola.clockula.testing.upcomingAlarm
import org.junit.jupiter.api.Test
import java.time.ZoneId
import java.time.ZonedDateTime
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
import kotlin.time.Instant
import kotlin.time.toJavaInstant
class NextAlarmWidgetModelTest {
private val zone: ZoneId = ZoneId.of("UTC")
@Test
fun `no alarms at all reads as empty`() {
val model = NextAlarmWidgetPresenter.present(emptyList(), T0, zone)
assertThat(model.hasAlarm).isFalse()
}
@Test
fun `every alarm with no resolved fire time also reads as empty`() {
val rows = listOf(upcomingAlarm(id = 1, nextFire = null), upcomingAlarm(id = 2, nextFire = null))
val model = NextAlarmWidgetPresenter.present(rows, T0, zone)
assertThat(model.hasAlarm).isFalse()
}
@Test
fun `the first row with a resolved fire time wins, in list order`() {
val fireAt = T0 + 2.hours
val rows = listOf(
upcomingAlarm(id = 1, nextFire = null, label = "no fire"),
upcomingAlarm(id = 2, nextFire = fireAt, label = "Wake up"),
upcomingAlarm(id = 3, nextFire = fireAt + 1.hours, label = "later"),
)
val model = NextAlarmWidgetPresenter.present(rows, T0, zone)
assertThat(model.hasAlarm).isTrue()
assertThat(model.label).isEqualTo("Wake up")
}
@Test
fun `hour and minute are read in the given zone`() {
val fireAt = T0 + 2.hours
val rows = listOf(upcomingAlarm(nextFire = fireAt))
val model = NextAlarmWidgetPresenter.present(rows, T0, zone)
val expected = fireAt.toJavaTime(zone)
assertThat(model.hour).isEqualTo(expected.hour)
assertThat(model.minute).isEqualTo(expected.minute)
assertThat(model.dayOfWeek).isEqualTo(expected.dayOfWeek)
}
@Test
fun `a fire time later today is today`() {
// T0 is 2023-11-14 22:13:20 UTC — one hour later is still the 14th.
val rows = listOf(upcomingAlarm(nextFire = T0 + 1.hours))
val model = NextAlarmWidgetPresenter.present(rows, T0, zone)
assertThat(model.isToday).isTrue()
}
@Test
fun `a fire time tomorrow is not today`() {
val rows = listOf(upcomingAlarm(nextFire = T0 + 1.days))
val model = NextAlarmWidgetPresenter.present(rows, T0, zone)
assertThat(model.isToday).isFalse()
}
private fun Instant.toJavaTime(zone: ZoneId): ZonedDateTime =
ZonedDateTime.ofInstant(toJavaInstant(), zone)
}
@@ -0,0 +1,76 @@
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 TimerWidgetModelTest {
@Test
fun `no timers at all is empty`() {
val model = TimerWidgetPresenter.present(emptyList(), Duration.ZERO, T0)
assertThat(model.items).isEmpty()
}
@Test
fun `every timer idle is also empty`() {
val timers = listOf(idleTimer(id = 1), idleTimer(id = 2))
val model = TimerWidgetPresenter.present(timers, Duration.ZERO, T0)
assertThat(model.items).isEmpty()
}
@Test
fun `an expired timer outranks a running one`() {
val timers = listOf(
runningTimerWith(id = 1, left = 1.minutes),
expiredTimer(id = 2),
)
val model = TimerWidgetPresenter.present(timers, Duration.ZERO, T0)
assertThat(model.items.first().id).isEqualTo(2L)
assertThat(model.items.first().mode).isEqualTo(TimerMode.EXPIRED)
}
@Test
fun `at most three timers are shown, in subject order`() {
val timers = (1..5L).map { id -> 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)
}
}