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

Add settings, align task detail, reminder duration, live preview

Task detail sheet:
- "open in memo" and "close" aligned on same line (left/right)
- Due date+time, reminder, priority, tags as labeled rows with
  bordered value boxes (accent when set, muted when not)
- No presets for date/time — tap opens DatePicker/TimePicker
- Reminder picker: duration options (15min to 1week)
- Tag editor: text field for #tag input
- Dialog stays open after selections, "close" to dismiss

Reminder as duration (!Nunit):
- !30min, !1hr, !2day, !1week parsed into ReminderDuration
- Notification worker: alarm at dueTime - duration offset
- Shown in task list metadata row

Live preview in compose card:
- Shows parsed metadata chips as you type
- Auto-checklist: Enter after task line inserts "- [ ] "

Settings additions:
- Default visibility (tap cycles: private → protected → public)
- Default reminder (tap cycles: none → 15min → 30min → 1hr → 1day)
- Week starts on (tap cycles through days, for calendar)
- All new settings persisted in DataStore

Parser rewritten:
- Removed @labels entirely (@ not a Memos concept)
- Natural dates without prefix: today, tomorrow, yesterday
- Word boundary checks: mp3 ≠ p3, todaying ≠ today
- ISO date colons don't match as time
- !reminder not parsed as due time

102 tests (64 parser, 16 mapper, 7 serialization, 7 backup,
5 visibility, 3 apiResult), all passing.

Co-Authored-By: Claude Opus 4.6 (1M context)

Signed-off-by: Avinal Kumar <avinal.xlvii@gmail.com>
This commit is contained in:
2026-05-21 16:46:44 +05:30
parent 4416d7e98b
commit 2f60ac3e13
10 changed files with 524 additions and 438 deletions
@@ -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()
@@ -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<String> = emptyList(),
val lists: List<String> = emptyList(),
)
@@ -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("""(?<!\w)@(\w+)""")
private val listRegex = Regex("""(?<!\w)#(\w+)""")
// Time patterns: @14:30, @2:30pm, @5pm, @17:00
private val time24Regex = Regex("""(?<!\d)(\d{1,2}):(\d{2})(?!\d)""")
private val time12Regex = Regex("""(?<!\w)(\d{1,2})(?::(\d{2}))?\s*(am|pm)""", RegexOption.IGNORE_CASE)
private val dateKeywords = setOf("today", "tomorrow", "yesterday")
private val isoDateRegex = Regex("""\b(\d{4}-\d{2}-\d{2})\b""")
private val naturalDateRegex = Regex("""\b(today|tomorrow|yesterday)\b""", RegexOption.IGNORE_CASE)
private val time12Regex = Regex("""\b(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b""", RegexOption.IGNORE_CASE)
private val time24Regex = Regex("""\b(\d{1,2}):(\d{2})\b""")
// Reminder duration: !30min, !1hr, !2day, !1week
private val reminderRegex = Regex("""!(\d+)\s*(min|hr|day|week)s?\b""", RegexOption.IGNORE_CASE)
private val priorityRegex = Regex("""\bp([1-3])\b""")
private val listRegex = Regex("""(?<!\w)#([a-zA-Z]\w*)""")
fun extractTasks(memoId: String, content: String): List<Task> {
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<String>, 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<String> =
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<String> =
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,}"""), " ")
}
@@ -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",
@@ -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)
}
}
@@ -36,6 +36,15 @@ class SettingsViewModel(
val notificationsEnabled: StateFlow<Boolean> = tokenStore.notificationsEnabled
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true)
val defaultVisibility: StateFlow<String> = tokenStore.defaultVisibility
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "PRIVATE")
val defaultReminder: StateFlow<String> = tokenStore.defaultReminder
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "")
val weekStartDay: StateFlow<Int> = 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()
@@ -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')}"
@@ -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()) {
@@ -14,6 +14,9 @@ class TokenStore(private val dataStore: DataStore<Preferences>) {
val theme: Flow<String> = dataStore.data.map { it[KEY_THEME] ?: "DARK" }
val accentColor: Flow<String> = dataStore.data.map { it[KEY_ACCENT] ?: "Cobalt" }
val notificationsEnabled: Flow<Boolean> = dataStore.data.map { (it[KEY_NOTIFICATIONS] ?: "true") == "true" }
val defaultVisibility: Flow<String> = dataStore.data.map { it[KEY_DEFAULT_VIS] ?: "PRIVATE" }
val defaultReminder: Flow<String> = dataStore.data.map { it[KEY_DEFAULT_REMINDER] ?: "" }
val weekStartDay: Flow<Int> = 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<Preferences>) {
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<Preferences>) {
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")
}
}
@@ -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)
}
}