diff --git a/CHANGELOG.md b/CHANGELOG.md index d39ab26..21f6261 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ are no binary releases; "version" tracks the shared `version` in the root build ### Added +- **`components`** — `GroupedRow` takes an `AnnotatedString` title and summary + as well as plain `String`s, so a row can carry its own emphasis: marking the + run a search matched, without every caller having to rebuild the row. The + `String` overload delegates to it, so existing callers are unchanged. + - **`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 diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedList.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedList.kt index f11b6d6..62f206d 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedList.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedList.kt @@ -1,6 +1,8 @@ package de.jeanlucmakiola.floret.components import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.fillMaxWidth @@ -13,12 +15,16 @@ import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -37,6 +43,16 @@ fun positionOf(index: Int, count: Int): Position = when { else -> Position.Middle } +/** + * The inset a grouped list keeps from the screen edge. + * + * Exported because it is a *shared* line, not a private detail of [GroupedRow]: + * everything that sits outside the cards but belongs to them — section headers, + * hints, picker descriptions — has to start on the same edge, or the page reads + * as two ragged columns. Take this value rather than writing 16.dp again. + */ +val GroupedListInset = 16.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) @@ -55,13 +71,17 @@ fun groupedShape(position: Position, full: Dp, small: Dp): Shape = when (positio * Set [gapBelow] to false to suppress that 2dp separation — for a caller that * owns uniform spacing itself, e.g. [ReorderableColumn], where every slot must * share the same pitch for the drag maths to line up. + * + * [onLongClick] adds a long-press to the same surface (with the platform's + * long-press haptic), for lists that enter a selection mode from a row. */ -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun GroupedSurface( position: Position, modifier: Modifier = Modifier, onClick: (() -> Unit)? = null, + onLongClick: (() -> Unit)? = null, color: Color = MaterialTheme.colorScheme.surfaceContainerHigh, gapBelow: Boolean = true, content: @Composable () -> Unit, @@ -77,10 +97,24 @@ fun GroupedSurface( else -> Modifier } val base = modifier.fillMaxWidth().then(gap) - if (onClick != null) { - Surface(onClick = onClick, color = color, shape = shape, interactionSource = interaction, modifier = base) { content() } - } else { - Surface(color = color, shape = shape, modifier = base) { content() } + val haptics = LocalHapticFeedback.current + when { + onLongClick != null -> Surface( + color = color, + shape = shape, + modifier = base.combinedClickable( + interactionSource = interaction, + indication = ripple(), + onClick = { onClick?.invoke() }, + onLongClick = { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + onLongClick() + }, + ), + ) { content() } + onClick != null -> + Surface(onClick = onClick, color = color, shape = shape, interactionSource = interaction, modifier = base) { content() } + else -> Surface(color = color, shape = shape, modifier = base) { content() } } } @@ -95,8 +129,8 @@ fun GroupedSurface( * [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. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun GroupedRow( title: String, @@ -111,6 +145,44 @@ fun GroupedRow( leading: @Composable (() -> Unit)? = null, trailing: @Composable (() -> Unit)? = null, onClick: (() -> Unit)? = null, + onLongClick: (() -> Unit)? = null, +) = GroupedRow( + title = AnnotatedString(title), + position = position, + modifier = modifier, + summary = summary?.let { AnnotatedString(it) }, + selected = selected, + dimmed = dimmed, + container = container, + minHeight = minHeight, + gapBelow = gapBelow, + leading = leading, + trailing = trailing, + onClick = onClick, + onLongClick = onLongClick, +) + +/** + * [GroupedRow] with styled text, for a row whose title or summary carries its own + * emphasis — a search hit marking the run that matched, say. Identical otherwise; + * the plain-[String] overload delegates here. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GroupedRow( + title: AnnotatedString, + position: Position, + modifier: Modifier = Modifier, + summary: AnnotatedString? = null, + selected: Boolean = false, + dimmed: Boolean = false, + container: Color? = null, + minHeight: Dp = 72.dp, + gapBelow: Boolean = true, + leading: @Composable (() -> Unit)? = null, + trailing: @Composable (() -> Unit)? = null, + onClick: (() -> Unit)? = null, + onLongClick: (() -> Unit)? = null, ) { val itemColors = if (selected) { ListItemDefaults.colors( @@ -140,8 +212,9 @@ fun GroupedRow( } GroupedSurface( position = position, - modifier = modifier.padding(horizontal = 16.dp), + modifier = modifier.padding(horizontal = GroupedListInset), onClick = onClick, + onLongClick = onLongClick, color = containerColor, gapBelow = gapBelow, ) { diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/Onboarding.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/Onboarding.kt index e56348f..f8eb277 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/Onboarding.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/Onboarding.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.floret.components +import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -13,6 +14,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape @@ -22,10 +24,13 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp /** MD3 8dp spacing scale shared by the onboarding screens. */ @@ -43,17 +48,35 @@ object OnboardingSpace { * bar). The content slot is centred horizontally; benefit rows fill the width so * their own content left-aligns. Each app supplies its own hero so the apps read * as one family while keeping their own mark. + * + * A multi-step flow passes [progress] (see [OnboardingProgress]), which is + * pinned above the scrolling content. [topSpacing] gives a step whose hero is a + * large picture rather than a mark room to breathe without overflowing. */ @Composable fun OnboardingScaffold( hero: @Composable () -> Unit, actions: @Composable ColumnScope.() -> Unit, modifier: Modifier = Modifier, + progress: (@Composable () -> Unit)? = null, + topSpacing: Dp = OnboardingSpace.xl, body: @Composable ColumnScope.() -> Unit, ) { Scaffold( modifier = modifier, containerColor = MaterialTheme.colorScheme.surface, + topBar = { + if (progress != null) { + Box( + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(horizontal = OnboardingSpace.md, vertical = OnboardingSpace.sm), + ) { + progress() + } + } + }, bottomBar = { Column( modifier = Modifier @@ -74,7 +97,7 @@ fun OnboardingScaffold( .padding(horizontal = OnboardingSpace.md), horizontalAlignment = Alignment.CenterHorizontally, ) { - Spacer(Modifier.height(OnboardingSpace.xl)) + Spacer(Modifier.height(topSpacing)) hero() Spacer(Modifier.height(OnboardingSpace.lg)) body() @@ -83,6 +106,60 @@ fun OnboardingScaffold( } } +/** + * Where a multi-step onboarding flow has got to: one capsule per step, filled up + * to and including [step] (1-based), with [label] — the caller's own localised + * "Step 2 of 4" — underneath. + * + * The capsules carry no semantics of their own; [label] is the accessible + * description, so a screen reader hears the position once rather than counting + * decorations. + */ +@Composable +fun OnboardingProgress( + step: Int, + total: Int, + label: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clearAndSetSemantics { }, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + repeat(total) { index -> + val done = index < step + val color by animateColorAsState( + targetValue = if (done) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceContainerHighest + }, + label = "onboarding-progress-segment", + ) + Box( + modifier = Modifier + .weight(1f) + .height(4.dp) + .clip(CircleShape) + .background(color), + ) + } + } + Spacer(Modifier.height(OnboardingSpace.xs)) + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + /** One trust point: a tonal icon chip on the left, title + supporting text right. */ @Composable fun BenefitRow(icon: ImageVector, title: String, body: String) { diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/OptionPicker.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/OptionPicker.kt index 6c0d984..dd5e550 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/OptionPicker.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/OptionPicker.kt @@ -26,6 +26,20 @@ import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme * [scrollable] forwards to [CollapsingScaffold]: leave it on unless [content] * scrolls itself, which a picker only needs for an option list long enough to * warrant a lazy container. + * + * **Picker richness.** Three shapes, in ascending order — take the cheapest one + * that makes the choice obvious: + * 1. plain option rows, when the label *is* the meaning ("English", "Dark"); + * 2. rows plus a per-option summary of the concrete effect, when the meaning is + * a date, a time or a number the label only names ("Next 7 days" → + * "30 Jul – 5 Aug"). [OptionPicker]'s `summary` covers this; + * 3. a live preview above the rows, when the effect is *visual* and no words + * carry it (month grid style, dimmed past events, week start). + * + * A picker of the third kind **must not close when an option is tapped** — it + * applies immediately and stays open, because closing would hide the very thing + * the screen exists to show. The user leaves via back. The first two kinds close + * on tap as usual. */ @Composable fun FullScreenPicker( @@ -82,6 +96,15 @@ fun FullScreenPicker( * General single-select picker, full-screen: each option is a connected grouped * row and the current one carries a check. The drop-in for the family's option * dialogs (theme, default list, reminder offset, …). + * + * [summary] adds a second line per option — reserve it for the option's + * *concrete effect*, the thing its label names but doesn't spell out: the dates + * a range resolves to, the time a format renders as, what "follow the system" + * currently means. Returning null leaves that row single-line, so one option + * (typically the automatic one) can carry a note the others don't need. It is + * the cheap middle rung of the richness ladder described on [FullScreenPicker]: + * every option shows its effect at once and stays comparable, without the cost + * of a live preview. */ @Composable fun OptionPicker( @@ -91,6 +114,7 @@ fun OptionPicker( label: @Composable (T) -> String, onSelect: (T) -> Unit, onDismiss: () -> Unit, + summary: (@Composable (T) -> String?)? = null, leading: (@Composable (T) -> Unit)? = null, header: (@Composable ColumnScope.() -> Unit)? = null, predictiveBack: Boolean = false, @@ -101,6 +125,7 @@ fun OptionPicker( val isSelected = option == selected GroupedRow( title = label(option), + summary = summary?.invoke(option), position = positionOf(index, options.size), selected = isSelected, leading = leading?.let { { it(option) } }, diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/SettingsRecipes.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/SettingsRecipes.kt index 9f647da..aaebaa4 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/SettingsRecipes.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/SettingsRecipes.kt @@ -13,12 +13,10 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -41,6 +39,12 @@ data class AboutLink(val icon: ImageVector, val label: String, val url: String) * outlined buttons (e.g. source, licence) and an optional full-width * [highlightLink] tonal button (e.g. donate). Each link opens its URL in the * browser. The app supplies its own logo, strings and links. + * + * [position] lets the card join a grouped list instead of standing alone: with + * [Position.Top] it takes the group's top corners and the 2dp gap below, so a + * [GroupedRow] underneath continues the same container. Use that when the card's + * call to action reads better as a row of its own than as a button inside the + * card — the two then form one block rather than a card with a lid on it. */ @Composable fun AboutCard( @@ -49,15 +53,16 @@ fun AboutCard( author: String, primaryLinks: List, highlightLink: AboutLink? = null, + position: Position = Position.Alone, ) { val context = LocalContext.current val open = { url: String -> runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) } Unit } - Surface( + GroupedSurface( + position = position, color = MaterialTheme.colorScheme.surfaceContainerHigh, - shape = RoundedCornerShape(24.dp), modifier = Modifier.fillMaxWidth(), ) { Column(Modifier.fillMaxWidth().padding(16.dp)) {