mirror of
https://github.com/avinal/nikki.git
synced 2026-07-04 05:50:10 +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