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 -> allTasks.forEach { task ->
if (task.isCompleted || task.dueDate == null || task.id in scheduledIds) return@forEach 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) { if (task.dueTime != null) {
// Specific time: schedule one alarm at that exact time
val alarmInstant = task.dueDate.atTime(task.dueTime).toInstant(tz) val alarmInstant = task.dueDate.atTime(task.dueTime).toInstant(tz)
val alarmMs = alarmInstant.toEpochMilliseconds() val alarmMs = alarmInstant.toEpochMilliseconds()
if (alarmMs > nowMillis) { if (alarmMs > nowMillis) {
@@ -62,7 +76,6 @@ class TaskCheckWorker(
newScheduledIds.add(task.id) newScheduledIds.add(task.id)
} }
} else { } 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 morning = task.dueDate.atTime(LocalTime(8, 0)).toInstant(tz).toEpochMilliseconds()
val evening = task.dueDate.atTime(LocalTime(20, 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.LocalDate
import kotlinx.datetime.LocalTime 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( data class Task(
val id: String, val id: String,
val memoId: String, val memoId: String,
@@ -13,7 +24,7 @@ data class Task(
val isCompleted: Boolean, val isCompleted: Boolean,
val dueDate: LocalDate? = null, val dueDate: LocalDate? = null,
val dueTime: LocalTime? = null, val dueTime: LocalTime? = null,
val reminder: ReminderDuration? = null,
val priority: Int? = null, val priority: Int? = null,
val labels: List<String> = emptyList(),
val lists: List<String> = emptyList(), val lists: List<String> = emptyList(),
) )
@@ -1,5 +1,7 @@
package com.avinal.memos.parser package com.avinal.memos.parser
import com.avinal.memos.domain.ReminderDuration
import com.avinal.memos.domain.ReminderUnit
import com.avinal.memos.domain.Task import com.avinal.memos.domain.Task
import kotlin.time.Clock import kotlin.time.Clock
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
@@ -12,15 +14,18 @@ import kotlinx.datetime.todayIn
object TaskParser { object TaskParser {
private val taskLineRegex = Regex("""^\s*- \[([ xX])]\s+(.*)$""") 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> { fun extractTasks(memoId: String, content: String): List<Task> {
var taskOrdinal = 0 var taskOrdinal = 0
@@ -31,10 +36,8 @@ object TaskParser {
val cleanText = cleanTaskText(rawText) val cleanText = cleanTaskText(rawText)
taskOrdinal++ taskOrdinal++
val stableId = "${memoId}:${hashTaskContent(cleanText, taskOrdinal)}"
Task( Task(
id = stableId, id = "${memoId}:${hashContent(cleanText, taskOrdinal)}",
memoId = memoId, memoId = memoId,
lineIndex = index, lineIndex = index,
text = cleanText, text = cleanText,
@@ -43,8 +46,8 @@ object TaskParser {
isCompleted = completed, isCompleted = completed,
dueDate = parseDueDate(rawText), dueDate = parseDueDate(rawText),
dueTime = parseDueTime(rawText), dueTime = parseDueTime(rawText),
reminder = parseReminder(rawText),
priority = parsePriority(rawText), priority = parsePriority(rawText),
labels = parseLabels(rawText),
lists = parseLists(rawText), lists = parseLists(rawText),
) )
} }
@@ -52,10 +55,10 @@ object TaskParser {
fun toggleTaskInContent(content: String, task: Task): String { fun toggleTaskInContent(content: String, task: Task): String {
val lines = content.lines().toMutableList() val lines = content.lines().toMutableList()
val targetIndex = findTaskLine(lines, task) val idx = findTaskLine(lines, task)
if (targetIndex < 0) return content if (idx < 0) return content
val line = lines[targetIndex] val line = lines[idx]
lines[targetIndex] = if (task.isCompleted) { lines[idx] = if (task.isCompleted) {
line.replaceFirst("- [x]", "- [ ]").replaceFirst("- [X]", "- [ ]") line.replaceFirst("- [x]", "- [ ]").replaceFirst("- [X]", "- [ ]")
} else { } else {
line.replaceFirst("- [ ]", "- [x]") line.replaceFirst("- [ ]", "- [x]")
@@ -65,12 +68,23 @@ object TaskParser {
fun replaceTaskLineInContent(content: String, task: Task, newLine: String): String { fun replaceTaskLineInContent(content: String, task: Task, newLine: String): String {
val lines = content.lines().toMutableList() val lines = content.lines().toMutableList()
val targetIndex = findTaskLine(lines, task) val idx = findTaskLine(lines, task)
if (targetIndex < 0) return content if (idx < 0) return content
lines[targetIndex] = newLine lines[idx] = newLine
return lines.joinToString("\n") 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 { private fun findTaskLine(lines: List<String>, task: Task): Int {
if (task.lineIndex in lines.indices && lines[task.lineIndex].trim() == task.originalLine.trim()) { if (task.lineIndex in lines.indices && lines[task.lineIndex].trim() == task.originalLine.trim()) {
return task.lineIndex return task.lineIndex
@@ -78,104 +92,91 @@ object TaskParser {
return lines.indexOfFirst { it.trim() == task.originalLine.trim() } 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 { private fun formatTime(time: LocalTime): String {
val hour = time.hour val h = time.hour; val m = time.minute
val minute = time.minute
return when { return when {
minute == 0 && hour <= 12 -> "${if (hour == 0) 12 else hour}${if (hour < 12) "am" else "pm"}" m == 0 && h == 0 -> "12am"
minute == 0 && hour > 12 -> "${hour - 12}pm" m == 0 && h < 12 -> "${h}am"
else -> "${hour}:${minute.toString().padStart(2, '0')}" 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 { private fun hashContent(text: String, ordinal: Int): String {
val input = "$cleanText#$ordinal"
var hash = 0L var hash = 0L
for (c in input) { for (c in "$text#$ordinal") hash = hash * 31 + c.code
hash = hash * 31 + c.code
}
return hash.toULong().toString(36) return hash.toULong().toString(36)
} }
private fun parseDueDate(text: String): LocalDate? { private fun parseDueDate(text: String): LocalDate? {
val dateMatch = dateRegex.find(text) isoDateRegex.find(text)?.let {
if (dateMatch != null) { return try { LocalDate.parse(it.groupValues[1]) } catch (_: Exception) { null }
return try { LocalDate.parse(dateMatch.value) } catch (_: Exception) { null }
} }
naturalDateRegex.find(text)?.let {
val labels = labelRegex.findAll(text)
for (label in labels) {
val word = label.groupValues[1].lowercase()
val today = Clock.System.todayIn(TimeZone.currentSystemDefault()) val today = Clock.System.todayIn(TimeZone.currentSystemDefault())
when (word) { return when (it.groupValues[1].lowercase()) {
"today" -> return today "today" -> today
"tomorrow" -> return today.plus(1, DateTimeUnit.DAY) "tomorrow" -> today.plus(1, DateTimeUnit.DAY)
"yesterday" -> return today.plus(-1, DateTimeUnit.DAY) "yesterday" -> today.plus(-1, DateTimeUnit.DAY)
else -> null
} }
} }
return null return null
} }
private fun parseDueTime(text: String): LocalTime? { private fun parseDueTime(text: String): LocalTime? {
// Check 12-hour format first: 5pm, 2:30pm, 11am val cleaned = text.replace(reminderRegex, "")
val match12 = time12Regex.find(text) time12Regex.find(cleaned)?.let { m ->
if (match12 != null) { var h = m.groupValues[1].toIntOrNull() ?: return null
var hour = match12.groupValues[1].toIntOrNull() ?: return null val min = m.groupValues[2].toIntOrNull() ?: 0
val minute = match12.groupValues[2].toIntOrNull() ?: 0 val ampm = m.groupValues[3].lowercase()
val ampm = match12.groupValues[3].lowercase() if (h !in 1..12 || min !in 0..59) return null
if (hour !in 1..12 || minute !in 0..59) return null if (ampm == "pm" && h != 12) h += 12
if (ampm == "pm" && hour != 12) hour += 12 if (ampm == "am" && h == 12) h = 0
if (ampm == "am" && hour == 12) hour = 0 return LocalTime(h, min)
return LocalTime(hour, minute)
} }
time24Regex.find(cleaned)?.let { m ->
// Check 24-hour format: 14:30, 9:00 val h = m.groupValues[1].toIntOrNull() ?: return null
val match24 = time24Regex.find(text) val min = m.groupValues[2].toIntOrNull() ?: return null
if (match24 != null) { if (h !in 0..23 || min !in 0..59) return null
val hour = match24.groupValues[1].toIntOrNull() ?: return null val start = m.range.first
val minute = match24.groupValues[2].toIntOrNull() ?: return null if (start >= 4 && cleaned.substring(start - 4, start).matches(Regex("""\d{4}"""))) return null
if (hour !in 0..23 || minute !in 0..59) return null return LocalTime(h, min)
return LocalTime(hour, minute)
} }
return null return null
} }
private fun parsePriority(text: String): Int? { private fun parseReminder(text: String): ReminderDuration? {
val match = priorityRegex.find(text) ?: return null reminderRegex.find(text)?.let { m ->
return match.groupValues[1].toIntOrNull() 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> = private fun parsePriority(text: String): Int? =
labelRegex.findAll(text) priorityRegex.find(text)?.groupValues?.get(1)?.toIntOrNull()
.map { it.groupValues[1] }
.filter { it.lowercase() !in dateKeywords }
.toList()
private fun parseLists(text: String): List<String> = private fun parseLists(text: String): List<String> =
listRegex.findAll(text) listRegex.findAll(text).map { it.groupValues[1] }.toList()
.map { it.groupValues[1] }
.filter { it.first().isLetter() }
.toList()
private fun cleanTaskText(text: String): String { private fun cleanTaskText(text: String): String {
var clean = text var clean = text
clean = priorityRegex.replace(clean, "") 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 = time12Regex.replace(clean, "")
clean = time24Regex.replace(clean, "") clean = time24Regex.replace(clean, "")
clean = labelRegex.replace(clean, "")
clean = listRegex.replace(clean, "") clean = listRegex.replace(clean, "")
return clean.trim().replace(Regex("""\s{2,}"""), " ") 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)) { Column(modifier = Modifier.padding(start = 24.dp, end = 12.dp, top = 10.dp, bottom = 10.dp)) {
TextField( TextField(
value = composeText, 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(), modifier = Modifier.fillMaxWidth(),
placeholder = { placeholder = {
Text("any thoughts...", fontSize = 15.sp, color = subtleColor.copy(alpha = 0.4f)) 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()) { if (uploadedAttachmentNames.isNotEmpty()) {
Text( Text(
"${uploadedAttachmentNames.size} attachment(s) ready", "${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)) Spacer(Modifier.height(24.dp))
SectionHeader("notifications") SectionHeader("notifications")
Spacer(Modifier.height(6.dp)) 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) 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 val notificationsEnabled: StateFlow<Boolean> = tokenStore.notificationsEnabled
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true) .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 { init {
viewModelScope.launch { authRepository.validateToken() } viewModelScope.launch { authRepository.validateToken() }
} }
@@ -52,6 +61,18 @@ class SettingsViewModel(
viewModelScope.launch { tokenStore.saveNotificationsEnabled(enabled) } 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) { fun getExportJson(onResult: (String) -> Unit) {
viewModelScope.launch { viewModelScope.launch {
val memos = memoRepository.observeMemos().first() val memos = memoRepository.observeMemos().first()
@@ -1,35 +1,53 @@
package com.avinal.memos.ui.tasks package com.avinal.memos.ui.tasks
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog 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.MaterialTheme
import androidx.compose.material3.Text 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.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.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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.domain.Task
import com.avinal.memos.parser.TaskParser import com.avinal.memos.parser.TaskParser
import com.avinal.memos.ui.theme.LocalAccentColor import com.avinal.memos.ui.theme.LocalAccentColor
import kotlin.time.Clock import kotlinx.datetime.Instant
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.todayIn import kotlinx.datetime.toLocalDateTime
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
fun TaskDetailSheet( fun TaskDetailSheet(
task: Task, task: Task,
@@ -40,85 +58,115 @@ fun TaskDetailSheet(
val accent = LocalAccentColor.current val accent = LocalAccentColor.current
val textColor = MaterialTheme.colorScheme.onBackground val textColor = MaterialTheme.colorScheme.onBackground
val subtleColor = MaterialTheme.colorScheme.onSurfaceVariant 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( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = MaterialTheme.colorScheme.surfaceContainer,
title = null, title = null,
text = { text = {
Column { Column(modifier = Modifier.fillMaxWidth()) {
Text(task.text, fontSize = 17.sp, fontWeight = FontWeight.Medium, color = textColor) Text(currentTask.text, fontSize = 17.sp, fontWeight = FontWeight.Medium, color = textColor)
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Text("due date", fontSize = 13.sp, color = subtleColor) val labelWidth = 72.dp
Spacer(Modifier.height(6.dp)) // due: date + time as tappable boxes
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
val today = Clock.System.todayIn(TimeZone.currentSystemDefault()) Text("due", fontSize = 13.sp, color = subtleColor, modifier = Modifier.width(labelWidth))
listOf( ValueBox(currentTask.dueDate?.toString() ?: "no date", currentTask.dueDate != null, accent, subtleColor) { showDatePicker = true }
"today" to today, Spacer(Modifier.width(6.dp))
"tomorrow" to today.plus(1, DateTimeUnit.DAY), ValueBox(currentTask.dueTime?.let { fmt(it) } ?: "no time", currentTask.dueTime != null, accent, subtleColor) { showTimePicker = true }
"next week" to today.plus(7, DateTimeUnit.DAY), }
"no date" to null, Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
).forEach { (label, date) -> Text("reminder", fontSize = 13.sp, color = subtleColor, modifier = Modifier.width(labelWidth))
val isSelected = task.dueDate == date ValueBox(currentTask.reminder?.toString() ?: "no reminder", currentTask.reminder != null, accent, subtleColor) { showReminderPicker = true }
Text( }
label, Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
fontSize = 14.sp, Text("priority", fontSize = 13.sp, color = subtleColor, modifier = Modifier.width(labelWidth))
color = if (isSelected) accent else textColor, ValueBox(currentTask.priority?.let { "p$it" } ?: "none", currentTask.priority != null, accent, subtleColor) { showPriorityPicker = true }
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, }
modifier = Modifier Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
.background( Text("tags", fontSize = 13.sp, color = subtleColor, modifier = Modifier.width(labelWidth))
if (isSelected) accent.copy(alpha = 0.1f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), ValueBox(currentTask.lists.joinToString(" ") { "#$it" }.ifEmpty { "no tags" }, currentTask.lists.isNotEmpty(), accent, subtleColor) { showTagEditor = true }
RoundedCornerShape(4.dp),
)
.clickable { onUpdate(task, TaskParser.reconstructLine(task.copy(dueDate = date))) }
.padding(horizontal = 10.dp, vertical = 6.dp),
)
}
} }
Spacer(Modifier.height(14.dp)) Spacer(Modifier.height(14.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("priority", fontSize = 13.sp, color = subtleColor) Text("open in memo", fontSize = 13.sp, color = accent, modifier = Modifier.clickable { onOpenMemo(currentTask.memoId) }.padding(vertical = 4.dp))
Spacer(Modifier.height(6.dp)) Text("close", fontSize = 13.sp, color = subtleColor, modifier = Modifier.clickable(onClick = onDismiss).padding(vertical = 4.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),
)
}
} }
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 = {}, 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 val isOverdue = date < today
add(formatRelativeDate(date, today) to if (isOverdue) OverdueRed else accent) 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 -> task.priority?.let { p ->
val color = when (p) { 1 -> PriorityP1; 2 -> PriorityP2; else -> PriorityP3 } val color = when (p) { 1 -> PriorityP1; 2 -> PriorityP2; else -> PriorityP3 }
add("p$p" to color) add("p$p" to color)
} }
task.lists.forEach { add("#$it" to accent) } task.lists.forEach { add("#$it" to accent) }
task.labels.forEach { add("@$it" to subtleColor) }
} }
if (metadata.isNotEmpty()) { 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 theme: Flow<String> = dataStore.data.map { it[KEY_THEME] ?: "DARK" }
val accentColor: Flow<String> = dataStore.data.map { it[KEY_ACCENT] ?: "Cobalt" } val accentColor: Flow<String> = dataStore.data.map { it[KEY_ACCENT] ?: "Cobalt" }
val notificationsEnabled: Flow<Boolean> = dataStore.data.map { (it[KEY_NOTIFICATIONS] ?: "true") == "true" } 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) { suspend fun saveCredentials(serverUrl: String, token: String) {
dataStore.edit { prefs -> dataStore.edit { prefs ->
@@ -34,6 +37,18 @@ class TokenStore(private val dataStore: DataStore<Preferences>) {
dataStore.edit { it[KEY_NOTIFICATIONS] = enabled.toString() } 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() { suspend fun clear() {
dataStore.edit { dataStore.edit {
val theme = it[KEY_THEME] 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_THEME = stringPreferencesKey("app_theme")
private val KEY_ACCENT = stringPreferencesKey("accent_color") private val KEY_ACCENT = stringPreferencesKey("accent_color")
private val KEY_NOTIFICATIONS = stringPreferencesKey("notifications_enabled") 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 package com.avinal.memos
import com.avinal.memos.domain.ReminderUnit
import com.avinal.memos.parser.TaskParser import com.avinal.memos.parser.TaskParser
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
@@ -10,293 +11,177 @@ import kotlin.test.assertTrue
class TaskParserTest { class TaskParserTest {
@Test // --- Basic extraction ---
fun extractsBasicUncheckedTask() {
val tasks = TaskParser.extractTasks("m1", "- [ ] Buy milk") @Test fun extractsUncheckedTask() {
assertEquals(1, tasks.size) val t = TaskParser.extractTasks("m1", "- [ ] Buy milk")
assertEquals("Buy milk", tasks[0].text) assertEquals(1, t.size); assertEquals("Buy milk", t[0].text); assertFalse(t[0].isCompleted)
assertFalse(tasks[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 // --- Priority ---
fun extractsCheckedTask() {
val tasks = TaskParser.extractTasks("m1", "- [x] Done task") @Test fun extractsP1() { assertEquals(1, TaskParser.extractTasks("m1", "- [ ] Fix p1")[0].priority) }
assertEquals(1, tasks.size) @Test fun extractsP2() { assertEquals(2, TaskParser.extractTasks("m1", "- [ ] Fix p2")[0].priority) }
assertTrue(tasks[0].isCompleted) @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 // --- Reconstruct ---
fun extractsCheckedTaskUppercaseX() {
val tasks = TaskParser.extractTasks("m1", "- [X] Done task") @Test fun reconstructFull() {
assertEquals(1, tasks.size) val t = TaskParser.extractTasks("m1", "- [ ] Buy milk 2026-06-01 5pm !30min p2 #personal")[0]
assertTrue(tasks[0].isCompleted) 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 // --- Line index / metadata ---
fun extractsPriority() {
val tasks = TaskParser.extractTasks("m1", "- [ ] Fix bug p1") @Test fun lineIndex() {
assertEquals(1, tasks[0].priority) val t = TaskParser.extractTasks("m1", "h\n\n- [ ] A\nt\n- [ ] B")
assertEquals("Fix bug", tasks[0].text) 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 // --- Combined / corner cases ---
fun extractsAllPriorities() {
val content = """ @Test fun fullCombined() {
- [ ] Task A p1 val t = TaskParser.extractTasks("m1", "- [ ] Review PR 2026-05-25 3pm !1hr p1 #work #devops")[0]
- [ ] Task B p2 assertEquals("Review PR", t.text)
- [ ] Task C p3 assertEquals(2026, t.dueDate!!.year); assertEquals(15, t.dueTime!!.hour)
""".trimIndent() assertEquals(1, t.reminder!!.value); assertEquals(ReminderUnit.HR, t.reminder!!.unit)
val tasks = TaskParser.extractTasks("m1", content) assertEquals(1, t.priority); assertEquals(listOf("work", "devops"), t.lists)
assertEquals(3, tasks.size)
assertEquals(1, tasks[0].priority)
assertEquals(2, tasks[1].priority)
assertEquals(3, tasks[2].priority)
} }
@Test fun emptyContent() { assertTrue(TaskParser.extractTasks("m1", "").isEmpty()) }
@Test @Test fun whitespaceOnly() { assertTrue(TaskParser.extractTasks("m1", " \n ").isEmpty()) }
fun extractsExplicitDate() { @Test fun noMetadata() {
val tasks = TaskParser.extractTasks("m1", "- [ ] Review PR 2026-05-25") val t = TaskParser.extractTasks("m1", "- [ ] Just a plain task")[0]
assertNotNull(tasks[0].dueDate) assertNull(t.dueDate); assertNull(t.dueTime); assertNull(t.reminder); assertNull(t.priority); assertTrue(t.lists.isEmpty())
assertEquals(2026, tasks[0].dueDate!!.year)
assertEquals(25, tasks[0].dueDate!!.dayOfMonth)
} }
@Test fun urlHash() { assertTrue(TaskParser.extractTasks("m1", "- [ ] Check https://ex.com/p#sec")[0].text.contains("https://ex.com")) }
@Test @Test fun multipleTimesFirstWins() { assertEquals(9, TaskParser.extractTasks("m1", "- [ ] Call 9am then 5pm")[0].dueTime!!.hour) }
fun extractsTodayKeyword() { @Test fun dateAndTimeAndReminder() {
val tasks = TaskParser.extractTasks("m1", "- [ ] Do thing @today") val t = TaskParser.extractTasks("m1", "- [ ] Meet tomorrow 3pm !15min #work")[0]
assertNotNull(tasks[0].dueDate) assertNotNull(t.dueDate); assertEquals(15, t.dueTime!!.hour)
} assertEquals(15, t.reminder!!.value); assertEquals(ReminderUnit.MIN, t.reminder!!.unit)
@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"))
} }
} }