From daf8e8e3752ef7a1b671305d4abe3165036df61d Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 17 Jun 2026 22:27:17 +0200 Subject: [PATCH] UI: Material 3 Expressive Lists overview (home) 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) --- .../de/jeanlucmakiola/floret/ui/RootScreen.kt | 176 +++---------- .../floret/ui/lists/ListsScreen.kt | 242 ++++++++++++++++++ app/src/main/res/values/strings.xml | 11 + 3 files changed, 284 insertions(+), 145 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/floret/ui/lists/ListsScreen.kt diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/RootScreen.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/RootScreen.kt index 2870cae..d87e505 100644 --- a/app/src/main/java/de/jeanlucmakiola/floret/ui/RootScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/RootScreen.kt @@ -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( - title = stringResource(R.string.onboarding_no_provider_title), - body = stringResource(R.string.onboarding_no_provider_body), - ) - ProviderStatus.NEEDS_PERMISSION -> Gate( - 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() - } - } + 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 -> 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), - ) -} 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 new file mode 100644 index 0000000..ced1339 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/lists/ListsScreen.kt @@ -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, 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, + ) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 55188ec..846a17f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5,6 +5,17 @@ Untitled task + + Lists + New task + Could not read your tasks. + No task lists yet. Add one in your tasks app or with the + button. + Today + Overdue + Upcoming + All + %1$d open + Due %1$s Task reminders