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.
This commit is contained in:
2026-08-02 18:19:14 +02:00
parent fc4ff6b7dc
commit b614e427df
3 changed files with 151 additions and 1 deletions
+16
View File
@@ -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
+1 -1
View File
@@ -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
@@ -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)
}
}
}
}
}
}