From 71212c49d2b4ee82dd6fb57057d690d88bf8e980 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 15 Oct 2026 16:50:19 +0200 Subject: [PATCH] feat(components): icon info card, onboarding headline and language row header IconInfoCard for the detail/edit cards, OnboardingScaffold headline/supporting plus OnboardingPrimaryButton, and header/predictiveBack on LanguagePickerRow. --- CHANGELOG.md | 11 +++ .../floret/components/IconInfoCard.kt | 83 +++++++++++++++++++ .../floret/components/Onboarding.kt | 58 ++++++++++++- .../floret/components/SettingsRecipes.kt | 8 +- 4 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 components/src/main/kotlin/de/jeanlucmakiola/floret/components/IconInfoCard.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index bb95f76..d3fe2fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,17 @@ are no binary releases; "version" tracks the shared `version` in the root build switch. `GroupedHint` is the muted text beside the cards (settings hints, picker descriptions), and `ExpandIcon` the turning chevron, now also used by `InstructionSteps`. +- **`components`** — `IconInfoCard`, the tonal icon-plus-content card behind + Calendula's event detail and edit screens (optionally clickable, a top-aligned + icon for multi-line fields, a `header` line the icon centres on). +- **`components`** — `OnboardingScaffold` takes `headline` and `supporting`, + the centred title and muted explanation most steps open `body` with; `body` + now defaults to empty. `OnboardingPrimaryButton` is the full-width 56dp + call-to-action, with an optional trailing icon. Existing calls are unchanged. +- **`components`** — `LanguagePickerRow` takes `header` and `predictiveBack`, + forwarded to its `OptionPicker`, so an app with a "help translate" row above + the languages can drop its own copy of the row. Both default to today's + behaviour. - **`identity`** — a pure-black (AMOLED) dark theme. `ColorScheme.toPureBlack()` sets `background`, `surface`, `surfaceDim` and `surfaceContainerLowest` to diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/IconInfoCard.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/IconInfoCard.kt new file mode 100644 index 0000000..dcecba8 --- /dev/null +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/IconInfoCard.kt @@ -0,0 +1,83 @@ +package de.jeanlucmakiola.floret.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +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.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +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.vector.ImageVector +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * A free-standing info card: a tonal container with [icon] in a 24dp gutter on + * the left and [content] beside it — a detail screen's "where / when / repeats" + * cards, or the same fields on an edit form. + * + * [onClick] makes the whole card tappable. [iconAlignment] Top with an + * [iconTopPadding] lines the icon up with a multi-line field's first line. A + * tall card passes its first line as [header]: the icon centres on that, and + * [content] continues below it on the same text column. + */ +@Composable +fun IconInfoCard( + icon: ImageVector, + iconContentDescription: String?, + modifier: Modifier = Modifier, + iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant, + onClick: (() -> Unit)? = null, + iconAlignment: Alignment.Vertical = Alignment.CenterVertically, + iconTopPadding: Dp = 0.dp, + header: (@Composable ColumnScope.() -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit, +) { + val shape = RoundedCornerShape(16.dp) + val color = MaterialTheme.colorScheme.surfaceContainerHigh + val inner: @Composable () -> Unit = { + if (header != null) { + Column(Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + CardIcon(icon, iconContentDescription, iconTint, Modifier) + Spacer(Modifier.width(IconGap)) + Column(Modifier.weight(1f), content = header) + } + Column(Modifier.padding(start = IconSize + IconGap), content = content) + } + } else { + Row(Modifier.padding(16.dp), verticalAlignment = iconAlignment) { + CardIcon(icon, iconContentDescription, iconTint, Modifier.padding(top = iconTopPadding)) + Spacer(Modifier.width(IconGap)) + Column(Modifier.weight(1f), content = content) + } + } + } + if (onClick != null) { + Surface(onClick = onClick, color = color, shape = shape, modifier = modifier.fillMaxWidth()) { inner() } + } else { + Surface(color = color, shape = shape, modifier = modifier.fillMaxWidth()) { inner() } + } +} + +@Composable +private fun CardIcon(icon: ImageVector, contentDescription: String?, tint: Color, modifier: Modifier) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = tint, + modifier = modifier.size(IconSize), + ) +} + +private val IconSize = 24.dp +private val IconGap = 16.dp 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 b82e6fd..22e50a5 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/Onboarding.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/Onboarding.kt @@ -19,6 +19,8 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -30,6 +32,7 @@ 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.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -66,6 +69,10 @@ object OnboardingSpace { * the scrolling content, for a step whose content is taller than the screen: a * pinned bar there eats the room the content needs and hides the fact that * there is more of it below. + * + * [headline] and [supporting] are the step's centred title and muted + * explanation, drawn at the top of [body] — the block nearly every step opens + * with. [body] then continues below them, and may be left out entirely. */ @Composable fun OnboardingScaffold( @@ -77,7 +84,9 @@ fun OnboardingScaffold( topSpacing: Dp = OnboardingSpace.xl, contentPadding: Dp = OnboardingSpace.md, scrollingActions: Boolean = false, - body: @Composable ColumnScope.() -> Unit, + headline: String? = null, + supporting: String? = null, + body: @Composable ColumnScope.() -> Unit = {}, ) { Scaffold( modifier = modifier, @@ -120,6 +129,22 @@ fun OnboardingScaffold( Spacer(Modifier.height(topSpacing)) hero() Spacer(Modifier.height(OnboardingSpace.lg)) + if (headline != null) { + Text( + text = headline, + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + ) + } + if (headline != null && supporting != null) Spacer(Modifier.height(12.dp)) + if (supporting != null) { + Text( + text = supporting, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } body() // No inset of its own: scrolling actions sit inside the content // column, so they line up with whatever is above them. @@ -135,6 +160,37 @@ fun OnboardingScaffold( } } +/** + * The step's main call to action for [OnboardingScaffold]'s `actions`: a + * full-width 56dp filled button with a `titleMedium` label, and an optional + * [trailingIcon] (e.g. a forward arrow) after it. + */ +@Composable +fun OnboardingPrimaryButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + trailingIcon: ImageVector? = null, +) { + Button( + onClick = onClick, + enabled = enabled, + modifier = modifier.fillMaxWidth().height(56.dp), + contentPadding = if (trailingIcon != null) { + ButtonDefaults.ButtonWithIconContentPadding + } else { + ButtonDefaults.ContentPadding + }, + ) { + Text(text = text, style = MaterialTheme.typography.titleMedium) + if (trailingIcon != null) { + Spacer(Modifier.width(OnboardingSpace.xs)) + Icon(imageVector = trailingIcon, contentDescription = null, modifier = Modifier.size(20.dp)) + } + } +} + /** The call-to-action stack, wherever the shell has put it. */ @Composable private fun ActionsColumn( 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 3f82d9a..e8ec3db 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/SettingsRecipes.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/SettingsRecipes.kt @@ -5,6 +5,7 @@ import android.net.Uri import androidx.annotation.XmlRes import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.PaddingValues @@ -111,7 +112,8 @@ fun AboutCard( * [AppLanguage]. The choice is mirrored locally so the row updates instantly * before the activity recreation lands. [autoLabel] names the follow-the-system * option (the `null` tag); [localesConfig] is the app's `res/xml/locales_config`; - * [leading] is the row's icon. + * [leading] is the row's icon. [header] and [predictiveBack] are forwarded to the + * picker's [OptionPicker] (e.g. a "help translate" row above the languages). */ @Composable fun LanguagePickerRow( @@ -119,6 +121,8 @@ fun LanguagePickerRow( title: String, autoLabel: String, @XmlRes localesConfig: Int, + header: (@Composable ColumnScope.() -> Unit)? = null, + predictiveBack: Boolean = false, leading: @Composable () -> Unit, ) { val context = LocalContext.current @@ -148,6 +152,8 @@ fun LanguagePickerRow( AppLanguage.apply(context, it) }, onDismiss = { showDialog = false }, + header = header, + predictiveBack = predictiveBack, ) } }