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:
+15
-49
@@ -13,12 +13,7 @@ import com.avinal.memos.db.entity.toDomain
|
|||||||
import com.avinal.memos.parser.TaskParser
|
import com.avinal.memos.parser.TaskParser
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.datetime.LocalDate
|
|
||||||
import kotlinx.datetime.LocalTime
|
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.atTime
|
|
||||||
import kotlinx.datetime.toInstant
|
|
||||||
import kotlinx.datetime.todayIn
|
|
||||||
|
|
||||||
class TaskCheckWorker(
|
class TaskCheckWorker(
|
||||||
private val appContext: Context,
|
private val appContext: Context,
|
||||||
@@ -27,11 +22,7 @@ class TaskCheckWorker(
|
|||||||
|
|
||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
val prefs = appContext.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 scheduledIds = prefs.getStringSet("scheduled_ids", emptySet()) ?: emptySet()
|
val scheduledIds = prefs.getStringSet("scheduled_ids", emptySet()) ?: emptySet()
|
||||||
val today = Clock.System.todayIn(TimeZone.currentSystemDefault())
|
|
||||||
val nowMillis = Clock.System.now().toEpochMilliseconds()
|
val nowMillis = Clock.System.now().toEpochMilliseconds()
|
||||||
|
|
||||||
val db = Room.databaseBuilder<MemosDatabase>(
|
val db = Room.databaseBuilder<MemosDatabase>(
|
||||||
@@ -48,51 +39,26 @@ class TaskCheckWorker(
|
|||||||
val allTasks = memos.flatMap { memo -> TaskParser.extractTasks(memo.id, memo.content) }
|
val allTasks = memos.flatMap { memo -> TaskParser.extractTasks(memo.id, memo.content) }
|
||||||
val alarmManager = appContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
val alarmManager = appContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||||
val tz = TimeZone.currentSystemDefault()
|
val tz = TimeZone.currentSystemDefault()
|
||||||
val newScheduledIds = scheduledIds.toMutableSet()
|
|
||||||
|
|
||||||
allTasks.forEach { task ->
|
android.util.Log.d("TaskCheckWorker", "Found ${memos.size} memos, ${allTasks.size} tasks, scheduled=${scheduledIds.size}")
|
||||||
if (task.isCompleted || task.dueDate == null || task.id in scheduledIds) return@forEach
|
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}")
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 activeTaskIds = allTasks.filter { !it.isCompleted }.map { it.id }.toSet()
|
||||||
val cleaned = newScheduledIds.filter { id ->
|
val cleaned = newScheduledIds.filter { id ->
|
||||||
val baseId = id.removeSuffix("_am").removeSuffix("_pm")
|
val baseId = id.removeSuffix("_am").removeSuffix("_pm").removeSuffix("_remind")
|
||||||
baseId in activeTaskIds
|
baseId in activeTaskIds
|
||||||
}.toSet()
|
}.toSet()
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,24 @@ package com.avinal.memos.notifications
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.work.ExistingPeriodicWorkPolicy
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
import androidx.work.PeriodicWorkRequestBuilder
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
fun runTaskCheckNow(context: Context) {
|
||||||
|
WorkManager.getInstance(context).enqueue(
|
||||||
|
OneTimeWorkRequestBuilder<TaskCheckWorker>().build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun scheduleTaskChecker(context: Context) {
|
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()
|
val request = PeriodicWorkRequestBuilder<TaskCheckWorker>(15, TimeUnit.MINUTES).build()
|
||||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||||
"task_check",
|
"task_check",
|
||||||
|
|||||||
+8
@@ -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("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))
|
Spacer(Modifier.height(24.dp))
|
||||||
SectionHeader("backup")
|
SectionHeader("backup")
|
||||||
|
|||||||
@@ -92,11 +92,52 @@ fun TaskDetailSheet(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (showReminderPicker) {
|
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))
|
var reminderValue by remember { mutableStateOf(currentTask.reminder?.value?.toString() ?: "30") }
|
||||||
AlertDialog(onDismissRequest = { showReminderPicker = false }, containerColor = MaterialTheme.colorScheme.surfaceContainer, title = null,
|
var reminderUnit by remember { mutableStateOf(currentTask.reminder?.unit ?: ReminderUnit.MIN) }
|
||||||
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))
|
AlertDialog(
|
||||||
}; 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 = {})
|
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) {
|
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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user