diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fa4ae4..b2b1f51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,26 @@ are no binary releases; "version" tracks the shared `version` in the root build ## [Unreleased] +### Added + +- **`components`** — `GroupedRow` takes a `contentColor`, the pair to the + existing `container`: a row given a tint now names the `on*` role that goes + with it instead of leaving its text on `onSurface`, which only happens to + read on a container the theme did not pick. Wanted by a status row that + colours itself by outcome (Agendula's export receipt: `secondaryContainer` on + success, `errorContainer` on failure). + +- **`components`** — `ScaledViewPreview` and `ViewPreviewFrame`, the preview + picker's two halves: the first measures real screen composables at a phone + viewport and scales the finished layout down (swallowing touches and + collapsing semantics, so a preview reads as a picture rather than a control), + the second is the tonal plate it sits on, cross-fading between options and + taking `GroupedListInset` and the grouped rows' outer radius so preview and + options read as one stack. Converged from Calendula's view/month-style + choosers, where the pattern was invented, and now Agendula's onboarding step + for the quick-add bar; the viewport height the app scales from is a parameter + rather than a calendar-shaped constant. + ## [0.3.0] - 2026-08-10 ### Added diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/ViewPreview.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/ViewPreview.kt new file mode 100644 index 0000000..d2a9fcb --- /dev/null +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/ViewPreview.kt @@ -0,0 +1,149 @@ +package de.jeanlucmakiola.floret.components + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.floret.identity.rememberReduceMotion +import kotlin.math.roundToInt + +/** + * The viewport a preview pretends to be, when the caller does not say: a phone's + * width by the height a full-screen view gets under a top bar. Scaling every + * preview from one fixed height keeps a chooser's options comparable — each + * shows the same slice of screen. + */ +val DefaultPreviewViewport = 440.dp + +/** + * Renders [content] at a full phone viewport and shrinks the finished layout + * into a box [height] tall, for a chooser that shows what it is offering rather + * than describing it. Callers pass the *real* screen composables, so a preview + * cannot drift from what it depicts. + * + * The [layout] below ignores the incoming constraints so the content measures at + * a phone viewport, and a [graphicsLayer] scale shrinks the result. + * `Modifier.requiredSize` is the obvious alternative but *centres* content that + * overflows its constraints, leaving only the bottom-right corner in the clip. + * + * [viewportHeight] is what the content is measured at before scaling; raise it + * for a screen that needs more room to read than [DefaultPreviewViewport]. + */ +@Composable +fun ScaledViewPreview( + height: Dp, + modifier: Modifier = Modifier, + viewportHeight: Dp = DefaultPreviewViewport, + content: @Composable () -> Unit, +) { + val screenWidth = LocalConfiguration.current.screenWidthDp.dp + val scale = height.value / viewportHeight.value + + Box( + modifier = modifier + .clipToBounds() + .background(MaterialTheme.colorScheme.surface) + // A preview is a picture, not a control: swallow touches before the + // content's own clickables see them, and give screen readers one + // label instead of every row inside it. + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + awaitPointerEvent(PointerEventPass.Initial).changes + .forEach { it.consume() } + } + } + } + .clearAndSetSemantics { } + // Measure at a full phone viewport but report the scaled size, so the + // node occupies exactly what it draws. + .layout { measurable, _ -> + val fullWidth = screenWidth.roundToPx() + val fullHeight = viewportHeight.roundToPx() + val placeable = measurable.measure(Constraints.fixed(fullWidth, fullHeight)) + layout((fullWidth * scale).roundToInt(), (fullHeight * scale).roundToInt()) { + placeable.place(0, 0) + } + } + .graphicsLayer { + scaleX = scale + scaleY = scale + transformOrigin = TransformOrigin(0f, 0f) + }, + ) { + Column(Modifier.fillMaxSize()) { content() } + } +} + +/** + * The stage a preview picker sets its preview on, cross-fading as [selected] + * changes. A preview draws the app's own surface, so a tonal plate gives it the + * edge it would otherwise lack on a chooser screen; the plate takes the width + * and outer radius of the [GroupedRow]s underneath so the two read as one stack. + * + * Only vertical spacing is the caller's to pass — the horizontal inset is + * [GroupedListInset], the rows' own, and the alignment is the point. + */ +@Composable +fun ViewPreviewFrame( + selected: T, + label: String, + modifier: Modifier = Modifier, + content: @Composable (T) -> Unit, +) { + val reduceMotion = rememberReduceMotion() + Box( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = GroupedListInset) + .clip(FrameShape) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .padding(FrameInset), + contentAlignment = Alignment.Center, + ) { + Crossfade( + targetState = selected, + animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250), + label = label, + ) { shown -> + // Each preview renders the real screen at phone size and scales it + // down, so its own corners are square — the plate rounds them. + Box( + modifier = Modifier + .clip(PreviewShape) + .background(MaterialTheme.colorScheme.surface) + .clipToBounds(), + ) { + content(shown) + } + } + } +} + +private val FrameInset = 8.dp + +/** The grouped rows' own outer radius, so the stack shares one silhouette. */ +private val FrameShape = RoundedCornerShape(22.dp) +private val PreviewShape = RoundedCornerShape(12.dp)