From 1bd3e03ad582f15f22938cec171a7786afaff9ff Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 28 Jun 2026 13:06:34 +0200 Subject: [PATCH] components: shared Compose vocabulary (GroupedSurface, InlineTextField, OptionCard, pastelize) Phase 4 (components). The family's canonical grouped-row primitive (GroupedSurface/GroupedRow + Position/positionOf, press-morphing tonal container), the borderless InlineTextField (capitalization parameterized), the OptionCard selection-dialog pick, and the pastelize() colour softener. All app-agnostic (no R, no domain) and taken from Agendula's versions as the canonical reference per design sign-off; apps keep their own identity chips and compose their own screens from these. android-library, Compose-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- components/build.gradle.kts | 40 ++++++ .../floret/components/ColorUtils.kt | 18 +++ .../floret/components/GroupedList.kt | 130 ++++++++++++++++++ .../floret/components/InlineTextField.kt | 83 +++++++++++ .../floret/components/OptionCard.kt | 94 +++++++++++++ settings.gradle.kts | 1 + 6 files changed, 366 insertions(+) create mode 100644 components/build.gradle.kts create mode 100644 components/src/main/kotlin/de/jeanlucmakiola/floret/components/ColorUtils.kt create mode 100644 components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedList.kt create mode 100644 components/src/main/kotlin/de/jeanlucmakiola/floret/components/InlineTextField.kt create mode 100644 components/src/main/kotlin/de/jeanlucmakiola/floret/components/OptionCard.kt diff --git a/components/build.gradle.kts b/components/build.gradle.kts new file mode 100644 index 0000000..e0e71cd --- /dev/null +++ b/components/build.gradle.kts @@ -0,0 +1,40 @@ +// components — the family's shared Compose component vocabulary: the grouped-row +// primitive (GroupedSurface/GroupedRow, the press-morphing tonal container), the +// borderless InlineTextField, the OptionCard selection-dialog pick, and the +// pastelize() colour softener. App-agnostic by design; each app composes its own +// screens (and keeps its own identity chips/icons) from these primitives. +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "de.jeanlucmakiola.floret.components" + compileSdk = 37 + + defaultConfig { + minSdk = 29 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + compose = true + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +dependencies { + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.foundation) + implementation(libs.androidx.material3) +} diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/ColorUtils.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/ColorUtils.kt new file mode 100644 index 0000000..aea43c8 --- /dev/null +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/ColorUtils.kt @@ -0,0 +1,18 @@ +package de.jeanlucmakiola.floret.components + +import androidx.compose.ui.graphics.Color + +/** + * Soften a raw provider colour toward a pastel that fits the active theme — + * keeps the hue (entries stay recognisable), caps saturation so harsh sync + * colours stop screaming, and pins brightness to read on light and dark. Shared + * so the family's colours behave identically. Each app wraps it in its own + * identity chips (calendar dot, task glyph, …). + */ +fun pastelize(rawArgb: Int, dark: Boolean): Color { + val hsv = FloatArray(3) + android.graphics.Color.colorToHSV(rawArgb, hsv) + hsv[1] = (hsv[1] * 0.6f).coerceIn(0.25f, 0.65f) + hsv[2] = if (dark) 0.82f else 0.72f + return Color(android.graphics.Color.HSVToColor(hsv)) +} diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedList.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedList.kt new file mode 100644 index 0000000..817bb35 --- /dev/null +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedList.kt @@ -0,0 +1,130 @@ +package de.jeanlucmakiola.floret.components + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +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.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Position of a row within a grouped list (the Android-15 settings pattern): a + * run of rows shares one rounded container, full corners at the group's outer + * edges and small corners between, separated by small gaps. The family's + * canonical grouped-row primitive. + */ +enum class Position { Top, Middle, Bottom, Alone } + +fun positionOf(index: Int, count: Int): Position = when { + count <= 1 -> Position.Alone + index == 0 -> Position.Top + index == count - 1 -> Position.Bottom + else -> Position.Middle +} + +/** 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) + Position.Top -> RoundedCornerShape(topStart = full, topEnd = full, bottomStart = small, bottomEnd = small) + Position.Middle -> RoundedCornerShape(small) + Position.Bottom -> RoundedCornerShape(topStart = small, topEnd = small, bottomStart = full, bottomEnd = full) +} + +/** + * One segment of a grouped list: a tonal [Surface] whose corner radii come from + * [position] (so a run reads as a single rounded card), with a small gap below + * all but the last. Corners morph rounder on press — the expressive shape play. + * The caller supplies [content] and any horizontal inset via [modifier], so the + * same primitive serves both the 16dp-inset lists and full-width detail cards. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GroupedSurface( + position: Position, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + color: Color = MaterialTheme.colorScheme.surfaceContainerHigh, + content: @Composable () -> Unit, +) { + val interaction = remember { MutableInteractionSource() } + val pressed by interaction.collectIsPressedAsState() + val full by animateDpAsState(if (pressed) 36.dp else 22.dp, label = "fullCorner") + val small by animateDpAsState(if (pressed) 36.dp else 6.dp, label = "smallCorner") + val shape = groupedShape(position, full, small) + val gap = when (position) { + Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp) + Position.Bottom, Position.Alone -> 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() } + } +} + +/** + * One row in a grouped list: an M3 [ListItem] over a [GroupedSurface] (the + * tonal, position-shaped, press-morphing family container). Insets 16dp like + * the lists overview. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GroupedRow( + title: String, + position: Position, + modifier: Modifier = Modifier, + summary: String? = null, + selected: Boolean = false, + minHeight: Dp = 72.dp, + leading: @Composable (() -> Unit)? = null, + trailing: @Composable (() -> Unit)? = null, + onClick: (() -> Unit)? = null, +) { + val itemColors = if (selected) { + ListItemDefaults.colors( + containerColor = Color.Transparent, + headlineColor = MaterialTheme.colorScheme.onSecondaryContainer, + leadingIconColor = MaterialTheme.colorScheme.onSecondaryContainer, + supportingColor = MaterialTheme.colorScheme.onSecondaryContainer, + trailingIconColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } else { + ListItemDefaults.colors(containerColor = Color.Transparent) + } + val containerColor = if (selected) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + } + GroupedSurface( + position = position, + modifier = modifier.padding(horizontal = 16.dp), + onClick = onClick, + color = containerColor, + ) { + ListItem( + headlineContent = { Text(title) }, + supportingContent = summary?.let { text -> { Text(text) } }, + leadingContent = leading, + trailingContent = trailing, + colors = itemColors, + modifier = Modifier.heightIn(min = minHeight), + ) + } +} diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/InlineTextField.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/InlineTextField.kt new file mode 100644 index 0000000..538d1c5 --- /dev/null +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/InlineTextField.kt @@ -0,0 +1,83 @@ +package de.jeanlucmakiola.floret.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp + +/** + * The family's borderless text input: no underline, no outline, just the tonal + * card behind it. Sit it inside a tonal [androidx.compose.material3.Surface] so + * anything that takes text reads as one family rather than a boxed Material + * field. [capitalization] is a parameter so each app picks its convention + * (sentence-case by default). + */ +@Composable +fun InlineTextField( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + modifier: Modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + textStyle: TextStyle = MaterialTheme.typography.titleMedium, + singleLine: Boolean = true, + minLines: Int = 1, + keyboardType: KeyboardType = KeyboardType.Text, + capitalization: KeyboardCapitalization = KeyboardCapitalization.Sentences, + imeAction: ImeAction = ImeAction.Default, + /** Invoked when the IME action key (e.g. Done) is pressed. */ + onImeAction: (() -> Unit)? = null, +) { + val resolvedStyle = textStyle.copy( + color = if (textStyle.color.isSpecified) { + textStyle.color + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + BasicTextField( + value = value, + onValueChange = onValueChange, + textStyle = resolvedStyle, + singleLine = singleLine, + minLines = minLines, + keyboardOptions = KeyboardOptions( + keyboardType = keyboardType, + capitalization = capitalization, + imeAction = imeAction, + ), + keyboardActions = onImeAction?.let { action -> + KeyboardActions(onAny = { action() }) + } ?: KeyboardActions.Default, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + decorationBox = { innerTextField -> + Box { + if (value.isEmpty()) { + // Clearly fainter than typed text, so a hint never reads as + // prefilled content. + Text( + text = placeholder, + style = resolvedStyle, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + } + innerTextField() + } + }, + modifier = modifier, + ) +} diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/OptionCard.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/OptionCard.kt new file mode 100644 index 0000000..6aa0de3 --- /dev/null +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/OptionCard.kt @@ -0,0 +1,94 @@ +package de.jeanlucmakiola.floret.components + +import androidx.compose.foundation.layout.Column +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.material3.Text +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.isSpecified +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp + +/** + * The family's standard pick in a selection dialog: a full-width tonal card, + * optionally with a leading icon and a supporting line; the selected option is + * highlighted. Stack with 8dp gaps inside an AlertDialog — the only sanctioned + * selection-modal style (no radio rows, no bare text lists). + */ +@Composable +fun OptionCard( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + icon: ImageVector? = null, + /** Icon tint override, e.g. a list colour; unspecified follows selection. */ + iconTint: Color = Color.Unspecified, + supportingText: String? = null, + selected: Boolean = false, + /** Label colour override, e.g. primary for an emphasised entry. */ + labelColor: Color = Color.Unspecified, +) { + val contentColor = if (selected) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onSurface + } + Surface( + onClick = onClick, + color = if (selected) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainerHighest + }, + shape = RoundedCornerShape(12.dp), + modifier = modifier.fillMaxWidth(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + tint = when { + iconTint.isSpecified -> iconTint + selected -> MaterialTheme.colorScheme.onSecondaryContainer + else -> MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.width(12.dp)) + } + Column { + Text( + text = label, + style = MaterialTheme.typography.titleMedium, + color = if (labelColor.isSpecified) labelColor else contentColor, + ) + if (supportingText != null) { + Text( + text = supportingText, + style = MaterialTheme.typography.bodySmall, + color = if (selected) { + MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f) + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index f3a32aa..c1fad67 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -34,3 +34,4 @@ rootProject.name = "floret-kit" include(":core-time") include(":core-crash") include(":identity") +include(":components")