Merge pull request 'floret-kit 0.4.0' (#8) from feat/agendula-1.0 into main
This commit is contained in:
+47
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -10,5 +10,5 @@ plugins {
|
||||
|
||||
subprojects {
|
||||
group = "de.jeanlucmakiola.floret"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<String>,
|
||||
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
|
||||
+50
-7
@@ -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 <T> ReorderableColumn(
|
||||
@@ -59,9 +69,11 @@ fun <T> ReorderableColumn(
|
||||
keyOf: (T) -> Any,
|
||||
onReorder: (List<T>) -> 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 <T> 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<Job?>(null) }
|
||||
@@ -119,10 +133,13 @@ fun <T> 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 <T> 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 <T> 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 <T> 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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)}"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user