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

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

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

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

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

Closes #6
This commit is contained in:
2026-10-05 19:48:59 +02:00
parent 8cbb8b311c
commit 19ee678a1b
33 changed files with 910 additions and 159 deletions
+19
View File
@@ -318,6 +318,25 @@
</intent-filter>
</activity>
<!-- The next-alarm and timer widgets' per-instance colours: the same
APPWIDGET_CONFIGURE contract as the four above. -->
<activity
android:name=".ui.widget.config.NextAlarmConfigActivity"
android:exported="true"
android:theme="@style/Theme.Clockula">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<activity
android:name=".ui.widget.config.TimerConfigActivity"
android:exported="true"
android:theme="@style/Theme.Clockula">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<!-- The quick-timer widget's one tap: creates and starts a timer at
its configured duration. A broadcast, not a service start or an
activity, for the same reason as `TimerActionReceiver` (M11 D16). -->
@@ -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)
}
}
@@ -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
@@ -78,7 +78,8 @@ fun ClockWidgetRoot(entryPoint: WidgetEntryPoint, initialCities: List<ClockWidge
* the widget's live size. Like the analog widget, the face draws its own
* container (a card, pills, or a shape) and takes its colours from the
* `widget_*` palette resources, so it follows the launcher's day/night and
* the wallpaper by itself. A tap opens [tapTarget]'s tab.
* the wallpaper by itself — unless the instance's [LocalWidgetColors] say
* otherwise. A tap opens [tapTarget]'s tab.
*/
@Composable
fun ClockWidgetContent(cities: List<ClockWidgetCity>, settings: DigitalClockSettings, tapTarget: ClockWidgetTapTarget) {
@@ -86,7 +87,7 @@ fun ClockWidgetContent(cities: List<ClockWidgetCity>, 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(),
)
}
@@ -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<ClockWidgetCity>,
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<ClockWidgetCity>,
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)
}
@@ -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)
}
}
}
@@ -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
},
)
}
@@ -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,
)
}
}
@@ -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())
}
@@ -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<Preferences>().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))
}
}
@@ -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<AnalogClockStyle?>(null) }
var tapTarget by remember { mutableStateOf(ClockWidgetTapTarget.ALARMS) }
var colors by remember { mutableStateOf<WidgetColors?>(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 ->
@@ -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<DigitalClockSettings?>(null) }
var tapTarget by remember { mutableStateOf(ClockWidgetTapTarget.ALARMS) }
var colors by remember { mutableStateOf<WidgetColors?>(null) }
val cities by produceState(initialValue = emptyList<ClockWidgetCity>()) {
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<ClockWidgetCity>) {
private fun DigitalStylePreview(settings: DigitalClockSettings, cities: List<ClockWidgetCity>, 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) },
@@ -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()
}
@@ -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<Duration?>(null) }
var colors by remember { mutableStateOf<WidgetColors?>(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 },
)
@@ -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<StopwatchWidgetStyle?>(null) }
var colors by remember { mutableStateOf<WidgetColors?>(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<RemoteViews?>(initialValue = null, style) {
val views by produceState<RemoteViews?>(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(
@@ -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()
}
@@ -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<WidgetColors?>(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)
}
}
}
@@ -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
},
)
@@ -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() }
}
}
}
@@ -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) }
}
}
}
@@ -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
}
@@ -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) } }
}
}
@@ -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) } }
}
}
@@ -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) } }
}
}
@@ -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) } }
}
}
@@ -8,6 +8,7 @@
android:layout_height="match_parent">
<ImageView
android:id="@+id/widget_digital_shape"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
+25 -25
View File
@@ -1,29 +1,29 @@
<resources>
<!-- Dark-mode counterparts of the analog widget's colours — see the light
`values/colors.xml` for why these are literal, resource-qualified
colours rather than `?android:attr/...`. -->
<color name="widget_analog_dial_fill">#FF2B2F26</color>
<color name="widget_analog_dial_primary">#FF3E4C32</color>
<color name="widget_analog_dial_tertiary">#FF334B47</color>
<color name="widget_analog_hand_hour">#FFE4E9E0</color>
<color name="widget_analog_hand_minute">#FFB4C7A4</color>
<color name="widget_analog_numeral">#FFC9D9B9</color>
<color name="widget_analog_second">#FFA5CEC7</color>
<color name="widget_analog_date">#FFE3E3DC</color>
<color name="widget_analog_badge_pill">#FFB4C7A4</color>
<color name="widget_analog_badge_pill_text">#FF22261C</color>
<!-- Dark-mode counterparts of the analog widget's app-palette colours —
see the light `values/colors.xml` for why these are literal,
resource-qualified colours rather than `?android:attr/...`. -->
<color name="widget_app_analog_dial_fill">#FF2B2F26</color>
<color name="widget_app_analog_dial_primary">#FF3E4C32</color>
<color name="widget_app_analog_dial_tertiary">#FF334B47</color>
<color name="widget_app_analog_hand_hour">#FFE4E9E0</color>
<color name="widget_app_analog_hand_minute">#FFB4C7A4</color>
<color name="widget_app_analog_numeral">#FFC9D9B9</color>
<color name="widget_app_analog_second">#FFA5CEC7</color>
<color name="widget_app_analog_date">#FFE3E3DC</color>
<color name="widget_app_analog_badge_pill">#FFB4C7A4</color>
<color name="widget_app_analog_badge_pill_text">#FF22261C</color>
<!-- Dark tones of the shared widget palette in `values/colors.xml`. -->
<color name="widget_surface">#FF2B2F26</color>
<color name="widget_on_surface">#FFE4E9E0</color>
<color name="widget_on_surface_variant">#FFC4C8BA</color>
<color name="widget_primary">#FFCEE2BA</color>
<color name="widget_primary_container">#FF4A583B</color>
<color name="widget_on_primary_container">#FFE8F6D9</color>
<color name="widget_on_primary">#FF213510</color>
<color name="widget_secondary_container">#FF3D4A35</color>
<color name="widget_on_secondary_container">#FFD8E7CB</color>
<color name="widget_tertiary">#FFA5CEC7</color>
<color name="widget_tertiary_container">#FF244D48</color>
<color name="widget_on_tertiary_container">#FFC1ECE4</color>
<color name="widget_app_surface">#FF2B2F26</color>
<color name="widget_app_on_surface">#FFE4E9E0</color>
<color name="widget_app_on_surface_variant">#FFC4C8BA</color>
<color name="widget_app_primary">#FFCEE2BA</color>
<color name="widget_app_primary_container">#FF4A583B</color>
<color name="widget_app_on_primary_container">#FFE8F6D9</color>
<color name="widget_app_on_primary">#FF213510</color>
<color name="widget_app_secondary_container">#FF3D4A35</color>
<color name="widget_app_on_secondary_container">#FFD8E7CB</color>
<color name="widget_app_tertiary">#FFA5CEC7</color>
<color name="widget_app_tertiary_container">#FF244D48</color>
<color name="widget_app_on_tertiary_container">#FFC1ECE4</color>
</resources>
+50 -22
View File
@@ -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. -->
<color name="widget_analog_dial_fill">#FFE8EAE1</color>
<color name="widget_analog_dial_primary">#FFD7E8C6</color>
<color name="widget_analog_dial_tertiary">#FFCCE8E3</color>
<color name="widget_analog_hand_hour">#FF44483E</color>
<color name="widget_analog_hand_minute">#FF6B7A5C</color>
<color name="widget_analog_numeral">#FF4E5C40</color>
<color name="widget_analog_second">#FF3D6660</color>
<color name="widget_analog_date">#FF1A1C18</color>
<color name="widget_analog_badge_pill">#FF6B7A5C</color>
<color name="widget_analog_badge_pill_text">#FFFFFFFF</color>
<color name="widget_app_analog_dial_fill">#FFE8EAE1</color>
<color name="widget_app_analog_dial_primary">#FFD7E8C6</color>
<color name="widget_app_analog_dial_tertiary">#FFCCE8E3</color>
<color name="widget_app_analog_hand_hour">#FF44483E</color>
<color name="widget_app_analog_hand_minute">#FF6B7A5C</color>
<color name="widget_app_analog_numeral">#FF4E5C40</color>
<color name="widget_app_analog_second">#FF3D6660</color>
<color name="widget_app_analog_date">#FF1A1C18</color>
<color name="widget_app_analog_badge_pill">#FF6B7A5C</color>
<color name="widget_app_analog_badge_pill_text">#FFFFFFFF</color>
<!-- Every other widget's palette (light), named after the M3 roles they
stand in for. Like the analog colours above, resource-qualified
rather than `?android:attr/...` or a Glance theme, so the launcher
resolves them against its own day/night (`values-night/`), and on
API 31+ against the wallpaper (`values-v31/`). -->
<color name="widget_surface">#FFE8EAE1</color>
<color name="widget_on_surface">#FF1C1F1A</color>
<color name="widget_on_surface_variant">#FF44483E</color>
<color name="widget_primary">#FF5C6A4E</color>
<color name="widget_primary_container">#FFD7E8C6</color>
<color name="widget_on_primary_container">#FF1D2A10</color>
<color name="widget_on_primary">#FFFFFFFF</color>
<color name="widget_secondary_container">#FFDCE7D0</color>
<color name="widget_on_secondary_container">#FF161E10</color>
<color name="widget_tertiary">#FF3D6660</color>
<color name="widget_tertiary_container">#FFCCE8E3</color>
<color name="widget_on_tertiary_container">#FF05201C</color>
<color name="widget_app_surface">#FFE8EAE1</color>
<color name="widget_app_on_surface">#FF1C1F1A</color>
<color name="widget_app_on_surface_variant">#FF44483E</color>
<color name="widget_app_primary">#FF5C6A4E</color>
<color name="widget_app_primary_container">#FFD7E8C6</color>
<color name="widget_app_on_primary_container">#FF1D2A10</color>
<color name="widget_app_on_primary">#FFFFFFFF</color>
<color name="widget_app_secondary_container">#FFDCE7D0</color>
<color name="widget_app_on_secondary_container">#FF161E10</color>
<color name="widget_app_tertiary">#FF3D6660</color>
<color name="widget_app_tertiary_container">#FFCCE8E3</color>
<color name="widget_app_on_tertiary_container">#FF05201C</color>
<!-- What the widgets' layouts and drawables actually reference. Below
API 31 these are just the app palette above; `values-v31/` points
them at the wallpaper's tones instead. The `widget_app_*` tones stay
reachable on every API level, so a widget whose wallpaper colours are
switched off can still resolve them (`ui/widget/WidgetPalette.kt`). -->
<color name="widget_analog_dial_fill">@color/widget_app_analog_dial_fill</color>
<color name="widget_analog_dial_primary">@color/widget_app_analog_dial_primary</color>
<color name="widget_analog_dial_tertiary">@color/widget_app_analog_dial_tertiary</color>
<color name="widget_analog_hand_hour">@color/widget_app_analog_hand_hour</color>
<color name="widget_analog_hand_minute">@color/widget_app_analog_hand_minute</color>
<color name="widget_analog_numeral">@color/widget_app_analog_numeral</color>
<color name="widget_analog_second">@color/widget_app_analog_second</color>
<color name="widget_analog_date">@color/widget_app_analog_date</color>
<color name="widget_analog_badge_pill">@color/widget_app_analog_badge_pill</color>
<color name="widget_analog_badge_pill_text">@color/widget_app_analog_badge_pill_text</color>
<color name="widget_surface">@color/widget_app_surface</color>
<color name="widget_on_surface">@color/widget_app_on_surface</color>
<color name="widget_on_surface_variant">@color/widget_app_on_surface_variant</color>
<color name="widget_primary">@color/widget_app_primary</color>
<color name="widget_primary_container">@color/widget_app_primary_container</color>
<color name="widget_on_primary_container">@color/widget_app_on_primary_container</color>
<color name="widget_on_primary">@color/widget_app_on_primary</color>
<color name="widget_secondary_container">@color/widget_app_secondary_container</color>
<color name="widget_on_secondary_container">@color/widget_app_on_secondary_container</color>
<color name="widget_tertiary">@color/widget_app_tertiary</color>
<color name="widget_tertiary_container">@color/widget_app_tertiary_container</color>
<color name="widget_on_tertiary_container">@color/widget_app_on_tertiary_container</color>
</resources>
@@ -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" />
+3 -1
View File
@@ -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" />
@@ -31,7 +31,7 @@ class ManifestRulesTest {
}
@Test
fun `exactly the launcher and its two icon aliases, the door, the four widget configurators and the six widget receivers are exported`() {
fun `exactly the launcher and its two icon aliases, the door, the six widget configurators and the six widget receivers are exported`() {
val exported = Regex("<(activity|service|receiver|provider)\\b[\\s\\S]*?(?:/>|</\\1>)")
.findAll(text)
.map { it.value }
@@ -42,10 +42,11 @@ class ManifestRulesTest {
// A GlanceAppWidgetReceiver must be exported for the platform to pick
// it as a widget provider at all, and `the six widget receivers
// answer only APPWIDGET_UPDATE` pins that each one is guarded to
// that one platform-delivered broadcast and nothing wider. M11 adds
// four widget config activities: exported for the same reason —
// that one platform-delivered broadcast and nothing wider. The six
// widget config activities (M11's four, plus the next-alarm and
// timer widgets' colour screens) are exported for the same reason —
// only the launcher can start an `APPWIDGET_CONFIGURE` activity
// cross-package — and guarded to that one action by `the four
// cross-package — and guarded to that one action by `the six
// widget configurators answer only APPWIDGET_CONFIGURE` below.
assertThat(exported).containsExactly(
".MainActivity",
@@ -56,6 +57,8 @@ class ManifestRulesTest {
".ui.widget.config.AnalogClockConfigActivity",
".ui.widget.config.StopwatchConfigActivity",
".ui.widget.config.QuickTimerConfigActivity",
".ui.widget.config.NextAlarmConfigActivity",
".ui.widget.config.TimerConfigActivity",
".widget.NextAlarmWidgetReceiver",
".widget.TimerWidgetReceiver",
".widget.ClockWidgetReceiver",
@@ -67,16 +70,18 @@ class ManifestRulesTest {
/**
* M11: the mirror of `the six widget receivers answer only
* APPWIDGET_UPDATE` for the four widget config activities — being
* APPWIDGET_UPDATE` for the six widget config activities — being
* exported cannot be widened into a second, wider surface by accident.
*/
@Test
fun `the four widget configurators answer only APPWIDGET_CONFIGURE`() {
fun `the six widget configurators answer only APPWIDGET_CONFIGURE`() {
val configurators = listOf(
".ui.widget.config.ClockWidgetConfigActivity",
".ui.widget.config.AnalogClockConfigActivity",
".ui.widget.config.StopwatchConfigActivity",
".ui.widget.config.QuickTimerConfigActivity",
".ui.widget.config.NextAlarmConfigActivity",
".ui.widget.config.TimerConfigActivity",
)
for (name in configurators) {
val block = activityBlock(name)
@@ -0,0 +1,52 @@
package de.jeanlucmakiola.clockula.domain.widget
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class WidgetColorsTest {
@Test
fun `nothing stored is today's look`() {
assertThat(parseWidgetColors(null, null)).isEqualTo(WidgetColors(WidgetColorTheme.SYSTEM, wallpaperColors = true))
}
@Test
fun `an unknown theme name falls back to System`() {
assertThat(parseWidgetColors("Sepia", false)).isEqualTo(WidgetColors(WidgetColorTheme.SYSTEM, wallpaperColors = false))
}
@Test
fun `each theme's own name round-trips`() {
WidgetColorTheme.entries.forEach { theme ->
assertThat(parseWidgetColors(theme.name, true).theme).isEqualTo(theme)
}
}
@Test
fun `the defaults leave the palette to the launcher on every API level`() {
assertThat(WidgetColors().paletteMode(dynamicAvailable = true)).isEqualTo(WidgetPaletteMode.Launcher)
assertThat(WidgetColors().paletteMode(dynamicAvailable = false)).isEqualTo(WidgetPaletteMode.Launcher)
}
@Test
fun `wallpaper colours off under System uses the app palette, day and night`() {
val colors = WidgetColors(WidgetColorTheme.SYSTEM, wallpaperColors = false)
assertThat(colors.paletteMode(dynamicAvailable = true)).isEqualTo(WidgetPaletteMode.AppDayNight)
}
@Test
fun `without wallpaper colours on the device, the switch changes nothing`() {
val colors = WidgetColors(WidgetColorTheme.SYSTEM, wallpaperColors = false)
assertThat(colors.paletteMode(dynamicAvailable = false)).isEqualTo(WidgetPaletteMode.Launcher)
assertThat(WidgetColors(WidgetColorTheme.DARK, wallpaperColors = true).paletteMode(dynamicAvailable = false))
.isEqualTo(WidgetPaletteMode.Pinned(dark = true, dynamic = false))
}
@Test
fun `light and dark pin a tone of the chosen palette`() {
assertThat(WidgetColors(WidgetColorTheme.LIGHT, wallpaperColors = true).paletteMode(dynamicAvailable = true))
.isEqualTo(WidgetPaletteMode.Pinned(dark = false, dynamic = true))
assertThat(WidgetColors(WidgetColorTheme.DARK, wallpaperColors = false).paletteMode(dynamicAvailable = true))
.isEqualTo(WidgetPaletteMode.Pinned(dark = true, dynamic = false))
}
}
+1 -1
View File
@@ -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` |
---