From d6b55476d1e1d52e9eecffbf9491e2d47ea7e736 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 23 Oct 2026 14:51:22 +0200 Subject: [PATCH] glance: tonal and text header pills, header action gap, transparent title pill hugs its text, preview at true size cropped with widget corners; components: GroupedChoiceRow side inset --- .../floret/components/GroupedChoiceRow.kt | 2 +- .../floret/glance/WidgetHeader.kt | 106 +++++++++++++----- .../floret/glance/WidgetPreview.kt | 29 ++++- .../floret/glance/WidgetSurface.kt | 2 +- 4 files changed, 108 insertions(+), 31 deletions(-) diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedChoiceRow.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedChoiceRow.kt index 8219f3e..036c3a5 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedChoiceRow.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedChoiceRow.kt @@ -36,7 +36,7 @@ fun GroupedChoiceRow( position: Position, modifier: Modifier = Modifier, ) { - GroupedSurface(position = position, modifier = modifier) { + GroupedSurface(position = position, modifier = modifier.padding(horizontal = GroupedListInset)) { Column(modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 12.dp)) { Text(text = title, style = MaterialTheme.typography.bodyLarge) Spacer(Modifier.height(8.dp)) diff --git a/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetHeader.kt b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetHeader.kt index 98df204..db5fff9 100644 --- a/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetHeader.kt +++ b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetHeader.kt @@ -13,6 +13,8 @@ import androidx.glance.action.Action import androidx.glance.action.clickable import androidx.glance.appwidget.cornerRadius import androidx.glance.background +import androidx.glance.semantics.contentDescription +import androidx.glance.semantics.semantics import androidx.glance.layout.Alignment import androidx.glance.layout.Box import androidx.glance.layout.Row @@ -50,20 +52,21 @@ fun WidgetHeader( modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { - val titleModifier = GlanceModifier.defaultWeight().let { - if (titleAction != null) it.clickable(titleAction) else it - } + val clickable = if (titleAction != null) GlanceModifier.clickable(titleAction) else GlanceModifier if (transparentTitle) { + // The pill hugs the title; a spacer takes the rest of the row so it + // doesn't stretch into a full-width bar. Box( - modifier = titleModifier + modifier = clickable .cornerRadius(12.dp) .background(GlanceTheme.colors.widgetBackground) .padding(horizontal = 10.dp, vertical = 4.dp), ) { HeaderText(title) } + Spacer(GlanceModifier.defaultWeight()) } else { - Box(modifier = titleModifier) { + Box(modifier = GlanceModifier.defaultWeight().then(clickable)) { HeaderText(title) } } @@ -85,9 +88,10 @@ private fun HeaderText(title: String) { } /** - * The standard trailing action of a [WidgetHeader]: a filled, flat pill + * The standard trailing action of a [WidgetHeader]: a flat pill * ([HeaderActionWidth] × [HeaderActionHeight]) that leads without crowding - * the rows below it. + * the rows below it. Filled by default; a header with several actions makes + * the secondary ones [WidgetActionStyle.TONAL]. */ @Composable fun WidgetHeaderAction( @@ -95,20 +99,86 @@ fun WidgetHeaderAction( contentDescription: String, action: Action, modifier: GlanceModifier = GlanceModifier, + style: WidgetActionStyle = WidgetActionStyle.FILLED, ) { WidgetActionButton( icon = icon, contentDescription = contentDescription, action = action, modifier = modifier, - style = WidgetActionStyle.FILLED, + style = style, size = HeaderActionWidth, height = HeaderActionHeight, ) } +/** A [WidgetHeaderAction] that carries a short [text] (a day number, a count) instead of an icon. */ +@Composable +fun WidgetHeaderTextAction( + text: String, + contentDescription: String, + action: Action, + modifier: GlanceModifier = GlanceModifier, + style: WidgetActionStyle = WidgetActionStyle.FILLED, +) { + ActionPill( + style = style, + width = HeaderActionWidth, + height = HeaderActionHeight, + action = action, + modifier = modifier.semantics { this.contentDescription = contentDescription }, + ) { + Text( + text = text, + maxLines = 1, + style = TextStyle(color = style.content(), fontSize = 14.sp, fontWeight = FontWeight.Medium), + ) + } +} + +@Composable +private fun WidgetActionStyle.container(): ColorProvider = when (this) { + WidgetActionStyle.TONAL -> GlanceTheme.colors.primaryContainer + WidgetActionStyle.FILLED -> GlanceTheme.colors.primary +} + +@Composable +private fun WidgetActionStyle.content(): ColorProvider = when (this) { + WidgetActionStyle.TONAL -> GlanceTheme.colors.onPrimaryContainer + WidgetActionStyle.FILLED -> GlanceTheme.colors.onPrimary +} + +/** The standard space between two actions in a [WidgetHeader]'s trailing slot. */ +@Composable +fun RowScope.WidgetHeaderActionGap() { + Spacer(GlanceModifier.width(HeaderActionGap)) +} + +/** The fixed-size, full-pill container both icon and text actions are drawn in. */ +@Composable +private fun ActionPill( + style: WidgetActionStyle, + width: Dp, + height: Dp, + action: Action, + modifier: GlanceModifier = GlanceModifier, + content: @Composable () -> Unit, +) { + Box( + modifier = modifier + .width(width) + .height(height) + .cornerRadius(height / 2) + .background(style.container()) + .clickable(action), + contentAlignment = Alignment.Center, + content = content, + ) +} + private val HeaderActionWidth = 36.dp private val HeaderActionHeight = 28.dp +private val HeaderActionGap = 4.dp /** * A widget's primary tappable action: a [size] × [height] target (a 48dp @@ -131,25 +201,11 @@ fun WidgetActionButton( size: Dp = 48.dp, height: Dp = size, ) { - val container = when (style) { - WidgetActionStyle.TONAL -> GlanceTheme.colors.primaryContainer - WidgetActionStyle.FILLED -> GlanceTheme.colors.primary - } - val content = when (style) { - WidgetActionStyle.TONAL -> GlanceTheme.colors.onPrimaryContainer - WidgetActionStyle.FILLED -> GlanceTheme.colors.onPrimary - } + val container = style.container() + val content = style.content() val iconSize = height * 0.65f if (label == null) { - Box( - modifier = modifier - .width(size) - .height(height) - .cornerRadius(height / 2) - .background(container) - .clickable(action), - contentAlignment = Alignment.Center, - ) { + ActionPill(style = style, width = size, height = height, action = action, modifier = modifier) { ActionIcon(icon, contentDescription, content, iconSize = iconSize) } } else { diff --git a/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetPreview.kt b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetPreview.kt index 34c0782..a2fd0a3 100644 --- a/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetPreview.kt +++ b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetPreview.kt @@ -1,7 +1,9 @@ package de.jeanlucmakiola.floret.glance +import android.os.Build import android.widget.FrameLayout import android.widget.RemoteViews +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraintsScope @@ -9,6 +11,7 @@ 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.foundation.layout.wrapContentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -17,6 +20,7 @@ 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.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset @@ -24,9 +28,11 @@ 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.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize @@ -74,7 +80,9 @@ fun WallpaperWindow( /** * 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 + * at the widget's own [size], shrunk only to fit [maxSize]'s width so its text + * reads as it does on the home screen, and cropped below [maxSize]'s height, + * keeping the top and closing the cut with the widget's own corner radius. 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. * @@ -105,9 +113,12 @@ fun GlanceWidgetPreview( failed = views == null } } - val scale = minOf(1f, maxSize.width / size.width, maxSize.height / size.height) + val scale = minOf(1f, maxSize.width / size.width) + val corner = widgetCornerRadius() * scale Box( - modifier = modifier.size(size.width * scale, size.height * scale), + modifier = modifier + .size(size.width * scale, minOf(size.height * scale, maxSize.height)) + .clip(RoundedCornerShape(corner)), contentAlignment = Alignment.Center, ) { val current = views @@ -119,8 +130,9 @@ fun GlanceWidgetPreview( frame.addView(current.apply(frame.context, frame)) }, modifier = Modifier + .wrapContentSize(Alignment.TopCenter, unbounded = true) .requiredSize(size) - .graphicsLayer(scaleX = scale, scaleY = scale) + .graphicsLayer(scaleX = scale, scaleY = scale, transformOrigin = TransformOrigin(0.5f, 0f)) .clearAndSetSemantics {} .pointerInput(Unit) { awaitPointerEventScope { @@ -133,3 +145,12 @@ fun GlanceWidgetPreview( } } } + +/** The corner [WidgetSurface] draws: the system widget radius on API 31+, its fallback below. */ +@Composable +private fun widgetCornerRadius(): Dp = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + dimensionResource(android.R.dimen.system_app_widget_background_radius) + } else { + LegacyWidgetCornerRadius + } 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 4778b11..7b38ee1 100644 --- a/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetSurface.kt +++ b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetSurface.kt @@ -28,7 +28,7 @@ enum class WidgetBackground { SOLID, TINTED, TRANSPARENT } val LocalWidgetBackground = compositionLocalOf { WidgetBackground.SOLID } /** Falls back to this radius below API 31, where there's no system widget-corner dimension. */ -private val LegacyWidgetCornerRadius = 20.dp +internal val LegacyWidgetCornerRadius = 20.dp /** * A widget's root container: fills the host, takes the system's rounded corner