diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 9f1c454..cb6fb12 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -3,6 +3,9 @@ + + + + + + + + + + + diff --git a/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/BootReceiver.kt b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/BootReceiver.kt new file mode 100644 index 0000000..c80e2fd --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/BootReceiver.kt @@ -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) + } + } +} diff --git a/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskAlarmReceiver.kt b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskAlarmReceiver.kt new file mode 100644 index 0000000..528eece --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskAlarmReceiver.kt @@ -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, + ) + } +} diff --git a/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskCheckWorker.kt b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskCheckWorker.kt index b0f7bb5..0062902 100644 --- a/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskCheckWorker.kt +++ b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskCheckWorker.kt @@ -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( - 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) + } + } } diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/Task.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/Task.kt index 745434b..d3253a1 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/Task.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/Task.kt @@ -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 = emptyList(), val lists: List = emptyList(), diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/parser/TaskParser.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/parser/TaskParser.kt index f785837..3a4f321 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/parser/TaskParser.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/parser/TaskParser.kt @@ -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("""(? "${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,}"""), " ") diff --git a/composeApp/src/commonTest/kotlin/com/avinal/memos/TaskParserTest.kt b/composeApp/src/commonTest/kotlin/com/avinal/memos/TaskParserTest.kt index 06a71cd..26e5ee8 100644 --- a/composeApp/src/commonTest/kotlin/com/avinal/memos/TaskParserTest.kt +++ b/composeApp/src/commonTest/kotlin/com/avinal/memos/TaskParserTest.kt @@ -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")) + } }