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..0a06c6c --- /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.scaleIn +import androidx.compose.animation.scaleOut +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.ExperimentalMaterial3ExpressiveApi +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.graphics.TransformOrigin +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 + +/** 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, + * 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 + * 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. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@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), +) { + // 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, + 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 + 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(MaterialTheme.motionScheme.fastSpatialSpec()), + ) { + 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(effects) togetherWith fadeOut(effects) }, + 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) + } + } + } + } + } +}