diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 4f69466..1160653 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -318,6 +318,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/WidgetColors.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/WidgetColors.kt
new file mode 100644
index 0000000..feed880
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/WidgetColors.kt
@@ -0,0 +1,52 @@
+package de.jeanlucmakiola.clockula.domain.widget
+
+/** A widget instance's light/dark: follow the launcher, or pin one. */
+enum class WidgetColorTheme { SYSTEM, LIGHT, DARK }
+
+/**
+ * A widget instance's colours, set in its config screen and stored in its
+ * own Glance state. The defaults are what every widget did before this was
+ * configurable — follow the launcher's light/dark and, on Android 12+, the
+ * wallpaper — so an instance with nothing stored looks exactly as it did.
+ *
+ * There is deliberately no free colour choice: every combination here is
+ * one of two tested palettes in one of its two tones, so text always keeps
+ * its contrast.
+ */
+data class WidgetColors(
+ val theme: WidgetColorTheme = WidgetColorTheme.SYSTEM,
+ val wallpaperColors: Boolean = true,
+)
+
+/** Unset or unknown values fall back to [WidgetColors]' defaults. */
+fun parseWidgetColors(theme: String?, wallpaperColors: Boolean?): WidgetColors = WidgetColors(
+ theme = WidgetColorTheme.entries.firstOrNull { it.name == theme } ?: WidgetColorTheme.SYSTEM,
+ wallpaperColors = wallpaperColors ?: true,
+)
+
+/** Where a widget's `widget_*` palette colours come from, once its [WidgetColors] meet the device. */
+sealed interface WidgetPaletteMode {
+
+ /** The launcher resolves the palette resources against its own day/night and wallpaper — the default look. */
+ data object Launcher : WidgetPaletteMode
+
+ /** The app's own palette, with day/night still left to the launcher. */
+ data object AppDayNight : WidgetPaletteMode
+
+ /** One tone, [dark] or light, of the wallpaper's palette when [dynamic], else of the app's. */
+ data class Pinned(val dark: Boolean, val dynamic: Boolean) : WidgetPaletteMode
+}
+
+/**
+ * [WidgetColors] resolved for a device: [dynamicAvailable] is Android 12+,
+ * where wallpaper colours exist. Below that the launcher's palette already
+ * *is* the app's, so "wallpaper colours off" changes nothing there.
+ */
+fun WidgetColors.paletteMode(dynamicAvailable: Boolean): WidgetPaletteMode {
+ val dynamic = wallpaperColors && dynamicAvailable
+ return when (theme) {
+ WidgetColorTheme.SYSTEM -> if (dynamic || !dynamicAvailable) WidgetPaletteMode.Launcher else WidgetPaletteMode.AppDayNight
+ WidgetColorTheme.LIGHT -> WidgetPaletteMode.Pinned(dark = false, dynamic = dynamic)
+ WidgetColorTheme.DARK -> WidgetPaletteMode.Pinned(dark = true, dynamic = dynamic)
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/AnalogClockWidgetContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/AnalogClockWidgetContent.kt
index d1942be..d1ffdfb 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/AnalogClockWidgetContent.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/AnalogClockWidgetContent.kt
@@ -8,6 +8,7 @@ import android.graphics.Typeface
import android.graphics.drawable.Icon
import android.os.Build
import androidx.annotation.DrawableRes
+import androidx.annotation.RequiresApi
import android.text.format.DateFormat
import android.util.TypedValue
import android.view.View
@@ -28,6 +29,7 @@ import androidx.glance.layout.size
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.widget.AnalogClockStyle
import de.jeanlucmakiola.clockula.domain.widget.ClockWidgetTapTarget
+import de.jeanlucmakiola.clockula.domain.widget.WidgetColors
import de.jeanlucmakiola.clockula.domain.widget.parseAnalogClockStyle
import de.jeanlucmakiola.clockula.domain.widget.parseClockWidgetTapTarget
import de.jeanlucmakiola.clockula.widget.WIDGET_ANALOG_STYLE_KEY
@@ -48,7 +50,8 @@ fun AnalogClockWidgetRoot() {
* floating directly on the home screen, not on a card, the way a platform
* clock widget looks. Its colours are the analog palette resources
* (`res/values/colors.xml` and its night/v31 variants), so it follows the launcher's day/night and, on
- * API 31+, the wallpaper by itself. A tap opens [tapTarget]'s tab.
+ * API 31+, the wallpaper by itself — unless the instance's [LocalWidgetColors]
+ * say otherwise. A tap opens [tapTarget]'s tab.
*/
@Composable
fun AnalogClockWidgetContent(style: AnalogClockStyle, tapTarget: ClockWidgetTapTarget) {
@@ -73,7 +76,7 @@ private fun AnalogFace(style: AnalogClockStyle) {
val side = minOf(size.width, size.height)
Box(modifier = GlanceModifier.size(side)) {
AndroidRemoteViews(
- remoteViews = analogClockViews(context, style, side.value),
+ remoteViews = analogClockViews(context, style, side.value, LocalWidgetColors.current),
modifier = GlanceModifier.fillMaxSize(),
)
}
@@ -132,6 +135,14 @@ private fun AnalogClockStyle.seconds(): AnalogSeconds = when (this) {
AnalogClockStyle.SCALLOP -> AnalogSeconds.DotAndDate(R.drawable.widget_analog_second_dot_scallop)
}
+/** The palette role a style's dial drawable is filled with (`res/drawable/widget_analog_dial_*`). */
+private fun AnalogClockStyle.dialRole(): WidgetRole = when (this) {
+ AnalogClockStyle.ROUND, AnalogClockStyle.NUMERAL, AnalogClockStyle.BADGE,
+ AnalogClockStyle.SCALLOP -> WidgetRole.ANALOG_DIAL_FILL
+ AnalogClockStyle.COOKIE, AnalogClockStyle.CLOVER -> WidgetRole.ANALOG_DIAL_PRIMARY
+ AnalogClockStyle.PETAL, AnalogClockStyle.SUNNY, AnalogClockStyle.SQUARE -> WidgetRole.ANALOG_DIAL_TERTIARY
+}
+
private fun AnalogClockStyle.overlay(): AnalogOverlay = when (this) {
AnalogClockStyle.NUMERAL, AnalogClockStyle.CLOVER -> AnalogOverlay.NUMERALS
AnalogClockStyle.BADGE -> AnalogOverlay.DAY
@@ -146,8 +157,19 @@ private fun AnalogClockStyle.overlay(): AnalogOverlay = when (this) {
* inflate these same views in-app), so a preview is the real widget.
* Numerals, badge and their insets are fractions of [sideDp], so they grow
* and shrink with the dial instead of staying at a fixed sp/dp.
+ *
+ * [colors] other than the defaults re-colour the face ([recolor]) on API
+ * 31+ only: below that `AnalogClock`'s hands can be neither swapped nor
+ * tinted, so a pinned tone would leave them in the launcher's, and the
+ * config screen doesn't offer it.
*/
-internal fun analogClockViews(context: Context, style: AnalogClockStyle, sideDp: Float): RemoteViews {
+internal fun analogClockViews(
+ context: Context,
+ style: AnalogClockStyle,
+ sideDp: Float,
+ colors: WidgetColors = WidgetColors(),
+): RemoteViews {
+ val palette = WidgetPalette(context, colors)
val overlay = style.overlay()
val layoutRes = when (overlay) {
AnalogOverlay.NONE -> R.layout.widget_analog_plain
@@ -175,9 +197,10 @@ internal fun analogClockViews(context: Context, style: AnalogClockStyle, sideDp:
// plain faces are dated.
setViewVisibility(R.id.widget_clock_analog_date, View.VISIBLE)
setIcon(R.id.widget_clock_analog_date, "setSecondHand", Icon.createWithBitmap(dateHand(context)))
- setColorStateList(R.id.widget_clock_analog_date, "setSecondHandTintList", R.color.widget_analog_date)
+ setWidgetTint(R.id.widget_clock_analog_date, "setSecondHandTintList", palette.color(WidgetRole.ANALOG_DATE))
}
}
+ if (!palette.followsLauncher) recolor(context, style, overlay, palette)
}
if (overlay == AnalogOverlay.NONE) return@apply
val inset = frac(0.07f)
@@ -209,6 +232,32 @@ internal fun analogClockViews(context: Context, style: AnalogClockStyle, sideDp:
}
}
+/**
+ * [style]'s face in [palette]'s colours: the dial, each hand and the
+ * overlay, in the same roles their drawables and layouts name. A hand takes
+ * one tint, so the slim minute hand's two-tone cap comes out in the minute
+ * colour throughout.
+ */
+@RequiresApi(Build.VERSION_CODES.S)
+private fun RemoteViews.recolor(context: Context, style: AnalogClockStyle, overlay: AnalogOverlay, palette: WidgetPalette) {
+ setWidgetColor(context, R.id.widget_analog_dial, "setColorFilter", palette.color(style.dialRole()))
+ setWidgetTint(R.id.widget_clock_analog, "setHourHandTintList", palette.color(WidgetRole.ANALOG_HAND_HOUR))
+ setWidgetTint(R.id.widget_clock_analog, "setMinuteHandTintList", palette.color(WidgetRole.ANALOG_HAND_MINUTE))
+ if (style.seconds() != AnalogSeconds.None) {
+ setWidgetTint(R.id.widget_clock_analog, "setSecondHandTintList", palette.color(WidgetRole.ANALOG_SECOND))
+ }
+ val numerals = when (overlay) {
+ AnalogOverlay.NONE, AnalogOverlay.DATE_PILL -> emptyList()
+ AnalogOverlay.NUMERALS -> listOf(R.id.widget_analog_numeral_12, R.id.widget_analog_numeral_3, R.id.widget_analog_numeral_6, R.id.widget_analog_numeral_9)
+ AnalogOverlay.DAY -> listOf(R.id.widget_analog_numeral_12, R.id.widget_analog_numeral_6, R.id.widget_analog_numeral_9)
+ }
+ numerals.forEach { setWidgetColor(context, it, "setTextColor", palette.color(WidgetRole.ANALOG_NUMERAL)) }
+ if (overlay == AnalogOverlay.DAY || overlay == AnalogOverlay.DATE_PILL) {
+ setWidgetTint(R.id.widget_analog_badge_day, "setBackgroundTintList", palette.color(WidgetRole.ANALOG_BADGE_PILL))
+ setWidgetColor(context, R.id.widget_analog_badge_day, "setTextColor", palette.color(WidgetRole.ANALOG_BADGE_PILL_TEXT))
+ }
+}
+
/**
* The date half of Google Clock's seconds hand: today's date ("Fri 2")
* opposite the seconds dot, on the layout's separate date clock. Text cannot
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/ClockWidgetContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/ClockWidgetContent.kt
index c851011..a12d7b2 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/ClockWidgetContent.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/ClockWidgetContent.kt
@@ -78,7 +78,8 @@ fun ClockWidgetRoot(entryPoint: WidgetEntryPoint, initialCities: List, settings: DigitalClockSettings, tapTarget: ClockWidgetTapTarget) {
@@ -86,7 +87,7 @@ fun ClockWidgetContent(cities: List, settings: DigitalClockSett
val size = LocalSize.current
Box(modifier = GlanceModifier.fillMaxSize().clickable(actionStartActivity(clockTapIntent(context, tapTarget)))) {
AndroidRemoteViews(
- remoteViews = digitalClockViews(context, settings, cities, size.width.value, size.height.value),
+ remoteViews = digitalClockViews(context, settings, cities, size.width.value, size.height.value, LocalWidgetColors.current),
modifier = GlanceModifier.fillMaxSize(),
)
}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/DigitalFaces.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/DigitalFaces.kt
index 666fa82..8d777ed 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/DigitalFaces.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/DigitalFaces.kt
@@ -1,13 +1,16 @@
package de.jeanlucmakiola.clockula.ui.widget
import android.content.Context
+import android.os.Build
import android.text.format.DateFormat
import android.util.TypedValue
import android.view.View
import android.widget.RemoteViews
+import androidx.annotation.RequiresApi
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.widget.DigitalClockSettings
import de.jeanlucmakiola.clockula.domain.widget.DigitalClockStyle
+import de.jeanlucmakiola.clockula.domain.widget.WidgetColors
import de.jeanlucmakiola.clockula.domain.widget.showsWorldClocks
/** Below this the time is too small to share the card with world-clock rows, so they drop first. */
@@ -27,6 +30,11 @@ private const val MIN_TIME_WITH_DATE = 22f
*
* The time and date are `TextClock`s ticking in the launcher; the time drops
* the 12-hour AM/PM marker, as the platform's own clock widgets do.
+ *
+ * The layouts carry the launcher-resolved palette; [colors] other than the
+ * defaults re-colour them ([recolor]), on API 31+ only — below that a
+ * shape's background tint can't be set through `RemoteViews`, and the config
+ * screen doesn't offer them.
*/
internal fun digitalClockViews(
context: Context,
@@ -34,20 +42,67 @@ internal fun digitalClockViews(
cities: List,
widthDp: Float,
heightDp: Float,
-): RemoteViews = when (settings.style) {
- DigitalClockStyle.REGULAR, DigitalClockStyle.LIGHT, DigitalClockStyle.BOLD ->
- cardViews(context, settings, cities, widthDp, heightDp)
- DigitalClockStyle.PILL -> pillViews(context, settings.showDate, widthDp, heightDp)
- DigitalClockStyle.STACKED -> stackedViews(context, settings.showDate, minOf(widthDp, heightDp))
+ colors: WidgetColors = WidgetColors(),
+): RemoteViews {
+ val palette = WidgetPalette(context, colors).takeIf { Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !it.followsLauncher }
+ val views = when (settings.style) {
+ DigitalClockStyle.REGULAR, DigitalClockStyle.LIGHT, DigitalClockStyle.BOLD ->
+ cardViews(context, settings, cities, widthDp, heightDp, palette)
+ DigitalClockStyle.PILL -> pillViews(context, settings.showDate, widthDp, heightDp)
+ DigitalClockStyle.STACKED -> stackedViews(context, settings.showDate, minOf(widthDp, heightDp))
+ }
+ if (palette != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) views.recolor(context, settings.style, palette)
+ return views
}
-/** The card faces: time, date, then up to three world-clock rows. */
+/**
+ * [style]'s face in [palette]'s colours: the same roles its layout names
+ * (`res/layout/widget_digital_*.xml`), resolved for this instance. City rows
+ * are re-coloured as [cardViews] adds them.
+ */
+@RequiresApi(Build.VERSION_CODES.S)
+private fun RemoteViews.recolor(context: Context, style: DigitalClockStyle, palette: WidgetPalette) {
+ fun text(viewId: Int, role: WidgetRole) = setWidgetColor(context, viewId, "setTextColor", palette.color(role))
+ fun background(viewId: Int, role: WidgetRole) = setWidgetTint(viewId, "setBackgroundTintList", palette.color(role))
+ when (style) {
+ DigitalClockStyle.REGULAR -> {
+ background(R.id.widget_digital_root, WidgetRole.SURFACE)
+ text(R.id.widget_digital_time, WidgetRole.ON_SURFACE)
+ text(R.id.widget_digital_date, WidgetRole.PRIMARY)
+ }
+ DigitalClockStyle.LIGHT -> {
+ background(R.id.widget_digital_root, WidgetRole.SURFACE)
+ text(R.id.widget_digital_time, WidgetRole.PRIMARY)
+ text(R.id.widget_digital_date, WidgetRole.ON_SURFACE_VARIANT)
+ }
+ DigitalClockStyle.BOLD -> {
+ background(R.id.widget_digital_root, WidgetRole.PRIMARY_CONTAINER)
+ text(R.id.widget_digital_time, WidgetRole.ON_PRIMARY_CONTAINER)
+ text(R.id.widget_digital_date, WidgetRole.ON_PRIMARY_CONTAINER)
+ }
+ DigitalClockStyle.PILL -> {
+ background(R.id.widget_digital_time, WidgetRole.PRIMARY_CONTAINER)
+ text(R.id.widget_digital_time, WidgetRole.ON_PRIMARY_CONTAINER)
+ background(R.id.widget_digital_date, WidgetRole.TERTIARY_CONTAINER)
+ text(R.id.widget_digital_date, WidgetRole.ON_TERTIARY_CONTAINER)
+ }
+ DigitalClockStyle.STACKED -> {
+ setWidgetColor(context, R.id.widget_digital_shape, "setColorFilter", palette.color(WidgetRole.TERTIARY_CONTAINER))
+ text(R.id.widget_digital_hours, WidgetRole.ON_TERTIARY_CONTAINER)
+ text(R.id.widget_digital_minutes, WidgetRole.TERTIARY)
+ text(R.id.widget_digital_date, WidgetRole.ON_TERTIARY_CONTAINER)
+ }
+ }
+}
+
+/** The card faces: time, date, then up to three world-clock rows ([palette]'s colours, when re-coloured). */
private fun cardViews(
context: Context,
settings: DigitalClockSettings,
cities: List,
w: Float,
h: Float,
+ palette: WidgetPalette?,
): RemoteViews {
val (layout, cityLayout, emsWide) = when (settings.style) {
DigitalClockStyle.LIGHT -> Triple(R.layout.widget_digital_thin, R.layout.widget_digital_city, 2.6f)
@@ -91,6 +146,13 @@ private fun cardViews(
// Each row after the first sits a little below the one before.
if (index > 0) setViewPadding(R.id.widget_digital_city_name, 0, px(small * 0.4f), 0, 0)
if (index > 0) setViewPadding(R.id.widget_digital_city_time, 0, px(small * 0.4f), 0, 0)
+ if (palette != null) {
+ val onPrimary = settings.style == DigitalClockStyle.BOLD
+ val name = if (onPrimary) WidgetRole.ON_PRIMARY_CONTAINER else WidgetRole.ON_SURFACE_VARIANT
+ val time = if (onPrimary) WidgetRole.ON_PRIMARY_CONTAINER else WidgetRole.ON_SURFACE
+ setWidgetColor(context, R.id.widget_digital_city_name, "setTextColor", palette.color(name))
+ setWidgetColor(context, R.id.widget_digital_city_time, "setTextColor", palette.color(time))
+ }
}
addView(R.id.widget_digital_cities, row)
}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/QuickTimerWidgetContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/QuickTimerWidgetContent.kt
index b2217d1..f7edea3 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/QuickTimerWidgetContent.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/QuickTimerWidgetContent.kt
@@ -107,7 +107,7 @@ private fun AtRest(context: Context, model: QuickTimerWidgetModel, s: Float, sha
description = context.getString(R.string.widget_quick_timer_start, label),
s = s,
shape = shape,
- ) { size -> WidgetReadout(label, size, R.color.widget_on_primary_container) }
+ ) { size -> WidgetReadout(label, size, WidgetRole.ON_PRIMARY_CONTAINER) }
}
/**
@@ -130,11 +130,12 @@ private fun Running(context: Context, item: TimerWidgetItem, elapsedRealtimeNow:
base = elapsedRealtimeNow + item.remaining.inWholeMilliseconds,
countDown = true,
textSizeDp = size,
+ role = WidgetRole.ON_PRIMARY_CONTAINER,
)
} else {
val text = if (expired) context.getString(R.string.widget_timer_expired) else ClockFormat.countdown(item.remaining)
// Shrunk to the same width should the word be longer than a time.
- WidgetReadout(text, size * minOf(1f, 5f / text.length), R.color.widget_on_primary_container)
+ WidgetReadout(text, size * minOf(1f, 5f / text.length), WidgetRole.ON_PRIMARY_CONTAINER)
}
}
}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/StopwatchWidgetContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/StopwatchWidgetContent.kt
index 77eceb9..4d8bb30 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/StopwatchWidgetContent.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/StopwatchWidgetContent.kt
@@ -265,15 +265,16 @@ private fun Readout(context: Context, model: StopwatchWidgetModel, elapsedRealti
base = elapsedRealtimeNow - model.elapsed.inWholeMilliseconds,
countDown = false,
textSizeDp = sizeDp,
+ role = if (onShape) WidgetRole.ON_PRIMARY_CONTAINER else WidgetRole.ON_SURFACE,
)
} else {
WidgetReadout(
text = ClockFormat.elapsed(model.elapsed),
textSizeDp = sizeDp,
- colorRes = when {
- onShape -> R.color.widget_on_primary_container
- model.mode == StopwatchState.IDLE -> R.color.widget_on_surface_variant
- else -> R.color.widget_on_surface
+ role = when {
+ onShape -> WidgetRole.ON_PRIMARY_CONTAINER
+ model.mode == StopwatchState.IDLE -> WidgetRole.ON_SURFACE_VARIANT
+ else -> WidgetRole.ON_SURFACE
},
)
}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/TimerWidgetContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/TimerWidgetContent.kt
index 82bbd4b..7be80fc 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/TimerWidgetContent.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/TimerWidgetContent.kt
@@ -199,12 +199,13 @@ private fun Readout(context: Context, item: TimerWidgetItem, elapsedRealtimeNow:
base = elapsedRealtimeNow + item.remaining.inWholeMilliseconds,
countDown = true,
textSizeDp = timeSize,
+ role = WidgetRole.ON_SURFACE,
)
} else {
WidgetReadout(
text = staticReadout(context, item),
textSizeDp = timeSize,
- colorRes = if (item.mode == TimerMode.EXPIRED) R.color.widget_tertiary else R.color.widget_on_surface,
+ role = if (item.mode == TimerMode.EXPIRED) WidgetRole.TERTIARY else WidgetRole.ON_SURFACE,
)
}
}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/WidgetKit.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/WidgetKit.kt
index 8f02ada..420962d 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/WidgetKit.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/WidgetKit.kt
@@ -5,7 +5,6 @@ import android.content.Intent
import android.os.Build
import android.util.TypedValue
import android.widget.RemoteViews
-import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes
import androidx.annotation.LayoutRes
import androidx.compose.runtime.Composable
@@ -35,6 +34,8 @@ import de.jeanlucmakiola.clockula.MainActivity
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.widget.ClockWidgetTapTarget
import de.jeanlucmakiola.clockula.ui.shell.ClockulaDestination
+import de.jeanlucmakiola.clockula.domain.widget.WidgetColorTheme
+import de.jeanlucmakiola.clockula.domain.widget.WidgetColors
import de.jeanlucmakiola.clockula.ui.theme.ClockulaDarkFallback
import de.jeanlucmakiola.clockula.ui.theme.ClockulaLightFallback
import de.jeanlucmakiola.clockula.ui.shell.ShellNavigation
@@ -46,7 +47,8 @@ import java.util.Locale
* The pieces every widget but the analog one is built from — the same
* patterns `AnalogClockWidgetContent.kt` set:
* - colour follows the launcher's day/night and, on API 31+, the wallpaper
- * by itself ([WidgetTheme]) — no app-side theme pinning;
+ * by itself ([WidgetTheme]) — unless the instance's own [WidgetColors]
+ * pin a tone or switch the wallpaper's colours off (`WidgetPalette.kt`);
* - containers are M3 shapes (`widget_bg_*`, `widget_shape_*`), white
* drawables tinted here;
* - every size is a fraction of the widget's live `LocalSize` (`SizeMode.Exact`
@@ -55,18 +57,25 @@ import java.util.Locale
*/
/**
- * The widgets' colours: on API 31+ Glance's own dynamic scheme — resource
- * colours the launcher resolves against its day/night and the wallpaper, the
- * same system tones as the `widget_*` palette the plain-`RemoteViews` faces
- * use — and the app's own fallback schemes below that.
+ * The widgets' colours, per the instance's [LocalWidgetColors]. By default,
+ * on API 31+ Glance's own dynamic scheme — resource colours the launcher
+ * resolves against its day/night and the wallpaper, the same system tones as
+ * the `widget_*` palette the plain-`RemoteViews` faces use — and the app's
+ * own fallback schemes below that, or always with wallpaper colours off.
+ * Light or Dark pins that one scheme whatever the launcher's day/night.
*/
@Composable
internal fun WidgetTheme(content: @Composable () -> Unit) {
+ val colors = LocalWidgetColors.current
FloretGlanceTheme(
lightScheme = ClockulaLightFallback,
darkScheme = ClockulaDarkFallback,
- dynamicColor = true,
- mode = WidgetThemeMode.SYSTEM,
+ dynamicColor = colors.wallpaperColors,
+ mode = when (colors.theme) {
+ WidgetColorTheme.SYSTEM -> WidgetThemeMode.SYSTEM
+ WidgetColorTheme.LIGHT -> WidgetThemeMode.LIGHT
+ WidgetColorTheme.DARK -> WidgetThemeMode.DARK
+ },
content = content,
)
}
@@ -128,16 +137,20 @@ internal fun ShapedIcon(
/**
* A platform `Chronometer` ([layoutRes], ticking in the launcher, not the
- * app) at a proportional [textSizeDp]. [base] is in
- * `SystemClock.elapsedRealtime()` time, as `RemoteViews.setChronometer` wants.
+ * app) at a proportional [textSizeDp], in [role] — the colour [layoutRes]
+ * already sets, which only needs repeating when the instance's colours
+ * aren't the launcher's. [base] is in `SystemClock.elapsedRealtime()` time,
+ * as `RemoteViews.setChronometer` wants.
*/
@Composable
-internal fun WidgetChronometer(@LayoutRes layoutRes: Int, viewId: Int, base: Long, countDown: Boolean, textSizeDp: Float) {
+internal fun WidgetChronometer(@LayoutRes layoutRes: Int, viewId: Int, base: Long, countDown: Boolean, textSizeDp: Float, role: WidgetRole) {
val context = LocalContext.current
+ val palette = WidgetPalette(context, LocalWidgetColors.current)
val views = RemoteViews(context.packageName, layoutRes).apply {
setChronometer(viewId, base, null, true)
setChronometerCountDown(viewId, countDown)
setTextViewTextSize(viewId, TypedValue.COMPLEX_UNIT_DIP, textSizeDp)
+ if (!palette.followsLauncher) setWidgetColor(context, viewId, "setTextColor", palette.color(role))
}
// Without an explicit wrap, Glance lets embedded views fill their
// parent, and the readout pushes everything after it out of the widget.
@@ -145,23 +158,20 @@ internal fun WidgetChronometer(@LayoutRes layoutRes: Int, viewId: Int, base: Lon
}
/**
- * A stopped readout ([text] at [textSizeDp], in palette colour [colorRes]):
+ * A stopped readout ([text] at [textSizeDp], in palette colour [role]):
* the same padding-free view as [WidgetChronometer]'s, unlike a Glance
* `Text`, so a readout takes the same height whether it is ticking or not.
*/
@Composable
-internal fun WidgetReadout(text: String, textSizeDp: Float, @ColorRes colorRes: Int) {
+internal fun WidgetReadout(text: String, textSizeDp: Float, role: WidgetRole) {
val context = LocalContext.current
+ val palette = WidgetPalette(context, LocalWidgetColors.current)
val views = RemoteViews(context.packageName, R.layout.widget_readout).apply {
setTextViewText(R.id.widget_readout, text)
setTextViewTextSize(R.id.widget_readout, TypedValue.COMPLEX_UNIT_DIP, textSizeDp)
- // From API 31 the launcher resolves the colour against its own
- // day/night; before that it can only be resolved here.
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- setColorStateList(R.id.widget_readout, "setTextColor", colorRes)
- } else {
- setTextColor(R.id.widget_readout, context.getColor(colorRes))
- }
+ // By default, from API 31 the launcher resolves the colour against
+ // its own day/night; before that it can only be resolved here.
+ setWidgetColor(context, R.id.widget_readout, "setTextColor", palette.color(role))
}
AndroidRemoteViews(remoteViews = views, modifier = GlanceModifier.wrapContentSize())
}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/WidgetPalette.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/WidgetPalette.kt
new file mode 100644
index 0000000..422c597
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/WidgetPalette.kt
@@ -0,0 +1,137 @@
+package de.jeanlucmakiola.clockula.ui.widget
+
+import android.content.Context
+import android.content.res.ColorStateList
+import android.content.res.Configuration
+import android.os.Build
+import android.widget.RemoteViews
+import androidx.annotation.ColorInt
+import androidx.annotation.ColorRes
+import androidx.annotation.RequiresApi
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.datastore.preferences.core.Preferences
+import androidx.glance.currentState
+import de.jeanlucmakiola.clockula.R
+import de.jeanlucmakiola.clockula.domain.widget.WidgetColors
+import de.jeanlucmakiola.clockula.domain.widget.WidgetPaletteMode
+import de.jeanlucmakiola.clockula.domain.widget.paletteMode
+import de.jeanlucmakiola.clockula.widget.widgetColors
+
+/*
+ * A widget instance's own colours ([WidgetColors]) for the parts Glance's
+ * theme can't reach: the plain-`RemoteViews` faces, readouts and
+ * chronometers, whose colours are the `widget_*` palette resources. By
+ * default the launcher resolves those itself, against its day/night and
+ * wallpaper; anything else is resolved here, from the same resources under a
+ * forced day or night configuration, and handed to the views as concrete
+ * colours (or, on API 31+, a day/night pair the launcher picks between).
+ */
+
+/** The current widget instance's [WidgetColors]; the defaults outside a widget session. */
+internal val LocalWidgetColors = staticCompositionLocalOf { WidgetColors() }
+
+/** Provides this widget instance's stored [WidgetColors] as [LocalWidgetColors]. Inside `provideContent` only. */
+@Composable
+internal fun ProvideWidgetColors(content: @Composable () -> Unit) {
+ CompositionLocalProvider(LocalWidgetColors provides currentState().widgetColors(), content = content)
+}
+
+/**
+ * One `widget_*` palette role: [launcher] is the resource the widgets
+ * reference (the wallpaper's tones on API 31+), [app] its app-palette tone,
+ * which stays reachable on every API level (`res/values/colors.xml`).
+ */
+internal enum class WidgetRole(@ColorRes val launcher: Int, @ColorRes val app: Int) {
+ SURFACE(R.color.widget_surface, R.color.widget_app_surface),
+ ON_SURFACE(R.color.widget_on_surface, R.color.widget_app_on_surface),
+ ON_SURFACE_VARIANT(R.color.widget_on_surface_variant, R.color.widget_app_on_surface_variant),
+ PRIMARY(R.color.widget_primary, R.color.widget_app_primary),
+ PRIMARY_CONTAINER(R.color.widget_primary_container, R.color.widget_app_primary_container),
+ ON_PRIMARY_CONTAINER(R.color.widget_on_primary_container, R.color.widget_app_on_primary_container),
+ TERTIARY(R.color.widget_tertiary, R.color.widget_app_tertiary),
+ TERTIARY_CONTAINER(R.color.widget_tertiary_container, R.color.widget_app_tertiary_container),
+ ON_TERTIARY_CONTAINER(R.color.widget_on_tertiary_container, R.color.widget_app_on_tertiary_container),
+ ANALOG_DIAL_FILL(R.color.widget_analog_dial_fill, R.color.widget_app_analog_dial_fill),
+ ANALOG_DIAL_PRIMARY(R.color.widget_analog_dial_primary, R.color.widget_app_analog_dial_primary),
+ ANALOG_DIAL_TERTIARY(R.color.widget_analog_dial_tertiary, R.color.widget_app_analog_dial_tertiary),
+ ANALOG_HAND_HOUR(R.color.widget_analog_hand_hour, R.color.widget_app_analog_hand_hour),
+ ANALOG_HAND_MINUTE(R.color.widget_analog_hand_minute, R.color.widget_app_analog_hand_minute),
+ ANALOG_NUMERAL(R.color.widget_analog_numeral, R.color.widget_app_analog_numeral),
+ ANALOG_SECOND(R.color.widget_analog_second, R.color.widget_app_analog_second),
+ ANALOG_DATE(R.color.widget_analog_date, R.color.widget_app_analog_date),
+ ANALOG_BADGE_PILL(R.color.widget_analog_badge_pill, R.color.widget_app_analog_badge_pill),
+ ANALOG_BADGE_PILL_TEXT(R.color.widget_analog_badge_pill_text, R.color.widget_app_analog_badge_pill_text),
+}
+
+/** One role's colour as it reaches a view: a resource the launcher resolves, one fixed colour, or a day/night pair. */
+internal sealed interface WidgetColor {
+ data class Launcher(@ColorRes val res: Int) : WidgetColor
+ data class Fixed(@ColorInt val color: Int) : WidgetColor
+ data class DayNight(@ColorInt val day: Int, @ColorInt val night: Int) : WidgetColor
+}
+
+/** [colors] resolved for this device, one [WidgetRole] at a time. */
+internal class WidgetPalette(private val context: Context, colors: WidgetColors) {
+
+ val mode: WidgetPaletteMode = colors.paletteMode(dynamicAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
+
+ /** True for the default look, where the layouts' own resource colours are already right. */
+ val followsLauncher: Boolean get() = mode == WidgetPaletteMode.Launcher
+
+ private val day by lazy { tone(dark = false) }
+ private val night by lazy { tone(dark = true) }
+
+ fun color(role: WidgetRole): WidgetColor = when (val mode = mode) {
+ WidgetPaletteMode.Launcher -> WidgetColor.Launcher(role.launcher)
+ WidgetPaletteMode.AppDayNight -> WidgetColor.DayNight(day.getColor(role.app), night.getColor(role.app))
+ is WidgetPaletteMode.Pinned ->
+ WidgetColor.Fixed((if (mode.dark) night else day).getColor(if (mode.dynamic) role.launcher else role.app))
+ }
+
+ /**
+ * The app's resources under a forced day or night: `values-night` (and
+ * `-night-v31`'s wallpaper tones) by choice rather than by whatever the
+ * app's own configuration happens to be.
+ */
+ private fun tone(dark: Boolean): Context {
+ val config = Configuration(context.resources.configuration)
+ val night = if (dark) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO
+ config.uiMode = (config.uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or night
+ return context.createConfigurationContext(config)
+ }
+}
+
+/**
+ * [color] through [method], a view method taking an `int` colour
+ * (`setTextColor`, `setColorFilter`). Every API level; [WidgetColor.DayNight]
+ * only ever arises on API 31+.
+ */
+internal fun RemoteViews.setWidgetColor(context: Context, viewId: Int, method: String, color: WidgetColor) {
+ when (color) {
+ is WidgetColor.Launcher -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ // Resolved by the launcher, against its own day/night.
+ setColor(viewId, method, color.res)
+ } else {
+ setInt(viewId, method, context.getColor(color.res))
+ }
+ is WidgetColor.Fixed -> setInt(viewId, method, color.color)
+ is WidgetColor.DayNight -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ setColorInt(viewId, method, color.day, color.night)
+ } else {
+ setInt(viewId, method, color.day)
+ }
+ }
+}
+
+/** [color] through [method], a view method taking a `ColorStateList` (`setBackgroundTintList`, an `AnalogClock` hand's tint). */
+@RequiresApi(Build.VERSION_CODES.S)
+internal fun RemoteViews.setWidgetTint(viewId: Int, method: String, color: WidgetColor) {
+ when (color) {
+ is WidgetColor.Launcher -> setColorStateList(viewId, method, color.res)
+ is WidgetColor.Fixed -> setColorStateList(viewId, method, ColorStateList.valueOf(color.color))
+ is WidgetColor.DayNight ->
+ setColorStateList(viewId, method, ColorStateList.valueOf(color.day), ColorStateList.valueOf(color.night))
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/AnalogClockConfigActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/AnalogClockConfigActivity.kt
index 2c67337..957b5f4 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/AnalogClockConfigActivity.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/AnalogClockConfigActivity.kt
@@ -1,21 +1,15 @@
package de.jeanlucmakiola.clockula.ui.widget.config
import android.widget.FrameLayout
-import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@@ -28,21 +22,24 @@ import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.widget.AnalogClockStyle
import de.jeanlucmakiola.clockula.domain.widget.ClockWidgetTapTarget
+import de.jeanlucmakiola.clockula.domain.widget.WidgetColors
import de.jeanlucmakiola.clockula.domain.widget.parseAnalogClockStyle
import de.jeanlucmakiola.clockula.domain.widget.parseClockWidgetTapTarget
import de.jeanlucmakiola.clockula.ui.widget.analogClockViews
import de.jeanlucmakiola.clockula.widget.AnalogClockWidget
import de.jeanlucmakiola.clockula.widget.WIDGET_ANALOG_STYLE_KEY
import de.jeanlucmakiola.clockula.widget.WIDGET_TAP_TARGET_KEY
-import de.jeanlucmakiola.floret.components.FullScreenPicker
+import de.jeanlucmakiola.clockula.widget.setWidgetColors
+import de.jeanlucmakiola.clockula.widget.widgetColors
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
/**
- * The analog widget's configuration: its style and the tab a tap opens (#8).
- * Laid out like the digital widget's — the live face above the settings, the
- * style behind a row that opens [WidgetStylePicker], saved with OK — since a
- * second setting means a tap on a style can no longer be the whole answer.
+ * The analog widget's configuration: its style, the tab a tap opens (#8) and,
+ * on API 31+, its colours ([WidgetColorsSection]). Laid out like the digital
+ * widget's — the live face above the settings, the style behind a row that
+ * opens [WidgetStylePicker], saved with OK — since more than one setting
+ * means a tap on a style can no longer be the whole answer.
*/
@AndroidEntryPoint
class AnalogClockConfigActivity : WidgetConfigActivity() {
@@ -54,22 +51,28 @@ class AnalogClockConfigActivity : WidgetConfigActivity() {
// defaults over what the user has.
var style by remember { mutableStateOf(null) }
var tapTarget by remember { mutableStateOf(ClockWidgetTapTarget.ALARMS) }
+ var colors by remember { mutableStateOf(null) }
LaunchedEffect(glanceId) {
val state = getAppWidgetState(this@AnalogClockConfigActivity, PreferencesGlanceStateDefinition, glanceId)
tapTarget = parseClockWidgetTapTarget(state[WIDGET_TAP_TARGET_KEY])
style = parseAnalogClockStyle(state[WIDGET_ANALOG_STYLE_KEY])
+ colors = state.widgetColors()
}
val current = style ?: return
+ val currentColors = colors ?: return
AnalogClockConfigScreen(
style = current,
onStyleChange = { style = it },
tapTarget = tapTarget,
onTapTargetChange = { tapTarget = it },
+ colors = currentColors,
+ onColorsChange = { colors = it },
onSave = {
save {
updateAppWidgetState(this@AnalogClockConfigActivity, glanceId) { prefs ->
prefs[WIDGET_ANALOG_STYLE_KEY] = current.name
prefs[WIDGET_TAP_TARGET_KEY] = tapTarget.name
+ prefs.setWidgetColors(currentColors)
}
AnalogClockWidget().update(this@AnalogClockConfigActivity, glanceId)
}
@@ -85,22 +88,19 @@ private fun AnalogClockConfigScreen(
onStyleChange: (AnalogClockStyle) -> Unit,
tapTarget: ClockWidgetTapTarget,
onTapTargetChange: (ClockWidgetTapTarget) -> Unit,
+ colors: WidgetColors,
+ onColorsChange: (WidgetColors) -> Unit,
onSave: () -> Unit,
onDismiss: () -> Unit,
) {
var showStylePicker by remember { mutableStateOf(false) }
- FullScreenPicker(
+ WidgetSettingsScreen(
title = stringResource(R.string.widget_analog_config_title),
+ onSave = onSave,
onDismiss = onDismiss,
- actions = {
- TextButton(onClick = onSave) { Text(stringResource(android.R.string.ok)) }
- },
+ preview = { AnalogStylePreview(style, colors) },
) {
- // The widget as it will look, live, above the settings that change it.
- Box(modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp), contentAlignment = Alignment.Center) {
- AnalogStylePreview(style)
- }
GroupedRow(
title = stringResource(R.string.widget_config_style),
summary = stringResource(analogStyleLabel(style)),
@@ -109,7 +109,7 @@ private fun AnalogClockConfigScreen(
)
Spacer(Modifier.height(8.dp))
TapTargetRow(target = tapTarget, position = Position.Alone, onTargetChange = onTapTargetChange)
- Spacer(Modifier.height(24.dp))
+ WidgetColorsSection(colors = colors, onColorsChange = onColorsChange, available = WidgetDynamicColorAvailable)
}
if (showStylePicker) {
@@ -118,7 +118,7 @@ private fun AnalogClockConfigScreen(
options = AnalogClockStyle.entries,
selected = style,
label = { stringResource(analogStyleLabel(it)) },
- preview = { AnalogStylePreview(it) },
+ preview = { AnalogStylePreview(it, colors) },
onSelect = onStyleChange,
onDismiss = { showStylePicker = false },
)
@@ -143,9 +143,9 @@ internal fun analogStyleLabel(style: AnalogClockStyle): Int = when (style) {
* ticks and the dial, numerals and colours match exactly.
*/
@Composable
-private fun AnalogStylePreview(style: AnalogClockStyle) {
+private fun AnalogStylePreview(style: AnalogClockStyle, colors: WidgetColors) {
val context = LocalContext.current
- val views = remember(style) { analogClockViews(context, style, PreviewSize.value) }
+ val views = remember(style, colors) { analogClockViews(context, style, PreviewSize.value, colors) }
AndroidView(
factory = { FrameLayout(it) },
update = { frame ->
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/ClockWidgetConfigActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/ClockWidgetConfigActivity.kt
index 57bab5b..6ee6a45 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/ClockWidgetConfigActivity.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/ClockWidgetConfigActivity.kt
@@ -34,6 +34,7 @@ import de.jeanlucmakiola.clockula.domain.widget.ClockWidgetTapTarget
import de.jeanlucmakiola.clockula.domain.widget.DigitalClockSettings
import de.jeanlucmakiola.clockula.domain.widget.DigitalClockStyle
import de.jeanlucmakiola.clockula.domain.widget.parseClockWidgetTapTarget
+import de.jeanlucmakiola.clockula.domain.widget.WidgetColors
import de.jeanlucmakiola.clockula.domain.widget.showsWorldClocks
import de.jeanlucmakiola.clockula.ui.widget.ClockWidgetCity
import de.jeanlucmakiola.clockula.ui.widget.digitalClockViews
@@ -44,12 +45,14 @@ import de.jeanlucmakiola.clockula.widget.WIDGET_SHOW_DATE_KEY
import de.jeanlucmakiola.clockula.widget.WIDGET_SHOW_WORLD_CLOCKS_KEY
import de.jeanlucmakiola.clockula.widget.WIDGET_TAP_TARGET_KEY
import de.jeanlucmakiola.clockula.widget.parseDigitalClockSettings
+import de.jeanlucmakiola.clockula.widget.setWidgetColors
+import de.jeanlucmakiola.clockula.widget.widgetColors
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import javax.inject.Inject
-/** Picks the digital widget instance's style, the two switches, and the tab a tap opens. */
+/** Picks the digital widget instance's style, the two switches, the tab a tap opens, and its colours. */
@AndroidEntryPoint
class ClockWidgetConfigActivity : WidgetConfigActivity() {
@@ -63,6 +66,7 @@ class ClockWidgetConfigActivity : WidgetConfigActivity() {
// late load can write defaults over what the user has.
var settings by remember { mutableStateOf(null) }
var tapTarget by remember { mutableStateOf(ClockWidgetTapTarget.ALARMS) }
+ var colors by remember { mutableStateOf(null) }
val cities by produceState(initialValue = emptyList()) {
value = widgetCities(worldClockRepository, zoneNames)
}
@@ -74,14 +78,18 @@ class ClockWidgetConfigActivity : WidgetConfigActivity() {
showDate = state[WIDGET_SHOW_DATE_KEY],
showWorldClocks = state[WIDGET_SHOW_WORLD_CLOCKS_KEY],
)
+ colors = state.widgetColors()
}
val current = settings ?: return
+ val currentColors = colors ?: return
ClockWidgetConfigScreen(
settings = current,
cities = cities,
onSettingsChange = { settings = it },
tapTarget = tapTarget,
onTapTargetChange = { tapTarget = it },
+ colors = currentColors,
+ onColorsChange = { colors = it },
onSave = {
save {
updateAppWidgetState(this@ClockWidgetConfigActivity, glanceId) { prefs ->
@@ -89,6 +97,7 @@ class ClockWidgetConfigActivity : WidgetConfigActivity() {
prefs[WIDGET_SHOW_DATE_KEY] = current.showDate
prefs[WIDGET_SHOW_WORLD_CLOCKS_KEY] = current.showWorldClocks
prefs[WIDGET_TAP_TARGET_KEY] = tapTarget.name
+ prefs.setWidgetColors(currentColors)
}
ClockWidget().update(this@ClockWidgetConfigActivity, glanceId)
}
@@ -105,6 +114,8 @@ private fun ClockWidgetConfigScreen(
onSettingsChange: (DigitalClockSettings) -> Unit,
tapTarget: ClockWidgetTapTarget,
onTapTargetChange: (ClockWidgetTapTarget) -> Unit,
+ colors: WidgetColors,
+ onColorsChange: (WidgetColors) -> Unit,
onSave: () -> Unit,
onDismiss: () -> Unit,
) {
@@ -120,7 +131,7 @@ private fun ClockWidgetConfigScreen(
) {
// The widget as it will look, live, above the settings that change it.
Box(modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp), contentAlignment = Alignment.Center) {
- DigitalStylePreview(settings, cities)
+ DigitalStylePreview(settings, cities, colors)
}
GroupedRow(
title = stringResource(R.string.widget_config_style),
@@ -146,6 +157,7 @@ private fun ClockWidgetConfigScreen(
}
Spacer(Modifier.height(8.dp))
TapTargetRow(target = tapTarget, position = Position.Alone, onTargetChange = onTapTargetChange)
+ WidgetColorsSection(colors = colors, onColorsChange = onColorsChange, available = WidgetDynamicColorAvailable)
Spacer(Modifier.height(24.dp))
}
@@ -155,7 +167,7 @@ private fun ClockWidgetConfigScreen(
options = DigitalClockStyle.entries,
selected = settings.style,
label = { stringResource(digitalStyleLabel(it)) },
- preview = { style -> DigitalStylePreview(settings.copy(style = style), cities) },
+ preview = { style -> DigitalStylePreview(settings.copy(style = style), cities, colors) },
onSelect = { onSettingsChange(settings.copy(style = it)) },
onDismiss = { showStylePicker = false },
)
@@ -176,10 +188,10 @@ internal fun digitalStyleLabel(style: DigitalClockStyle): Int = when (style) {
* tick and the layout, sizes and colours match exactly.
*/
@Composable
-private fun DigitalStylePreview(settings: DigitalClockSettings, cities: List) {
+private fun DigitalStylePreview(settings: DigitalClockSettings, cities: List, colors: WidgetColors) {
val context = LocalContext.current
- val views = remember(settings, cities) {
- digitalClockViews(context, settings, cities, PreviewWidth.value, PreviewHeight.value)
+ val views = remember(settings, cities, colors) {
+ digitalClockViews(context, settings, cities, PreviewWidth.value, PreviewHeight.value, colors)
}
AndroidView(
factory = { FrameLayout(it) },
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/NextAlarmConfigActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/NextAlarmConfigActivity.kt
new file mode 100644
index 0000000..96fb2a9
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/NextAlarmConfigActivity.kt
@@ -0,0 +1,13 @@
+package de.jeanlucmakiola.clockula.ui.widget.config
+
+import androidx.glance.appwidget.GlanceAppWidget
+import dagger.hilt.android.AndroidEntryPoint
+import de.jeanlucmakiola.clockula.R
+import de.jeanlucmakiola.clockula.widget.NextAlarmWidget
+
+/** The next-alarm widget's colours. */
+@AndroidEntryPoint
+class NextAlarmConfigActivity : WidgetColorsConfigActivity() {
+ override val titleRes: Int = R.string.widget_next_alarm_label
+ override fun widget(): GlanceAppWidget = NextAlarmWidget()
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/QuickTimerConfigActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/QuickTimerConfigActivity.kt
index a8a8ac0..973f179 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/QuickTimerConfigActivity.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/QuickTimerConfigActivity.kt
@@ -15,46 +15,72 @@ import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.format.ClockFormat
import de.jeanlucmakiola.clockula.domain.widget.QuickTimerWidgetSettings
+import de.jeanlucmakiola.clockula.domain.widget.WidgetColors
import de.jeanlucmakiola.clockula.domain.widget.parseQuickTimerDuration
import de.jeanlucmakiola.clockula.ui.timers.TimerDurationDialog
import de.jeanlucmakiola.clockula.widget.QuickTimerWidget
import de.jeanlucmakiola.clockula.widget.WIDGET_QUICK_TIMER_DURATION_KEY
+import de.jeanlucmakiola.clockula.widget.setWidgetColors
+import de.jeanlucmakiola.clockula.widget.widgetColors
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedRow
+import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf
import kotlin.time.Duration
/**
- * The quick-timer widget's entire configuration: which duration it starts on
- * tap — one of [QuickTimerWidgetSettings.OPTIONS], the Timers screen's own
- * built-in quick-start durations up to the widget's 59:59 cap, or a custom
- * one typed on the timer keypad. Like [AnalogClockConfigActivity], picking a
- * duration saves and finishes immediately.
+ * The quick-timer widget's configuration: which duration it starts on tap —
+ * one of [QuickTimerWidgetSettings.OPTIONS], the Timers screen's own built-in
+ * quick-start durations up to the widget's 59:59 cap, or a custom one typed
+ * on the timer keypad — and its colours ([WidgetColorsSection]), saved
+ * together on OK.
*/
@AndroidEntryPoint
class QuickTimerConfigActivity : WidgetConfigActivity() {
@Composable
override fun Content() {
- var duration by remember { mutableStateOf(QuickTimerWidgetSettings.DEFAULT) }
+ // `null` until read: see `ClockWidgetConfigActivity`.
+ var duration by remember { mutableStateOf(null) }
+ var colors by remember { mutableStateOf(null) }
+ var showDurationPicker by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(glanceId) {
val state = getAppWidgetState(this@QuickTimerConfigActivity, PreferencesGlanceStateDefinition, glanceId)
duration = parseQuickTimerDuration(state[WIDGET_QUICK_TIMER_DURATION_KEY])
+ colors = state.widgetColors()
}
- QuickTimerPicker(
- selected = duration,
- onSelect = { picked ->
- duration = picked
+ val currentDuration = duration ?: return
+ val currentColors = colors ?: return
+ WidgetSettingsScreen(
+ title = stringResource(R.string.widget_quick_timer_config_title),
+ onSave = {
save {
updateAppWidgetState(this@QuickTimerConfigActivity, glanceId) { prefs ->
- prefs[WIDGET_QUICK_TIMER_DURATION_KEY] = picked.inWholeMilliseconds
+ prefs[WIDGET_QUICK_TIMER_DURATION_KEY] = currentDuration.inWholeMilliseconds
+ prefs.setWidgetColors(currentColors)
}
QuickTimerWidget().update(this@QuickTimerConfigActivity, glanceId)
}
},
onDismiss = ::dismiss,
- )
+ ) {
+ GroupedRow(
+ title = stringResource(R.string.timer_editor_duration),
+ summary = ClockFormat.countdown(currentDuration),
+ position = Position.Alone,
+ onClick = { showDurationPicker = true },
+ )
+ WidgetColorsSection(colors = currentColors, onColorsChange = { colors = it })
+ }
+
+ if (showDurationPicker) {
+ QuickTimerPicker(
+ selected = currentDuration,
+ onSelect = { duration = it },
+ onDismiss = { showDurationPicker = false },
+ )
+ }
}
}
@@ -62,6 +88,7 @@ class QuickTimerConfigActivity : WidgetConfigActivity() {
* The listed durations, then "Custom…", which opens the timer keypad. A
* stored duration that isn't listed marks the Custom row instead, with the
* value as its summary, so the picker always shows what the widget starts.
+ * Picking either closes it.
*/
@Composable
private fun QuickTimerPicker(selected: Duration, onSelect: (Duration) -> Unit, onDismiss: () -> Unit) {
@@ -69,7 +96,7 @@ private fun QuickTimerPicker(selected: Duration, onSelect: (Duration) -> Unit, o
val options = QuickTimerWidgetSettings.OPTIONS
val count = options.size + 1
- FullScreenPicker(title = stringResource(R.string.widget_quick_timer_config_title), onDismiss = onDismiss) {
+ FullScreenPicker(title = stringResource(R.string.timer_editor_duration), onDismiss = onDismiss) {
options.forEachIndexed { index, option ->
val isSelected = option == selected
GroupedRow(
@@ -81,7 +108,10 @@ private fun QuickTimerPicker(selected: Duration, onSelect: (Duration) -> Unit, o
} else {
null
},
- onClick = { onSelect(option) },
+ onClick = {
+ onSelect(option)
+ onDismiss()
+ },
)
}
val custom = QuickTimerWidgetSettings.isCustom(selected)
@@ -107,6 +137,7 @@ private fun QuickTimerPicker(selected: Duration, onSelect: (Duration) -> Unit, o
onConfirm = {
typing = false
onSelect(it)
+ onDismiss()
},
onDismiss = { typing = false },
)
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/StopwatchConfigActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/StopwatchConfigActivity.kt
index 916f312..273fd0f 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/StopwatchConfigActivity.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/StopwatchConfigActivity.kt
@@ -6,6 +6,7 @@ import android.widget.FrameLayout
import android.widget.RemoteViews
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -28,37 +29,48 @@ import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.StopwatchState
import de.jeanlucmakiola.clockula.domain.widget.StopwatchWidgetModel
import de.jeanlucmakiola.clockula.domain.widget.StopwatchWidgetStyle
+import de.jeanlucmakiola.clockula.domain.widget.WidgetColors
import de.jeanlucmakiola.clockula.domain.widget.parseStopwatchWidgetStyle
+import de.jeanlucmakiola.clockula.ui.widget.LocalWidgetColors
import de.jeanlucmakiola.clockula.ui.widget.StopwatchWidgetContent
import de.jeanlucmakiola.clockula.widget.StopwatchWidget
import de.jeanlucmakiola.clockula.widget.WIDGET_STOPWATCH_STYLE_KEY
+import de.jeanlucmakiola.clockula.widget.setWidgetColors
+import de.jeanlucmakiola.clockula.widget.widgetColors
+import de.jeanlucmakiola.floret.components.GroupedRow
+import de.jeanlucmakiola.floret.components.Position
import kotlin.time.Duration.Companion.seconds
/**
- * The stopwatch widget's entire configuration: a style pick. Tapping an
- * option saves and finishes immediately.
+ * The stopwatch widget's configuration: its style and its colours
+ * ([WidgetColorsSection]), under a live preview, saved together on OK — the
+ * shape of [ClockWidgetConfigActivity].
*/
@AndroidEntryPoint
class StopwatchConfigActivity : WidgetConfigActivity() {
@Composable
override fun Content() {
- var style by remember { mutableStateOf(StopwatchWidgetStyle.CARD) }
+ // `null` until read: see `ClockWidgetConfigActivity`.
+ var style by remember { mutableStateOf(null) }
+ var colors by remember { mutableStateOf(null) }
LaunchedEffect(glanceId) {
val state = getAppWidgetState(this@StopwatchConfigActivity, PreferencesGlanceStateDefinition, glanceId)
style = parseStopwatchWidgetStyle(state[WIDGET_STOPWATCH_STYLE_KEY])
+ colors = state.widgetColors()
}
- WidgetStylePicker(
- title = stringResource(R.string.widget_stopwatch_config_title),
- options = StopwatchWidgetStyle.entries,
- selected = style,
- label = { stringResource(stopwatchStyleLabel(it)) },
- preview = { StopwatchStylePreview(it) },
- onSelect = { picked ->
- style = picked
+ val currentStyle = style ?: return
+ val currentColors = colors ?: return
+ StopwatchConfigScreen(
+ style = currentStyle,
+ onStyleChange = { style = it },
+ colors = currentColors,
+ onColorsChange = { colors = it },
+ onSave = {
save {
updateAppWidgetState(this@StopwatchConfigActivity, glanceId) { prefs ->
- prefs[WIDGET_STOPWATCH_STYLE_KEY] = picked.name
+ prefs[WIDGET_STOPWATCH_STYLE_KEY] = currentStyle.name
+ prefs.setWidgetColors(currentColors)
}
StopwatchWidget().update(this@StopwatchConfigActivity, glanceId)
}
@@ -68,6 +80,45 @@ class StopwatchConfigActivity : WidgetConfigActivity() {
}
}
+@Composable
+private fun StopwatchConfigScreen(
+ style: StopwatchWidgetStyle,
+ onStyleChange: (StopwatchWidgetStyle) -> Unit,
+ colors: WidgetColors,
+ onColorsChange: (WidgetColors) -> Unit,
+ onSave: () -> Unit,
+ onDismiss: () -> Unit,
+) {
+ var showStylePicker by remember { mutableStateOf(false) }
+
+ WidgetSettingsScreen(
+ title = stringResource(R.string.widget_stopwatch_config_title),
+ onSave = onSave,
+ onDismiss = onDismiss,
+ preview = { StopwatchStylePreview(style, colors) },
+ ) {
+ GroupedRow(
+ title = stringResource(R.string.widget_config_style),
+ summary = stringResource(stopwatchStyleLabel(style)),
+ position = Position.Alone,
+ onClick = { showStylePicker = true },
+ )
+ WidgetColorsSection(colors = colors, onColorsChange = onColorsChange)
+ }
+
+ if (showStylePicker) {
+ WidgetStylePicker(
+ title = stringResource(R.string.widget_config_style),
+ options = StopwatchWidgetStyle.entries,
+ selected = style,
+ label = { stringResource(stopwatchStyleLabel(it)) },
+ preview = { StopwatchStylePreview(it, colors) },
+ onSelect = onStyleChange,
+ onDismiss = { showStylePicker = false },
+ )
+ }
+}
+
internal fun stopwatchStyleLabel(style: StopwatchWidgetStyle): Int = when (style) {
StopwatchWidgetStyle.CARD -> R.string.stopwatch_style_card
StopwatchWidgetStyle.CIRCLE -> R.string.stopwatch_style_circle
@@ -84,13 +135,15 @@ internal fun stopwatchStyleLabel(style: StopwatchWidgetStyle): Int = when (style
*/
@OptIn(ExperimentalGlanceRemoteViewsApi::class)
@Composable
-private fun StopwatchStylePreview(style: StopwatchWidgetStyle) {
+private fun StopwatchStylePreview(style: StopwatchWidgetStyle, colors: WidgetColors) {
val context = LocalContext.current
val size = if (style == StopwatchWidgetStyle.CARD) CardPreviewSize else ShapePreviewSize
- val views by produceState(initialValue = null, style) {
+ val views by produceState(initialValue = null, style, colors) {
val now = SystemClock.elapsedRealtime()
value = GlanceRemoteViews().compose(context, size) {
- StopwatchWidgetContent(PreviewModel, now, style)
+ CompositionLocalProvider(LocalWidgetColors provides colors) {
+ StopwatchWidgetContent(PreviewModel, now, style)
+ }
}.remoteViews
}
AndroidView(
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/TimerConfigActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/TimerConfigActivity.kt
new file mode 100644
index 0000000..f8c2448
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/TimerConfigActivity.kt
@@ -0,0 +1,13 @@
+package de.jeanlucmakiola.clockula.ui.widget.config
+
+import androidx.glance.appwidget.GlanceAppWidget
+import dagger.hilt.android.AndroidEntryPoint
+import de.jeanlucmakiola.clockula.R
+import de.jeanlucmakiola.clockula.widget.TimerWidget
+
+/** The timer widget's colours. */
+@AndroidEntryPoint
+class TimerConfigActivity : WidgetColorsConfigActivity() {
+ override val titleRes: Int = R.string.widget_timer_label
+ override fun widget(): GlanceAppWidget = TimerWidget()
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/WidgetColorsConfigActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/WidgetColorsConfigActivity.kt
new file mode 100644
index 0000000..ead4af3
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/WidgetColorsConfigActivity.kt
@@ -0,0 +1,54 @@
+package de.jeanlucmakiola.clockula.ui.widget.config
+
+import androidx.annotation.StringRes
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.res.stringResource
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.state.getAppWidgetState
+import androidx.glance.appwidget.state.updateAppWidgetState
+import androidx.glance.state.PreferencesGlanceStateDefinition
+import de.jeanlucmakiola.clockula.domain.widget.WidgetColors
+import de.jeanlucmakiola.clockula.widget.setWidgetColors
+import de.jeanlucmakiola.clockula.widget.widgetColors
+
+/**
+ * The config screen of a widget whose only settings are its colours
+ * ([WidgetColorsSection]) — the next-alarm and timer widgets, which had none
+ * before. Both are `configuration_optional`, so placing one never stops here.
+ */
+abstract class WidgetColorsConfigActivity : WidgetConfigActivity() {
+
+ @get:StringRes
+ protected abstract val titleRes: Int
+
+ /** A fresh instance of the widget, to redraw the configured one straight after saving. */
+ protected abstract fun widget(): GlanceAppWidget
+
+ @Composable
+ override fun Content() {
+ // `null` until read, so neither an early save nor a late load can
+ // write defaults over what the user has (as `ClockWidgetConfigActivity`).
+ var colors by remember { mutableStateOf(null) }
+ LaunchedEffect(glanceId) {
+ colors = getAppWidgetState(this@WidgetColorsConfigActivity, PreferencesGlanceStateDefinition, glanceId).widgetColors()
+ }
+ val current = colors ?: return
+ WidgetSettingsScreen(
+ title = stringResource(titleRes),
+ onSave = {
+ save {
+ updateAppWidgetState(this@WidgetColorsConfigActivity, glanceId) { prefs -> prefs.setWidgetColors(current) }
+ widget().update(this@WidgetColorsConfigActivity, glanceId)
+ }
+ },
+ onDismiss = ::dismiss,
+ ) {
+ WidgetColorsSection(colors = current, onColorsChange = { colors = it }, gapAbove = false)
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/WidgetColorsSection.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/WidgetColorsSection.kt
new file mode 100644
index 0000000..005f3e7
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/config/WidgetColorsSection.kt
@@ -0,0 +1,127 @@
+package de.jeanlucmakiola.clockula.ui.widget.config
+
+import android.os.Build
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import de.jeanlucmakiola.clockula.R
+import de.jeanlucmakiola.clockula.domain.widget.WidgetColorTheme
+import de.jeanlucmakiola.clockula.domain.widget.WidgetColors
+import de.jeanlucmakiola.floret.components.FullScreenPicker
+import de.jeanlucmakiola.floret.components.GroupedRow
+import de.jeanlucmakiola.floret.components.OptionPicker
+import de.jeanlucmakiola.floret.components.Position
+
+/** Android 12+: where wallpaper colours exist at all, and where the plain-`RemoteViews` clock faces can be re-coloured. */
+internal val WidgetDynamicColorAvailable: Boolean get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
+
+/**
+ * A widget config screen with settings rather than a single pick: the
+ * widget's [preview], if it has one, above its rows, and an OK that [onSave]s
+ * them all at once — the shape `ClockWidgetConfigActivity` set.
+ */
+@Composable
+internal fun WidgetSettingsScreen(
+ title: String,
+ onSave: () -> Unit,
+ onDismiss: () -> Unit,
+ preview: (@Composable () -> Unit)? = null,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ FullScreenPicker(
+ title = title,
+ onDismiss = onDismiss,
+ actions = {
+ TextButton(onClick = onSave) { Text(stringResource(android.R.string.ok)) }
+ },
+ ) {
+ if (preview != null) {
+ // The widget as it will look, live, above the settings that change it.
+ Box(modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp), contentAlignment = Alignment.Center) {
+ preview()
+ }
+ }
+ content()
+ Spacer(Modifier.height(24.dp))
+ }
+}
+
+/**
+ * A widget instance's colours, as the app's own Appearance screen offers
+ * them: the theme — follow the system, or pin Light or Dark — and, on
+ * Android 12+, whether to take the wallpaper's colours. Its own group, set
+ * off from the widget's other settings above it by a gap unless [gapAbove]
+ * is off (a screen with nothing else on it).
+ *
+ * Nothing when not [available]: the analog and digital clocks can only be
+ * re-coloured from API 31 (`AnalogClockWidgetContent.kt`, `DigitalFaces.kt`).
+ */
+@Composable
+internal fun WidgetColorsSection(
+ colors: WidgetColors,
+ onColorsChange: (WidgetColors) -> Unit,
+ available: Boolean = true,
+ gapAbove: Boolean = true,
+) {
+ if (!available) return
+ if (gapAbove) Spacer(Modifier.height(16.dp))
+ var showTheme by rememberSaveable { mutableStateOf(false) }
+ val dynamic = WidgetDynamicColorAvailable
+
+ GroupedRow(
+ title = stringResource(R.string.settings_theme),
+ summary = widgetThemeLabel(colors.theme),
+ position = if (dynamic) Position.Top else Position.Alone,
+ onClick = { showTheme = true },
+ )
+ // Below Android 12 there are no wallpaper colours to switch off.
+ if (dynamic) {
+ GroupedRow(
+ title = stringResource(R.string.settings_dynamic_color),
+ summary = stringResource(R.string.settings_dynamic_color_hint),
+ position = Position.Bottom,
+ trailing = {
+ Switch(
+ checked = colors.wallpaperColors,
+ onCheckedChange = { onColorsChange(colors.copy(wallpaperColors = it)) },
+ )
+ },
+ onClick = { onColorsChange(colors.copy(wallpaperColors = !colors.wallpaperColors)) },
+ )
+ }
+
+ if (showTheme) {
+ OptionPicker(
+ title = stringResource(R.string.settings_theme),
+ options = WidgetColorTheme.entries.toList(),
+ selected = colors.theme,
+ label = { widgetThemeLabel(it) },
+ onSelect = { onColorsChange(colors.copy(theme = it)) },
+ onDismiss = { showTheme = false },
+ )
+ }
+}
+
+@Composable
+private fun widgetThemeLabel(theme: WidgetColorTheme): String = stringResource(
+ when (theme) {
+ WidgetColorTheme.SYSTEM -> R.string.settings_theme_system
+ WidgetColorTheme.LIGHT -> R.string.settings_theme_light
+ WidgetColorTheme.DARK -> R.string.settings_theme_dark
+ },
+)
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/widget/AnalogClockWidget.kt b/app/src/main/java/de/jeanlucmakiola/clockula/widget/AnalogClockWidget.kt
index 6bd4ca0..a3ab571 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/widget/AnalogClockWidget.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/widget/AnalogClockWidget.kt
@@ -8,6 +8,7 @@ import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.provideContent
+import de.jeanlucmakiola.clockula.ui.widget.ProvideWidgetColors
import de.jeanlucmakiola.clockula.ui.widget.AnalogClockWidgetRoot
/**
@@ -29,7 +30,7 @@ class AnalogClockWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
- AnalogClockWidgetRoot()
+ ProvideWidgetColors { AnalogClockWidgetRoot() }
}
}
}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/widget/ClockWidget.kt b/app/src/main/java/de/jeanlucmakiola/clockula/widget/ClockWidget.kt
index fa7b029..945ca65 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/widget/ClockWidget.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/widget/ClockWidget.kt
@@ -6,6 +6,7 @@ import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.provideContent
+import de.jeanlucmakiola.clockula.ui.widget.ProvideWidgetColors
import de.jeanlucmakiola.clockula.ui.widget.ClockWidgetRoot
import de.jeanlucmakiola.clockula.ui.widget.widgetCities
import de.jeanlucmakiola.clockula.widget.di.widgetEntryPoint
@@ -36,7 +37,7 @@ class ClockWidget : GlanceAppWidget() {
val entryPoint = widgetEntryPoint(context)
val initialCities = widgetCities(entryPoint.worldClockRepository(), entryPoint.zoneNames())
provideContent {
- ClockWidgetRoot(entryPoint, initialCities)
+ ProvideWidgetColors { ClockWidgetRoot(entryPoint, initialCities) }
}
}
}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/widget/ClockWidgetState.kt b/app/src/main/java/de/jeanlucmakiola/clockula/widget/ClockWidgetState.kt
index aba429c..500bf85 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/widget/ClockWidgetState.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/widget/ClockWidgetState.kt
@@ -1,12 +1,17 @@
package de.jeanlucmakiola.clockula.widget
+import androidx.datastore.preferences.core.MutablePreferences
+import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import de.jeanlucmakiola.clockula.domain.widget.AnalogClockStyle
import de.jeanlucmakiola.clockula.domain.widget.DigitalClockSettings
import de.jeanlucmakiola.clockula.domain.widget.DigitalClockStyle
+import de.jeanlucmakiola.clockula.domain.widget.WidgetColorTheme
+import de.jeanlucmakiola.clockula.domain.widget.WidgetColors
import de.jeanlucmakiola.clockula.domain.widget.parseDigitalClockStyle
+import de.jeanlucmakiola.clockula.domain.widget.parseWidgetColors
/** Per-instance Glance state key holding the digital widget's [DigitalClockStyle] ([name]). */
internal val WIDGET_DIGITAL_STYLE_KEY = stringPreferencesKey("digital_style")
@@ -41,3 +46,17 @@ internal fun parseDigitalClockSettings(style: String?, showDate: Boolean?, showW
showDate = showDate ?: true,
showWorldClocks = showWorldClocks ?: false,
)
+
+/** Per-instance Glance state key holding any widget's [WidgetColorTheme] ([name]); every widget has its colours. */
+internal val WIDGET_THEME_KEY = stringPreferencesKey("widget_theme")
+
+/** Per-instance Glance state key: use the wallpaper's colours (Android 12+) rather than the app's own. */
+internal val WIDGET_WALLPAPER_COLORS_KEY = booleanPreferencesKey("widget_wallpaper_colors")
+
+/** This instance's [WidgetColors]; an instance that never saved any gets the defaults, i.e. the look from before they existed. */
+internal fun Preferences.widgetColors(): WidgetColors = parseWidgetColors(this[WIDGET_THEME_KEY], this[WIDGET_WALLPAPER_COLORS_KEY])
+
+internal fun MutablePreferences.setWidgetColors(colors: WidgetColors) {
+ this[WIDGET_THEME_KEY] = colors.theme.name
+ this[WIDGET_WALLPAPER_COLORS_KEY] = colors.wallpaperColors
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/widget/NextAlarmWidget.kt b/app/src/main/java/de/jeanlucmakiola/clockula/widget/NextAlarmWidget.kt
index e54c690..8b3672e 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/widget/NextAlarmWidget.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/widget/NextAlarmWidget.kt
@@ -6,6 +6,7 @@ import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.provideContent
+import de.jeanlucmakiola.clockula.ui.widget.ProvideWidgetColors
import de.jeanlucmakiola.clockula.ui.widget.NextAlarmWidgetRoot
import de.jeanlucmakiola.clockula.widget.di.widgetEntryPoint
import kotlinx.coroutines.flow.first
@@ -20,7 +21,7 @@ class NextAlarmWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
val entryPoint = widgetEntryPoint(context)
val initial = entryPoint.alarmEngine().upcoming().first()
- provideContent { NextAlarmWidgetRoot(entryPoint, initial) }
+ provideContent { ProvideWidgetColors { NextAlarmWidgetRoot(entryPoint, initial) } }
}
}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/widget/QuickTimerWidget.kt b/app/src/main/java/de/jeanlucmakiola/clockula/widget/QuickTimerWidget.kt
index fc5a5ae..b9482d6 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/widget/QuickTimerWidget.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/widget/QuickTimerWidget.kt
@@ -6,6 +6,7 @@ import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.provideContent
+import de.jeanlucmakiola.clockula.ui.widget.ProvideWidgetColors
import de.jeanlucmakiola.clockula.ui.widget.QuickTimerWidgetRoot
import de.jeanlucmakiola.clockula.widget.di.widgetEntryPoint
import kotlinx.coroutines.flow.first
@@ -27,7 +28,7 @@ class QuickTimerWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
val entryPoint = widgetEntryPoint(context)
val initial = entryPoint.timerRepository().timers().first()
- provideContent { QuickTimerWidgetRoot(entryPoint, initial) }
+ provideContent { ProvideWidgetColors { QuickTimerWidgetRoot(entryPoint, initial) } }
}
}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/widget/StopwatchWidget.kt b/app/src/main/java/de/jeanlucmakiola/clockula/widget/StopwatchWidget.kt
index 1731573..f7dc424 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/widget/StopwatchWidget.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/widget/StopwatchWidget.kt
@@ -6,6 +6,7 @@ import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.provideContent
+import de.jeanlucmakiola.clockula.ui.widget.ProvideWidgetColors
import de.jeanlucmakiola.clockula.ui.widget.StopwatchWidgetRoot
import de.jeanlucmakiola.clockula.widget.di.widgetEntryPoint
import kotlinx.coroutines.flow.first
@@ -22,7 +23,7 @@ class StopwatchWidget : GlanceAppWidget() {
val repository = entryPoint.stopwatchRepository()
val initialRun = repository.run().first()
val initialLaps = repository.laps().first()
- provideContent { StopwatchWidgetRoot(entryPoint, initialRun, initialLaps.size) }
+ provideContent { ProvideWidgetColors { StopwatchWidgetRoot(entryPoint, initialRun, initialLaps.size) } }
}
}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/widget/TimerWidget.kt b/app/src/main/java/de/jeanlucmakiola/clockula/widget/TimerWidget.kt
index a4d7cb8..a3da19e 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/widget/TimerWidget.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/widget/TimerWidget.kt
@@ -6,6 +6,7 @@ import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.provideContent
+import de.jeanlucmakiola.clockula.ui.widget.ProvideWidgetColors
import de.jeanlucmakiola.clockula.ui.widget.TimerWidgetRoot
import de.jeanlucmakiola.clockula.widget.di.widgetEntryPoint
import kotlinx.coroutines.flow.first
@@ -21,7 +22,7 @@ class TimerWidget : GlanceAppWidget() {
val entryPoint = widgetEntryPoint(context)
val repository = entryPoint.timerRepository()
val initial = repository.timers().first()
- provideContent { TimerWidgetRoot(entryPoint, initial) }
+ provideContent { ProvideWidgetColors { TimerWidgetRoot(entryPoint, initial) } }
}
}
diff --git a/app/src/main/res/layout/widget_digital_stacked.xml b/app/src/main/res/layout/widget_digital_stacked.xml
index 88bb9ae..22bf8b7 100644
--- a/app/src/main/res/layout/widget_digital_stacked.xml
+++ b/app/src/main/res/layout/widget_digital_stacked.xml
@@ -8,6 +8,7 @@
android:layout_height="match_parent">
-
- #FF2B2F26
- #FF3E4C32
- #FF334B47
- #FFE4E9E0
- #FFB4C7A4
- #FFC9D9B9
- #FFA5CEC7
- #FFE3E3DC
- #FFB4C7A4
- #FF22261C
+
+ #FF2B2F26
+ #FF3E4C32
+ #FF334B47
+ #FFE4E9E0
+ #FFB4C7A4
+ #FFC9D9B9
+ #FFA5CEC7
+ #FFE3E3DC
+ #FFB4C7A4
+ #FF22261C
- #FF2B2F26
- #FFE4E9E0
- #FFC4C8BA
- #FFCEE2BA
- #FF4A583B
- #FFE8F6D9
- #FF213510
- #FF3D4A35
- #FFD8E7CB
- #FFA5CEC7
- #FF244D48
- #FFC1ECE4
+ #FF2B2F26
+ #FFE4E9E0
+ #FFC4C8BA
+ #FFCEE2BA
+ #FF4A583B
+ #FFE8F6D9
+ #FF213510
+ #FF3D4A35
+ #FFD8E7CB
+ #FFA5CEC7
+ #FF244D48
+ #FFC1ECE4
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index 5857ff0..26676d4 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -12,32 +12,60 @@
resolved against the host process's own configuration (`values-night/`
below), the same mechanism the platform itself uses for day/night —
not a `?android:attr`, which proved unreliable here. -->
- #FFE8EAE1
- #FFD7E8C6
- #FFCCE8E3
- #FF44483E
- #FF6B7A5C
- #FF4E5C40
- #FF3D6660
- #FF1A1C18
- #FF6B7A5C
- #FFFFFFFF
+ #FFE8EAE1
+ #FFD7E8C6
+ #FFCCE8E3
+ #FF44483E
+ #FF6B7A5C
+ #FF4E5C40
+ #FF3D6660
+ #FF1A1C18
+ #FF6B7A5C
+ #FFFFFFFF
- #FFE8EAE1
- #FF1C1F1A
- #FF44483E
- #FF5C6A4E
- #FFD7E8C6
- #FF1D2A10
- #FFFFFFFF
- #FFDCE7D0
- #FF161E10
- #FF3D6660
- #FFCCE8E3
- #FF05201C
+ #FFE8EAE1
+ #FF1C1F1A
+ #FF44483E
+ #FF5C6A4E
+ #FFD7E8C6
+ #FF1D2A10
+ #FFFFFFFF
+ #FFDCE7D0
+ #FF161E10
+ #FF3D6660
+ #FFCCE8E3
+ #FF05201C
+
+
+ @color/widget_app_analog_dial_fill
+ @color/widget_app_analog_dial_primary
+ @color/widget_app_analog_dial_tertiary
+ @color/widget_app_analog_hand_hour
+ @color/widget_app_analog_hand_minute
+ @color/widget_app_analog_numeral
+ @color/widget_app_analog_second
+ @color/widget_app_analog_date
+ @color/widget_app_analog_badge_pill
+ @color/widget_app_analog_badge_pill_text
+ @color/widget_app_surface
+ @color/widget_app_on_surface
+ @color/widget_app_on_surface_variant
+ @color/widget_app_primary
+ @color/widget_app_primary_container
+ @color/widget_app_on_primary_container
+ @color/widget_app_on_primary
+ @color/widget_app_secondary_container
+ @color/widget_app_on_secondary_container
+ @color/widget_app_tertiary
+ @color/widget_app_tertiary_container
+ @color/widget_app_on_tertiary_container
diff --git a/app/src/main/res/xml/widget_next_alarm_info.xml b/app/src/main/res/xml/widget_next_alarm_info.xml
index 8e7d58f..7342145 100644
--- a/app/src/main/res/xml/widget_next_alarm_info.xml
+++ b/app/src/main/res/xml/widget_next_alarm_info.xml
@@ -10,4 +10,6 @@
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:previewImage="@mipmap/ic_launcher"
- android:previewLayout="@layout/widget_preview_next_alarm" />
+ android:previewLayout="@layout/widget_preview_next_alarm"
+ android:configure="de.jeanlucmakiola.clockula.ui.widget.config.NextAlarmConfigActivity"
+ android:widgetFeatures="reconfigurable|configuration_optional" />
diff --git a/app/src/main/res/xml/widget_timer_info.xml b/app/src/main/res/xml/widget_timer_info.xml
index 46202e9..ac6c1df 100644
--- a/app/src/main/res/xml/widget_timer_info.xml
+++ b/app/src/main/res/xml/widget_timer_info.xml
@@ -11,4 +11,6 @@
android:widgetCategory="home_screen"
android:previewImage="@mipmap/ic_launcher"
android:previewLayout="@layout/widget_preview_timer"
- android:description="@string/widget_timer_description" />
+ android:description="@string/widget_timer_description"
+ android:configure="de.jeanlucmakiola.clockula.ui.widget.config.TimerConfigActivity"
+ android:widgetFeatures="reconfigurable|configuration_optional" />
diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt
index 8e7eaa0..fc5bea3 100644
--- a/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt
@@ -31,7 +31,7 @@ class ManifestRulesTest {
}
@Test
- fun `exactly 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)
diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/WidgetColorsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/WidgetColorsTest.kt
new file mode 100644
index 0000000..7707ca8
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/widget/WidgetColorsTest.kt
@@ -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))
+ }
+}
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index eacea2f..d00b8c4 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -139,7 +139,7 @@ kit module — the kit's roadmap keeps schedulers app-local.
| `ui/worldclock/` | the real World clock tab (M8): the pure row builders (`WorldClockRows`, `ZonePickerRows`) and their state, `WorldClockSource`, the ViewModel, the tab, the analog face, the row and the zone picker, `WorldClockDefaults` |
| `ui/ring/` | `AlarmRingActivity`, its ViewModel, `RingScreen` and the dismiss-challenge controls |
| `ui/settings/` | the settings hub (M10): `SettingsScreen`/`SettingsViewModel` (appearance, alarm/timer/clock defaults, reliability, data, help), `SelfCheckScreen`/`SelfCheckViewModel`, `BackupScreen`/`BackupViewModel` — all reachable from a gear icon on every tab, with no owning tab of their own |
-| `ui/widget/` | the three widgets' `@Composable` Glance content (M10) — the only Compose this milestone adds outside a tab; the `GlanceAppWidget`/`GlanceAppWidgetReceiver` classes themselves live in `widget/`, since they are manifest components, not UI |
+| `ui/widget/` | the three widgets' `@Composable` Glance content (M10) — the only Compose this milestone adds outside a tab; the `GlanceAppWidget`/`GlanceAppWidgetReceiver` classes themselves live in `widget/`, since they are manifest components, not UI. `ui/widget/config/` holds each widget's config activity; every widget has one, if only for its per-instance colours (`domain/widget/WidgetColors.kt`: System/Light/Dark and, on API 31+, wallpaper colours on/off). Glance content takes those through `WidgetTheme`; the plain-`RemoteViews` faces, readouts and chronometers through `WidgetPalette.kt`, which resolves the `widget_*` palette under a forced day/night (the app's own tones are `widget_app_*`) and sets concrete colours, or day/night pairs, on the views. The analog and digital clocks are re-coloured on API 31+ only, since below that `AnalogClock`'s hands and a shape's background tint can't be set through `RemoteViews` |
---