From 5f2c1c02b29604ce8de9f9fae04eecdd873bb97c Mon Sep 17 00:00:00 2001 From: Avinal Kumar Date: Tue, 19 May 2026 17:11:42 +0530 Subject: [PATCH] Add tasks pivot and Metro settings screen Tasks screen: - Group by: Due Date, List/Topic, Priority, Source Memo, Status - Sort by: Due Date, Priority (within groups) - Collapsible groups with count badges - Accent-colored checkboxes with content-based task identification - Task detail bottom sheet: edit due date, priority, open source memo - Completed tasks in collapsed group at bottom Settings screen: - Metro-style large section headers (24sp Light) - Accent color picker: grid of 20 WP8 color circles - Theme toggle: dark / light / amoled - Account info display, sign-out with confirmation Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Avinal Kumar --- .../memos/ui/settings/SettingsScreen.kt | 159 +++++++++++ .../memos/ui/settings/SettingsViewModel.kt | 50 ++++ .../avinal/memos/ui/tasks/TaskDetailSheet.kt | 125 ++++++++ .../avinal/memos/ui/tasks/TaskListScreen.kt | 269 ++++++++++++++++++ .../memos/ui/tasks/TaskListViewModel.kt | 232 +++++++++++++++ 5 files changed, 835 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/com/avinal/memos/ui/settings/SettingsScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/com/avinal/memos/ui/settings/SettingsViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/com/avinal/memos/ui/tasks/TaskDetailSheet.kt create mode 100644 composeApp/src/commonMain/kotlin/com/avinal/memos/ui/tasks/TaskListScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/com/avinal/memos/ui/tasks/TaskListViewModel.kt diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..37dabc5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/settings/SettingsScreen.kt @@ -0,0 +1,159 @@ +package com.avinal.memos.ui.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +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.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.avinal.memos.AppDependencies +import com.avinal.memos.ui.theme.LocalAccentColor +import com.avinal.memos.ui.theme.MetroTheme +import com.avinal.memos.ui.theme.WpAccentColors + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun SettingsScreen( + deps: AppDependencies, + onLogout: () -> Unit, +) { + val viewModel = viewModel { SettingsViewModel(deps.authRepository, deps.tokenStore, deps.memoRepository) } + val serverUrl by viewModel.serverUrl.collectAsState() + val currentUser by viewModel.currentUser.collectAsState() + val currentTheme by viewModel.currentTheme.collectAsState() + val currentAccent by viewModel.currentAccent.collectAsState() + val accent = LocalAccentColor.current + var showLogoutDialog by remember { mutableStateOf(false) } + + if (showLogoutDialog) { + AlertDialog( + onDismissRequest = { showLogoutDialog = false }, + title = { Text("sign out?") }, + confirmButton = { + TextButton(onClick = { showLogoutDialog = false; viewModel.logout(); onLogout() }) { + Text("sign out", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { showLogoutDialog = false }) { Text("cancel") } + }, + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(start = 24.dp, end = 24.dp, top = 6.dp, bottom = 24.dp), + ) { + SectionHeader("account") + SettingsItem("server", serverUrl ?: "not connected") + currentUser?.let { user -> + SettingsItem("username", user.username) + if (user.nickname.isNotEmpty()) SettingsItem("name", user.nickname) + } + + Spacer(Modifier.height(24.dp)) + SectionHeader("accent color") + Spacer(Modifier.height(8.dp)) + + FlowRow( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + WpAccentColors.forEach { ac -> + val isSelected = ac.name == currentAccent + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(ac.color) + .then( + if (isSelected) Modifier.border(3.dp, MaterialTheme.colorScheme.onBackground, CircleShape) + else Modifier + ) + .clickable { viewModel.setAccentColor(ac.name) }, + ) + } + } + + Spacer(Modifier.height(24.dp)) + SectionHeader("theme") + Spacer(Modifier.height(8.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + MetroTheme.entries.forEach { theme -> + val isSelected = currentTheme == theme.label + Text( + text = theme.label, + fontSize = 15.sp, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, + color = if (isSelected) accent else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.clickable { viewModel.setTheme(theme) }, + ) + } + } + + Spacer(Modifier.height(36.dp)) + SectionHeader("about") + SettingsItem("version", "1.0.0") + + Spacer(Modifier.height(36.dp)) + + Text( + "sign out", + fontSize = 15.sp, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.clickable { showLogoutDialog = true }, + ) + } +} + +@Composable +private fun SectionHeader(text: String) { + Text( + text = text, + fontSize = 24.sp, + fontWeight = FontWeight.Light, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(bottom = 6.dp), + ) +} + +@Composable +private fun SettingsItem(label: String, value: String) { + Column(modifier = Modifier.padding(vertical = 6.dp)) { + Text(label, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, fontSize = 15.sp, color = MaterialTheme.colorScheme.onBackground) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/settings/SettingsViewModel.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/settings/SettingsViewModel.kt new file mode 100644 index 0000000..b7f3601 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/settings/SettingsViewModel.kt @@ -0,0 +1,50 @@ +package com.avinal.memos.ui.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.avinal.memos.domain.AuthRepository +import com.avinal.memos.domain.MemoRepository +import com.avinal.memos.domain.User +import com.avinal.memos.ui.theme.MetroTheme +import com.avinal.memos.util.TokenStore +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class SettingsViewModel( + private val authRepository: AuthRepository, + private val tokenStore: TokenStore, + private val memoRepository: MemoRepository, +) : ViewModel() { + + val serverUrl: StateFlow = tokenStore.serverUrl + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + + val currentUser: StateFlow = authRepository.currentUser + + val currentTheme: StateFlow = tokenStore.theme + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "DARK") + + val currentAccent: StateFlow = tokenStore.accentColor + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "Cobalt") + + init { + viewModelScope.launch { authRepository.validateToken() } + } + + fun setTheme(theme: MetroTheme) { + viewModelScope.launch { tokenStore.saveTheme(theme.name) } + } + + fun setAccentColor(name: String) { + viewModelScope.launch { tokenStore.saveAccentColor(name) } + } + + fun logout() { + viewModelScope.launch { + memoRepository.clearCache() + authRepository.logout() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/tasks/TaskDetailSheet.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/tasks/TaskDetailSheet.kt new file mode 100644 index 0000000..3844bc6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/tasks/TaskDetailSheet.kt @@ -0,0 +1,125 @@ +package com.avinal.memos.ui.tasks + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.avinal.memos.domain.Task +import com.avinal.memos.parser.TaskParser +import com.avinal.memos.ui.theme.DuePurple +import com.avinal.memos.ui.theme.PriorityP1 +import com.avinal.memos.ui.theme.PriorityP2 +import com.avinal.memos.ui.theme.PriorityP3 +import kotlin.time.Clock +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.plus +import kotlinx.datetime.todayIn + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun TaskDetailSheet( + task: Task, + onDismiss: () -> Unit, + onUpdate: (Task, String) -> Unit, + onOpenMemo: (String) -> Unit, +) { + val sheetState = rememberModalBottomSheetState() + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Text( + text = task.text, + style = MaterialTheme.typography.titleMedium, + ) + + Spacer(Modifier.height(16.dp)) + + Text("Due Date", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(4.dp)) + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + val today = Clock.System.todayIn(TimeZone.currentSystemDefault()) + val dateOptions = listOf( + "Today" to today, + "Tomorrow" to today.plus(1, DateTimeUnit.DAY), + "Next week" to today.plus(7, DateTimeUnit.DAY), + "No date" to null, + ) + dateOptions.forEach { (label, date) -> + FilterChip( + selected = task.dueDate == date, + onClick = { + val updated = task.copy(dueDate = date) + onUpdate(task, TaskParser.reconstructLine(updated)) + }, + label = { Text(label) }, + ) + } + } + + Spacer(Modifier.height(12.dp)) + + Text("Priority", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(4.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + val priorityOptions = listOf(null to "None", 1 to "P1", 2 to "P2", 3 to "P3") + priorityOptions.forEach { (p, label) -> + FilterChip( + selected = task.priority == p, + onClick = { + val updated = task.copy(priority = p) + onUpdate(task, TaskParser.reconstructLine(updated)) + }, + label = { Text(label) }, + ) + } + } + + if (task.labels.isNotEmpty() || task.lists.isNotEmpty()) { + Spacer(Modifier.height(12.dp)) + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + task.labels.forEach { label -> + Text("@$label", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + task.lists.forEach { list -> + Text("#$list", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + + Spacer(Modifier.height(16.dp)) + HorizontalDivider() + Spacer(Modifier.height(8.dp)) + + TextButton(onClick = { onOpenMemo(task.memoId) }) { + Text("Open in memo") + } + + Spacer(Modifier.height(16.dp)) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/tasks/TaskListScreen.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/tasks/TaskListScreen.kt new file mode 100644 index 0000000..3b3e6f6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/tasks/TaskListScreen.kt @@ -0,0 +1,269 @@ +package com.avinal.memos.ui.tasks + +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.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.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +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.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.avinal.memos.AppDependencies +import com.avinal.memos.domain.Task +import com.avinal.memos.ui.theme.LocalAccentColor +import com.avinal.memos.ui.theme.OverdueRed +import com.avinal.memos.ui.theme.PriorityP1 +import com.avinal.memos.ui.theme.PriorityP2 +import com.avinal.memos.ui.theme.PriorityP3 +import kotlin.time.Clock +import kotlinx.datetime.TimeZone +import kotlinx.datetime.todayIn +import kotlinx.datetime.daysUntil + +@Composable +fun TaskListScreen( + deps: AppDependencies, + onMemoClick: (String) -> Unit, +) { + val viewModel = viewModel { TaskListViewModel(deps.memoRepository) } + val grouped by viewModel.groupedTasks.collectAsState() + val filters by viewModel.filterState.collectAsState() + val accent = LocalAccentColor.current + val textColor = MaterialTheme.colorScheme.onBackground + val subtleColor = MaterialTheme.colorScheme.onSurfaceVariant + var selectedTask by remember { mutableStateOf(null) } + + selectedTask?.let { task -> + TaskDetailSheet( + task = task, + onDismiss = { selectedTask = null }, + onUpdate = { original, newLine -> viewModel.updateTaskInMemo(original, newLine); selectedTask = null }, + onOpenMemo = { memoId -> selectedTask = null; onMemoClick(memoId) }, + ) + } + + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, end = 12.dp, top = 6.dp, bottom = 2.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + MetroDropdown("group: ${filters.groupBy.label}", GroupBy.entries.toList(), filters.groupBy, { it.label }, accent, subtleColor) { + viewModel.setGroupBy(it) + } + MetroDropdown("sort: ${filters.sortBy.label}", SortBy.entries.toList(), filters.sortBy, { it.label }, accent, subtleColor) { + viewModel.setSortBy(it) + } + } + + Spacer( + Modifier.fillMaxWidth().height(1.dp).padding(start = 24.dp) + .background(subtleColor.copy(alpha = 0.15f)) + ) + + LazyColumn(modifier = Modifier.weight(1f)) { + grouped.groups.forEachIndexed { groupIndex, group -> + item(key = "header_${group.title}") { + if (groupIndex > 0) { + Spacer(Modifier.height(6.dp)) + Spacer( + Modifier.fillMaxWidth().height(1.dp).padding(start = 24.dp) + .background(subtleColor.copy(alpha = 0.15f)) + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { viewModel.toggleGroupCollapse(group.title) } + .padding(start = 24.dp, end = 12.dp, top = 14.dp, bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + group.title.lowercase(), + fontSize = 19.sp, + fontWeight = FontWeight.Light, + color = textColor, + modifier = Modifier.weight(1f), + ) + Text( + "${group.tasks.size}", + fontSize = 13.sp, + color = subtleColor, + ) + Spacer(Modifier.width(6.dp)) + Icon( + if (group.collapsed) Icons.Default.KeyboardArrowDown else Icons.Default.KeyboardArrowUp, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = subtleColor, + ) + } + } + + if (!group.collapsed) { + items(group.tasks, key = { it.id }) { task -> + MetroTaskRow( + task = task, + accent = accent, + textColor = textColor, + subtleColor = subtleColor, + onToggle = { viewModel.toggleTask(task) }, + onClick = { selectedTask = task }, + ) + } + } + } + + if (grouped.groups.isEmpty() || grouped.groups.all { it.tasks.isEmpty() }) { + item { + Box(Modifier.fillMaxWidth().padding(top = 48.dp), contentAlignment = Alignment.Center) { + Text("no tasks", fontSize = 15.sp, color = subtleColor) + } + } + } + } + } +} + +@Composable +private fun MetroDropdown( + label: String, + options: List, + selected: T, + display: (T) -> String, + accent: Color, + subtleColor: Color, + onSelect: (T) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + Box { + Text( + label.lowercase(), + fontSize = 12.sp, + color = subtleColor, + modifier = Modifier.clickable { expanded = true }, + ) + androidx.compose.material3.DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + options.forEach { option -> + Text( + display(option).lowercase(), + fontSize = 14.sp, + color = if (option == selected) accent else MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .fillMaxWidth() + .clickable { onSelect(option); expanded = false } + .padding(horizontal = 16.dp, vertical = 10.dp), + ) + } + } + } +} + +@Composable +private fun MetroTaskRow( + task: Task, + accent: Color, + textColor: Color, + subtleColor: Color, + onToggle: () -> Unit, + onClick: () -> Unit, +) { + val today = Clock.System.todayIn(TimeZone.currentSystemDefault()) + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(start = 24.dp, end = 12.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = task.isCompleted, + onCheckedChange = { onToggle() }, + modifier = Modifier.size(20.dp), + colors = CheckboxDefaults.colors( + checkedColor = accent, + uncheckedColor = subtleColor, + checkmarkColor = Color.White, + ), + ) + + Spacer(Modifier.width(10.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + task.text, + fontSize = 15.sp, + textDecoration = if (task.isCompleted) TextDecoration.LineThrough else TextDecoration.None, + color = if (task.isCompleted) subtleColor else textColor, + ) + + if (!task.isCompleted) { + val metadata = buildList { + task.dueDate?.let { date -> + val isOverdue = date < today + add(formatRelativeDate(date, today) to if (isOverdue) OverdueRed else accent) + } + task.priority?.let { p -> + val color = when (p) { 1 -> PriorityP1; 2 -> PriorityP2; else -> PriorityP3 } + add("p$p" to color) + } + task.lists.forEach { add("#$it" to accent) } + task.labels.forEach { add("@$it" to subtleColor) } + } + + if (metadata.isNotEmpty()) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + metadata.forEach { (text, color) -> + Text(text, fontSize = 12.sp, color = color) + } + } + } + } + } + } +} + +private fun formatRelativeDate(date: kotlinx.datetime.LocalDate, today: kotlinx.datetime.LocalDate): String { + val days = today.daysUntil(date) + return when { + days < -1 -> "${-days}d overdue" + days == -1 -> "yesterday" + days == 0 -> "today" + days == 1 -> "tomorrow" + days < 7 -> "in ${days}d" + else -> date.toString() + } +} diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/tasks/TaskListViewModel.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/tasks/TaskListViewModel.kt new file mode 100644 index 0000000..1349a4c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/tasks/TaskListViewModel.kt @@ -0,0 +1,232 @@ +package com.avinal.memos.ui.tasks + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.avinal.memos.domain.Memo +import com.avinal.memos.domain.MemoRepository +import com.avinal.memos.domain.Task +import com.avinal.memos.parser.TaskParser +import kotlin.time.Clock +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.todayIn + +enum class GroupBy(val label: String) { + DUE("Due Date"), + LIST("List / Topic"), + PRIORITY("Priority"), + MEMO("Source Memo"), + STATUS("Status"), +} + +enum class SortBy(val label: String) { + DUE("Due Date"), + PRIORITY("Priority"), +} + +data class TaskFilterState( + val groupBy: GroupBy = GroupBy.DUE, + val sortBy: SortBy = SortBy.DUE, + val quickAddText: String = "", +) + +data class TaskGroup( + val title: String, + val tasks: List, + val collapsed: Boolean = false, +) + +data class GroupedTasksResult( + val groups: List = emptyList(), + val availableLists: List = emptyList(), +) + +class TaskListViewModel(private val memoRepository: MemoRepository) : ViewModel() { + + private val _filterState = MutableStateFlow(TaskFilterState()) + val filterState: StateFlow = _filterState.asStateFlow() + + private val _collapsedGroups = MutableStateFlow>(emptySet()) + + val groupedTasks: StateFlow = combine( + memoRepository.observeMemos(), + _filterState, + _collapsedGroups, + ) { memos, filters, collapsed -> + buildGroups(memos, filters, collapsed) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), GroupedTasksResult()) + + private fun buildGroups(memos: List, filters: TaskFilterState, collapsed: Set): GroupedTasksResult { + val allTasks = memos.flatMap { memo -> TaskParser.extractTasks(memo.id, memo.content) } + val availableLists = allTasks.flatMap { it.lists }.distinct().sorted() + val today = Clock.System.todayIn(TimeZone.currentSystemDefault()) + + val sorter = when (filters.sortBy) { + SortBy.DUE -> compareBy { it.dueDate ?: LocalDate(9999, 12, 31) }.thenBy { it.priority ?: 9 } + SortBy.PRIORITY -> compareBy { it.priority ?: 9 }.thenBy { it.dueDate ?: LocalDate(9999, 12, 31) } + } + + val groups = when (filters.groupBy) { + GroupBy.DUE -> { + val overdue = mutableListOf() + val todayTasks = mutableListOf() + val upcoming = mutableListOf() + val noDate = mutableListOf() + val completed = mutableListOf() + + allTasks.forEach { task -> + if (task.isCompleted) { completed.add(task); return@forEach } + when { + task.dueDate == null -> noDate.add(task) + task.dueDate < today -> overdue.add(task) + task.dueDate == today -> todayTasks.add(task) + else -> upcoming.add(task) + } + } + + buildList { + if (overdue.isNotEmpty()) add(TaskGroup("Overdue", overdue.sortedWith(sorter))) + if (todayTasks.isNotEmpty()) add(TaskGroup("Today", todayTasks.sortedWith(sorter))) + if (upcoming.isNotEmpty()) add(TaskGroup("Upcoming", upcoming.sortedWith(sorter))) + if (noDate.isNotEmpty()) add(TaskGroup("No Date", noDate.sortedWith(sorter))) + if (completed.isNotEmpty()) add(TaskGroup("Completed", completed.sortedWith(sorter), collapsed = "Completed" in collapsed)) + } + } + + GroupBy.LIST -> { + val byList = mutableMapOf>() + val completed = mutableListOf() + + allTasks.forEach { task -> + if (task.isCompleted) { completed.add(task); return@forEach } + val listName = task.lists.firstOrNull() ?: "Untagged" + byList.getOrPut(listName) { mutableListOf() }.add(task) + } + + buildList { + byList.entries.sortedBy { it.key }.forEach { (name, tasks) -> + add(TaskGroup("#$name", tasks.sortedWith(sorter))) + } + if (completed.isNotEmpty()) add(TaskGroup("Completed", completed.sortedWith(sorter), collapsed = "Completed" in collapsed)) + } + } + + GroupBy.PRIORITY -> { + val p1 = mutableListOf() + val p2 = mutableListOf() + val p3 = mutableListOf() + val noPriority = mutableListOf() + val completed = mutableListOf() + + allTasks.forEach { task -> + if (task.isCompleted) { completed.add(task); return@forEach } + when (task.priority) { + 1 -> p1.add(task) + 2 -> p2.add(task) + 3 -> p3.add(task) + else -> noPriority.add(task) + } + } + + buildList { + if (p1.isNotEmpty()) add(TaskGroup("P1 — High", p1.sortedWith(sorter))) + if (p2.isNotEmpty()) add(TaskGroup("P2 — Medium", p2.sortedWith(sorter))) + if (p3.isNotEmpty()) add(TaskGroup("P3 — Low", p3.sortedWith(sorter))) + if (noPriority.isNotEmpty()) add(TaskGroup("No Priority", noPriority.sortedWith(sorter))) + if (completed.isNotEmpty()) add(TaskGroup("Completed", completed.sortedWith(sorter), collapsed = "Completed" in collapsed)) + } + } + + GroupBy.MEMO -> { + val byMemo = mutableMapOf>() + val completed = mutableListOf() + + allTasks.forEach { task -> + if (task.isCompleted) { completed.add(task); return@forEach } + byMemo.getOrPut(task.memoId) { mutableListOf() }.add(task) + } + + val memoTitles = memos.associate { it.id to (it.title.ifEmpty { it.content.lines().first().take(40) }) } + + buildList { + byMemo.entries.forEach { (memoId, tasks) -> + val title = memoTitles[memoId] ?: memoId.take(8) + add(TaskGroup(title, tasks.sortedWith(sorter))) + } + if (completed.isNotEmpty()) add(TaskGroup("Completed", completed.sortedWith(sorter), collapsed = "Completed" in collapsed)) + } + } + + GroupBy.STATUS -> { + val incomplete = allTasks.filter { !it.isCompleted }.sortedWith(sorter) + val completed = allTasks.filter { it.isCompleted }.sortedWith(sorter) + + buildList { + if (incomplete.isNotEmpty()) add(TaskGroup("Incomplete", incomplete)) + if (completed.isNotEmpty()) add(TaskGroup("Completed", completed, collapsed = "Completed" in collapsed)) + } + } + } + + return GroupedTasksResult( + groups = groups.map { it.copy(collapsed = it.title in collapsed) }, + availableLists = availableLists, + ) + } + + fun setGroupBy(groupBy: GroupBy) { + _filterState.update { it.copy(groupBy = groupBy) } + } + + fun setSortBy(sortBy: SortBy) { + _filterState.update { it.copy(sortBy = sortBy) } + } + + fun toggleGroupCollapse(title: String) { + _collapsedGroups.update { current -> + if (title in current) current - title else current + title + } + } + + fun updateQuickAddText(text: String) { + _filterState.update { it.copy(quickAddText = text) } + } + + fun quickAddTask() { + val text = _filterState.value.quickAddText.trim() + if (text.isEmpty()) return + + viewModelScope.launch { + memoRepository.createMemo("- [ ] $text") + _filterState.update { it.copy(quickAddText = "") } + } + } + + fun toggleTask(task: Task) { + viewModelScope.launch { + val memo = memoRepository.getMemo(task.memoId) ?: return@launch + val newContent = TaskParser.toggleTaskInContent(memo.content, task) + if (newContent != memo.content) { + memoRepository.updateMemo(task.memoId, content = newContent) + } + } + } + + fun updateTaskInMemo(task: Task, newLine: String) { + viewModelScope.launch { + val memo = memoRepository.getMemo(task.memoId) ?: return@launch + val newContent = TaskParser.replaceTaskLineInContent(memo.content, task, newLine) + if (newContent != memo.content) { + memoRepository.updateMemo(task.memoId, content = newContent) + } + } + } +}