feat(widgets): let each widget pick its theme and wallpaper colours

Every widget instance can now follow the system or be pinned to Light or
Dark, and on Android 12+ turn the wallpaper's colours off in favour of
the app's own palette. Both are set in the widget's config screen and
stored in its own Glance state; an instance without them keeps today's
look.

Glance content takes them through WidgetTheme. The plain-RemoteViews
parts (the analog and digital faces, readouts and chronometers) go
through a new WidgetPalette, which resolves the widget_* palette under a
forced day/night and sets concrete colours, or day/night pairs, on the
views. The app's own tones are now widget_app_*, so they stay reachable
on API 31+. The analog and digital clocks are re-coloured on API 31+
only: below that AnalogClock's hands and a shape's background tint can't
be set through RemoteViews, so their config screens don't offer it.

The next-alarm and timer widgets gain a config screen for this
(configuration_optional, so placing one doesn't stop there). The analog,
stopwatch and quick-timer config screens become settings screens with an
OK, like the digital clock's, since they now hold more than one choice.

Background opacity is left out: most faces are a shape with text in its
container colour rather than a card, so there is no background to fade
without losing contrast against the wallpaper.

Closes #6
This commit is contained in:
makiolaj committed 2026-10-05 19:48:59 +02:00
1 parent 8cbb8b311c
commit 19ee678a1b
33 files changed
+910 -159

No files matched your search

@@ -31,7 +31,7 @@ class ManifestRulesTest {
}
@Test
fun `exactly the launcher and its two icon aliases, the door, the four widget configurators and the six widget receivers are exported`() {
fun `exactly the launcher and its two icon aliases, the door, the six widget configurators and the six widget receivers are exported`() {
val exported = Regex("<(activity|service|receiver|provider)\\b[\\s\\S]*?(?:/>|</\\1>)")
.findAll(text)
.map { it.value }
@@ -42,10 +42,11 @@ class ManifestRulesTest {
// A GlanceAppWidgetReceiver must be exported for the platform to pick
// it as a widget provider at all, and `the six widget receivers
// answer only APPWIDGET_UPDATE` pins that each one is guarded to
// that one platform-delivered broadcast and nothing wider. M11 adds
// four widget config activities: exported for the same reason —
// that one platform-delivered broadcast and nothing wider. The six
// widget config activities (M11's four, plus the next-alarm and
// timer widgets' colour screens) are exported for the same reason —
// only the launcher can start an `APPWIDGET_CONFIGURE` activity
// cross-package — and guarded to that one action by `the four
// cross-package — and guarded to that one action by `the six
// widget configurators answer only APPWIDGET_CONFIGURE` below.
assertThat(exported).containsExactly(
".MainActivity",
@@ -56,6 +57,8 @@ class ManifestRulesTest {
".ui.widget.config.AnalogClockConfigActivity",
".ui.widget.config.StopwatchConfigActivity",
".ui.widget.config.QuickTimerConfigActivity",
".ui.widget.config.NextAlarmConfigActivity",
".ui.widget.config.TimerConfigActivity",
".widget.NextAlarmWidgetReceiver",
".widget.TimerWidgetReceiver",
".widget.ClockWidgetReceiver",
@@ -67,16 +70,18 @@ class ManifestRulesTest {
/**
* M11: the mirror of `the six widget receivers answer only
* APPWIDGET_UPDATE` for the four widget config activities — being
* APPWIDGET_UPDATE` for the six widget config activities — being
* exported cannot be widened into a second, wider surface by accident.
*/
@Test
fun `the four widget configurators answer only APPWIDGET_CONFIGURE`() {
fun `the six widget configurators answer only APPWIDGET_CONFIGURE`() {
val configurators = listOf(
".ui.widget.config.ClockWidgetConfigActivity",
".ui.widget.config.AnalogClockConfigActivity",
".ui.widget.config.StopwatchConfigActivity",
".ui.widget.config.QuickTimerConfigActivity",
".ui.widget.config.NextAlarmConfigActivity",
".ui.widget.config.TimerConfigActivity",
)
for (name in configurators) {
val block = activityBlock(name)
@@ -0,0 +1,52 @@
package de.jeanlucmakiola.clockula.domain.widget
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class WidgetColorsTest {
@Test
fun `nothing stored is today's look`() {
assertThat(parseWidgetColors(null, null)).isEqualTo(WidgetColors(WidgetColorTheme.SYSTEM, wallpaperColors = true))
}
@Test
fun `an unknown theme name falls back to System`() {
assertThat(parseWidgetColors("Sepia", false)).isEqualTo(WidgetColors(WidgetColorTheme.SYSTEM, wallpaperColors = false))
}
@Test
fun `each theme's own name round-trips`() {
WidgetColorTheme.entries.forEach { theme ->
assertThat(parseWidgetColors(theme.name, true).theme).isEqualTo(theme)
}
}
@Test
fun `the defaults leave the palette to the launcher on every API level`() {
assertThat(WidgetColors().paletteMode(dynamicAvailable = true)).isEqualTo(WidgetPaletteMode.Launcher)
assertThat(WidgetColors().paletteMode(dynamicAvailable = false)).isEqualTo(WidgetPaletteMode.Launcher)
}
@Test
fun `wallpaper colours off under System uses the app palette, day and night`() {
val colors = WidgetColors(WidgetColorTheme.SYSTEM, wallpaperColors = false)
assertThat(colors.paletteMode(dynamicAvailable = true)).isEqualTo(WidgetPaletteMode.AppDayNight)
}
@Test
fun `without wallpaper colours on the device, the switch changes nothing`() {
val colors = WidgetColors(WidgetColorTheme.SYSTEM, wallpaperColors = false)
assertThat(colors.paletteMode(dynamicAvailable = false)).isEqualTo(WidgetPaletteMode.Launcher)
assertThat(WidgetColors(WidgetColorTheme.DARK, wallpaperColors = true).paletteMode(dynamicAvailable = false))
.isEqualTo(WidgetPaletteMode.Pinned(dark = true, dynamic = false))
}
@Test
fun `light and dark pin a tone of the chosen palette`() {
assertThat(WidgetColors(WidgetColorTheme.LIGHT, wallpaperColors = true).paletteMode(dynamicAvailable = true))
.isEqualTo(WidgetPaletteMode.Pinned(dark = false, dynamic = true))
assertThat(WidgetColors(WidgetColorTheme.DARK, wallpaperColors = false).paletteMode(dynamicAvailable = true))
.isEqualTo(WidgetPaletteMode.Pinned(dark = true, dynamic = false))
}
}