glance: wallpaper backing, themed colours, tinted text roles, state restore, widget preview; components: GroupedChoiceRow (calendula#292)
This commit is contained in:
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
|
||||
|
||||
Reference in new issue
Block a user