1
0
mirror of https://github.com/avinal/nikki.git synced 2026-07-03 21:40:09 +05:30

Fix notification scheduling, custom reminder picker, 125 tests

Notification fixes:
- Removed scheduledIds skip — alarms always recomputed on each worker
  run (AlarmManager deduplicates via FLAG_UPDATE_CURRENT)
- Tasks with dueTime but no dueDate now assume today
- Worker runs once immediately on app launch + periodic 15min
- Added debug logging to trace task/alarm computation
- "check reminders now" button in settings triggers immediate check

Custom reminder picker:
- Number input field (digits only) + unit selector (min/hr/day/week)
- Replaces the preset-only list with free-form input
- "save" validates > 0 before applying, "clear" removes reminder

ReminderScheduler extracted to commonMain (testable):
- 18 tests covering: completed/no-date skip, due time alarms,
  default 8am/8pm, duration offsets (min/hr/day/week),
  past alarm filtering, multiple tasks, label preservation

125 tests total, all passing.

Co-Authored-By: Claude Opus 4.6 (1M context)

Signed-off-by: Avinal Kumar <avinal.xlvii@gmail.com>
This commit is contained in:
2026-05-21 20:00:48 +05:30
parent 9bebc628bd
commit 8c3ab59f2f
9 changed files with 366 additions and 54 deletions
@@ -13,12 +13,7 @@ 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 appContext: Context,
@@ -27,11 +22,7 @@ class TaskCheckWorker(
override suspend fun doWork(): Result {
val prefs = appContext.getSharedPreferences("task_notifications", Context.MODE_PRIVATE)
val notificationsEnabled = prefs.getBoolean("enabled", true)
if (!notificationsEnabled) return Result.success()
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>(
@@ -48,51 +39,26 @@ class TaskCheckWorker(
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()
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) {
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 {
val morning = task.dueDate.atTime(LocalTime(8, 0)).toInstant(tz).toEpochMilliseconds()
val evening = task.dueDate.atTime(LocalTime(20, 0)).toInstant(tz).toEpochMilliseconds()
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)
}
android.util.Log.d("TaskCheckWorker", "Found ${memos.size} memos, ${allTasks.size} tasks, scheduled=${scheduledIds.size}")
allTasks.forEach { t ->
android.util.Log.d("TaskCheckWorker", "Task: ${t.text}, date=${t.dueDate}, time=${t.dueTime}, reminder=${t.reminder}, completed=${t.isCompleted}, id=${t.id}")
}
// Clean up IDs for completed/removed tasks
val alarms = ReminderScheduler.computeAlarms(allTasks, nowMillis, tz, scheduledIds)
android.util.Log.d("TaskCheckWorker", "Computed ${alarms.size} alarms")
alarms.forEach { alarm ->
android.util.Log.d("TaskCheckWorker", "Scheduling: ${alarm.taskText} at ${alarm.triggerAtMillis} (${alarm.label})")
scheduleAlarm(alarmManager, alarm.taskId, alarm.taskText, alarm.label, alarm.triggerAtMillis)
}
val newScheduledIds = scheduledIds.toMutableSet()
alarms.forEach { newScheduledIds.add(it.taskId) }
val activeTaskIds = allTasks.filter { !it.isCompleted }.map { it.id }.toSet()
val cleaned = newScheduledIds.filter { id ->
val baseId = id.removeSuffix("_am").removeSuffix("_pm")
val baseId = id.removeSuffix("_am").removeSuffix("_pm").removeSuffix("_remind")
baseId in activeTaskIds
}.toSet()
@@ -2,11 +2,24 @@ package com.avinal.memos.notifications
import android.content.Context
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import java.util.concurrent.TimeUnit
fun runTaskCheckNow(context: Context) {
WorkManager.getInstance(context).enqueue(
OneTimeWorkRequestBuilder<TaskCheckWorker>().build()
)
}
fun scheduleTaskChecker(context: Context) {
// Run once immediately on app launch
WorkManager.getInstance(context).enqueue(
OneTimeWorkRequestBuilder<TaskCheckWorker>().build()
)
// Then every 15 minutes
val request = PeriodicWorkRequestBuilder<TaskCheckWorker>(15, TimeUnit.MINUTES).build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"task_check",
@@ -0,0 +1,8 @@
package com.avinal.memos.util
import com.avinal.memos.notifications.runTaskCheckNow
actual fun triggerReminderCheck() {
val ctx = appContext ?: return
runTaskCheckNow(ctx)
}
@@ -0,0 +1,70 @@
package com.avinal.memos.notifications
import com.avinal.memos.domain.ReminderUnit
import com.avinal.memos.domain.Task
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atTime
import kotlinx.datetime.toInstant
import kotlinx.datetime.todayIn
data class ScheduledAlarm(
val taskId: String,
val taskText: String,
val label: String,
val triggerAtMillis: Long,
)
object ReminderScheduler {
fun computeAlarms(
tasks: List<Task>,
nowMillis: Long,
timeZone: TimeZone,
alreadyScheduledIds: Set<String>,
): List<ScheduledAlarm> {
val alarms = mutableListOf<ScheduledAlarm>()
val today = kotlin.time.Clock.System.todayIn(timeZone)
tasks.forEach { task ->
if (task.isCompleted) return@forEach
val effectiveDate = task.dueDate ?: if (task.dueTime != null) today else return@forEach
// Explicit reminder: fire at dueDateTime - duration
if (task.reminder != null) {
val dueInstant = effectiveDate.atTime(task.dueTime ?: LocalTime(8, 0)).toInstant(timeZone)
val offsetMs = when (task.reminder.unit) {
ReminderUnit.MIN -> task.reminder.value * 60_000L
ReminderUnit.HR -> task.reminder.value * 3_600_000L
ReminderUnit.DAY -> task.reminder.value * 86_400_000L
ReminderUnit.WEEK -> task.reminder.value * 604_800_000L
}
val reminderMs = dueInstant.toEpochMilliseconds() - offsetMs
if (reminderMs > nowMillis) {
alarms.add(ScheduledAlarm("${task.id}_remind", task.text, "reminder: ${task.reminder}", reminderMs))
}
}
// Due time alarm
if (task.dueTime != null) {
val alarmMs = effectiveDate.atTime(task.dueTime).toInstant(timeZone).toEpochMilliseconds()
if (alarmMs > nowMillis) {
alarms.add(ScheduledAlarm(task.id, task.text, "due at ${task.dueTime}", alarmMs))
}
} else {
// Default: 8am and 8pm on due date
val morning = effectiveDate.atTime(LocalTime(8, 0)).toInstant(timeZone).toEpochMilliseconds()
val evening = effectiveDate.atTime(LocalTime(20, 0)).toInstant(timeZone).toEpochMilliseconds()
if (morning > nowMillis) {
alarms.add(ScheduledAlarm("${task.id}_am", task.text, "due today", morning))
}
if (evening > nowMillis) {
alarms.add(ScheduledAlarm("${task.id}_pm", task.text, "reminder: still due today", evening))
}
}
}
return alarms
}
}
@@ -165,6 +165,13 @@ fun SettingsScreen(
)
}
Text("get notified when tasks are due or overdue", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
"check reminders now",
fontSize = 13.sp, color = accent,
modifier = Modifier.clickable {
com.avinal.memos.util.triggerReminderCheck()
}.padding(vertical = 4.dp),
)
Spacer(Modifier.height(24.dp))
SectionHeader("backup")
@@ -92,11 +92,52 @@ fun TaskDetailSheet(
}
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 = {})
var reminderValue by remember { mutableStateOf(currentTask.reminder?.value?.toString() ?: "30") }
var reminderUnit by remember { mutableStateOf(currentTask.reminder?.unit ?: ReminderUnit.MIN) }
AlertDialog(
onDismissRequest = { showReminderPicker = false },
containerColor = MaterialTheme.colorScheme.surfaceContainer,
title = null,
text = {
Column {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextField(
value = reminderValue,
onValueChange = { reminderValue = it.filter { c -> c.isDigit() } },
modifier = Modifier.width(72.dp),
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),
)
ReminderUnit.entries.forEach { unit ->
val sel = reminderUnit == unit
Text(
unit.suffix, fontSize = 14.sp,
color = if (sel) accent else textColor,
fontWeight = if (sel) FontWeight.SemiBold else FontWeight.Normal,
modifier = Modifier.clickable { reminderUnit = unit }.padding(horizontal = 6.dp, vertical = 4.dp),
)
}
}
Spacer(Modifier.height(12.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
if (currentTask.reminder != null) {
Text("clear", fontSize = 14.sp, color = subtleColor, modifier = Modifier.clickable { showReminderPicker = false; doUpdate(currentTask.copy(reminder = null)) })
} else {
Spacer(Modifier.width(1.dp))
}
Text("save", fontSize = 14.sp, color = accent, fontWeight = FontWeight.SemiBold,
modifier = Modifier.clickable {
showReminderPicker = false
val v = reminderValue.toIntOrNull()
if (v != null && v > 0) doUpdate(currentTask.copy(reminder = ReminderDuration(v, reminderUnit)))
})
}
}
},
confirmButton = {},
)
}
if (showPriorityPicker) {
@@ -0,0 +1,3 @@
package com.avinal.memos.util
expect fun triggerReminderCheck()
@@ -0,0 +1,199 @@
package com.avinal.memos
import com.avinal.memos.domain.ReminderDuration
import com.avinal.memos.domain.ReminderUnit
import com.avinal.memos.domain.Task
import com.avinal.memos.notifications.ReminderScheduler
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atTime
import kotlinx.datetime.toInstant
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ReminderSchedulerTest {
private val tz = TimeZone.UTC
private val dueDate = LocalDate(2026, 6, 15)
// nowMillis = 2026-06-15 00:00 UTC
private val nowMillis = dueDate.atTime(LocalTime(0, 0)).toInstant(tz).toEpochMilliseconds()
private fun task(
id: String = "t1",
completed: Boolean = false,
date: LocalDate? = dueDate,
time: LocalTime? = null,
reminder: ReminderDuration? = null,
priority: Int? = null,
) = Task(
id = id, memoId = "m1", lineIndex = 0, text = "Test",
isCompleted = completed, dueDate = date, dueTime = time,
reminder = reminder, priority = priority,
)
@Test
fun completedTaskProducesNoAlarms() {
val alarms = ReminderScheduler.computeAlarms(listOf(task(completed = true)), nowMillis, tz, emptySet())
assertTrue(alarms.isEmpty())
}
@Test
fun taskWithNoDateProducesNoAlarms() {
val alarms = ReminderScheduler.computeAlarms(listOf(task(date = null)), nowMillis, tz, emptySet())
assertTrue(alarms.isEmpty())
}
@Test
fun alreadyScheduledTaskStillRecomputed() {
// Alarms are always recomputed — AlarmManager deduplicates via PendingIntent
val alarms = ReminderScheduler.computeAlarms(listOf(task()), nowMillis, tz, setOf("t1"))
assertTrue(alarms.isNotEmpty())
}
@Test
fun taskWithDueTimeProducesOneAlarm() {
val t = task(time = LocalTime(15, 0))
val alarms = ReminderScheduler.computeAlarms(listOf(t), nowMillis, tz, emptySet())
assertEquals(1, alarms.size)
assertEquals("t1", alarms[0].taskId)
assertTrue(alarms[0].label.contains("due at"))
}
@Test
fun taskWithDueTimeAlarmAtCorrectTime() {
val t = task(time = LocalTime(15, 0))
val alarms = ReminderScheduler.computeAlarms(listOf(t), nowMillis, tz, emptySet())
val expected = dueDate.atTime(LocalTime(15, 0)).toInstant(tz).toEpochMilliseconds()
assertEquals(expected, alarms[0].triggerAtMillis)
}
@Test
fun taskWithoutTimeProducesTwoAlarms() {
val alarms = ReminderScheduler.computeAlarms(listOf(task()), nowMillis, tz, emptySet())
assertEquals(2, alarms.size)
assertTrue(alarms.any { it.taskId == "t1_am" })
assertTrue(alarms.any { it.taskId == "t1_pm" })
}
@Test
fun defaultAlarmsAt8amAnd8pm() {
val alarms = ReminderScheduler.computeAlarms(listOf(task()), nowMillis, tz, emptySet())
val am = alarms.first { it.taskId == "t1_am" }
val pm = alarms.first { it.taskId == "t1_pm" }
val expected8am = dueDate.atTime(LocalTime(8, 0)).toInstant(tz).toEpochMilliseconds()
val expected8pm = dueDate.atTime(LocalTime(20, 0)).toInstant(tz).toEpochMilliseconds()
assertEquals(expected8am, am.triggerAtMillis)
assertEquals(expected8pm, pm.triggerAtMillis)
}
@Test
fun reminderDurationOffset30min() {
val t = task(time = LocalTime(15, 0), reminder = ReminderDuration(30, ReminderUnit.MIN))
val alarms = ReminderScheduler.computeAlarms(listOf(t), nowMillis, tz, emptySet())
val reminder = alarms.first { it.taskId.endsWith("_remind") }
val expected = dueDate.atTime(LocalTime(15, 0)).toInstant(tz).toEpochMilliseconds() - 30 * 60_000L
assertEquals(expected, reminder.triggerAtMillis)
}
@Test
fun reminderDurationOffset1hr() {
val t = task(time = LocalTime(10, 0), reminder = ReminderDuration(1, ReminderUnit.HR))
val alarms = ReminderScheduler.computeAlarms(listOf(t), nowMillis, tz, emptySet())
val reminder = alarms.first { it.taskId.endsWith("_remind") }
val expected = dueDate.atTime(LocalTime(10, 0)).toInstant(tz).toEpochMilliseconds() - 3_600_000L
assertEquals(expected, reminder.triggerAtMillis)
}
@Test
fun reminderDurationOffset1day() {
val t = task(reminder = ReminderDuration(1, ReminderUnit.DAY))
// Set now to 2 days before due date so the 1-day reminder is in the future
val earlyNow = dueDate.atTime(LocalTime(0, 0)).toInstant(tz).toEpochMilliseconds() - 2 * 86_400_000L
val alarms = ReminderScheduler.computeAlarms(listOf(t), earlyNow, tz, emptySet())
val reminder = alarms.first { it.taskId.endsWith("_remind") }
val expected = dueDate.atTime(LocalTime(8, 0)).toInstant(tz).toEpochMilliseconds() - 86_400_000L
assertEquals(expected, reminder.triggerAtMillis)
}
@Test
fun reminderDurationOffset1week() {
val t = task(reminder = ReminderDuration(1, ReminderUnit.WEEK))
val earlyNow = dueDate.atTime(LocalTime(0, 0)).toInstant(tz).toEpochMilliseconds() - 8 * 86_400_000L
val alarms = ReminderScheduler.computeAlarms(listOf(t), earlyNow, tz, emptySet())
val reminder = alarms.first { it.taskId.endsWith("_remind") }
val expected = dueDate.atTime(LocalTime(8, 0)).toInstant(tz).toEpochMilliseconds() - 604_800_000L
assertEquals(expected, reminder.triggerAtMillis)
}
@Test
fun reminderWithDueTimeProducesBothAlarms() {
val t = task(time = LocalTime(14, 0), reminder = ReminderDuration(30, ReminderUnit.MIN))
val alarms = ReminderScheduler.computeAlarms(listOf(t), nowMillis, tz, emptySet())
// Should have: reminder alarm + due time alarm
assertEquals(2, alarms.size)
assertTrue(alarms.any { it.taskId.endsWith("_remind") })
assertTrue(alarms.any { it.taskId == "t1" })
}
@Test
fun pastAlarmsNotScheduled() {
// now is after the due time
val lateNow = dueDate.atTime(LocalTime(23, 0)).toInstant(tz).toEpochMilliseconds()
val t = task(time = LocalTime(10, 0))
val alarms = ReminderScheduler.computeAlarms(listOf(t), lateNow, tz, emptySet())
assertTrue(alarms.isEmpty())
}
@Test
fun pastReminderNotScheduledButDueTimeStillIs() {
// now is after the reminder time but before due time
val t = task(time = LocalTime(15, 0), reminder = ReminderDuration(2, ReminderUnit.HR))
val midNow = dueDate.atTime(LocalTime(14, 0)).toInstant(tz).toEpochMilliseconds()
val alarms = ReminderScheduler.computeAlarms(listOf(t), midNow, tz, emptySet())
// Reminder at 13:00 is past, due at 15:00 is future
assertEquals(1, alarms.size)
assertEquals("t1", alarms[0].taskId)
}
@Test
fun multipleTasksProduceCorrectAlarms() {
val tasks = listOf(
task(id = "a", time = LocalTime(9, 0)),
task(id = "b", time = LocalTime(17, 0)),
task(id = "c", completed = true),
task(id = "d", date = null),
)
val alarms = ReminderScheduler.computeAlarms(tasks, nowMillis, tz, emptySet())
assertEquals(2, alarms.size)
assertTrue(alarms.any { it.taskId == "a" })
assertTrue(alarms.any { it.taskId == "b" })
}
@Test
fun alarmLabelsCorrect() {
val t = task(time = LocalTime(14, 0), reminder = ReminderDuration(30, ReminderUnit.MIN))
val alarms = ReminderScheduler.computeAlarms(listOf(t), nowMillis, tz, emptySet())
val reminder = alarms.first { it.taskId.endsWith("_remind") }
assertTrue(reminder.label.contains("reminder"))
val due = alarms.first { it.taskId == "t1" }
assertTrue(due.label.contains("due at"))
}
@Test
fun defaultAlarmLabelsCorrect() {
val alarms = ReminderScheduler.computeAlarms(listOf(task()), nowMillis, tz, emptySet())
val am = alarms.first { it.taskId == "t1_am" }
val pm = alarms.first { it.taskId == "t1_pm" }
assertEquals("due today", am.label)
assertTrue(pm.label.contains("still due"))
}
@Test
fun taskTextPreservedInAlarm() {
val t = task().copy(text = "Buy groceries")
val alarms = ReminderScheduler.computeAlarms(listOf(t), nowMillis, tz, emptySet())
assertTrue(alarms.all { it.taskText == "Buy groceries" })
}
}
@@ -0,0 +1,5 @@
package com.avinal.memos.util
actual fun triggerReminderCheck() {
// TODO: iOS notification check
}