Merge pull request 'feat(components): share the undo snack chip' (#2) from feat/snack-chip into main
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.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<Float>()
|
||||
val effects = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user