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