diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedChoiceRow.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedChoiceRow.kt new file mode 100644 index 0000000..8219f3e --- /dev/null +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedChoiceRow.kt @@ -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 GroupedChoiceRow( + title: String, + options: List, + 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) + } + } + } + } + } +} diff --git a/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/FloretGlanceTheme.kt b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/FloretGlanceTheme.kt index 9373922..8eb19f8 100644 --- a/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/FloretGlanceTheme.kt +++ b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/FloretGlanceTheme.kt @@ -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) } diff --git a/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetPreview.kt b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetPreview.kt new file mode 100644 index 0000000..34c0782 --- /dev/null +++ b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetPreview.kt @@ -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(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() + } + } +} diff --git a/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetSurface.kt b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetSurface.kt index ac82d14..4778b11 100644 --- a/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetSurface.kt +++ b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetSurface.kt @@ -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 + } diff --git a/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetUpdates.kt b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetUpdates.kt index db7346d..fef030b 100644 --- a/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetUpdates.kt +++ b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetUpdates.kt @@ -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