UI: Material 3 Expressive Lists overview (home)
All checks were successful
CI / ci (push) Successful in 5m22s

First real screen replacing the scaffold: LargeTopAppBar, smart-list cards
in a 2x2 grid with correct tonal pairings (Today=primary, Overdue=error,
Upcoming=tertiary, All=secondary containers), user lists grouped by account
with colour dots + open counts, and an Extended FAB. Consumes the live
ListsViewModel; navigation callbacks stubbed for the next screens.
Verified on device against real tasks.org/DAVx5 data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Luc Makiola
2026-06-17 22:27:17 +02:00
parent 529a596d17
commit daf8e8e375
3 changed files with 284 additions and 145 deletions

View File

@@ -5,51 +5,28 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextDecoration
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.data.tasks.ProviderStatus
import de.jeanlucmakiola.floret.domain.SmartList
import de.jeanlucmakiola.floret.domain.Task
import de.jeanlucmakiola.floret.domain.TaskFilter
import de.jeanlucmakiola.floret.ui.lists.ListsUiState
import de.jeanlucmakiola.floret.ui.lists.ListsViewModel
import de.jeanlucmakiola.floret.ui.lists.ListsScreen
import de.jeanlucmakiola.floret.ui.permission.PermissionViewModel
import de.jeanlucmakiola.floret.ui.tasklist.TaskListUiState
import de.jeanlucmakiola.floret.ui.tasklist.TaskListViewModel
/**
* ⚠️ Functional scaffold, not final UI. It proves the data layer end to end
* (provider gating → live lists/tasks → toggle/add) and is the canvas to replace
* screen-by-screen with the real Material 3 Expressive design (see docs/PLAN.md
* §4): ListsScreen, TaskListScreen, TaskDetail/Edit, Settings. The ViewModels it
* uses are the finished, render-only state holders those screens will consume.
* App root: gates on the tasks-provider permission, then shows the home
* ([ListsScreen]). Navigation to the task-list / edit screens is wired as those
* screens land (next milestones); the callbacks are stubs for now.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RootScreen(
modifier: Modifier = Modifier,
@@ -60,32 +37,38 @@ fun RootScreen(
ActivityResultContracts.RequestMultiplePermissions(),
) { permissionViewModel.refresh() }
Scaffold(
modifier = modifier,
topBar = { TopAppBar(title = { Text(stringResource(R.string.app_name)) }) },
) { inner ->
Column(Modifier.padding(inner).fillMaxSize()) {
when (permission.status) {
ProviderStatus.NO_PROVIDER -> Gate(
modifier = modifier,
title = stringResource(R.string.onboarding_no_provider_title),
body = stringResource(R.string.onboarding_no_provider_body),
)
ProviderStatus.NEEDS_PERMISSION -> Gate(
modifier = modifier,
title = stringResource(R.string.onboarding_permission_title),
body = stringResource(R.string.onboarding_permission_body),
action = stringResource(R.string.onboarding_permission_button),
onAction = { launcher.launch(permission.permissionsToRequest.toTypedArray()) },
)
ProviderStatus.READY -> TasksContent()
}
}
ProviderStatus.READY -> ListsScreen(
modifier = modifier,
onOpenFilter = { /* TODO: navigate to TaskListScreen */ },
onNewTask = { /* TODO: navigate to TaskEditScreen */ },
)
}
}
@Composable
private fun Gate(title: String, body: String, action: String? = null, onAction: () -> Unit = {}) {
private fun Gate(
title: String,
body: String,
modifier: Modifier = Modifier,
action: String? = null,
onAction: () -> Unit = {},
) {
Column(
Modifier.fillMaxSize().padding(24.dp),
modifier = modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically),
) {
Text(title, style = MaterialTheme.typography.headlineSmall)
@@ -93,100 +76,3 @@ private fun Gate(title: String, body: String, action: String? = null, onAction:
if (action != null) Button(onClick = onAction) { Text(action) }
}
}
@Composable
private fun TasksContent(
listsViewModel: ListsViewModel = hiltViewModel(),
taskListViewModel: TaskListViewModel = hiltViewModel(),
) {
androidx.compose.runtime.LaunchedEffect(Unit) {
taskListViewModel.bind(TaskFilter.Smart(SmartList.ALL))
}
val lists by listsViewModel.state.collectAsStateWithLifecycle()
val tasks by taskListViewModel.state.collectAsStateWithLifecycle()
val firstListId = (lists as? ListsUiState.Content)
?.groups?.firstOrNull()?.lists?.firstOrNull()?.list?.id
var newTitle by remember { mutableStateOf("") }
LazyColumn(Modifier.fillMaxSize()) {
item { SectionHeader("Lists") }
when (val l = lists) {
ListsUiState.Loading -> item { ListItem(headlineContent = { Text("Loading…") }) }
ListsUiState.Failure -> item { ListItem(headlineContent = { Text("Could not read tasks.") }) }
is ListsUiState.Content -> {
items(l.smartCounts) { sc ->
ListItem(
headlineContent = { Text(sc.smart.name) },
trailingContent = { Text(sc.count.toString()) },
)
}
l.groups.forEach { group ->
item { SectionHeader(group.accountName) }
items(group.lists) { overview ->
ListItem(
headlineContent = { Text(overview.list.name) },
trailingContent = { Text(overview.openCount.toString()) },
)
}
}
}
}
item { HorizontalDivider() }
item { SectionHeader("All open tasks") }
when (val t = tasks) {
TaskListUiState.Loading -> item { ListItem(headlineContent = { Text("Loading…") }) }
TaskListUiState.Failure -> item { ListItem(headlineContent = { Text("Could not read tasks.") }) }
is TaskListUiState.Content -> items(t.tasks) { task ->
TaskRow(task = task, onToggle = { taskListViewModel.toggleComplete(task) })
}
}
if (firstListId != null) {
item {
OutlinedTextField(
value = newTitle,
onValueChange = { newTitle = it },
label = { Text("New task") },
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(16.dp),
)
}
item {
Button(
onClick = {
taskListViewModel.quickAdd(newTitle, firstListId)
newTitle = ""
},
modifier = Modifier.padding(horizontal = 16.dp),
) { Text("Add") }
}
}
}
}
@Composable
private fun TaskRow(task: Task, onToggle: () -> Unit) {
ListItem(
leadingContent = { Checkbox(checked = task.isCompleted, onCheckedChange = { onToggle() }) },
headlineContent = {
Text(
task.title.ifBlank { stringResource(R.string.task_untitled) },
textDecoration = if (task.isCompleted) TextDecoration.LineThrough else null,
)
},
supportingContent = task.listName?.let { { Text(it) } },
)
}
@Composable
private fun SectionHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 4.dp),
)
}

View File

@@ -0,0 +1,242 @@
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
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
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.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
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.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
/**
* Home: smart lists (Today / Overdue / Upcoming / All) as tonal cards with live
* counts, then the user's lists grouped by account. Tapping a card or row opens
* that task list; the FAB starts a new task.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ListsScreen(
onOpenFilter: (TaskFilter) -> Unit,
onNewTask: () -> Unit,
modifier: Modifier = Modifier,
viewModel: ListsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
LargeTopAppBar(
title = { Text(stringResource(R.string.app_name)) },
scrollBehavior = scrollBehavior,
)
},
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = onNewTask,
icon = { Icon(Icons.Rounded.Add, contentDescription = null) },
text = { Text(stringResource(R.string.new_task)) },
)
},
) { inner ->
when (val s = state) {
ListsUiState.Loading -> Unit // brief; avoids a flash before first emission
ListsUiState.Failure -> CenteredMessage(stringResource(R.string.lists_failure), inner)
is ListsUiState.Content -> ListsContent(s, inner, onOpenFilter)
}
}
}
@Composable
private fun ListsContent(
state: ListsUiState.Content,
inner: PaddingValues,
onOpenFilter: (TaskFilter) -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
top = inner.calculateTopPadding(),
bottom = inner.calculateBottomPadding() + 96.dp,
),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item { SmartGrid(state.smartCounts, onOpenFilter) }
if (state.groups.isEmpty()) {
item { CenteredMessage(stringResource(R.string.lists_empty), PaddingValues(top = 24.dp)) }
} else {
item { SectionHeader(stringResource(R.string.lists_header)) }
state.groups.forEach { group ->
item(key = "acct-${group.accountName}") { AccountHeader(group.accountName) }
items(group.lists, key = { it.list.id }) { overview ->
ListRow(overview) { onOpenFilter(TaskFilter.OfList(overview.list.id)) }
}
}
}
}
}
@Composable
private fun SmartGrid(counts: List<SmartCount>, onOpenFilter: (TaskFilter) -> Unit) {
Column(
modifier = Modifier.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
counts.chunked(2).forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
row.forEach { smart ->
SmartCard(
count = smart,
modifier = Modifier.weight(1f),
onClick = { onOpenFilter(TaskFilter.Smart(smart.smart)) },
)
}
if (row.size == 1) Spacer(Modifier.weight(1f))
}
}
}
}
private data class SmartStyle(val icon: ImageVector, val labelRes: Int, val container: Color, val onContainer: Color)
@Composable
private fun smartStyle(smart: SmartList): SmartStyle {
val colors = MaterialTheme.colorScheme
return when (smart) {
SmartList.TODAY -> SmartStyle(Icons.Rounded.Today, R.string.smart_today, colors.primaryContainer, colors.onPrimaryContainer)
SmartList.OVERDUE -> SmartStyle(Icons.Rounded.ErrorOutline, R.string.smart_overdue, colors.errorContainer, colors.onErrorContainer)
SmartList.UPCOMING -> SmartStyle(Icons.Rounded.Upcoming, R.string.smart_upcoming, colors.tertiaryContainer, colors.onTertiaryContainer)
else -> SmartStyle(Icons.AutoMirrored.Rounded.ListAlt, R.string.smart_all, colors.secondaryContainer, colors.onSecondaryContainer)
}
}
@Composable
private fun SmartCard(count: SmartCount, modifier: Modifier = Modifier, onClick: () -> Unit) {
val style = smartStyle(count.smart)
ElevatedCard(
onClick = onClick,
modifier = modifier.height(116.dp),
colors = CardDefaults.elevatedCardColors(
containerColor = style.container,
contentColor = style.onContainer,
),
) {
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.SpaceBetween,
) {
Icon(style.icon, contentDescription = null)
Column {
Text(count.count.toString(), style = MaterialTheme.typography.headlineMedium)
Text(stringResource(style.labelRes), style = MaterialTheme.typography.labelLarge)
}
}
}
}
@Composable
private fun ListRow(overview: ListOverview, onClick: () -> Unit) {
ListItem(
modifier = Modifier.clickable(onClick = onClick),
leadingContent = { ColorDot(overview.list.color) },
headlineContent = { Text(overview.list.name) },
trailingContent = {
if (overview.openCount > 0) {
Text(
overview.openCount.toString(),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
)
}
@Composable
private fun ColorDot(colorInt: Int) {
val color = if (colorInt != 0) Color(colorInt) else MaterialTheme.colorScheme.primary
Box(
Modifier
.size(12.dp)
.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),
)
}
@Composable
private fun AccountHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp),
)
}
@Composable
private fun CenteredMessage(text: String, inner: PaddingValues) {
Box(
modifier = Modifier.fillMaxSize().padding(inner).padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}

View File

@@ -5,6 +5,17 @@
<!-- Generic -->
<string name="task_untitled">Untitled task</string>
<!-- Lists overview -->
<string name="lists_header">Lists</string>
<string name="new_task">New task</string>
<string name="lists_failure">Could not read your tasks.</string>
<string name="lists_empty">No task lists yet. Add one in your tasks app or with the + button.</string>
<string name="smart_today">Today</string>
<string name="smart_overdue">Overdue</string>
<string name="smart_upcoming">Upcoming</string>
<string name="smart_all">All</string>
<string name="open_count">%1$d open</string>
<!-- Reminders -->
<string name="reminder_due_at">Due %1$s</string>
<string name="reminder_channel_name">Task reminders</string>