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"))
+ }
}