From 6f9be10a0c0fd5fc9d9107f14711a13e07434f29 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 17 Jun 2026 23:26:39 +0200 Subject: [PATCH] UI: lists use Calendula's GroupedRow family pattern Copy Calendula's GroupedRow (connected surface segments, press-morph corners) and the circular colour chip into Floret's ui/common, and render the lists with them: full-width rows, 72dp height, 2dp connect-gap, ListColorChip (pastelised list colour + checklist glyph). Shared identity across the two apps. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../floret/ui/common/GroupedList.kt | 116 ++++++++++++++++++ .../floret/ui/common/ListChip.kt | 55 +++++++++ .../floret/ui/lists/ListsScreen.kt | 103 ++++------------ 3 files changed, 196 insertions(+), 78 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/floret/ui/common/GroupedList.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/floret/ui/common/ListChip.kt diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/common/GroupedList.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/common/GroupedList.kt new file mode 100644 index 0000000..944b9af --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/common/GroupedList.kt @@ -0,0 +1,116 @@ +package de.jeanlucmakiola.floret.ui.common + +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.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. Copied from + * Calendula so the two apps read as one family. + */ +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 +} + +/** + * One row in a grouped list: an M3 [ListItem] over a tonal [Surface] whose + * corner radii come from its [position] (so a run of rows reads as a single + * rounded card). Corners morph further on press — the expressive shape play. + */ +@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 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 = 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, + ) + } + val gap = when (position) { + Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp) + Position.Bottom, Position.Alone -> Modifier + } + 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 item: @Composable () -> Unit = { + ListItem( + headlineContent = { Text(title) }, + supportingContent = summary?.let { text -> { Text(text) } }, + leadingContent = leading, + trailingContent = trailing, + colors = itemColors, + modifier = Modifier.heightIn(min = minHeight), + ) + } + val base = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .then(gap) + val containerColor = if (selected) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + } + if (onClick != null) { + Surface( + onClick = onClick, + color = containerColor, + shape = shape, + interactionSource = interaction, + modifier = base, + ) { item() } + } else { + Surface(color = containerColor, shape = shape, modifier = base) { item() } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/common/ListChip.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/common/ListChip.kt new file mode 100644 index 0000000..d9a394c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/common/ListChip.kt @@ -0,0 +1,55 @@ +package de.jeanlucmakiola.floret.ui.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Checklist +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** + * Soften a raw provider colour toward a pastel that fits the active theme — + * keeps the hue (lists stay recognisable), caps saturation so harsh sync + * colours stop screaming, pins brightness to read on light and dark. Copied + * from Calendula so the families' colours behave identically. + */ +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)) +} + +/** + * Leading avatar for a task list: a neutral round chip holding a checklist glyph + * tinted in the list's (pastelised) colour — the Calendula calendar-chip pattern, + * with a task icon for Floret. + */ +@Composable +fun ListColorChip(color: Int, modifier: Modifier = Modifier) { + val dark = isSystemInDarkTheme() + Box( + modifier = modifier + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHighest), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Filled.Checklist, + contentDescription = null, + tint = pastelize(color, dark), + modifier = Modifier.size(22.dp), + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/lists/ListsScreen.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/lists/ListsScreen.kt index b36240d..d428d4d 100644 --- a/app/src/main/java/de/jeanlucmakiola/floret/ui/lists/ListsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/lists/ListsScreen.kt @@ -1,7 +1,5 @@ package de.jeanlucmakiola.floret.ui.lists -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -12,23 +10,18 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth 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.lazy.LazyColumn -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ListAlt import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.ErrorOutline import androidx.compose.material.icons.rounded.Today import androidx.compose.material.icons.rounded.Upcoming -import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.MaterialTheme @@ -39,19 +32,20 @@ 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.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.floret.R import de.jeanlucmakiola.floret.domain.SmartList import de.jeanlucmakiola.floret.domain.TaskFilter +import de.jeanlucmakiola.floret.ui.common.GroupedRow +import de.jeanlucmakiola.floret.ui.common.ListColorChip +import de.jeanlucmakiola.floret.ui.common.positionOf /** * Home: smart lists (Today / Overdue / Upcoming / All) as tonal cards with live @@ -105,7 +99,6 @@ private fun ListsContent( top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding() + 96.dp, ), - verticalArrangement = Arrangement.spacedBy(8.dp), ) { item { SmartGrid(state.smartCounts, onOpenFilter) } @@ -114,8 +107,24 @@ private fun ListsContent( } else { item { SectionHeader(stringResource(R.string.lists_header)) } state.groups.forEach { group -> - item(key = "acct-${group.accountName}") { - AccountGroupCard(group, onOpenFilter) + item(key = "acct-${group.accountName}") { AccountHeader(group.accountName) } + itemsIndexed(group.lists, key = { _, o -> o.list.id }) { index, overview -> + GroupedRow( + title = overview.list.name, + position = positionOf(index, group.lists.size), + minHeight = 72.dp, + leading = { ListColorChip(overview.list.color) }, + trailing = { + if (overview.openCount > 0) { + Text( + overview.openCount.toString(), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + onClick = { onOpenFilter(TaskFilter.OfList(overview.list.id)) }, + ) } } } @@ -180,74 +189,12 @@ private fun SmartCard(count: SmartCount, modifier: Modifier = Modifier, onClick: } } -/** One account's lists as a single rounded surface card (grouped-list pattern). */ -@Composable -private fun AccountGroupCard(group: AccountGroup, onOpenFilter: (TaskFilter) -> Unit) { - Column(Modifier.padding(horizontal = 16.dp)) { - AccountHeader(group.accountName) - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(28.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - ), - ) { - group.lists.forEachIndexed { index, overview -> - ListRow(overview) { onOpenFilter(TaskFilter.OfList(overview.list.id)) } - if (index < group.lists.lastIndex) { - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant, - modifier = Modifier.padding(start = 52.dp), - ) - } - } - } - } -} - -@Composable -private fun ListRow(overview: ListOverview, onClick: () -> Unit) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 20.dp, vertical = 20.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - ColorDot(overview.list.color, size = 16.dp) - Spacer(Modifier.width(16.dp)) - Text( - text = overview.list.name, - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.weight(1f), - ) - if (overview.openCount > 0) { - Text( - text = overview.openCount.toString(), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - -@Composable -private fun ColorDot(colorInt: Int, size: Dp = 12.dp) { - val color = if (colorInt != 0) Color(colorInt) else MaterialTheme.colorScheme.primary - Box( - Modifier - .size(size) - .clip(CircleShape) - .background(color), - ) -} - @Composable private fun SectionHeader(text: String) { Text( text = text, style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 4.dp), + modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 4.dp), ) } @@ -255,9 +202,9 @@ private fun SectionHeader(text: String) { private fun AccountHeader(text: String) { Text( text = text, - style = MaterialTheme.typography.labelLarge, + style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 4.dp, top = 12.dp, bottom = 6.dp), + modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp), ) }