mirror of
https://github.com/avinal/nikki.git
synced 2026-07-03 21:40:09 +05:30
Add time parsing, exact alarm notifications, boot reschedule
Time parsing: - 12-hour format: 5pm, 2:30pm, 11am, 12am (midnight), 12pm (noon) - 24-hour format: 14:30, 9:00, 17:00 - Time extracted alongside date: "@today 3pm p1 #work" - Time cleaned from display text - dueTime field added to Task domain model (LocalTime?) Notification scheduling: - Tasks with specific time: AlarmManager.setExactAndAllowWhileIdle at that exact time - Tasks with date only: two alarms at 8am and 8pm on the due date - SecurityException fallback to inexact alarm if permission denied - TaskAlarmReceiver fires notification when alarm triggers - BootReceiver re-schedules WorkManager on device reboot Permissions added: - SCHEDULE_EXACT_ALARM, USE_EXACT_ALARM for AlarmManager - RECEIVE_BOOT_COMPLETED for reschedule after reboot Test suite: 76 tests (38 TaskParser, 16 DtoMappers, 7 Serialization, 7 Backup, 5 Visibility, 3 ApiResult), all passing. 9 new time parsing tests: 12h am/pm, 12h with minutes, 24h, midnight, noon, no time returns null, time+date combo, time cleaned from text. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Avinal Kumar <avinal.xlvii@gmail.com>
This commit is contained in:
@@ -3,6 +3,9 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
@@ -23,6 +26,18 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<receiver
|
||||
android:name="com.avinal.memos.notifications.TaskAlarmReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name="com.avinal.memos.notifications.BootReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.avinal.memos.notifications
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
|
||||
scheduleTaskChecker(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.avinal.memos.notifications
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
class TaskAlarmReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val taskText = intent.getStringExtra("task_text") ?: "Task reminder"
|
||||
val dueLabel = intent.getStringExtra("due_label") ?: "due"
|
||||
val notificationId = intent.getIntExtra("notification_id", 0)
|
||||
|
||||
TaskNotificationManager.showTaskNotification(
|
||||
context = context,
|
||||
notificationId = notificationId,
|
||||
taskText = taskText,
|
||||
dueLabel = dueLabel,
|
||||
)
|
||||
}
|
||||
}
|
||||
+74
-35
@@ -1,6 +1,9 @@
|
||||
package com.avinal.memos.notifications
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.room.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.work.CoroutineWorker
|
||||
@@ -8,26 +11,32 @@ import androidx.work.WorkerParameters
|
||||
import com.avinal.memos.db.MemosDatabase
|
||||
import com.avinal.memos.db.entity.toDomain
|
||||
import com.avinal.memos.parser.TaskParser
|
||||
import kotlin.time.Clock
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.todayIn
|
||||
|
||||
class TaskCheckWorker(
|
||||
private val context: Context,
|
||||
private val appContext: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(context, params) {
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val prefs = context.getSharedPreferences("task_notifications", Context.MODE_PRIVATE)
|
||||
val prefs = appContext.getSharedPreferences("task_notifications", Context.MODE_PRIVATE)
|
||||
val notificationsEnabled = prefs.getBoolean("enabled", true)
|
||||
if (!notificationsEnabled) return Result.success()
|
||||
|
||||
val notifiedIds = prefs.getStringSet("notified_ids", emptySet()) ?: emptySet()
|
||||
val today = kotlin.time.Clock.System.todayIn(TimeZone.currentSystemDefault())
|
||||
val scheduledIds = prefs.getStringSet("scheduled_ids", emptySet()) ?: emptySet()
|
||||
val today = Clock.System.todayIn(TimeZone.currentSystemDefault())
|
||||
val nowMillis = Clock.System.now().toEpochMilliseconds()
|
||||
|
||||
val db = Room.databaseBuilder<MemosDatabase>(
|
||||
context = context,
|
||||
name = context.getDatabasePath("memos.db").absolutePath,
|
||||
context = appContext,
|
||||
name = appContext.getDatabasePath("memos.db").absolutePath,
|
||||
)
|
||||
.fallbackToDestructiveMigration(true)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
@@ -37,45 +46,75 @@ class TaskCheckWorker(
|
||||
try {
|
||||
val memos = db.memoDao().getAll().map { it.toDomain() }
|
||||
val allTasks = memos.flatMap { memo -> TaskParser.extractTasks(memo.id, memo.content) }
|
||||
val alarmManager = appContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
val tz = TimeZone.currentSystemDefault()
|
||||
val newScheduledIds = scheduledIds.toMutableSet()
|
||||
|
||||
val dueTasks = allTasks.filter { task ->
|
||||
!task.isCompleted &&
|
||||
task.dueDate != null &&
|
||||
task.dueDate <= today &&
|
||||
task.id !in notifiedIds
|
||||
}
|
||||
allTasks.forEach { task ->
|
||||
if (task.isCompleted || task.dueDate == null || task.id in scheduledIds) return@forEach
|
||||
|
||||
val newNotifiedIds = notifiedIds.toMutableSet()
|
||||
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) {
|
||||
scheduleAlarm(alarmManager, task.id, task.text, "due at ${task.dueTime}", alarmMs)
|
||||
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()
|
||||
|
||||
dueTasks.forEach { task ->
|
||||
val dueLabel = when {
|
||||
task.dueDate!! < today -> "overdue"
|
||||
else -> "due today"
|
||||
if (morning > nowMillis) {
|
||||
scheduleAlarm(alarmManager, "${task.id}_am", task.text, "due today", morning)
|
||||
}
|
||||
if (evening > nowMillis) {
|
||||
scheduleAlarm(alarmManager, "${task.id}_pm", task.text, "reminder: still due today", evening)
|
||||
}
|
||||
newScheduledIds.add(task.id)
|
||||
}
|
||||
val priority = task.priority?.let { " p$it" } ?: ""
|
||||
TaskNotificationManager.showTaskNotification(
|
||||
context = context,
|
||||
notificationId = task.id.hashCode(),
|
||||
taskText = task.text,
|
||||
dueLabel = "$dueLabel$priority",
|
||||
)
|
||||
newNotifiedIds.add(task.id)
|
||||
}
|
||||
|
||||
if (newNotifiedIds.size > notifiedIds.size) {
|
||||
prefs.edit().putStringSet("notified_ids", newNotifiedIds).apply()
|
||||
}
|
||||
// Clean up IDs for completed/removed tasks
|
||||
val activeTaskIds = allTasks.filter { !it.isCompleted }.map { it.id }.toSet()
|
||||
val cleaned = newScheduledIds.filter { id ->
|
||||
val baseId = id.removeSuffix("_am").removeSuffix("_pm")
|
||||
baseId in activeTaskIds
|
||||
}.toSet()
|
||||
|
||||
// Clean up old IDs (tasks that no longer exist)
|
||||
val allTaskIds = allTasks.map { it.id }.toSet()
|
||||
val cleaned = newNotifiedIds.filter { it in allTaskIds }.toSet()
|
||||
if (cleaned.size < newNotifiedIds.size) {
|
||||
prefs.edit().putStringSet("notified_ids", cleaned).apply()
|
||||
}
|
||||
prefs.edit().putStringSet("scheduled_ids", cleaned).apply()
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
private fun scheduleAlarm(
|
||||
alarmManager: AlarmManager,
|
||||
alarmId: String,
|
||||
taskText: String,
|
||||
dueLabel: String,
|
||||
triggerAtMillis: Long,
|
||||
) {
|
||||
val intent = Intent(appContext, TaskAlarmReceiver::class.java).apply {
|
||||
putExtra("task_text", taskText)
|
||||
putExtra("due_label", dueLabel)
|
||||
putExtra("notification_id", alarmId.hashCode())
|
||||
}
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
appContext,
|
||||
alarmId.hashCode(),
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
try {
|
||||
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent)
|
||||
} catch (_: SecurityException) {
|
||||
// Fallback if exact alarm permission not granted
|
||||
alarmManager.set(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.avinal.memos.domain
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalTime
|
||||
|
||||
data class Task(
|
||||
val id: String,
|
||||
@@ -11,6 +12,7 @@ data class Task(
|
||||
val originalLine: String = "",
|
||||
val isCompleted: Boolean,
|
||||
val dueDate: LocalDate? = null,
|
||||
val dueTime: LocalTime? = null,
|
||||
val priority: Int? = null,
|
||||
val labels: List<String> = emptyList(),
|
||||
val lists: List<String> = emptyList(),
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.avinal.memos.parser
|
||||
import com.avinal.memos.domain.Task
|
||||
import kotlin.time.Clock
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
@@ -15,6 +16,9 @@ object TaskParser {
|
||||
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")
|
||||
|
||||
@@ -38,6 +42,7 @@ object TaskParser {
|
||||
originalLine = line,
|
||||
isCompleted = completed,
|
||||
dueDate = parseDueDate(rawText),
|
||||
dueTime = parseDueTime(rawText),
|
||||
priority = parsePriority(rawText),
|
||||
labels = parseLabels(rawText),
|
||||
lists = parseLists(rawText),
|
||||
@@ -47,10 +52,8 @@ 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) {
|
||||
line.replaceFirst("- [x]", "- [ ]").replaceFirst("- [X]", "- [ ]")
|
||||
@@ -79,12 +82,23 @@ object TaskParser {
|
||||
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
|
||||
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')}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun hashTaskContent(cleanText: String, ordinal: Int): String {
|
||||
val input = "$cleanText#$ordinal"
|
||||
var hash = 0L
|
||||
@@ -113,6 +127,31 @@ object TaskParser {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun parsePriority(text: String): Int? {
|
||||
val match = priorityRegex.find(text) ?: return null
|
||||
return match.groupValues[1].toIntOrNull()
|
||||
@@ -134,6 +173,8 @@ object TaskParser {
|
||||
var clean = text
|
||||
clean = priorityRegex.replace(clean, "")
|
||||
clean = dateRegex.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,}"""), " ")
|
||||
|
||||
@@ -230,4 +230,73 @@ class TaskParserTest {
|
||||
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"))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user