Compare commits

..

1 Commits

Author SHA1 Message Date
1bd3e03ad5 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) <noreply@anthropic.com>
2026-06-28 13:06:34 +02:00
6 changed files with 366 additions and 0 deletions

View File

@@ -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)
}

View File

@@ -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))
}

View File

@@ -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),
)
}
}

View File

@@ -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,
)
}

View File

@@ -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
},
)
}
}
}
}
}

View File

@@ -34,3 +34,4 @@ rootProject.name = "floret-kit"
include(":core-time")
include(":core-crash")
include(":identity")
include(":components")