diff --git a/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskCheckWorker.kt b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskCheckWorker.kt index 0062902..fcfbcb4 100644 --- a/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskCheckWorker.kt +++ b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskCheckWorker.kt @@ -53,8 +53,22 @@ class TaskCheckWorker( allTasks.forEach { task -> if (task.isCompleted || task.dueDate == null || task.id in scheduledIds) return@forEach + // Schedule reminder as duration before due + if (task.reminder != null) { + val dueInstant = task.dueDate.atTime(task.dueTime ?: LocalTime(8, 0)).toInstant(tz) + val offsetMs = when (task.reminder.unit) { + com.avinal.memos.domain.ReminderUnit.MIN -> task.reminder.value * 60_000L + com.avinal.memos.domain.ReminderUnit.HR -> task.reminder.value * 3_600_000L + com.avinal.memos.domain.ReminderUnit.DAY -> task.reminder.value * 86_400_000L + com.avinal.memos.domain.ReminderUnit.WEEK -> task.reminder.value * 604_800_000L + } + val reminderMs = dueInstant.toEpochMilliseconds() - offsetMs + if (reminderMs > nowMillis) { + scheduleAlarm(alarmManager, "${task.id}_remind", task.text, "reminder: ${task.reminder}", reminderMs) + } + } + if (task.dueTime != null) { - // Specific time: schedule one alarm at that exact time val alarmInstant = task.dueDate.atTime(task.dueTime).toInstant(tz) val alarmMs = alarmInstant.toEpochMilliseconds() if (alarmMs > nowMillis) { @@ -62,7 +76,6 @@ class TaskCheckWorker( newScheduledIds.add(task.id) } } else { - // No specific time: schedule at 8am and 8pm on the due date val morning = task.dueDate.atTime(LocalTime(8, 0)).toInstant(tz).toEpochMilliseconds() val evening = task.dueDate.atTime(LocalTime(20, 0)).toInstant(tz).toEpochMilliseconds() diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/Task.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/Task.kt index d3253a1..1766f60 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/Task.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/Task.kt @@ -3,6 +3,17 @@ package com.avinal.memos.domain import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalTime +data class ReminderDuration( + val value: Int, + val unit: ReminderUnit, +) { + override fun toString(): String = "$value${unit.suffix}" +} + +enum class ReminderUnit(val suffix: String) { + MIN("min"), HR("hr"), DAY("day"), WEEK("week"); +} + data class Task( val id: String, val memoId: String, @@ -13,7 +24,7 @@ data class Task( val isCompleted: Boolean, val dueDate: LocalDate? = null, val dueTime: LocalTime? = null, + val reminder: ReminderDuration? = null, val priority: Int? = null, - val labels: List = emptyList(), val lists: List = emptyList(), ) diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/parser/TaskParser.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/parser/TaskParser.kt index 3a4f321..5e38c30 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/parser/TaskParser.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/parser/TaskParser.kt @@ -1,5 +1,7 @@ package com.avinal.memos.parser +import com.avinal.memos.domain.ReminderDuration +import com.avinal.memos.domain.ReminderUnit import com.avinal.memos.domain.Task import kotlin.time.Clock import kotlinx.datetime.LocalDate @@ -12,15 +14,18 @@ import kotlinx.datetime.todayIn object TaskParser { private val taskLineRegex = Regex("""^\s*- \[([ xX])]\s+(.*)$""") - private val dateRegex = Regex("""\d{4}-\d{2}-\d{2}""") - private val priorityRegex = Regex("""\bp([1-3])\b""") - private val labelRegex = Regex("""(? { var taskOrdinal = 0 @@ -31,10 +36,8 @@ object TaskParser { val cleanText = cleanTaskText(rawText) taskOrdinal++ - val stableId = "${memoId}:${hashTaskContent(cleanText, taskOrdinal)}" - Task( - id = stableId, + id = "${memoId}:${hashContent(cleanText, taskOrdinal)}", memoId = memoId, lineIndex = index, text = cleanText, @@ -43,8 +46,8 @@ object TaskParser { isCompleted = completed, dueDate = parseDueDate(rawText), dueTime = parseDueTime(rawText), + reminder = parseReminder(rawText), priority = parsePriority(rawText), - labels = parseLabels(rawText), lists = parseLists(rawText), ) } @@ -52,10 +55,10 @@ object TaskParser { fun toggleTaskInContent(content: String, task: Task): String { val lines = content.lines().toMutableList() - val targetIndex = findTaskLine(lines, task) - if (targetIndex < 0) return content - val line = lines[targetIndex] - lines[targetIndex] = if (task.isCompleted) { + val idx = findTaskLine(lines, task) + if (idx < 0) return content + val line = lines[idx] + lines[idx] = if (task.isCompleted) { line.replaceFirst("- [x]", "- [ ]").replaceFirst("- [X]", "- [ ]") } else { line.replaceFirst("- [ ]", "- [x]") @@ -65,12 +68,23 @@ object TaskParser { fun replaceTaskLineInContent(content: String, task: Task, newLine: String): String { val lines = content.lines().toMutableList() - val targetIndex = findTaskLine(lines, task) - if (targetIndex < 0) return content - lines[targetIndex] = newLine + val idx = findTaskLine(lines, task) + if (idx < 0) return content + lines[idx] = newLine return lines.joinToString("\n") } + fun reconstructLine(task: Task): String { + val checkbox = if (task.isCompleted) "- [x]" else "- [ ]" + val parts = mutableListOf(task.text) + task.dueDate?.let { parts.add(it.toString()) } + task.dueTime?.let { parts.add(formatTime(it)) } + task.reminder?.let { parts.add("!$it") } + task.priority?.let { parts.add("p$it") } + task.lists.forEach { parts.add("#$it") } + return "$checkbox ${parts.joinToString(" ")}" + } + private fun findTaskLine(lines: List, task: Task): Int { if (task.lineIndex in lines.indices && lines[task.lineIndex].trim() == task.originalLine.trim()) { return task.lineIndex @@ -78,104 +92,91 @@ object TaskParser { return lines.indexOfFirst { it.trim() == task.originalLine.trim() } } - fun reconstructLine(task: Task): String { - val checkbox = if (task.isCompleted) "- [x]" else "- [ ]" - val parts = mutableListOf(task.text) - task.dueDate?.let { parts.add(it.toString()) } - task.dueTime?.let { parts.add(formatTime(it)) } - task.priority?.let { parts.add("p$it") } - task.labels.forEach { parts.add("@$it") } - task.lists.forEach { parts.add("#$it") } - return "$checkbox ${parts.joinToString(" ")}" - } - private fun formatTime(time: LocalTime): String { - val hour = time.hour - val minute = time.minute + val h = time.hour; val m = time.minute return when { - minute == 0 && hour <= 12 -> "${if (hour == 0) 12 else hour}${if (hour < 12) "am" else "pm"}" - minute == 0 && hour > 12 -> "${hour - 12}pm" - else -> "${hour}:${minute.toString().padStart(2, '0')}" + m == 0 && h == 0 -> "12am" + m == 0 && h < 12 -> "${h}am" + m == 0 && h == 12 -> "12pm" + m == 0 -> "${h - 12}pm" + else -> "${h}:${m.toString().padStart(2, '0')}" } } - private fun hashTaskContent(cleanText: String, ordinal: Int): String { - val input = "$cleanText#$ordinal" + private fun hashContent(text: String, ordinal: Int): String { var hash = 0L - for (c in input) { - hash = hash * 31 + c.code - } + for (c in "$text#$ordinal") hash = hash * 31 + c.code return hash.toULong().toString(36) } private fun parseDueDate(text: String): LocalDate? { - val dateMatch = dateRegex.find(text) - if (dateMatch != null) { - return try { LocalDate.parse(dateMatch.value) } catch (_: Exception) { null } + isoDateRegex.find(text)?.let { + return try { LocalDate.parse(it.groupValues[1]) } catch (_: Exception) { null } } - - val labels = labelRegex.findAll(text) - for (label in labels) { - val word = label.groupValues[1].lowercase() + naturalDateRegex.find(text)?.let { val today = Clock.System.todayIn(TimeZone.currentSystemDefault()) - when (word) { - "today" -> return today - "tomorrow" -> return today.plus(1, DateTimeUnit.DAY) - "yesterday" -> return today.plus(-1, DateTimeUnit.DAY) + return when (it.groupValues[1].lowercase()) { + "today" -> today + "tomorrow" -> today.plus(1, DateTimeUnit.DAY) + "yesterday" -> today.plus(-1, DateTimeUnit.DAY) + else -> null } } return null } private fun parseDueTime(text: String): LocalTime? { - // Check 12-hour format first: 5pm, 2:30pm, 11am - val match12 = time12Regex.find(text) - if (match12 != null) { - var hour = match12.groupValues[1].toIntOrNull() ?: return null - val minute = match12.groupValues[2].toIntOrNull() ?: 0 - val ampm = match12.groupValues[3].lowercase() - if (hour !in 1..12 || minute !in 0..59) return null - if (ampm == "pm" && hour != 12) hour += 12 - if (ampm == "am" && hour == 12) hour = 0 - return LocalTime(hour, minute) + val cleaned = text.replace(reminderRegex, "") + time12Regex.find(cleaned)?.let { m -> + var h = m.groupValues[1].toIntOrNull() ?: return null + val min = m.groupValues[2].toIntOrNull() ?: 0 + val ampm = m.groupValues[3].lowercase() + if (h !in 1..12 || min !in 0..59) return null + if (ampm == "pm" && h != 12) h += 12 + if (ampm == "am" && h == 12) h = 0 + return LocalTime(h, min) } - - // Check 24-hour format: 14:30, 9:00 - val match24 = time24Regex.find(text) - if (match24 != null) { - val hour = match24.groupValues[1].toIntOrNull() ?: return null - val minute = match24.groupValues[2].toIntOrNull() ?: return null - if (hour !in 0..23 || minute !in 0..59) return null - return LocalTime(hour, minute) + time24Regex.find(cleaned)?.let { m -> + val h = m.groupValues[1].toIntOrNull() ?: return null + val min = m.groupValues[2].toIntOrNull() ?: return null + if (h !in 0..23 || min !in 0..59) return null + val start = m.range.first + if (start >= 4 && cleaned.substring(start - 4, start).matches(Regex("""\d{4}"""))) return null + return LocalTime(h, min) } - return null } - private fun parsePriority(text: String): Int? { - val match = priorityRegex.find(text) ?: return null - return match.groupValues[1].toIntOrNull() + private fun parseReminder(text: String): ReminderDuration? { + reminderRegex.find(text)?.let { m -> + val value = m.groupValues[1].toIntOrNull() ?: return null + if (value <= 0) return null + val unit = when (m.groupValues[2].lowercase()) { + "min" -> ReminderUnit.MIN + "hr" -> ReminderUnit.HR + "day" -> ReminderUnit.DAY + "week" -> ReminderUnit.WEEK + else -> return null + } + return ReminderDuration(value, unit) + } + return null } - private fun parseLabels(text: String): List = - labelRegex.findAll(text) - .map { it.groupValues[1] } - .filter { it.lowercase() !in dateKeywords } - .toList() + private fun parsePriority(text: String): Int? = + priorityRegex.find(text)?.groupValues?.get(1)?.toIntOrNull() private fun parseLists(text: String): List = - listRegex.findAll(text) - .map { it.groupValues[1] } - .filter { it.first().isLetter() } - .toList() + listRegex.findAll(text).map { it.groupValues[1] }.toList() private fun cleanTaskText(text: String): String { var clean = text clean = priorityRegex.replace(clean, "") - clean = dateRegex.replace(clean, "") + clean = isoDateRegex.replace(clean, "") + clean = naturalDateRegex.replace(clean, "") + clean = reminderRegex.replace(clean, "") clean = time12Regex.replace(clean, "") clean = time24Regex.replace(clean, "") - clean = labelRegex.replace(clean, "") clean = listRegex.replace(clean, "") return clean.trim().replace(Regex("""\s{2,}"""), " ") } diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoListScreen.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoListScreen.kt index 7212099..60b8939 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoListScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoListScreen.kt @@ -216,7 +216,18 @@ fun MemoListScreen( Column(modifier = Modifier.padding(start = 24.dp, end = 12.dp, top = 10.dp, bottom = 10.dp)) { TextField( value = composeText, - onValueChange = { composeText = it }, + onValueChange = { newText -> + // Auto-checklist: if user pressed enter after a task line, auto-insert "- [ ] " + if (newText.length > composeText.length && newText.endsWith("\n")) { + val beforeNewline = newText.dropLast(1) + val lastLine = beforeNewline.lines().lastOrNull() ?: "" + if (lastLine.trimStart().startsWith("- [")) { + composeText = newText + "- [ ] " + return@TextField + } + } + composeText = newText + }, modifier = Modifier.fillMaxWidth(), placeholder = { Text("any thoughts...", fontSize = 15.sp, color = subtleColor.copy(alpha = 0.4f)) @@ -234,6 +245,48 @@ fun MemoListScreen( ), ) + // Live metadata preview + val previewChips = remember(composeText) { + val parser = com.avinal.memos.parser.TaskParser + val tasks = parser.extractTasks("preview", composeText) + if (tasks.isEmpty()) emptyList() + else tasks.flatMap { task -> + buildList { + task.dueDate?.let { add("due: $it" to accent) } + task.dueTime?.let { add("at: $it" to accent) } + task.reminder?.let { add("!$it" to accent) } + task.priority?.let { + val color = when (it) { + 1 -> com.avinal.memos.ui.theme.PriorityP1 + 2 -> com.avinal.memos.ui.theme.PriorityP2 + else -> com.avinal.memos.ui.theme.PriorityP3 + } + add("p$it" to color) + } + task.lists.forEach { add("#$it" to accent) } + } + }.distinct() + } + + if (previewChips.isNotEmpty()) { + androidx.compose.foundation.layout.FlowRow( + modifier = Modifier.padding(top = 4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + previewChips.forEach { (label, color) -> + Text( + label, + fontSize = 11.sp, + color = color, + modifier = Modifier + .background(color.copy(alpha = 0.1f), androidx.compose.foundation.shape.RoundedCornerShape(4.dp)) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + } + } + if (uploadedAttachmentNames.isNotEmpty()) { Text( "${uploadedAttachmentNames.size} attachment(s) ready", diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/settings/SettingsScreen.kt index ead3df0..be34ae6 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/settings/SettingsScreen.kt @@ -124,6 +124,29 @@ fun SettingsScreen( } } + Spacer(Modifier.height(24.dp)) + SectionHeader("memos") + Spacer(Modifier.height(6.dp)) + + val defaultVis by viewModel.defaultVisibility.collectAsState() + SettingToggle("default visibility", defaultVis.lowercase(), accent, MaterialTheme.colorScheme.onSurfaceVariant) { + val next = when (defaultVis) { "PRIVATE" -> "PROTECTED"; "PROTECTED" -> "PUBLIC"; else -> "PRIVATE" } + viewModel.setDefaultVisibility(next) + } + + val defaultReminder by viewModel.defaultReminder.collectAsState() + SettingToggle("default reminder", defaultReminder.ifEmpty { "none" }, accent, MaterialTheme.colorScheme.onSurfaceVariant) { + val options = listOf("", "15min", "30min", "1hr", "1day") + val idx = options.indexOf(defaultReminder) + viewModel.setDefaultReminder(options[(idx + 1) % options.size]) + } + + val weekStart by viewModel.weekStartDay.collectAsState() + val dayNames = listOf("sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday") + SettingToggle("week starts on", dayNames[weekStart], accent, MaterialTheme.colorScheme.onSurfaceVariant) { + viewModel.setWeekStartDay((weekStart + 1) % 7) + } + Spacer(Modifier.height(24.dp)) SectionHeader("notifications") Spacer(Modifier.height(6.dp)) @@ -209,3 +232,15 @@ private fun SettingsItem(label: String, value: String) { Text(value, fontSize = 15.sp, color = MaterialTheme.colorScheme.onBackground) } } + +@Composable +private fun SettingToggle(label: String, value: String, accent: androidx.compose.ui.graphics.Color, subtleColor: androidx.compose.ui.graphics.Color, onClick: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 6.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, fontSize = 15.sp, color = MaterialTheme.colorScheme.onBackground) + Text(value, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = accent) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/settings/SettingsViewModel.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/settings/SettingsViewModel.kt index 1a9d274..41e0775 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/settings/SettingsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/settings/SettingsViewModel.kt @@ -36,6 +36,15 @@ class SettingsViewModel( val notificationsEnabled: StateFlow = tokenStore.notificationsEnabled .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true) + val defaultVisibility: StateFlow = tokenStore.defaultVisibility + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "PRIVATE") + + val defaultReminder: StateFlow = tokenStore.defaultReminder + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") + + val weekStartDay: StateFlow = tokenStore.weekStartDay + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0) + init { viewModelScope.launch { authRepository.validateToken() } } @@ -52,6 +61,18 @@ class SettingsViewModel( viewModelScope.launch { tokenStore.saveNotificationsEnabled(enabled) } } + fun setDefaultVisibility(vis: String) { + viewModelScope.launch { tokenStore.saveDefaultVisibility(vis) } + } + + fun setDefaultReminder(reminder: String) { + viewModelScope.launch { tokenStore.saveDefaultReminder(reminder) } + } + + fun setWeekStartDay(day: Int) { + viewModelScope.launch { tokenStore.saveWeekStartDay(day) } + } + fun getExportJson(onResult: (String) -> Unit) { viewModelScope.launch { val memos = memoRepository.observeMemos().first() diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/tasks/TaskDetailSheet.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/tasks/TaskDetailSheet.kt index 9f6aa27..c34b339 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/tasks/TaskDetailSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/tasks/TaskDetailSheet.kt @@ -1,35 +1,53 @@ package com.avinal.memos.ui.tasks 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.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.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TimePicker +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberTimePickerState import androidx.compose.runtime.Composable +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.unit.dp import androidx.compose.ui.unit.sp +import com.avinal.memos.domain.ReminderDuration +import com.avinal.memos.domain.ReminderUnit import com.avinal.memos.domain.Task import com.avinal.memos.parser.TaskParser import com.avinal.memos.ui.theme.LocalAccentColor -import kotlin.time.Clock -import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone -import kotlinx.datetime.plus -import kotlinx.datetime.todayIn +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.toLocalDateTime -@OptIn(ExperimentalLayoutApi::class) +@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) @Composable fun TaskDetailSheet( task: Task, @@ -40,85 +58,115 @@ fun TaskDetailSheet( val accent = LocalAccentColor.current val textColor = MaterialTheme.colorScheme.onBackground val subtleColor = MaterialTheme.colorScheme.onSurfaceVariant + var showDatePicker by remember { mutableStateOf(false) } + var showTimePicker by remember { mutableStateOf(false) } + var showReminderPicker by remember { mutableStateOf(false) } + var showPriorityPicker by remember { mutableStateOf(false) } + var showTagEditor by remember { mutableStateOf(false) } + + // Track current task state for live updates without closing + var currentTask by remember { mutableStateOf(task) } + val doUpdate = { updated: Task -> + currentTask = updated + onUpdate(task, TaskParser.reconstructLine(updated)) + } + + if (showDatePicker) { + val initial = currentTask.dueDate?.atStartOfDayIn(TimeZone.currentSystemDefault())?.toEpochMilliseconds() + val state = rememberDatePickerState(initialSelectedDateMillis = initial) + DatePickerDialog( + onDismissRequest = { showDatePicker = false }, + confirmButton = { TextButton(onClick = { showDatePicker = false; state.selectedDateMillis?.let { ms -> + doUpdate(currentTask.copy(dueDate = Instant.fromEpochMilliseconds(ms).toLocalDateTime(TimeZone.currentSystemDefault()).date)) + } }) { Text("ok", color = accent) } }, + dismissButton = { Row { if (currentTask.dueDate != null) { TextButton(onClick = { showDatePicker = false; doUpdate(currentTask.copy(dueDate = null, dueTime = null)) }) { Text("clear", color = subtleColor) } }; TextButton(onClick = { showDatePicker = false }) { Text("cancel", color = subtleColor) } } }, + ) { DatePicker(state = state) } + } + + if (showTimePicker) { + val state = rememberTimePickerState(initialHour = currentTask.dueTime?.hour ?: 12, initialMinute = currentTask.dueTime?.minute ?: 0, is24Hour = true) + AlertDialog(onDismissRequest = { showTimePicker = false }, containerColor = MaterialTheme.colorScheme.surfaceContainer, title = null, + text = { TimePicker(state = state) }, + confirmButton = { TextButton(onClick = { showTimePicker = false; doUpdate(currentTask.copy(dueTime = LocalTime(state.hour, state.minute))) }) { Text("ok", color = accent) } }, + dismissButton = { Row { if (currentTask.dueTime != null) { TextButton(onClick = { showTimePicker = false; doUpdate(currentTask.copy(dueTime = null)) }) { Text("clear", color = subtleColor) } }; TextButton(onClick = { showTimePicker = false }) { Text("cancel", color = subtleColor) } } }) + } + + if (showReminderPicker) { + val options = listOf(ReminderDuration(15, ReminderUnit.MIN), ReminderDuration(30, ReminderUnit.MIN), ReminderDuration(1, ReminderUnit.HR), ReminderDuration(2, ReminderUnit.HR), ReminderDuration(1, ReminderUnit.DAY), ReminderDuration(2, ReminderUnit.DAY), ReminderDuration(1, ReminderUnit.WEEK)) + AlertDialog(onDismissRequest = { showReminderPicker = false }, containerColor = MaterialTheme.colorScheme.surfaceContainer, title = null, + text = { Column { options.forEach { d -> Text(d.toString(), fontSize = 15.sp, color = if (currentTask.reminder == d) accent else textColor, fontWeight = if (currentTask.reminder == d) FontWeight.SemiBold else FontWeight.Normal, + modifier = Modifier.fillMaxWidth().clickable { doUpdate(currentTask.copy(reminder = d)); showReminderPicker = false }.padding(vertical = 8.dp)) + }; if (currentTask.reminder != null) { Text("no reminder", fontSize = 15.sp, color = subtleColor, modifier = Modifier.fillMaxWidth().clickable { doUpdate(currentTask.copy(reminder = null)); showReminderPicker = false }.padding(vertical = 8.dp)) } } }, confirmButton = {}) + } + + if (showPriorityPicker) { + AlertDialog(onDismissRequest = { showPriorityPicker = false }, containerColor = MaterialTheme.colorScheme.surfaceContainer, title = null, + text = { Column { listOf(null to "none", 1 to "p1", 2 to "p2", 3 to "p3").forEach { (p, label) -> Text(label, fontSize = 15.sp, color = if (currentTask.priority == p) accent else textColor, fontWeight = if (currentTask.priority == p) FontWeight.SemiBold else FontWeight.Normal, + modifier = Modifier.fillMaxWidth().clickable { doUpdate(currentTask.copy(priority = p)); showPriorityPicker = false }.padding(vertical = 8.dp)) } } }, confirmButton = {}) + } + + if (showTagEditor) { + var tagInput by remember { mutableStateOf(currentTask.lists.joinToString(" ") { "#$it" }) } + AlertDialog(onDismissRequest = { showTagEditor = false }, containerColor = MaterialTheme.colorScheme.surfaceContainer, title = null, + text = { Column { Text("space-separated #tags", fontSize = 12.sp, color = subtleColor); Spacer(Modifier.height(8.dp)) + TextField(value = tagInput, onValueChange = { tagInput = it }, modifier = Modifier.fillMaxWidth(), placeholder = { Text("#work #personal") }, singleLine = true, textStyle = MaterialTheme.typography.bodyMedium.copy(color = textColor), + colors = TextFieldDefaults.colors(focusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent, focusedIndicatorColor = accent, unfocusedIndicatorColor = subtleColor.copy(alpha = 0.3f), cursorColor = accent)) } }, + confirmButton = { TextButton(onClick = { showTagEditor = false; doUpdate(currentTask.copy(lists = Regex("""#?([a-zA-Z]\w*)""").findAll(tagInput).map { it.groupValues[1] }.toList())) }) { Text("save", color = accent) } }, + dismissButton = { TextButton(onClick = { showTagEditor = false }) { Text("cancel", color = subtleColor) } }) + } AlertDialog( onDismissRequest = onDismiss, containerColor = MaterialTheme.colorScheme.surfaceContainer, title = null, text = { - Column { - Text(task.text, fontSize = 17.sp, fontWeight = FontWeight.Medium, color = textColor) - + Column(modifier = Modifier.fillMaxWidth()) { + Text(currentTask.text, fontSize = 17.sp, fontWeight = FontWeight.Medium, color = textColor) Spacer(Modifier.height(16.dp)) - Text("due date", fontSize = 13.sp, color = subtleColor) - Spacer(Modifier.height(6.dp)) - FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { - val today = Clock.System.todayIn(TimeZone.currentSystemDefault()) - listOf( - "today" to today, - "tomorrow" to today.plus(1, DateTimeUnit.DAY), - "next week" to today.plus(7, DateTimeUnit.DAY), - "no date" to null, - ).forEach { (label, date) -> - val isSelected = task.dueDate == date - Text( - label, - fontSize = 14.sp, - color = if (isSelected) accent else textColor, - fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, - modifier = Modifier - .background( - if (isSelected) accent.copy(alpha = 0.1f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), - RoundedCornerShape(4.dp), - ) - .clickable { onUpdate(task, TaskParser.reconstructLine(task.copy(dueDate = date))) } - .padding(horizontal = 10.dp, vertical = 6.dp), - ) - } + val labelWidth = 72.dp + // due: date + time as tappable boxes + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Text("due", fontSize = 13.sp, color = subtleColor, modifier = Modifier.width(labelWidth)) + ValueBox(currentTask.dueDate?.toString() ?: "no date", currentTask.dueDate != null, accent, subtleColor) { showDatePicker = true } + Spacer(Modifier.width(6.dp)) + ValueBox(currentTask.dueTime?.let { fmt(it) } ?: "no time", currentTask.dueTime != null, accent, subtleColor) { showTimePicker = true } + } + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Text("reminder", fontSize = 13.sp, color = subtleColor, modifier = Modifier.width(labelWidth)) + ValueBox(currentTask.reminder?.toString() ?: "no reminder", currentTask.reminder != null, accent, subtleColor) { showReminderPicker = true } + } + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Text("priority", fontSize = 13.sp, color = subtleColor, modifier = Modifier.width(labelWidth)) + ValueBox(currentTask.priority?.let { "p$it" } ?: "none", currentTask.priority != null, accent, subtleColor) { showPriorityPicker = true } + } + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Text("tags", fontSize = 13.sp, color = subtleColor, modifier = Modifier.width(labelWidth)) + ValueBox(currentTask.lists.joinToString(" ") { "#$it" }.ifEmpty { "no tags" }, currentTask.lists.isNotEmpty(), accent, subtleColor) { showTagEditor = true } } Spacer(Modifier.height(14.dp)) - - Text("priority", fontSize = 13.sp, color = subtleColor) - Spacer(Modifier.height(6.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - listOf(null to "none", 1 to "p1", 2 to "p2", 3 to "p3").forEach { (p, label) -> - val isSelected = task.priority == p - Text( - label, - fontSize = 14.sp, - color = if (isSelected) accent else textColor, - fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, - modifier = Modifier - .background( - if (isSelected) accent.copy(alpha = 0.1f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), - RoundedCornerShape(4.dp), - ) - .clickable { onUpdate(task, TaskParser.reconstructLine(task.copy(priority = p))) } - .padding(horizontal = 10.dp, vertical = 6.dp), - ) - } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text("open in memo", fontSize = 13.sp, color = accent, modifier = Modifier.clickable { onOpenMemo(currentTask.memoId) }.padding(vertical = 4.dp)) + Text("close", fontSize = 13.sp, color = subtleColor, modifier = Modifier.clickable(onClick = onDismiss).padding(vertical = 4.dp)) } - - if (task.labels.isNotEmpty() || task.lists.isNotEmpty()) { - Spacer(Modifier.height(12.dp)) - FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - task.labels.forEach { Text("@$it", fontSize = 13.sp, color = subtleColor) } - task.lists.forEach { Text("#$it", fontSize = 13.sp, color = accent) } - } - } - - Spacer(Modifier.height(16.dp)) - - Text( - "open in memo", - fontSize = 15.sp, - color = accent, - modifier = Modifier.clickable { onOpenMemo(task.memoId) }.padding(vertical = 6.dp), - ) } }, confirmButton = {}, ) } + +@Composable +private fun ValueBox(text: String, isSet: Boolean, accent: Color, subtleColor: Color, onClick: () -> Unit) { + Text( + text, fontSize = 13.sp, + color = if (isSet) accent else subtleColor.copy(alpha = 0.5f), + fontWeight = if (isSet) FontWeight.SemiBold else FontWeight.Normal, + modifier = Modifier + .border(1.dp, if (isSet) accent.copy(alpha = 0.3f) else subtleColor.copy(alpha = 0.2f), RoundedCornerShape(4.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 10.dp, vertical = 5.dp), + ) +} + +private fun fmt(time: LocalTime): String = "${time.hour.toString().padStart(2, '0')}:${time.minute.toString().padStart(2, '0')}" diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/tasks/TaskListScreen.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/tasks/TaskListScreen.kt index 3b3e6f6..40d2b7a 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/tasks/TaskListScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/tasks/TaskListScreen.kt @@ -236,12 +236,13 @@ private fun MetroTaskRow( val isOverdue = date < today add(formatRelativeDate(date, today) to if (isOverdue) OverdueRed else accent) } + task.dueTime?.let { add("${it.hour.toString().padStart(2,'0')}:${it.minute.toString().padStart(2,'0')}" to accent) } + task.reminder?.let { add("!$it" to subtleColor) } 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()) { diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/util/TokenStore.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/util/TokenStore.kt index c1657a9..b090595 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/util/TokenStore.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/util/TokenStore.kt @@ -14,6 +14,9 @@ class TokenStore(private val dataStore: DataStore) { val theme: Flow = dataStore.data.map { it[KEY_THEME] ?: "DARK" } val accentColor: Flow = dataStore.data.map { it[KEY_ACCENT] ?: "Cobalt" } val notificationsEnabled: Flow = dataStore.data.map { (it[KEY_NOTIFICATIONS] ?: "true") == "true" } + val defaultVisibility: Flow = dataStore.data.map { it[KEY_DEFAULT_VIS] ?: "PRIVATE" } + val defaultReminder: Flow = dataStore.data.map { it[KEY_DEFAULT_REMINDER] ?: "" } + val weekStartDay: Flow = dataStore.data.map { (it[KEY_WEEK_START] ?: "0").toIntOrNull() ?: 0 } suspend fun saveCredentials(serverUrl: String, token: String) { dataStore.edit { prefs -> @@ -34,6 +37,18 @@ class TokenStore(private val dataStore: DataStore) { dataStore.edit { it[KEY_NOTIFICATIONS] = enabled.toString() } } + suspend fun saveDefaultVisibility(vis: String) { + dataStore.edit { it[KEY_DEFAULT_VIS] = vis } + } + + suspend fun saveDefaultReminder(reminder: String) { + dataStore.edit { it[KEY_DEFAULT_REMINDER] = reminder } + } + + suspend fun saveWeekStartDay(day: Int) { + dataStore.edit { it[KEY_WEEK_START] = day.toString() } + } + suspend fun clear() { dataStore.edit { val theme = it[KEY_THEME] @@ -50,5 +65,8 @@ class TokenStore(private val dataStore: DataStore) { private val KEY_THEME = stringPreferencesKey("app_theme") private val KEY_ACCENT = stringPreferencesKey("accent_color") private val KEY_NOTIFICATIONS = stringPreferencesKey("notifications_enabled") + private val KEY_DEFAULT_VIS = stringPreferencesKey("default_visibility") + private val KEY_DEFAULT_REMINDER = stringPreferencesKey("default_reminder") + private val KEY_WEEK_START = stringPreferencesKey("week_start_day") } } diff --git a/composeApp/src/commonTest/kotlin/com/avinal/memos/TaskParserTest.kt b/composeApp/src/commonTest/kotlin/com/avinal/memos/TaskParserTest.kt index 26e5ee8..ae644bf 100644 --- a/composeApp/src/commonTest/kotlin/com/avinal/memos/TaskParserTest.kt +++ b/composeApp/src/commonTest/kotlin/com/avinal/memos/TaskParserTest.kt @@ -1,5 +1,6 @@ package com.avinal.memos +import com.avinal.memos.domain.ReminderUnit import com.avinal.memos.parser.TaskParser import kotlin.test.Test import kotlin.test.assertEquals @@ -10,293 +11,177 @@ import kotlin.test.assertTrue class TaskParserTest { - @Test - fun extractsBasicUncheckedTask() { - val tasks = TaskParser.extractTasks("m1", "- [ ] Buy milk") - assertEquals(1, tasks.size) - assertEquals("Buy milk", tasks[0].text) - assertFalse(tasks[0].isCompleted) + // --- Basic extraction --- + + @Test fun extractsUncheckedTask() { + val t = TaskParser.extractTasks("m1", "- [ ] Buy milk") + assertEquals(1, t.size); assertEquals("Buy milk", t[0].text); assertFalse(t[0].isCompleted) + } + @Test fun extractsCheckedTask() { assertTrue(TaskParser.extractTasks("m1", "- [x] Done")[0].isCompleted) } + @Test fun extractsCheckedUpperX() { assertTrue(TaskParser.extractTasks("m1", "- [X] Done")[0].isCompleted) } + @Test fun multipleTasks() { assertEquals(3, TaskParser.extractTasks("m1", "t\n- [ ] A\n- [x] B\n- [ ] C\nm").size) } + @Test fun noTasksInPlainText() { assertTrue(TaskParser.extractTasks("m1", "Just text").isEmpty()) } + @Test fun bulletNotTask() { assertTrue(TaskParser.extractTasks("m1", "- Regular\n* Another").isEmpty()) } + @Test fun indentedTask() { assertEquals("Indented", TaskParser.extractTasks("m1", " - [ ] Indented")[0].text) } + + // --- ISO Date --- + + @Test fun parsesIsoDate() { + val t = TaskParser.extractTasks("m1", "- [ ] Review 2026-05-25")[0] + assertEquals(2026, t.dueDate!!.year); assertEquals(25, t.dueDate!!.dayOfMonth) + } + @Test fun invalidIsoDate() { assertNull(TaskParser.extractTasks("m1", "- [ ] Review 2026-13-45")[0].dueDate) } + @Test fun isoDateCleaned() { assertFalse(TaskParser.extractTasks("m1", "- [ ] Fix 2026-05-25")[0].text.contains("2026")) } + + // --- Natural dates (no @ prefix) --- + + @Test fun parsesToday() { assertNotNull(TaskParser.extractTasks("m1", "- [ ] Do today")[0].dueDate) } + @Test fun parsesTomorrow() { assertNotNull(TaskParser.extractTasks("m1", "- [ ] Do tomorrow")[0].dueDate) } + @Test fun parsesYesterday() { assertNotNull(TaskParser.extractTasks("m1", "- [ ] Missed yesterday")[0].dueDate) } + @Test fun todayCleaned() { assertEquals("Buy groceries", TaskParser.extractTasks("m1", "- [ ] Buy groceries today")[0].text) } + @Test fun naturalDateCaseInsensitive() { assertNotNull(TaskParser.extractTasks("m1", "- [ ] Fix TODAY")[0].dueDate) } + @Test fun todayInWord() { assertNull(TaskParser.extractTasks("m1", "- [ ] todaying stuff")[0].dueDate) } + + // --- Due time --- + + @Test fun parses12hPm() { assertEquals(17, TaskParser.extractTasks("m1", "- [ ] Meeting 5pm")[0].dueTime!!.hour) } + @Test fun parses12hAm() { assertEquals(9, TaskParser.extractTasks("m1", "- [ ] Standup 9am")[0].dueTime!!.hour) } + @Test fun parses12hMinutes() { + val t = TaskParser.extractTasks("m1", "- [ ] Call 2:30pm")[0] + assertEquals(14, t.dueTime!!.hour); assertEquals(30, t.dueTime!!.minute) + } + @Test fun parses24h() { + val t = TaskParser.extractTasks("m1", "- [ ] Deploy 14:30")[0] + assertEquals(14, t.dueTime!!.hour); assertEquals(30, t.dueTime!!.minute) + } + @Test fun parses12am() { assertEquals(0, TaskParser.extractTasks("m1", "- [ ] Reset 12am")[0].dueTime!!.hour) } + @Test fun parses12pm() { assertEquals(12, TaskParser.extractTasks("m1", "- [ ] Lunch 12pm")[0].dueTime!!.hour) } + @Test fun noTime() { assertNull(TaskParser.extractTasks("m1", "- [ ] Simple")[0].dueTime) } + @Test fun timeCleaned() { assertFalse(TaskParser.extractTasks("m1", "- [ ] Meeting 5pm today")[0].text.contains("5pm")) } + @Test fun isoDateColonNotTime() { assertNull(TaskParser.extractTasks("m1", "- [ ] Fix 2026-05-25")[0].dueTime) } + + // --- Reminder duration (!Nunit) --- + + @Test fun parsesReminderMin() { + val r = TaskParser.extractTasks("m1", "- [ ] Call !30min today")[0].reminder + assertNotNull(r); assertEquals(30, r.value); assertEquals(ReminderUnit.MIN, r.unit) + } + @Test fun parsesReminderHr() { + val r = TaskParser.extractTasks("m1", "- [ ] Deploy !2hr tomorrow")[0].reminder + assertEquals(2, r!!.value); assertEquals(ReminderUnit.HR, r.unit) + } + @Test fun parsesReminderDay() { + val r = TaskParser.extractTasks("m1", "- [ ] Review !1day 2026-05-25")[0].reminder + assertEquals(1, r!!.value); assertEquals(ReminderUnit.DAY, r.unit) + } + @Test fun parsesReminderWeek() { + val r = TaskParser.extractTasks("m1", "- [ ] Plan !1week")[0].reminder + assertEquals(1, r!!.value); assertEquals(ReminderUnit.WEEK, r.unit) + } + @Test fun reminderPlural() { + val r = TaskParser.extractTasks("m1", "- [ ] Call !2days")[0].reminder + assertEquals(2, r!!.value); assertEquals(ReminderUnit.DAY, r.unit) + } + @Test fun noReminder() { assertNull(TaskParser.extractTasks("m1", "- [ ] Simple")[0].reminder) } + @Test fun reminderCleaned() { assertFalse(TaskParser.extractTasks("m1", "- [ ] Call !30min")[0].text.contains("!")) } + @Test fun reminderNotDueTime() { + val t = TaskParser.extractTasks("m1", "- [ ] Task !30min today")[0] + assertNotNull(t.reminder); assertNull(t.dueTime) + } + @Test fun reminderToString() { + val r = TaskParser.extractTasks("m1", "- [ ] Task !30min")[0].reminder + assertEquals("30min", r.toString()) + } + @Test fun reminderDayToString() { + assertEquals("2day", TaskParser.extractTasks("m1", "- [ ] Task !2day")[0].reminder.toString()) } - @Test - fun extractsCheckedTask() { - val tasks = TaskParser.extractTasks("m1", "- [x] Done task") - assertEquals(1, tasks.size) - assertTrue(tasks[0].isCompleted) + // --- Priority --- + + @Test fun extractsP1() { assertEquals(1, TaskParser.extractTasks("m1", "- [ ] Fix p1")[0].priority) } + @Test fun extractsP2() { assertEquals(2, TaskParser.extractTasks("m1", "- [ ] Fix p2")[0].priority) } + @Test fun extractsP3() { assertEquals(3, TaskParser.extractTasks("m1", "- [ ] Fix p3")[0].priority) } + @Test fun noPriority() { assertNull(TaskParser.extractTasks("m1", "- [ ] Fix")[0].priority) } + @Test fun priorityCleaned() { assertEquals("Fix bug", TaskParser.extractTasks("m1", "- [ ] Fix bug p1")[0].text) } + @Test fun p4NotParsed() { assertNull(TaskParser.extractTasks("m1", "- [ ] Fix p4")[0].priority) } + @Test fun priorityInWord() { assertNull(TaskParser.extractTasks("m1", "- [ ] Download mp3")[0].priority) } + + // --- Lists (#tag) --- + + @Test fun extractsList() { assertEquals(listOf("work"), TaskParser.extractTasks("m1", "- [ ] Task #work")[0].lists) } + @Test fun multipleList() { assertEquals(listOf("work", "devops"), TaskParser.extractTasks("m1", "- [ ] Task #work #devops")[0].lists) } + @Test fun numericHash() { assertTrue(TaskParser.extractTasks("m1", "- [ ] Issue #123")[0].lists.isEmpty()) } + @Test fun listCleaned() { assertFalse(TaskParser.extractTasks("m1", "- [ ] Task #work")[0].text.contains("#")) } + + // --- No @labels --- + + @Test fun atNotParsed() { assertTrue(TaskParser.extractTasks("m1", "- [ ] Email user@example.com")[0].text.contains("user@example.com")) } + + // --- Stable IDs --- + + @Test fun stableIds() { + val a = TaskParser.extractTasks("m1", "- [ ] A\n- [ ] B") + val b = TaskParser.extractTasks("m1", "- [ ] A\n- [ ] B") + assertEquals(a[0].id, b[0].id); assertEquals(a[1].id, b[1].id) + } + @Test fun differentMemoIds() { assertTrue(TaskParser.extractTasks("m1", "- [ ] T")[0].id != TaskParser.extractTasks("m2", "- [ ] T")[0].id) } + + // --- Toggle / Replace --- + + @Test fun toggleChecks() { + val c = "t\n- [ ] A\n- [ ] B" + assertTrue(TaskParser.toggleTaskInContent(c, TaskParser.extractTasks("m1", c)[0]).contains("- [x] A")) + } + @Test fun toggleUnchecks() { + val c = "- [x] Done\n- [ ] Not" + assertTrue(TaskParser.toggleTaskInContent(c, TaskParser.extractTasks("m1", c)[0]).contains("- [ ] Done")) + } + @Test fun replaceLine() { + val c = "a\n- [ ] Old\nb" + val r = TaskParser.replaceTaskLineInContent(c, TaskParser.extractTasks("m1", c)[0], "- [ ] New p1") + assertTrue(r.contains("New p1")); assertFalse(r.contains("Old")) } - @Test - fun extractsCheckedTaskUppercaseX() { - val tasks = TaskParser.extractTasks("m1", "- [X] Done task") - assertEquals(1, tasks.size) - assertTrue(tasks[0].isCompleted) + // --- Reconstruct --- + + @Test fun reconstructFull() { + val t = TaskParser.extractTasks("m1", "- [ ] Buy milk 2026-06-01 5pm !30min p2 #personal")[0] + val line = TaskParser.reconstructLine(t) + assertTrue(line.startsWith("- [ ]")); assertTrue(line.contains("Buy milk")) + assertTrue(line.contains("2026-06-01")); assertTrue(line.contains("p2")) + assertTrue(line.contains("#personal")); assertTrue(line.contains("!30min")) } + @Test fun reconstructCompleted() { assertTrue(TaskParser.reconstructLine(TaskParser.extractTasks("m1", "- [x] Done p1")[0]).startsWith("- [x]")) } - @Test - fun extractsPriority() { - val tasks = TaskParser.extractTasks("m1", "- [ ] Fix bug p1") - assertEquals(1, tasks[0].priority) - assertEquals("Fix bug", tasks[0].text) + // --- Line index / metadata --- + + @Test fun lineIndex() { + val t = TaskParser.extractTasks("m1", "h\n\n- [ ] A\nt\n- [ ] B") + assertEquals(2, t[0].lineIndex); assertEquals(4, t[1].lineIndex) } + @Test fun originalLine() { assertEquals("- [ ] Buy today p2 #a", TaskParser.extractTasks("m1", "- [ ] Buy today p2 #a")[0].originalLine) } + @Test fun memoIdStored() { assertEquals("m123", TaskParser.extractTasks("m123", "- [ ] T")[0].memoId) } - @Test - fun extractsAllPriorities() { - val content = """ - - [ ] Task A p1 - - [ ] Task B p2 - - [ ] Task C p3 - """.trimIndent() - val tasks = TaskParser.extractTasks("m1", content) - assertEquals(3, tasks.size) - assertEquals(1, tasks[0].priority) - assertEquals(2, tasks[1].priority) - assertEquals(3, tasks[2].priority) + // --- Combined / corner cases --- + + @Test fun fullCombined() { + val t = TaskParser.extractTasks("m1", "- [ ] Review PR 2026-05-25 3pm !1hr p1 #work #devops")[0] + assertEquals("Review PR", t.text) + assertEquals(2026, t.dueDate!!.year); assertEquals(15, t.dueTime!!.hour) + assertEquals(1, t.reminder!!.value); assertEquals(ReminderUnit.HR, t.reminder!!.unit) + assertEquals(1, t.priority); assertEquals(listOf("work", "devops"), t.lists) } - - @Test - fun extractsExplicitDate() { - val tasks = TaskParser.extractTasks("m1", "- [ ] Review PR 2026-05-25") - assertNotNull(tasks[0].dueDate) - assertEquals(2026, tasks[0].dueDate!!.year) - assertEquals(25, tasks[0].dueDate!!.dayOfMonth) + @Test fun emptyContent() { assertTrue(TaskParser.extractTasks("m1", "").isEmpty()) } + @Test fun whitespaceOnly() { assertTrue(TaskParser.extractTasks("m1", " \n ").isEmpty()) } + @Test fun noMetadata() { + val t = TaskParser.extractTasks("m1", "- [ ] Just a plain task")[0] + assertNull(t.dueDate); assertNull(t.dueTime); assertNull(t.reminder); assertNull(t.priority); assertTrue(t.lists.isEmpty()) } - - @Test - fun extractsTodayKeyword() { - val tasks = TaskParser.extractTasks("m1", "- [ ] Do thing @today") - assertNotNull(tasks[0].dueDate) - } - - @Test - fun extractsTomorrowKeyword() { - val tasks = TaskParser.extractTasks("m1", "- [ ] Do thing @tomorrow") - assertNotNull(tasks[0].dueDate) - } - - @Test - fun extractsLabels() { - val tasks = TaskParser.extractTasks("m1", "- [ ] Fix @backend @urgent") - assertEquals(listOf("backend", "urgent"), tasks[0].labels) - } - - @Test - fun labelsExcludeDateKeywords() { - val tasks = TaskParser.extractTasks("m1", "- [ ] Fix @today @backend") - assertEquals(listOf("backend"), tasks[0].labels) - } - - @Test - fun extractsLists() { - val tasks = TaskParser.extractTasks("m1", "- [ ] Review #work #devops") - assertEquals(listOf("work", "devops"), tasks[0].lists) - } - - @Test - fun listsIgnoreNumericStart() { - val tasks = TaskParser.extractTasks("m1", "- [ ] Task #123") - assertTrue(tasks[0].lists.isEmpty()) - } - - @Test - fun cleansMetadataFromText() { - val tasks = TaskParser.extractTasks("m1", "- [ ] Buy groceries @today p2 @shopping #personal") - assertEquals("Buy groceries", tasks[0].text) - } - - @Test - fun multipleTasksFromOneMemo() { - val content = """ - Some text - - [ ] Task 1 - - [x] Task 2 - - [ ] Task 3 - More text - """.trimIndent() - val tasks = TaskParser.extractTasks("m1", content) - assertEquals(3, tasks.size) - assertFalse(tasks[0].isCompleted) - assertTrue(tasks[1].isCompleted) - assertFalse(tasks[2].isCompleted) - } - - @Test - fun noTasksInPlainText() { - val tasks = TaskParser.extractTasks("m1", "Just some text\nNo tasks here") - assertTrue(tasks.isEmpty()) - } - - @Test - fun indentedTasksWork() { - val tasks = TaskParser.extractTasks("m1", " - [ ] Indented task") - assertEquals(1, tasks.size) - assertEquals("Indented task", tasks[0].text) - } - - @Test - fun stableIdsAcrossParses() { - val content = "- [ ] Task A\n- [ ] Task B" - val first = TaskParser.extractTasks("m1", content) - val second = TaskParser.extractTasks("m1", content) - assertEquals(first[0].id, second[0].id) - assertEquals(first[1].id, second[1].id) - } - - @Test - fun differentMemosProduceDifferentIds() { - val content = "- [ ] Same task" - val fromMemo1 = TaskParser.extractTasks("m1", content) - val fromMemo2 = TaskParser.extractTasks("m2", content) - assertTrue(fromMemo1[0].id != fromMemo2[0].id) - } - - @Test - fun reconstructLinePreservesMetadata() { - val tasks = TaskParser.extractTasks("m1", "- [ ] Buy milk @today p2 #personal") - val reconstructed = TaskParser.reconstructLine(tasks[0]) - assertTrue(reconstructed.startsWith("- [ ]")) - assertTrue(reconstructed.contains("Buy milk")) - assertTrue(reconstructed.contains("p2")) - assertTrue(reconstructed.contains("#personal")) - } - - @Test - fun reconstructLineForCompletedTask() { - val tasks = TaskParser.extractTasks("m1", "- [x] Done task p1") - val reconstructed = TaskParser.reconstructLine(tasks[0]) - assertTrue(reconstructed.startsWith("- [x]")) - } - - @Test - fun toggleTaskInContentChecks() { - val content = "some text\n- [ ] Task A\n- [ ] Task B\nmore" - val tasks = TaskParser.extractTasks("m1", content) - val toggled = TaskParser.toggleTaskInContent(content, tasks[0]) - assertTrue(toggled.contains("- [x] Task A")) - assertTrue(toggled.contains("- [ ] Task B")) - } - - @Test - fun toggleTaskInContentUnchecks() { - val content = "- [x] Done\n- [ ] Not done" - val tasks = TaskParser.extractTasks("m1", content) - val toggled = TaskParser.toggleTaskInContent(content, tasks[0]) - assertTrue(toggled.contains("- [ ] Done")) - } - - @Test - fun replaceTaskLineInContent() { - val content = "line1\n- [ ] Old task\nline3" - val tasks = TaskParser.extractTasks("m1", content) - val replaced = TaskParser.replaceTaskLineInContent(content, tasks[0], "- [ ] New task p1") - assertTrue(replaced.contains("- [ ] New task p1")) - assertFalse(replaced.contains("Old task")) - } - - @Test - fun noPriorityReturnsNull() { - val tasks = TaskParser.extractTasks("m1", "- [ ] Simple task") - assertNull(tasks[0].priority) - } - - @Test - fun noDateReturnsNull() { - val tasks = TaskParser.extractTasks("m1", "- [ ] Simple task") - assertNull(tasks[0].dueDate) - } - - @Test - fun originalLinePreserved() { - val line = "- [ ] Buy groceries @today p2 #personal" - val tasks = TaskParser.extractTasks("m1", line) - assertEquals(line, tasks[0].originalLine) - } - - @Test - fun bulletListNotParsedAsTask() { - val tasks = TaskParser.extractTasks("m1", "- Regular list item\n* Another item") - assertTrue(tasks.isEmpty()) - } - - @Test - fun memoIdStoredCorrectly() { - val tasks = TaskParser.extractTasks("memo123", "- [ ] Task") - assertEquals("memo123", tasks[0].memoId) - } - - @Test - fun lineIndexCorrect() { - val content = "header\n\n- [ ] First\ntext\n- [ ] Second" - val tasks = TaskParser.extractTasks("m1", content) - assertEquals(2, tasks[0].lineIndex) - assertEquals(4, tasks[1].lineIndex) - } - - // --- Time parsing tests --- - - @Test - fun parses12HourPm() { - val tasks = TaskParser.extractTasks("m1", "- [ ] Meeting 5pm") - assertNotNull(tasks[0].dueTime) - assertEquals(17, tasks[0].dueTime!!.hour) - assertEquals(0, tasks[0].dueTime!!.minute) - } - - @Test - fun parses12HourAm() { - val tasks = TaskParser.extractTasks("m1", "- [ ] Standup 9am") - assertNotNull(tasks[0].dueTime) - assertEquals(9, tasks[0].dueTime!!.hour) - } - - @Test - fun parses12HourWithMinutes() { - val tasks = TaskParser.extractTasks("m1", "- [ ] Call 2:30pm") - assertNotNull(tasks[0].dueTime) - assertEquals(14, tasks[0].dueTime!!.hour) - assertEquals(30, tasks[0].dueTime!!.minute) - } - - @Test - fun parses24Hour() { - val tasks = TaskParser.extractTasks("m1", "- [ ] Deploy 14:30") - assertNotNull(tasks[0].dueTime) - assertEquals(14, tasks[0].dueTime!!.hour) - assertEquals(30, tasks[0].dueTime!!.minute) - } - - @Test - fun parsesMidnight12am() { - val tasks = TaskParser.extractTasks("m1", "- [ ] Reset 12am") - assertNotNull(tasks[0].dueTime) - assertEquals(0, tasks[0].dueTime!!.hour) - } - - @Test - fun parsesNoon12pm() { - val tasks = TaskParser.extractTasks("m1", "- [ ] Lunch 12pm") - assertNotNull(tasks[0].dueTime) - assertEquals(12, tasks[0].dueTime!!.hour) - } - - @Test - fun noTimeReturnsNull() { - val tasks = TaskParser.extractTasks("m1", "- [ ] Simple task") - assertNull(tasks[0].dueTime) - } - - @Test - fun timeAndDateTogether() { - val tasks = TaskParser.extractTasks("m1", "- [ ] Review @today 3pm p1 #work") - assertNotNull(tasks[0].dueDate) - assertNotNull(tasks[0].dueTime) - assertEquals(15, tasks[0].dueTime!!.hour) - assertEquals(1, tasks[0].priority) - } - - @Test - fun timeCleanedFromText() { - val tasks = TaskParser.extractTasks("m1", "- [ ] Meeting 5pm @today") - assertFalse(tasks[0].text.contains("5pm")) - assertFalse(tasks[0].text.contains("@today")) + @Test fun urlHash() { assertTrue(TaskParser.extractTasks("m1", "- [ ] Check https://ex.com/p#sec")[0].text.contains("https://ex.com")) } + @Test fun multipleTimesFirstWins() { assertEquals(9, TaskParser.extractTasks("m1", "- [ ] Call 9am then 5pm")[0].dueTime!!.hour) } + @Test fun dateAndTimeAndReminder() { + val t = TaskParser.extractTasks("m1", "- [ ] Meet tomorrow 3pm !15min #work")[0] + assertNotNull(t.dueDate); assertEquals(15, t.dueTime!!.hour) + assertEquals(15, t.reminder!!.value); assertEquals(ReminderUnit.MIN, t.reminder!!.unit) } }