From b614e427dfde835f1e93f880e463561477b29336 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 2 Aug 2026 18:02:51 +0200 Subject: [PATCH 1/2] feat(components): share the undo snack chip Agendula's swipe-delete chip and Calendula's reschedule confirmation were the same pill twice. Placement and timing stay with the caller; the shared one keeps the polite live region and takes a maxWidth so the message can't run into a bottom-end FAB. --- CHANGELOG.md | 16 +++ README.md | 2 +- .../floret/components/SnackChip.kt | 134 ++++++++++++++++++ 3 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 components/src/main/kotlin/de/jeanlucmakiola/floret/components/SnackChip.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 7234dba..d39ab26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,22 @@ are no binary releases; "version" tracks the shared `version` in the root build ## [Unreleased] +### Added + +- **`components`** — `SnackChip`, the family's compact confirmation: a + content-sized pill sliding up from the bottom with an optional trailing action, + for the case where the surface behind the confirmation *is* the confirmation + (a list that just lost a row, a calendar that just moved an event) and a + full-width snackbar would cover it. Placement and timing stay with the caller — + the bottom inset to clear differs per screen, and what an expiring undo window + commits is the app's business. Converged from Agendula's swipe-delete undo chip + and Calendula's drag-to-reschedule confirmation; the shared version keeps the + polite live region so the chip is announced, takes a `maxWidth` so a message + can't run into a bottom-end FAB, and takes `color`/`contentColor` so an app can + put it on the FAB's own fill. A message replaced in place crossfades and the + pill resizes with it, so an answered confirmation ("Moved" → "Move undone") + reads as one chip changing rather than two chips swapping. + ## [0.2.1] - 2026-07-17 ### Added diff --git a/README.md b/README.md index c406f73..1e18014 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the full consumption mode | `core-locale` | Android | Everything "what language are we in". Per-app language plumbing (`AppLanguage`): read the shipped languages from the app's `res/xml/locales_config.xml`, get/set the applied language via `AppCompatDelegate`, render each language's autonym. Plus `currentLocale()` (observes the applied language from Compose) and `localizedDateFormatter(locale, skeleton)` (lays a field skeleton out in the locale's own order). The app passes its own `locales_config`. | | `core-crash` | Android | Privacy-respecting on-device crash capture + report dialog + issue-tracker hand-off. Parameterized per app via `CrashConfig`. | | `identity` | Android | The M3 Expressive theme factory `FloretExpressiveTheme(…)`, `rememberNavSlideSpec()`, the content transitions (`expandEnter`/`collapseExit`/`itemEnter`/…) and the `predictiveBack` peek. Each app supplies its own seed/palette. | -| `components` | Android | Shared Compose vocabulary + recipes: `GroupedSurface`/`GroupedRow`, `InlineTextField`, `OptionCard`, `CollapsingScaffold`, `OptionPicker`, `ReorderableColumn`, `DebugRibbon`, `pastelize()`, `DialogControls`, `OnboardingScaffold`, `OptionalFormSection`, `AboutCard`, `LanguagePickerRow`. | +| `components` | Android | Shared Compose vocabulary + recipes: `GroupedSurface`/`GroupedRow`, `InlineTextField`, `OptionCard`, `CollapsingScaffold`, `OptionPicker`, `ReorderableColumn`, `DebugRibbon`, `pastelize()`, `DialogControls`, `OnboardingScaffold`, `OptionalFormSection`, `AboutCard`, `LanguagePickerRow`, `SnackChip`. | _The principle: the **mechanics** are shared, the **look** stays per-app. See [`docs/ROADMAP.md`](docs/ROADMAP.md) for what's extracted, deferred, and diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/SnackChip.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/SnackChip.kt new file mode 100644 index 0000000..76dbe31 --- /dev/null +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/SnackChip.kt @@ -0,0 +1,134 @@ +package de.jeanlucmakiola.floret.components + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.core.tween +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.contentColorFor +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +private const val CHIP_ENTER_MILLIS = 160 +private const val CHIP_EXIT_MILLIS = 120 +private const val CHIP_SWAP_MILLIS = 110 + +/** The band a snack chip is meant to sit in: the FAB's height, and its margin. */ +val SnackChipHeight: Dp = 56.dp +val SnackChipMargin: Dp = 16.dp + +/** + * The family's compact confirmation: a rounded pill sized to its content, + * sliding up from the bottom, with an optional trailing action ("Undo"). + * + * Preferred over a full-width snackbar wherever the surface behind it *is* the + * confirmation — a task list that just lost a row, a calendar that just moved an + * event: a bar across the bottom hides the very change it reports, while a pill + * on the FAB's own band leaves it visible. + * + * Placement stays with the caller, because the bottom inset to clear differs per + * screen (a scaffold's own padding, the navigation bar, a bottom bar). Anchor it + * bottom-start in a [SnackChipHeight] band with [SnackChipMargin] around it, and + * pass [maxWidth] where a bottom-end FAB shares that band — the message then + * ellipsises instead of running into it. + * + * The chip carries no timer: how long a confirmation stands, and what expiry + * commits, belongs to the caller's own undo window. + */ +@Composable +fun SnackChip( + visible: Boolean, + message: String, + modifier: Modifier = Modifier, + actionLabel: String? = null, + onAction: (() -> Unit)? = null, + maxWidth: Dp = Dp.Unspecified, + color: Color = MaterialTheme.colorScheme.surfaceContainerHighest, + contentColor: Color = contentColorFor(color), +) { + AnimatedVisibility( + visible = visible, + // Deliberately short: a chip is a receipt, not a transition. The default + // visibility spring overshoots and reads as sluggish at this size. + enter = slideInVertically(tween(CHIP_ENTER_MILLIS)) { it } + + fadeIn(tween(CHIP_ENTER_MILLIS)), + exit = slideOutVertically(tween(CHIP_EXIT_MILLIS)) { it } + + fadeOut(tween(CHIP_EXIT_MILLIS)), + modifier = modifier, + ) { + val action = if (actionLabel != null && onAction != null) actionLabel else null + Surface( + color = color, + contentColor = contentColor, + shape = RoundedCornerShape(50), + shadowElevation = 6.dp, + // A chip whose message is replaced in place — "Moved" answered by + // "Move undone" — grows or shrinks into the new width instead of + // snapping, so the swap reads as one chip changing its mind. + modifier = Modifier + .widthIn(max = maxWidth) + .animateContentSize(tween(CHIP_SWAP_MILLIS)), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding( + start = 20.dp, + end = if (action != null) 8.dp else 20.dp, + top = 6.dp, + bottom = 6.dp, + ), + ) { + AnimatedContent( + targetState = message, + transitionSpec = { + fadeIn(tween(CHIP_SWAP_MILLIS)) togetherWith + fadeOut(tween(CHIP_SWAP_MILLIS)) + }, + label = "snack-chip-message", + modifier = Modifier.weight(1f, fill = false), + ) { text -> + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + // A chip that appears without being focused is announced + // only if it says it is worth announcing. + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, + ) + } + if (action != null) { + TextButton( + onClick = { onAction?.invoke() }, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp), + ) { + Text(text = action, style = MaterialTheme.typography.labelLarge) + } + } + } + } + } +} From b08fb69843ea7447a9be58f35f703ab9c607a76c Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 2 Aug 2026 18:27:20 +0200 Subject: [PATCH 2/2] feat(components): spring the snack chip open A slide-in is clipped to the animating bounds, so at pill size the chip looked painted on from one edge. It now springs from its anchored corner on the expressive fast specs, which are also quicker than the default visibility spring. --- .../floret/components/SnackChip.kt | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/SnackChip.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/SnackChip.kt index 76dbe31..0a06c6c 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/SnackChip.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/SnackChip.kt @@ -5,9 +5,8 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.animation.core.tween +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues @@ -15,6 +14,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.contentColorFor @@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.semantics @@ -31,17 +32,13 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -private const val CHIP_ENTER_MILLIS = 160 -private const val CHIP_EXIT_MILLIS = 120 -private const val CHIP_SWAP_MILLIS = 110 - /** The band a snack chip is meant to sit in: the FAB's height, and its margin. */ val SnackChipHeight: Dp = 56.dp val SnackChipMargin: Dp = 16.dp /** * The family's compact confirmation: a rounded pill sized to its content, - * sliding up from the bottom, with an optional trailing action ("Undo"). + * springing open at the bottom, with an optional trailing action ("Undo"). * * Preferred over a full-width snackbar wherever the surface behind it *is* the * confirmation — a task list that just lost a row, a calendar that just moved an @@ -57,6 +54,7 @@ val SnackChipMargin: Dp = 16.dp * The chip carries no timer: how long a confirmation stands, and what expiry * commits, belongs to the caller's own undo window. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun SnackChip( visible: Boolean, @@ -68,14 +66,19 @@ fun SnackChip( color: Color = MaterialTheme.colorScheme.surfaceContainerHighest, contentColor: Color = contentColorFor(color), ) { + // The expressive *fast* springs: a chip is a receipt, not a transition. It + // springs open from its anchored corner rather than sliding in — a slide is + // clipped to the animating bounds, so the pill appears to be painted on from + // one edge, which at this size reads as a glitch rather than as motion. + val spatial = MaterialTheme.motionScheme.fastSpatialSpec() + val effects = MaterialTheme.motionScheme.fastEffectsSpec() + val corner = TransformOrigin(0f, 1f) AnimatedVisibility( visible = visible, - // Deliberately short: a chip is a receipt, not a transition. The default - // visibility spring overshoots and reads as sluggish at this size. - enter = slideInVertically(tween(CHIP_ENTER_MILLIS)) { it } + - fadeIn(tween(CHIP_ENTER_MILLIS)), - exit = slideOutVertically(tween(CHIP_EXIT_MILLIS)) { it } + - fadeOut(tween(CHIP_EXIT_MILLIS)), + enter = scaleIn(spatial, initialScale = 0.8f, transformOrigin = corner) + + fadeIn(effects), + exit = scaleOut(effects, targetScale = 0.9f, transformOrigin = corner) + + fadeOut(effects), modifier = modifier, ) { val action = if (actionLabel != null && onAction != null) actionLabel else null @@ -89,7 +92,7 @@ fun SnackChip( // snapping, so the swap reads as one chip changing its mind. modifier = Modifier .widthIn(max = maxWidth) - .animateContentSize(tween(CHIP_SWAP_MILLIS)), + .animateContentSize(MaterialTheme.motionScheme.fastSpatialSpec()), ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -103,10 +106,7 @@ fun SnackChip( ) { AnimatedContent( targetState = message, - transitionSpec = { - fadeIn(tween(CHIP_SWAP_MILLIS)) togetherWith - fadeOut(tween(CHIP_SWAP_MILLIS)) - }, + transitionSpec = { fadeIn(effects) togetherWith fadeOut(effects) }, label = "snack-chip-message", modifier = Modifier.weight(1f, fill = false), ) { text ->