glance: RemoteColor helpers for widget views drawn as plain RemoteViews, dynamic roles as palette resources

This commit is contained in:
2026-10-23 15:45:54 +02:00
parent d6b55476d1
commit 513c135ec2
6 changed files with 171 additions and 1 deletions
@@ -46,7 +46,7 @@ fun FloretGlanceTheme(
WidgetThemeMode.LIGHT -> ColorProviders(if (dynamic) dynamicLightColorScheme(context) else lightScheme)
WidgetThemeMode.DARK -> ColorProviders(if (dynamic) dynamicDarkColorScheme(context) else darkScheme)
}
CompositionLocalProvider(LocalWidgetThemeMode provides mode) {
CompositionLocalProvider(LocalWidgetThemeMode provides mode, LocalWidgetDynamicColor provides dynamic) {
GlanceTheme(colors = colors, content = content)
}
}
@@ -54,6 +54,9 @@ fun FloretGlanceTheme(
/** The [WidgetThemeMode] the nearest [FloretGlanceTheme] was given. */
val LocalWidgetThemeMode = compositionLocalOf { WidgetThemeMode.SYSTEM }
/** Whether the nearest [FloretGlanceTheme] draws Material You colours. */
val LocalWidgetDynamicColor = compositionLocalOf { false }
/**
* A colour an app computes per theme (an event colour, say), resolved like the
* theme around it: a day/night pair on [WidgetThemeMode.SYSTEM], only [day] or
@@ -0,0 +1,131 @@
package de.jeanlucmakiola.floret.glance
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.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.glance.GlanceTheme
import androidx.glance.LocalContext
import androidx.glance.color.ColorProviders
import androidx.glance.unit.ColorProvider
/**
* A colour for a view a widget draws as plain [RemoteViews] (embedded through
* `AndroidRemoteViews`), resolved like the Glance theme around it.
*/
sealed interface RemoteColor {
/** A colour resource with its own night variant: the host resolves it, so it follows palette and light/dark changes. */
data class Resource(@ColorRes val resId: Int) : RemoteColor
/** A light/dark pair; equal when the widget pins one theme. */
data class DayNight(@ColorInt val day: Int, @ColorInt val night: Int) : RemoteColor
}
/** The theme roles [remoteColor] hands to plain RemoteViews. */
enum class WidgetColorRole(@ColorRes internal val resId: Int) {
ON_SURFACE(R.color.floret_widget_on_surface),
ON_SURFACE_VARIANT(R.color.floret_widget_on_surface_variant),
PRIMARY(R.color.floret_widget_primary),
ON_PRIMARY(R.color.floret_widget_on_primary),
ON_SECONDARY_CONTAINER(R.color.floret_widget_on_secondary_container),
}
/**
* [role] as the nearest [FloretGlanceTheme] and [WidgetSurface] draw it: the
* system palette's resource when the theme follows Material You and the device,
* else the theme's own light/dark pair. Text roles on a [WidgetBackground.TINTED]
* surface move to `onSecondaryContainer`, as `GlanceTheme.colors` does there.
*/
@Composable
fun remoteColor(role: WidgetColorRole): RemoteColor {
val tinted = LocalWidgetBackground.current == WidgetBackground.TINTED
val resolved = if (tinted && (role == WidgetColorRole.ON_SURFACE || role == WidgetColorRole.ON_SURFACE_VARIANT)) {
WidgetColorRole.ON_SECONDARY_CONTAINER
} else {
role
}
val mode = LocalWidgetThemeMode.current
if (mode == WidgetThemeMode.SYSTEM && LocalWidgetDynamicColor.current && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
return RemoteColor.Resource(resolved.resId)
}
val provider = GlanceTheme.colors.provider(resolved)
val contexts = dayNightContexts()
return when (mode) {
WidgetThemeMode.SYSTEM -> RemoteColor.DayNight(
day = provider.getColor(contexts.first).toArgb(),
night = provider.getColor(contexts.second).toArgb(),
)
WidgetThemeMode.LIGHT -> provider.getColor(contexts.first).toArgb().let { RemoteColor.DayNight(it, it) }
WidgetThemeMode.DARK -> provider.getColor(contexts.second).toArgb().let { RemoteColor.DayNight(it, it) }
}
}
/** An app-computed [day]/[night] colour (an event colour, say) for plain RemoteViews, pinned like [themedColor]. */
fun WidgetThemeMode.remoteColor(day: Color, night: Color): RemoteColor.DayNight = when (this) {
WidgetThemeMode.SYSTEM -> RemoteColor.DayNight(day.toArgb(), night.toArgb())
WidgetThemeMode.LIGHT -> RemoteColor.DayNight(day.toArgb(), day.toArgb())
WidgetThemeMode.DARK -> RemoteColor.DayNight(night.toArgb(), night.toArgb())
}
/** Sets [viewId]'s text colour to [color]. */
fun RemoteViews.setTextColor(context: Context, viewId: Int, color: RemoteColor) {
when {
Build.VERSION.SDK_INT < Build.VERSION_CODES.S -> setTextColor(viewId, color.current(context))
color is RemoteColor.Resource -> setColorStateList(viewId, "setTextColor", color.resId)
color is RemoteColor.DayNight -> setColorInt(viewId, "setTextColor", color.day, color.night)
}
}
/**
* Tints [viewId]'s image with [color]. Always the tint list from API 31 on, so a
* host reapplying a new rendering over the last never stacks a filter on a tint.
*/
fun RemoteViews.setImageTint(context: Context, viewId: Int, color: RemoteColor) {
when {
Build.VERSION.SDK_INT < Build.VERSION_CODES.S -> setInt(viewId, "setColorFilter", color.current(context))
color is RemoteColor.Resource -> setColorStateList(viewId, "setImageTintList", color.resId)
color is RemoteColor.DayNight -> setColorStateList(
viewId,
"setImageTintList",
ColorStateList.valueOf(color.day),
ColorStateList.valueOf(color.night),
)
}
}
private fun RemoteColor.current(context: Context): Int = when (this) {
is RemoteColor.Resource -> context.getColor(resId)
is RemoteColor.DayNight -> if (context.isNight()) night else day
}
private fun Context.isNight(): Boolean =
resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
@Composable
private fun dayNightContexts(): Pair<Context, Context> {
val context = LocalContext.current
return remember(context) { context.withNight(false) to context.withNight(true) }
}
private fun Context.withNight(night: Boolean): Context {
val config = Configuration(resources.configuration).apply {
uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or
if (night) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO
}
return createConfigurationContext(config)
}
private fun ColorProviders.provider(role: WidgetColorRole): ColorProvider = when (role) {
WidgetColorRole.ON_SURFACE -> onSurface
WidgetColorRole.ON_SURFACE_VARIANT -> onSurfaceVariant
WidgetColorRole.PRIMARY -> primary
WidgetColorRole.ON_PRIMARY -> onPrimary
WidgetColorRole.ON_SECONDARY_CONTAINER -> onSecondaryContainer
}
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The dynamic roles Glance's own theme maps, for widget views drawn as plain RemoteViews. -->
<resources>
<color name="floret_widget_on_surface">@android:color/system_neutral1_100</color>
<color name="floret_widget_on_surface_variant">@android:color/system_neutral2_200</color>
<color name="floret_widget_primary">@android:color/system_accent1_200</color>
<color name="floret_widget_on_primary">@android:color/system_accent1_800</color>
<color name="floret_widget_on_secondary_container">@android:color/system_accent2_100</color>
</resources>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Fallbacks only: floret reads these roles from the system palette, on API 31+ (values-v31). -->
<resources>
<color name="floret_widget_on_surface">#FFE4E1E6</color>
<color name="floret_widget_on_surface_variant">#FFC6C5D0</color>
<color name="floret_widget_primary">#FFBAC3FF</color>
<color name="floret_widget_on_primary">#FF08218A</color>
<color name="floret_widget_on_secondary_container">#FFDEE1F9</color>
</resources>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The dynamic roles Glance's own theme maps, for widget views drawn as plain RemoteViews. -->
<resources>
<color name="floret_widget_on_surface">@android:color/system_neutral1_900</color>
<color name="floret_widget_on_surface_variant">@android:color/system_neutral2_700</color>
<color name="floret_widget_primary">@android:color/system_accent1_600</color>
<color name="floret_widget_on_primary">@android:color/system_accent1_0</color>
<color name="floret_widget_on_secondary_container">@android:color/system_accent2_900</color>
</resources>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Fallbacks only: floret reads these roles from the system palette, on API 31+ (values-v31). -->
<resources>
<color name="floret_widget_on_surface">#FF1B1B1F</color>
<color name="floret_widget_on_surface_variant">#FF46464F</color>
<color name="floret_widget_primary">#FF4355B9</color>
<color name="floret_widget_on_primary">#FFFFFFFF</color>
<color name="floret_widget_on_secondary_container">#FF171B2C</color>
</resources>