glance: wallpaper backing, themed colours, tinted text roles, state restore, widget preview; components: GroupedChoiceRow (calendula#292)

This commit is contained in:
makiolaj committed 2026-10-23 11:12:18 +02:00
1 parent 96509d0f9d
commit 6dc7bc50c5
5 files changed
+327 -5

No files matched your search

@@ -0,0 +1,66 @@
package de.jeanlucmakiola.floret.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.ButtonGroupDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.ToggleButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/**
* A grouped row holding [title] over a connected M3 Expressive button group,
* for a choice of two or three short options that reads better inline than
* behind a full-screen picker. Selecting applies at once.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun <T> GroupedChoiceRow(
title: String,
options: List<T>,
selected: T,
label: @Composable (T) -> String,
onSelect: (T) -> Unit,
position: Position,
modifier: Modifier = Modifier,
) {
GroupedSurface(position = position, modifier = modifier) {
Column(modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 12.dp)) {
Text(text = title, style = MaterialTheme.typography.bodyLarge)
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween),
) {
options.forEachIndexed { index, option ->
ToggleButton(
checked = option == selected,
onCheckedChange = { onSelect(option) },
shapes = when (index) {
0 -> ButtonGroupDefaults.connectedLeadingButtonShapes()
options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes()
else -> ButtonGroupDefaults.connectedMiddleButtonShapes()
},
modifier = Modifier
.weight(1f)
.semantics { role = Role.RadioButton },
) {
Text(label(option), maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
}
}
@@ -5,6 +5,11 @@ import androidx.compose.material3.ColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.glance.color.ColorProvider
import androidx.glance.unit.ColorProvider
import androidx.glance.GlanceTheme
import androidx.glance.LocalContext
import androidx.glance.material3.ColorProviders
@@ -41,5 +46,23 @@ fun FloretGlanceTheme(
WidgetThemeMode.LIGHT -> ColorProviders(if (dynamic) dynamicLightColorScheme(context) else lightScheme)
WidgetThemeMode.DARK -> ColorProviders(if (dynamic) dynamicDarkColorScheme(context) else darkScheme)
}
GlanceTheme(colors = colors, content = content)
CompositionLocalProvider(LocalWidgetThemeMode provides mode) {
GlanceTheme(colors = colors, content = content)
}
}
/** The [WidgetThemeMode] the nearest [FloretGlanceTheme] was given. */
val LocalWidgetThemeMode = compositionLocalOf { WidgetThemeMode.SYSTEM }
/**
* 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
* only [night] when the widget pins a theme, so it never contradicts the
* pinned scheme it sits on.
*/
@Composable
fun themedColor(day: Color, night: Color): ColorProvider = when (LocalWidgetThemeMode.current) {
WidgetThemeMode.SYSTEM -> ColorProvider(day = day, night = night)
WidgetThemeMode.LIGHT -> ColorProvider(day)
WidgetThemeMode.DARK -> ColorProvider(night)
}
@@ -0,0 +1,135 @@
package de.jeanlucmakiola.floret.glance
import android.widget.FrameLayout
import android.widget.RemoteViews
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.BoxWithConstraintsScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size
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.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi
import androidx.glance.appwidget.GlanceRemoteViews
import kotlinx.coroutines.CancellationException
/**
* [color] with a rounded hole cut into it, [inset] in from the sides, through
* which whatever the window draws behind the app shows: the wallpaper, in an
* activity themed with `windowShowWallpaper`. For a widget config screen, so a
* transparent widget previews against the real home screen.
*/
@Composable
fun WallpaperWindow(
color: Color,
modifier: Modifier = Modifier,
inset: Dp = 16.dp,
cornerRadius: Dp = 28.dp,
content: @Composable BoxWithConstraintsScope.() -> Unit,
) {
BoxWithConstraints(
modifier = modifier
.fillMaxWidth()
.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
.drawBehind {
drawRect(color)
val side = inset.toPx()
drawRoundRect(
color = Color.Black,
topLeft = Offset(side, 0f),
size = Size(size.width - side * 2, size.height),
cornerRadius = CornerRadius(cornerRadius.toPx()),
blendMode = BlendMode.Clear,
)
}
.padding(horizontal = inset),
contentAlignment = Alignment.Center,
content = content,
)
}
/**
* A Glance [content] composition rendered to RemoteViews and inflated here, so
* a config screen previews the real widget rather than a copy of it. Laid out
* at the widget's own [size] and scaled to fit [maxSize]. A picture, not a
* control: touches stop here. [placeholder] shows until the first render and
* [failure] if it can't render; [keys] re-render it when they change.
*
* Below Android 12, RemoteViews applies a lazy list only inside a real widget
* host, so [content] should draw lists as plain columns here.
*/
@OptIn(ExperimentalGlanceRemoteViewsApi::class)
@Composable
fun GlanceWidgetPreview(
size: DpSize,
maxSize: DpSize,
vararg keys: Any?,
modifier: Modifier = Modifier,
placeholder: @Composable () -> Unit = {},
failure: @Composable () -> Unit = {},
content: @Composable () -> Unit,
) {
val context = LocalContext.current
var views by remember { mutableStateOf<RemoteViews?>(null) }
var failed by remember { mutableStateOf(false) }
LaunchedEffect(size, *keys) {
try {
views = GlanceRemoteViews().compose(context, size) { content() }.remoteViews
failed = false
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
failed = views == null
}
}
val scale = minOf(1f, maxSize.width / size.width, maxSize.height / size.height)
Box(
modifier = modifier.size(size.width * scale, size.height * scale),
contentAlignment = Alignment.Center,
) {
val current = views
when {
current != null -> AndroidView(
factory = { FrameLayout(it) },
update = { frame ->
frame.removeAllViews()
frame.addView(current.apply(frame.context, frame))
},
modifier = Modifier
.requiredSize(size)
.graphicsLayer(scaleX = scale, scaleY = scale)
.clearAndSetSemantics {}
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) awaitPointerEvent().changes.forEach { it.consume() }
}
},
)
failed -> failure()
else -> placeholder()
}
}
}
@@ -4,12 +4,15 @@ import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.appwidget.appWidgetBackground
import androidx.glance.appwidget.cornerRadius
import androidx.glance.background
import androidx.glance.color.ColorProviders
import androidx.glance.color.colorProviders
import androidx.glance.layout.Box
import androidx.glance.layout.fillMaxSize
@@ -54,7 +57,59 @@ fun WidgetSurface(
}
CompositionLocalProvider(LocalWidgetBackground provides background) {
Box(modifier = filled.fillMaxSize()) {
content()
if (background == WidgetBackground.TINTED) {
GlanceTheme(colors = GlanceTheme.colors.onTint(), content = content)
} else {
content()
}
}
}
}
/**
* The scheme with its surface roles moved onto `secondaryContainer`, so text a
* widget draws in `onSurface` / `onSurfaceVariant` reads in
* `onSecondaryContainer` on a [WidgetBackground.TINTED] fill.
*/
private fun ColorProviders.onTint(): ColorProviders = colorProviders(
primary = primary,
onPrimary = onPrimary,
primaryContainer = primaryContainer,
onPrimaryContainer = onPrimaryContainer,
secondary = secondary,
onSecondary = onSecondary,
secondaryContainer = secondaryContainer,
onSecondaryContainer = onSecondaryContainer,
tertiary = tertiary,
onTertiary = onTertiary,
tertiaryContainer = tertiaryContainer,
onTertiaryContainer = onTertiaryContainer,
error = error,
errorContainer = errorContainer,
onError = onError,
onErrorContainer = onErrorContainer,
background = secondaryContainer,
onBackground = onSecondaryContainer,
surface = secondaryContainer,
onSurface = onSecondaryContainer,
surfaceVariant = surfaceVariant,
onSurfaceVariant = onSecondaryContainer,
outline = outline,
inverseOnSurface = inverseOnSurface,
inverseSurface = inverseSurface,
inversePrimary = inversePrimary,
widgetBackground = widgetBackground,
)
/**
* Backs this element with the widget surface colour on a
* [WidgetBackground.TRANSPARENT] root, so what it holds stays readable over an
* arbitrary wallpaper (calendula#292). A no-op on a filled root.
*/
@Composable
fun GlanceModifier.wallpaperBacking(radius: Dp = 16.dp): GlanceModifier =
if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) {
cornerRadius(radius).background(GlanceTheme.colors.widgetBackground)
} else {
this
}
@@ -7,6 +7,7 @@ import android.util.Log
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.glance.GlanceId
import androidx.glance.appwidget.GlanceAppWidget
@@ -15,6 +16,7 @@ import androidx.glance.appwidget.GlanceAppWidgetReceiver
import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.currentState
import androidx.glance.state.GlanceState
import androidx.glance.state.PreferencesGlanceStateDefinition
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
@@ -60,12 +62,53 @@ suspend fun GlanceAppWidget.updatePlaced(
}
}
/** Moves one instance's [currentRefreshStamp], then redraws it, so a running session reloads too. */
suspend fun GlanceAppWidget.updateWithRefresh(context: Context, glanceId: GlanceId) {
updateAppWidgetState(context, glanceId) { it[RefreshStampKey] = nextRefreshStamp(it[RefreshStampKey]) }
/**
* Moves one instance's [currentRefreshStamp], then redraws it, so a running
* session reloads too. [edit] runs in the same state transaction, for a caller
* that changes the instance's own state along with it.
*/
suspend fun GlanceAppWidget.updateWithRefresh(
context: Context,
glanceId: GlanceId,
edit: (MutablePreferences) -> Unit = {},
) {
updateAppWidgetState(context, glanceId) {
edit(it)
it[RefreshStampKey] = nextRefreshStamp(it[RefreshStampKey])
}
update(context, glanceId)
}
/**
* A receiver's `onRestored`: moves each restored instance's Glance state from
* its old id to the one the host gave it, which Glance doesn't do itself, so
* per-instance settings survive a backup restore or device transfer.
*/
fun GlanceAppWidgetReceiver.restoreState(context: Context, oldIds: IntArray, newIds: IntArray) {
val appContext = context.applicationContext
val pending = goAsync()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
oldIds.zip(newIds).forEach { (old, new) ->
val stored = GlanceState.getValue(appContext, PreferencesGlanceStateDefinition, stateKey(old))
GlanceState.updateValue(appContext, PreferencesGlanceStateDefinition, stateKey(new)) {
stored.toMutablePreferences()
}
GlanceState.deleteStore(appContext, PreferencesGlanceStateDefinition, stateKey(old))
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "Widget state restore failed", e)
} finally {
pending?.finish()
}
}
}
/** Glance's own name for an app widget's state store. */
private fun stateKey(appWidgetId: Int) = "appWidget-$appWidgetId"
/**
* A receiver's `onUpdate`, in place of `super.onUpdate`: the system's periodic
* update also reloads each instance's data, rather than only recomposing a