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.
This commit is contained in:
2026-10-15 16:50:19 +02:00
parent 762ea5fe57
commit 71212c49d2
4 changed files with 158 additions and 2 deletions
+11
View File
@@ -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
@@ -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
@@ -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(
@@ -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,
)
}
}