UI: lists in grouped surface cards; debug demo seeder
Some checks failed
CI / ci (push) Has been cancelled

- Per-account lists now render inside rounded surfaceContainer cards
  (grouped-list pattern) with inset dividers and transparent rows, instead
  of bare ListItems on the background — surfaces-first per design feedback.
- Add debug-only DemoSeeder (local, non-syncing 'Floret Demo' list) behind
  BuildConfig.DEBUG + an intent extra, to populate the UI for design review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Luc Makiola
2026-06-17 22:53:18 +02:00
parent daf8e8e375
commit 6e1c9bec79
4 changed files with 107 additions and 5 deletions

View File

@@ -70,6 +70,7 @@ android {
buildFeatures {
compose = true
buildConfig = true
}
packaging {

View File

@@ -12,11 +12,15 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.floret.data.demo.DemoSeeder
import de.jeanlucmakiola.floret.data.prefs.ThemeMode
import de.jeanlucmakiola.floret.ui.RootScreen
import de.jeanlucmakiola.floret.ui.settings.SettingsViewModel
import de.jeanlucmakiola.floret.ui.theme.FloretTheme
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Single activity. The theme follows [SettingsViewModel]; [RootScreen] is the
@@ -26,9 +30,17 @@ import de.jeanlucmakiola.floret.ui.theme.FloretTheme
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject lateinit var demoSeeder: DemoSeeder
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
// Debug-only sample data: `am start ... --ez floret_seed true`. Seeds a
// local (non-syncing) demo list once; no-op without the extra.
if (BuildConfig.DEBUG && intent.getBooleanExtra(EXTRA_SEED, false)) {
lifecycleScope.launch { runCatching { demoSeeder.seed() } }
}
setContent {
val settingsViewModel: SettingsViewModel = hiltViewModel()
val ui by settingsViewModel.state.collectAsStateWithLifecycle()
@@ -45,6 +57,7 @@ class MainActivity : ComponentActivity() {
companion object {
const val EXTRA_TASK_ID = "de.jeanlucmakiola.floret.extra.TASK_ID"
private const val EXTRA_SEED = "floret_seed"
/** Opens the app focused on a task (reminder taps). Routing lands with the UI. */
fun taskIntent(context: Context, taskId: Long): Intent =

View File

@@ -0,0 +1,60 @@
package de.jeanlucmakiola.floret.data.demo
import de.jeanlucmakiola.floret.data.di.IoDispatcher
import de.jeanlucmakiola.floret.data.tasks.TasksRepository
import de.jeanlucmakiola.floret.domain.DayWindow
import de.jeanlucmakiola.floret.domain.Priority
import de.jeanlucmakiola.floret.domain.TaskForm
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import java.time.ZoneId
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Clock
import kotlin.time.Instant
/**
* Debug-only one-shot sample data. Creates a **local, device-only** list
* ("Floret Demo") — so nothing syncs to a CalDAV server — and fills it with
* tasks spread across overdue / today / upcoming / no-date / completed so every
* smart list shows content. Idempotent: skips if the demo list already exists.
* Triggered from [de.jeanlucmakiola.floret.MainActivity] only in debug builds.
*/
@Singleton
class DemoSeeder @Inject constructor(
private val repository: TasksRepository,
@IoDispatcher private val io: CoroutineDispatcher,
) {
suspend fun seed(): Unit = withContext(io) {
val lists = runCatching { repository.taskLists().first() }.getOrElse { return@withContext }
if (lists.any { it.name == DEMO_LIST && it.isLocal }) return@withContext
val listId = runCatching { repository.createLocalList(DEMO_LIST, DEMO_COLOR) }
.getOrElse { return@withContext }
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
val ts = todayStart.toEpochMilliseconds()
val te = todayEnd.toEpochMilliseconds()
val hour = 3_600_000L
val day = 86_400_000L
fun at(millis: Long): Instant = Instant.fromEpochMilliseconds(millis)
repository.createTask(TaskForm(title = "Water the office plants", listId = listId, due = at(ts - hour), priority = Priority.HIGH))
repository.createTask(TaskForm(title = "Reply to client email", listId = listId, due = at(te - 2 * hour), priority = Priority.MEDIUM))
repository.createTask(TaskForm(title = "Write stand-up notes", listId = listId, due = at(te - hour)))
val invoiceId = repository.createTask(TaskForm(title = "Prepare monthly invoice", listId = listId, due = at(te + 2 * day), priority = Priority.MEDIUM))
repository.createTask(TaskForm(title = "Gather receipts", listId = listId, parentId = invoiceId))
repository.createTask(TaskForm(title = "Book train tickets", listId = listId, due = at(te + 6 * day), priority = Priority.LOW))
repository.createTask(TaskForm(title = "Read Compose 1.5 release notes", listId = listId))
repository.createTask(TaskForm(title = "Sketch the Floret app icon", listId = listId))
val done = repository.createTask(TaskForm(title = "Renew domain name", listId = listId, due = at(ts - 2 * day)))
repository.setCompleted(done, completed = true)
}
private companion object {
const val DEMO_LIST = "Floret Demo"
const val DEMO_COLOR = 0xFF7A5C6B.toInt()
}
}

View File

@@ -14,21 +14,24 @@ 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.foundation.shape.RoundedCornerShape
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.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -111,9 +114,8 @@ private fun ListsContent(
} 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)) }
item(key = "acct-${group.accountName}") {
AccountGroupCard(group, onOpenFilter)
}
}
}
@@ -178,10 +180,36 @@ 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(20.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
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 = 56.dp),
)
}
}
}
}
}
@Composable
private fun ListRow(overview: ListOverview, onClick: () -> Unit) {
ListItem(
modifier = Modifier.clickable(onClick = onClick),
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
leadingContent = { ColorDot(overview.list.color) },
headlineContent = { Text(overview.list.name) },
trailingContent = {
@@ -222,7 +250,7 @@ private fun AccountHeader(text: String) {
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),
modifier = Modifier.padding(start = 4.dp, top = 12.dp, bottom = 6.dp),
)
}