1
0
mirror of https://github.com/avinal/nikki.git synced 2026-07-03 21:40:09 +05:30

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 <avinal.xlvii@gmail.com>
This commit is contained in:
2026-05-19 17:11:42 +05:30
parent 9b5e18939b
commit 5f2c1c02b2
5 changed files with 835 additions and 0 deletions
@@ -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)
}
}
@@ -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<String?> = tokenStore.serverUrl
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
val currentUser: StateFlow<User?> = authRepository.currentUser
val currentTheme: StateFlow<String> = tokenStore.theme
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "DARK")
val currentAccent: StateFlow<String> = 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()
}
}
}
@@ -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))
}
}
}
@@ -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<Task?>(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 <T> MetroDropdown(
label: String,
options: List<T>,
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()
}
}
@@ -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<Task>,
val collapsed: Boolean = false,
)
data class GroupedTasksResult(
val groups: List<TaskGroup> = emptyList(),
val availableLists: List<String> = emptyList(),
)
class TaskListViewModel(private val memoRepository: MemoRepository) : ViewModel() {
private val _filterState = MutableStateFlow(TaskFilterState())
val filterState: StateFlow<TaskFilterState> = _filterState.asStateFlow()
private val _collapsedGroups = MutableStateFlow<Set<String>>(emptySet())
val groupedTasks: StateFlow<GroupedTasksResult> = combine(
memoRepository.observeMemos(),
_filterState,
_collapsedGroups,
) { memos, filters, collapsed ->
buildGroups(memos, filters, collapsed)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), GroupedTasksResult())
private fun buildGroups(memos: List<Memo>, filters: TaskFilterState, collapsed: Set<String>): 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<Task> { it.dueDate ?: LocalDate(9999, 12, 31) }.thenBy { it.priority ?: 9 }
SortBy.PRIORITY -> compareBy<Task> { it.priority ?: 9 }.thenBy { it.dueDate ?: LocalDate(9999, 12, 31) }
}
val groups = when (filters.groupBy) {
GroupBy.DUE -> {
val overdue = mutableListOf<Task>()
val todayTasks = mutableListOf<Task>()
val upcoming = mutableListOf<Task>()
val noDate = mutableListOf<Task>()
val completed = mutableListOf<Task>()
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<String, MutableList<Task>>()
val completed = mutableListOf<Task>()
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<Task>()
val p2 = mutableListOf<Task>()
val p3 = mutableListOf<Task>()
val noPriority = mutableListOf<Task>()
val completed = mutableListOf<Task>()
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<String, MutableList<Task>>()
val completed = mutableListOf<Task>()
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)
}
}
}
}