Merge pull request 'floret-kit 0.4.0' (#8) from feat/agendula-1.0 into main

This commit is contained in:
Jean-Luc Makiola
2026-09-24 16:29:03 +02:00
8 changed files with 481 additions and 18 deletions
+47 -1
View File
@@ -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
View File
@@ -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
@@ -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)}"
}