diff --git a/CHANGELOG.md b/CHANGELOG.md index ae59535..dd00cf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,56 @@ All notable changes to floret-kit are documented here. The format follows floret-kit is consumed **from source** — each app embeds it as a git submodule and builds it through a Gradle composite build, pinning a specific commit. There are no binary releases; "version" tracks the shared `version` in the root build -(currently `0.3.0`). Entries are grouped by module. +(currently `0.4.0`). Entries are grouped by module. ## [Unreleased] +## [0.4.0] - 2026-09-24 + +### Added + +- **`components`** — `GroupedSectionHeader`, the label above a run of + `GroupedRow`s: small, `primary`, inset to `GroupedListInset` so it shares the + cards' left margin. Converged from Calendula's settings hub, where the recipe + was written as a private `SectionHeader`, and now Agendula's, which had grown + a flat run of a dozen rows with nothing separating how the app behaves from + what it is. + +- **`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. + +- **`components`** — `InstructionSteps`, a numbered, connected list for a + procedure the user carries out somewhere else. Collapsible, so it can come + back as a reference once it has had a screen of its own. + +- **`components`** — `InlineTextField` takes a `visualTransformation`, so a + password can be masked. `KeyboardType.Password` only drops IME suggestions; + without this the field rendered the secret in the clear. + +- **`components`** — `ReorderableColumn` takes a `rowHeight` (default + `ReorderableRowHeight`), so rows keep their normal height while reordering. + The drag shadow now follows the card's shape, and a row resists being dragged + past either end. + +- **`core-time`** — `formatTime`, `formatDateTime` and `formatDateTimeCompact` + take an optional `is24Hour`, for an app-level 12/24-hour setting. `null` + keeps following the system. + ### Fixed - **`core-crash`** — The prefilled crash issue is filed in English again. Its diff --git a/build.gradle.kts b/build.gradle.kts index 48ae938..637e3db 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,5 +10,5 @@ plugins { subprojects { group = "de.jeanlucmakiola.floret" - version = "0.3.0" + version = "0.4.0" } diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedList.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedList.kt index 62f206d..292fe67 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedList.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedList.kt @@ -53,6 +53,32 @@ fun positionOf(index: Int, count: Int): Position = when { */ val GroupedListInset = 16.dp +/** + * The label above a run of [GroupedRow]s, for a page whose groups are named + * rather than merely spaced apart — a settings hub that separates how the app + * behaves from what it is. + * + * Small, `primary`, and inset to [GroupedListInset] so it shares the left + * margin of the cards it names. Converged from Calendula's settings hub, where + * the recipe was written; a caller separates one group from the next with its + * own 8dp spacer, since a hub's first header follows the About card rather than + * another group. + */ +@Composable +fun GroupedSectionHeader(text: String, modifier: Modifier = Modifier) { + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = modifier.padding( + start = GroupedListInset, + end = GroupedListInset, + top = 16.dp, + bottom = 4.dp, + ), + ) +} + /** Corner radii for a grouped segment: full at the group's outer edges, small between. */ fun groupedShape(position: Position, full: Dp, small: Dp): Shape = when (position) { Position.Alone -> RoundedCornerShape(full) @@ -127,9 +153,12 @@ fun GroupedSurface( * headline and summary to the M3 disabled emphasis while leaving the [trailing] * control at full opacity — for rows that are present but switched off. * [container] overrides the row's tonal colour (e.g. a category tint); ignored - * when [selected]. [gapBelow] (see [GroupedSurface]) suppresses the inter-row - * gap for a caller that owns its own uniform spacing, e.g. [ReorderableColumn]. - * [onLongClick] (see [GroupedSurface]) adds a long-press to the row. + * when [selected]. [contentColor] is its pair — the `on*` role for that + * container, so a tinted row keeps its contrast in both themes instead of + * leaving the text on `onSurface`. [gapBelow] (see [GroupedSurface]) suppresses + * the inter-row gap for a caller that owns its own uniform spacing, e.g. + * [ReorderableColumn]. [onLongClick] (see [GroupedSurface]) adds a long-press to + * the row. */ @Composable fun GroupedRow( @@ -140,6 +169,7 @@ fun GroupedRow( selected: Boolean = false, dimmed: Boolean = false, container: Color? = null, + contentColor: Color? = null, minHeight: Dp = 72.dp, gapBelow: Boolean = true, leading: @Composable (() -> Unit)? = null, @@ -154,6 +184,7 @@ fun GroupedRow( selected = selected, dimmed = dimmed, container = container, + contentColor = contentColor, minHeight = minHeight, gapBelow = gapBelow, leading = leading, @@ -177,6 +208,7 @@ fun GroupedRow( selected: Boolean = false, dimmed: Boolean = false, container: Color? = null, + contentColor: Color? = null, minHeight: Dp = 72.dp, gapBelow: Boolean = true, leading: @Composable (() -> Unit)? = null, @@ -202,6 +234,14 @@ fun GroupedRow( leadingIconColor = muted, supportingColor = muted, ) + } else if (contentColor != null) { + ListItemDefaults.colors( + containerColor = Color.Transparent, + headlineColor = contentColor, + leadingIconColor = contentColor, + supportingColor = contentColor, + trailingIconColor = contentColor, + ) } else { ListItemDefaults.colors(containerColor = Color.Transparent) } diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/InlineTextField.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/InlineTextField.kt index f36aa93..4ec755a 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/InlineTextField.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/InlineTextField.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp /** @@ -42,6 +43,14 @@ fun InlineTextField( imeAction: ImeAction = ImeAction.Default, /** Invoked when the IME action key (e.g. Done) is pressed. */ onImeAction: (() -> Unit)? = null, + /** + * How the value is rendered, as opposed to stored. + * + * Exists so a password can be masked. [KeyboardType.Password] alone does + * not mask anything — it only tells the IME to drop suggestions — so a + * borderless field without this renders the secret in the clear. + */ + visualTransformation: VisualTransformation = VisualTransformation.None, ) { val resolvedStyle = textStyle.copy( color = when { @@ -66,6 +75,7 @@ fun InlineTextField( keyboardActions = onImeAction?.let { action -> KeyboardActions(onAny = { action() }) } ?: KeyboardActions.Default, + visualTransformation = visualTransformation, cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), decorationBox = { innerTextField -> Box { diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/InstructionSteps.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/InstructionSteps.kt new file mode 100644 index 0000000..2979ee9 --- /dev/null +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/InstructionSteps.kt @@ -0,0 +1,162 @@ +package de.jeanlucmakiola.floret.components + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.KeyboardArrowDown +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +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.rotate +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +/** + * A short procedure the user has to carry out somewhere else, as a connected + * list of numbered steps. + * + * A component rather than a paragraph, because the instructions that matter most + * in a sign-in flow are for a *different* app — go to this site, turn that + * setting on, bring the result back here — and four actions written as prose is + * the form in which people miss the third one. + * + * Built from [GroupedRow] rather than as a tinted card, so a procedure looks + * like every other run of rows in the family: one rounded container, full + * corners at the ends, small ones between. That buys two things a card did not. + * Each step is a row of its own, so a long one wraps inside its own container + * instead of pushing the rest of a paragraph around; and the numerals sit in the + * leading slot every other row puts an avatar or icon in, which is what makes + * the column of them scan as a sequence. + * + * [title] renders as the group's [GroupedSectionHeader] — omit it where the + * screen already carries the headline, or the same sentence is said twice. + * [footnote] is the caveat that is not a step: what this will cost, or what it + * will not do. + * + * [collapsible] folds the whole thing behind its [title] as a row that opens on + * a tap, closed to begin with. For a procedure the user has *already been shown* + * — carried along afterwards so it can be re-read rather than remembered — that + * is the difference between a reference and a wall repeating itself. It needs a + * [title], since collapsed there is nothing else to tap. + */ +@Composable +fun InstructionSteps( + steps: List, + modifier: Modifier = Modifier, + title: String? = null, + footnote: String? = null, + collapsible: Boolean = false, +) { + var open by rememberSaveable { mutableStateOf(false) } + // Collapsed only where there is a header to reopen from. + val folds = collapsible && title != null + Column(modifier) { + when { + folds -> { + GroupedRow( + title = title, + // Alone while shut, the head of the run once open — the + // corners are what say whether anything follows. + position = if (open) Position.Top else Position.Alone, + minHeight = 56.dp, + // No disc: the empty leading slot is what tells the header + // apart from the numbered rows it opens. + trailing = { Chevron(open) }, + onClick = { open = !open }, + ) + } + + title != null -> GroupedSectionHeader(title) + } + if (folds && !open) return@Column + steps.forEachIndexed { index, step -> + GroupedRow( + title = step, + // The header is the group's first row when there is one, so the + // steps run from the middle rather than starting the shape over. + position = if (folds) { + positionOf(index + 1, steps.size + 1) + } else { + positionOf(index, steps.size) + }, + // Roomy enough for one line, free to grow for three. A step that + // wraps is the normal case here, not the exception. + minHeight = 56.dp, + leading = { StepNumber(index + 1) }, + ) + } + footnote?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + // Outside the cards but belonging to them, so it starts on the + // same line they do. + modifier = Modifier.padding( + start = GroupedListInset, + end = GroupedListInset, + top = 8.dp, + ), + ) + } + } +} + +/** + * The step's number, in the leading slot. + * + * A filled `primary` disc rather than a bare numeral: on a neutral row the + * accent is what marks the column as a sequence rather than a list of unrelated + * settings, and it holds that reading at a glance without a rule down the side. + * + */ +@Composable +private fun StepNumber(number: Int) { + Box( + modifier = Modifier + .size(StepNumberSize) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center, + ) { + Text( + text = number.toString(), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onPrimary, + ) + } +} + +/** + * The open/shut marker. + * + * Decorative: the row it sits on carries the name and the click, so a screen + * reader announcing a chevron as well would be describing the furniture. + */ +@Composable +private fun Chevron(open: Boolean) { + val turn by animateFloatAsState(if (open) 180f else 0f, label = "instruction-chevron") + Icon( + imageVector = Icons.Rounded.KeyboardArrowDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.rotate(turn), + ) +} + +/** The leading slot's own size, matched to the icons that usually fill it. */ +private val StepNumberSize = 28.dp diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/ReorderableColumn.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/ReorderableColumn.kt index d63ff1f..edb362b 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/ReorderableColumn.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/ReorderableColumn.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.height -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -21,17 +20,25 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.translate +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlin.math.roundToInt -/** Uniform row height for [ReorderableColumn]; a fixed pitch keeps drag maths exact. */ +/** Default uniform row height for [ReorderableColumn]; a fixed pitch keeps drag maths exact. */ val ReorderableRowHeight: Dp = 64.dp private val RowGap: Dp = 2.dp @@ -41,7 +48,7 @@ private val RowGap: Dp = 2.dp * Built for the short, fixed grouped-card lists in Settings — no external * dependency and no [androidx.compose.foundation.lazy.LazyColumn] (the settings * screens are a single [androidx.compose.foundation.verticalScroll] column, which - * can't nest a scrolling list). Rows are a fixed [ReorderableRowHeight] with a + * can't nest a scrolling list). Rows are a fixed [rowHeight] with a * uniform gap, so a row's target slot is simply how many whole pitches it has * been dragged. The held row follows the finger while the others slide out of * its way (animated); on release the order is committed immediately with a @@ -52,6 +59,9 @@ private val RowGap: Dp = 2.dp * [rowContent] receives the [Position] for the row's place in the order (to reuse * [GroupedRow]'s card shaping — pass `gapBelow = false` there, this owns spacing) * and a `dragHandle` [Modifier] to attach to the element that starts a drag. + * Pass [rowHeight] matching the rows' non-reorder `minHeight`, so they keep their + * size when a screen switches into reordering. [rowInset] is the card's horizontal + * inset inside its slot, so the held row's shadow hugs the card, not the slot. */ @Composable fun ReorderableColumn( @@ -59,9 +69,11 @@ fun ReorderableColumn( keyOf: (T) -> Any, onReorder: (List) -> Unit, modifier: Modifier = Modifier, + rowHeight: Dp = ReorderableRowHeight, + rowInset: Dp = GroupedListInset, rowContent: @Composable (item: T, position: Position, dragHandle: Modifier, isDragging: Boolean) -> Unit, ) { - val pitchPx = with(LocalDensity.current) { (ReorderableRowHeight + RowGap).toPx() } + val pitchPx = with(LocalDensity.current) { (rowHeight + RowGap).toPx() } val scope = rememberCoroutineScope() // Local working copy; re-seeded when the incoming list changes (including the @@ -72,6 +84,8 @@ fun ReorderableColumn( // release settle — re-based onto the row's new slot once the order commits // (see onDragEnd), then eased down to zero. var dragOffset by remember { mutableFloatStateOf(0f) } + // The finger's own travel; [dragOffset] is this held to the list's ends. + var rawOffset by remember { mutableFloatStateOf(0f) } // The running release/cancel settle, cancelled if a new drag pre-empts it. // Purely visual — the order commit (see onDragEnd) never depends on it. var settleJob by remember { mutableStateOf(null) } @@ -119,10 +133,13 @@ fun ReorderableColumn( settleJob?.cancel() draggedKey = key dragOffset = 0f + rawOffset = 0f }, onDrag = { change, amount -> change.consume() - dragOffset += amount.y + rawOffset += amount.y + val from = order.indexOfFirst { keyOf(it) == key } + dragOffset = rubberBand(rawOffset, -from * pitchPx, (order.lastIndex - from) * pitchPx, pitchPx * 0.4f) }, onDragEnd = { val from = order.indexOfFirst { keyOf(it) == key } @@ -163,7 +180,7 @@ fun ReorderableColumn( Box( Modifier - .height(ReorderableRowHeight) + .height(rowHeight) .zIndex(if (isDragged) 1f else 0f) .graphicsLayer { translationY = if (isDragged) dragOffset else animatedShift @@ -171,7 +188,7 @@ fun ReorderableColumn( scaleX = 1.02f scaleY = 1.02f shadowElevation = 8.dp.toPx() - shape = RoundedCornerShape(20.dp) + shape = InsetShape(groupedShape(positionOf(index, order.size), 22.dp, 6.dp), rowInset) clip = false } }, @@ -181,3 +198,29 @@ fun ReorderableColumn( } } } + +/** [base] laid out [inset] in from both sides of the slot. */ +private class InsetShape(private val base: Shape, private val inset: Dp) : Shape { + override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline { + val dx = with(density) { inset.toPx() } + val inner = Size((size.width - 2 * dx).coerceAtLeast(0f), size.height) + return when (val outline = base.createOutline(inner, layoutDirection, density)) { + is Outline.Rectangle -> Outline.Rectangle(outline.rect.translate(Offset(dx, 0f))) + is Outline.Rounded -> Outline.Rounded(outline.roundRect.translate(Offset(dx, 0f))) + is Outline.Generic -> Outline.Generic(Path().apply { addPath(outline.path, Offset(dx, 0f)) }) + } + } +} + +/** + * [value] as is between [min] and [max]; past either end it keeps following with + * growing resistance and never gets further than [stretch] beyond it. + */ +private fun rubberBand(value: Float, min: Float, max: Float, stretch: Float): Float { + fun resist(over: Float) = stretch * (1f - 1f / (over / stretch + 1f)) + return when { + value > max -> max + resist(value - max) + value < min -> min - resist(min - value) + else -> value + } +} 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) diff --git a/core-time/src/main/kotlin/de/jeanlucmakiola/floret/time/DateTimeFormat.kt b/core-time/src/main/kotlin/de/jeanlucmakiola/floret/time/DateTimeFormat.kt index bb5367a..d7f2208 100644 --- a/core-time/src/main/kotlin/de/jeanlucmakiola/floret/time/DateTimeFormat.kt +++ b/core-time/src/main/kotlin/de/jeanlucmakiola/floret/time/DateTimeFormat.kt @@ -39,12 +39,25 @@ private fun Instant.atDisplayZone(allDay: Boolean) = fun Instant.formatDate(allDay: Boolean = false): String = atDisplayZone(allDay).format(dateFormatter) -/** Short localized time, e.g. "14:30". */ -fun Instant.formatTime(): String = atSystemZone().format(timeFormatter) +private val timeFormatter24: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm") +private val timeFormatter12: DateTimeFormatter = DateTimeFormatter.ofPattern("h:mm a") + +/** + * Short time, e.g. "14:30". [is24Hour] forces a 12- or 24-hour clock; null keeps + * the locale's own short pattern. + */ +fun Instant.formatTime(is24Hour: Boolean? = null): String { + val formatter = when (is24Hour) { + null -> timeFormatter + true -> timeFormatter24 + false -> timeFormatter12.withLocale(Locale.getDefault()) + } + return atSystemZone().format(formatter) +} /** Date alone for all-day items, otherwise date + time. */ -fun Instant.formatDateTime(allDay: Boolean): String = - if (allDay) formatDate(allDay = true) else "${formatDate()} · ${formatTime()}" +fun Instant.formatDateTime(allDay: Boolean, is24Hour: Boolean? = null): String = + if (allDay) formatDate(allDay = true) else "${formatDate()} · ${formatTime(is24Hour)}" // The locale's medium date pattern with the year token stripped, e.g. "20 Jun" / // "Jun 20" — derived from the pattern so day/month order still follows the locale. @@ -62,11 +75,11 @@ private val dateNoYearFormatter: DateTimeFormatter = run { * current year, and the time is dropped when it carries no information (all-day * items, or a midnight time). Examples: "20 Jun", "20 Jun · 14:30", "20 Jun 2027". */ -fun Instant.formatDateTimeCompact(allDay: Boolean): String { +fun Instant.formatDateTimeCompact(allDay: Boolean, is24Hour: Boolean? = null): String { val zdt = atDisplayZone(allDay) val datePart = if (zdt.year == LocalDate.now(ZoneId.systemDefault()).year) zdt.format(dateNoYearFormatter) else formatDate(allDay) return if (allDay || zdt.toLocalTime() == LocalTime.MIDNIGHT) datePart - else "$datePart · ${formatTime()}" + else "$datePart · ${formatTime(is24Hour)}" }