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]*?(?:/>|)") .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` | ---