feat(components): the preview picker's plate and scaled screen preview

ScaledViewPreview measures real screen composables at a phone viewport and
scales the finished layout down; ViewPreviewFrame is the tonal plate it sits
on, cross-fading between options at the grouped rows' width and radius so
preview and options read as one stack.

Converged from Calendula's view and month-style choosers, where the pattern
was invented, and now Agendula's quick-add step. The viewport height is a
parameter here rather than a calendar-shaped constant.
This commit is contained in:
2026-09-09 13:48:24 +02:00
parent 692c0a84c4
commit d7b7a542cd
2 changed files with 169 additions and 0 deletions
+20
View File
@@ -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
@@ -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 <T> 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)