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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user