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:
@@ -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" />
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
+83
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user