UI: lists use Calendula's GroupedRow family pattern
All checks were successful
CI / ci (push) Successful in 5m30s

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) <noreply@anthropic.com>
This commit is contained in:
Jean-Luc Makiola
2026-06-17 23:26:39 +02:00
parent f7297a69e6
commit 6f9be10a0c
3 changed files with 196 additions and 78 deletions

View File

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

View File

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

View File

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