mirror of
https://github.com/avinal/nikki.git
synced 2026-07-03 21:40:09 +05:30
Auto sync setting, pretty notifications, background permissions,
reliable alarm scheduling Auto sync: - Configurable interval: 1/2/5/10/15/30/60 min (default 5) - MemoRepository.syncIntervalMinutes updated live from DataStore - Setting shown under "memos" section in settings Notifications by priority: - p1: 🔴 alarm sound, long vibration, wakes screen, bypasses DND - p2: 🟠 notification sound, medium vibration, heads-up - p3: 🔵 notification sound, short vibration - none: silent, no vibration - BigTextStyle with priority tag, colored accent bar - 4 separate Android notification channels Background reliability: - FOREGROUND_SERVICE, WAKE_LOCK, REQUEST_IGNORE_BATTERY_OPTIMIZATIONS - Battery optimization exemption requested on first launch - DirectAlarmScheduler: schedules alarms directly from app process using live Room data (bypasses WorkManager DB sync issue) - onContentChanged fires direct scheduling on every memo create/update - TaskReminderReceiver moved to androidApp module for reliable cold-start instantiation - WorkManager kept as 15-min backup for server-side changes 123 tests passing. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Avinal Kumar <avinal.xlvii@gmail.com>
This commit is contained in:
+54
@@ -0,0 +1,54 @@
|
||||
package com.avinal.memos.notifications
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.avinal.memos.domain.Memo
|
||||
import com.avinal.memos.parser.TaskParser
|
||||
import kotlin.time.Clock
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
|
||||
object DirectAlarmScheduler {
|
||||
|
||||
fun scheduleFromMemos(context: Context, memos: List<Memo>) {
|
||||
val allTasks = memos.flatMap { memo -> TaskParser.extractTasks(memo.id, memo.content) }
|
||||
val nowMillis = Clock.System.now().toEpochMilliseconds()
|
||||
val tz = TimeZone.currentSystemDefault()
|
||||
|
||||
val prefs = context.getSharedPreferences("memos_prefs", Context.MODE_PRIVATE)
|
||||
val defaultTimeStr = prefs.getString("default_notify_time", "20:00") ?: "20:00"
|
||||
val parts = defaultTimeStr.split(":")
|
||||
val defaultTime = try { LocalTime(parts[0].toInt(), parts.getOrElse(1) { "0" }.toInt()) } catch (_: Exception) { LocalTime(20, 0) }
|
||||
|
||||
val alarms = ReminderScheduler.computeAlarms(allTasks, nowMillis, tz, emptySet(), defaultTime)
|
||||
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
|
||||
alarms.forEach { alarm ->
|
||||
val receiverClass = try {
|
||||
Class.forName("com.avinal.memos.TaskReminderReceiver")
|
||||
} catch (_: Exception) {
|
||||
TaskAlarmReceiver::class.java
|
||||
}
|
||||
val uniqueId = (alarm.taskId + alarm.triggerAtMillis.toString()).hashCode()
|
||||
val intent = Intent(context, receiverClass).apply {
|
||||
putExtra("task_text", alarm.taskText)
|
||||
putExtra("due_label", alarm.label)
|
||||
putExtra("notification_id", uniqueId)
|
||||
putExtra("priority", alarm.priority)
|
||||
}
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context, uniqueId, intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
try {
|
||||
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, alarm.triggerAtMillis, pendingIntent)
|
||||
} catch (_: SecurityException) {
|
||||
alarmManager.set(AlarmManager.RTC_WAKEUP, alarm.triggerAtMillis, pendingIntent)
|
||||
}
|
||||
}
|
||||
|
||||
android.util.Log.d("DirectAlarmScheduler", "Scheduled ${alarms.size} alarms from ${memos.size} memos")
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,15 @@ class TaskAlarmReceiver : BroadcastReceiver() {
|
||||
val taskText = intent.getStringExtra("task_text") ?: "Task reminder"
|
||||
val dueLabel = intent.getStringExtra("due_label") ?: "due"
|
||||
val notificationId = intent.getIntExtra("notification_id", 0)
|
||||
val priority = intent.getIntExtra("priority", 0)
|
||||
|
||||
TaskNotificationManager.createChannels(context)
|
||||
TaskNotificationManager.showTaskNotification(
|
||||
context = context,
|
||||
notificationId = notificationId,
|
||||
taskText = taskText,
|
||||
dueLabel = dueLabel,
|
||||
priority = priority,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+25
-6
@@ -25,6 +25,16 @@ class TaskCheckWorker(
|
||||
val scheduledIds = prefs.getStringSet("scheduled_ids", emptySet()) ?: emptySet()
|
||||
val nowMillis = Clock.System.now().toEpochMilliseconds()
|
||||
|
||||
// Read default notify time from DataStore file (shared prefs fallback)
|
||||
val notifyPrefs = appContext.getSharedPreferences("memos_prefs", Context.MODE_PRIVATE)
|
||||
val defaultTimeStr = notifyPrefs.getString("default_notify_time", "20:00") ?: "20:00"
|
||||
val defaultTimeParts = defaultTimeStr.split(":")
|
||||
val defaultTime = try {
|
||||
kotlinx.datetime.LocalTime(defaultTimeParts[0].toInt(), defaultTimeParts.getOrElse(1) { "0" }.toInt())
|
||||
} catch (_: Exception) {
|
||||
kotlinx.datetime.LocalTime(20, 0)
|
||||
}
|
||||
|
||||
val db = Room.databaseBuilder<MemosDatabase>(
|
||||
context = appContext,
|
||||
name = appContext.getDatabasePath("memos.db").absolutePath,
|
||||
@@ -45,12 +55,13 @@ class TaskCheckWorker(
|
||||
android.util.Log.d("TaskCheckWorker", "Task: ${t.text}, date=${t.dueDate}, time=${t.dueTime}, reminder=${t.reminder}, completed=${t.isCompleted}, id=${t.id}")
|
||||
}
|
||||
|
||||
val alarms = ReminderScheduler.computeAlarms(allTasks, nowMillis, tz, scheduledIds)
|
||||
android.util.Log.d("TaskCheckWorker", "nowMillis=$nowMillis, tz=$tz, defaultTime=$defaultTime")
|
||||
val alarms = ReminderScheduler.computeAlarms(allTasks, nowMillis, tz, scheduledIds, defaultTime)
|
||||
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)
|
||||
android.util.Log.d("TaskCheckWorker", "Scheduling: ${alarm.taskText} at ${alarm.triggerAtMillis} (${alarm.label}) p=${alarm.priority}")
|
||||
scheduleAlarm(alarmManager, alarm.taskId, alarm.taskText, alarm.label, alarm.triggerAtMillis, alarm.priority)
|
||||
}
|
||||
|
||||
val newScheduledIds = scheduledIds.toMutableSet()
|
||||
@@ -76,15 +87,23 @@ class TaskCheckWorker(
|
||||
taskText: String,
|
||||
dueLabel: String,
|
||||
triggerAtMillis: Long,
|
||||
priority: Int = 0,
|
||||
) {
|
||||
val intent = Intent(appContext, TaskAlarmReceiver::class.java).apply {
|
||||
val uniqueId = (alarmId + triggerAtMillis.toString()).hashCode()
|
||||
val receiverClass = try {
|
||||
Class.forName("com.avinal.memos.TaskReminderReceiver")
|
||||
} catch (_: Exception) {
|
||||
TaskAlarmReceiver::class.java
|
||||
}
|
||||
val intent = Intent(appContext, receiverClass).apply {
|
||||
putExtra("task_text", taskText)
|
||||
putExtra("due_label", dueLabel)
|
||||
putExtra("notification_id", alarmId.hashCode())
|
||||
putExtra("notification_id", uniqueId)
|
||||
putExtra("priority", priority)
|
||||
}
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
appContext,
|
||||
alarmId.hashCode(),
|
||||
uniqueId,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
+82
-17
@@ -5,24 +5,47 @@ import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.media.AudioAttributes
|
||||
import android.media.RingtoneManager
|
||||
import androidx.core.app.NotificationCompat
|
||||
|
||||
object TaskNotificationManager {
|
||||
|
||||
private const val CHANNEL_ID = "task_reminders"
|
||||
private const val CHANNEL_NAME = "Task Reminders"
|
||||
private const val CHANNEL_P1 = "task_p1"
|
||||
private const val CHANNEL_P2 = "task_p2"
|
||||
private const val CHANNEL_P3 = "task_p3"
|
||||
private const val CHANNEL_DEFAULT = "task_default"
|
||||
|
||||
fun createChannel(context: Context) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
).apply {
|
||||
description = "Reminders for tasks with due dates"
|
||||
}
|
||||
fun createChannels(context: Context) {
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.createNotificationChannel(channel)
|
||||
val soundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
|
||||
val alarmUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM)
|
||||
val audioAttr = AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.build()
|
||||
|
||||
manager.createNotificationChannel(NotificationChannel(CHANNEL_P1, "P1 — Urgent", NotificationManager.IMPORTANCE_HIGH).apply {
|
||||
description = "High priority — alarm sound, strong vibration, wakes screen"
|
||||
enableVibration(true); vibrationPattern = longArrayOf(0, 500, 200, 500, 200, 500)
|
||||
setSound(alarmUri, audioAttr); lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC; setShowBadge(true); setBypassDnd(true)
|
||||
})
|
||||
manager.createNotificationChannel(NotificationChannel(CHANNEL_P2, "P2 — Medium", NotificationManager.IMPORTANCE_HIGH).apply {
|
||||
description = "Medium priority — notification sound, vibration"
|
||||
enableVibration(true); vibrationPattern = longArrayOf(0, 300, 200, 300)
|
||||
setSound(soundUri, audioAttr); lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC; setShowBadge(true)
|
||||
})
|
||||
manager.createNotificationChannel(NotificationChannel(CHANNEL_P3, "P3 — Low", NotificationManager.IMPORTANCE_DEFAULT).apply {
|
||||
description = "Low priority — notification sound, short vibration"
|
||||
enableVibration(true); vibrationPattern = longArrayOf(0, 200)
|
||||
setSound(soundUri, audioAttr); lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC
|
||||
})
|
||||
manager.createNotificationChannel(NotificationChannel(CHANNEL_DEFAULT, "No Priority", NotificationManager.IMPORTANCE_LOW).apply {
|
||||
description = "No priority — silent notification"
|
||||
enableVibration(false); setSound(null, null)
|
||||
})
|
||||
|
||||
manager.deleteNotificationChannel("task_reminders")
|
||||
}
|
||||
|
||||
fun showTaskNotification(
|
||||
@@ -30,6 +53,7 @@ object TaskNotificationManager {
|
||||
notificationId: Int,
|
||||
taskText: String,
|
||||
dueLabel: String,
|
||||
priority: Int = 0,
|
||||
) {
|
||||
val openIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
@@ -39,16 +63,57 @@ object TaskNotificationManager {
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
val channelId = when (priority) { 1 -> CHANNEL_P1; 2 -> CHANNEL_P2; 3 -> CHANNEL_P3; else -> CHANNEL_DEFAULT }
|
||||
val notifPriority = when (priority) { 1 -> NotificationCompat.PRIORITY_MAX; 2 -> NotificationCompat.PRIORITY_HIGH; 3 -> NotificationCompat.PRIORITY_DEFAULT; else -> NotificationCompat.PRIORITY_LOW }
|
||||
|
||||
val priorityEmoji = when (priority) { 1 -> "🔴"; 2 -> "🟠"; 3 -> "🔵"; else -> "" }
|
||||
val priorityTag = when (priority) { 1 -> "URGENT"; 2 -> "MEDIUM"; 3 -> "LOW"; else -> "" }
|
||||
|
||||
val title = buildString {
|
||||
if (priorityEmoji.isNotEmpty()) append("$priorityEmoji ")
|
||||
append(taskText)
|
||||
}
|
||||
|
||||
val bigText = buildString {
|
||||
append(dueLabel)
|
||||
if (priorityTag.isNotEmpty()) append("\nPriority: $priorityTag")
|
||||
}
|
||||
|
||||
val builder = NotificationCompat.Builder(context, channelId)
|
||||
.setSmallIcon(android.R.drawable.ic_popup_reminder)
|
||||
.setContentTitle(taskText)
|
||||
.setContentTitle(title)
|
||||
.setContentText(dueLabel)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(bigText))
|
||||
.setSubText(when (priority) { 1 -> "p1 urgent"; 2 -> "p2 medium"; 3 -> "p3 low"; else -> "memos" })
|
||||
.setWhen(System.currentTimeMillis())
|
||||
.setShowWhen(true)
|
||||
.setPriority(notifPriority)
|
||||
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(false)
|
||||
.setContentIntent(pendingOpen)
|
||||
.build()
|
||||
.setDefaults(NotificationCompat.DEFAULT_LIGHTS)
|
||||
.setGroup("task_reminders_${notificationId}")
|
||||
.setColor(when (priority) { 1 -> 0xFFE51400.toInt(); 2 -> 0xFFF0A30A.toInt(); 3 -> 0xFF1BA1E2.toInt(); else -> 0xFF666666.toInt() })
|
||||
|
||||
when (priority) {
|
||||
1 -> {
|
||||
builder.setFullScreenIntent(pendingOpen, true)
|
||||
builder.setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM))
|
||||
builder.setVibrate(longArrayOf(0, 500, 200, 500, 200, 500))
|
||||
}
|
||||
2 -> {
|
||||
builder.setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION))
|
||||
builder.setVibrate(longArrayOf(0, 300, 200, 300))
|
||||
}
|
||||
3 -> {
|
||||
builder.setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION))
|
||||
builder.setVibrate(longArrayOf(0, 200))
|
||||
}
|
||||
}
|
||||
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.notify(notificationId, notification)
|
||||
manager.notify(notificationId, builder.build())
|
||||
}
|
||||
}
|
||||
|
||||
+16
-1
@@ -1,8 +1,23 @@
|
||||
package com.avinal.memos.util
|
||||
|
||||
import com.avinal.memos.notifications.DirectAlarmScheduler
|
||||
import com.avinal.memos.notifications.runTaskCheckNow
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private var liveMemosProvider: (() -> List<com.avinal.memos.domain.Memo>)? = null
|
||||
|
||||
actual fun setLiveMemosProvider(provider: () -> List<com.avinal.memos.domain.Memo>) {
|
||||
liveMemosProvider = provider
|
||||
}
|
||||
|
||||
actual fun triggerReminderCheck() {
|
||||
val ctx = appContext ?: return
|
||||
runTaskCheckNow(ctx)
|
||||
val memos = liveMemosProvider?.invoke()
|
||||
if (memos != null && memos.isNotEmpty()) {
|
||||
DirectAlarmScheduler.scheduleFromMemos(ctx, memos)
|
||||
} else {
|
||||
runTaskCheckNow(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import com.avinal.memos.db.entity.toDomain
|
||||
|
||||
class AppDependencies(
|
||||
dataStorePath: String,
|
||||
@@ -37,7 +39,11 @@ class AppDependencies(
|
||||
}
|
||||
|
||||
val authRepository: AuthRepository by lazy { AuthRepository(apiClient, tokenStore) }
|
||||
val memoRepository: MemoRepository by lazy { MemoRepository(apiClient, database.memoDao()) }
|
||||
val memoRepository: MemoRepository by lazy {
|
||||
MemoRepository(apiClient, database.memoDao()) {
|
||||
com.avinal.memos.util.triggerReminderCheck()
|
||||
}
|
||||
}
|
||||
|
||||
private var initJob: kotlinx.coroutines.Job? = null
|
||||
|
||||
@@ -46,6 +52,17 @@ class AppDependencies(
|
||||
initJob = CoroutineScope(Dispatchers.IO).launch {
|
||||
launch { tokenStore.accessToken.collect { cachedToken = it } }
|
||||
launch { tokenStore.serverUrl.collect { cachedServerUrl = it } }
|
||||
launch { tokenStore.syncInterval.collect { memoRepository.syncIntervalMinutes = it } }
|
||||
launch { initializeLiveMemosProvider() }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun initializeLiveMemosProvider() {
|
||||
try {
|
||||
val dao = database.memoDao()
|
||||
com.avinal.memos.util.setLiveMemosProvider {
|
||||
runBlocking { dao.getAll().map { it.toDomain() } }
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,14 +17,16 @@ import kotlinx.coroutines.launch
|
||||
class MemoRepository(
|
||||
private val apiClient: MemosApiClient,
|
||||
private val memoDao: MemoDao,
|
||||
private val onContentChanged: (() -> Unit)? = null,
|
||||
) {
|
||||
private var nextPageToken: String = ""
|
||||
private var hasMorePages: Boolean = true
|
||||
private var lastFetchTime: Long = 0L
|
||||
var syncIntervalMinutes: Int = 5
|
||||
|
||||
private fun nowMillis(): Long = Clock.System.now().toEpochMilliseconds()
|
||||
|
||||
private fun isCacheStale(): Boolean = (nowMillis() - lastFetchTime) > CACHE_TTL_MS
|
||||
private fun isCacheStale(): Boolean = (nowMillis() - lastFetchTime) > (syncIntervalMinutes * 60 * 1000L)
|
||||
|
||||
fun observeMemos(): Flow<List<Memo>> {
|
||||
if (isCacheStale()) {
|
||||
@@ -101,6 +103,7 @@ class MemoRepository(
|
||||
is ApiResult.Success -> {
|
||||
val memo = result.data.toDomain()
|
||||
memoDao.upsert(memo.toEntity(nowMillis()))
|
||||
onContentChanged?.invoke()
|
||||
ApiResult.Success(memo)
|
||||
}
|
||||
is ApiResult.Error -> result
|
||||
@@ -123,6 +126,7 @@ class MemoRepository(
|
||||
is ApiResult.Success -> {
|
||||
val memo = result.data.toDomain()
|
||||
memoDao.upsert(memo.toEntity(nowMillis()))
|
||||
onContentChanged?.invoke()
|
||||
ApiResult.Success(memo)
|
||||
}
|
||||
is ApiResult.Error -> result
|
||||
@@ -196,8 +200,4 @@ class MemoRepository(
|
||||
nextPageToken = ""
|
||||
hasMorePages = true
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CACHE_TTL_MS = 5 * 60 * 1000L
|
||||
}
|
||||
}
|
||||
|
||||
+49
-26
@@ -1,5 +1,6 @@
|
||||
package com.avinal.memos.notifications
|
||||
|
||||
import com.avinal.memos.domain.ReminderDuration
|
||||
import com.avinal.memos.domain.ReminderUnit
|
||||
import com.avinal.memos.domain.Task
|
||||
import kotlinx.datetime.LocalTime
|
||||
@@ -13,58 +14,80 @@ data class ScheduledAlarm(
|
||||
val taskText: String,
|
||||
val label: String,
|
||||
val triggerAtMillis: Long,
|
||||
val priority: Int = 0,
|
||||
)
|
||||
|
||||
object ReminderScheduler {
|
||||
|
||||
/**
|
||||
* Compute alarms for tasks. Logic:
|
||||
* - time only, no date → date = today
|
||||
* - date only, no time → time = [defaultTime] (default 20:00)
|
||||
* - both set → use as-is
|
||||
* - neither → skip (no alarm possible)
|
||||
*
|
||||
* Reminder:
|
||||
* - explicit !duration → alarm at dueDateTime - duration
|
||||
* - no explicit reminder → alarm at dueDateTime itself
|
||||
*/
|
||||
fun computeAlarms(
|
||||
tasks: List<Task>,
|
||||
nowMillis: Long,
|
||||
timeZone: TimeZone,
|
||||
alreadyScheduledIds: Set<String>,
|
||||
alreadyScheduledIds: Set<String> = emptySet(),
|
||||
defaultTime: LocalTime = LocalTime(20, 0),
|
||||
): 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
|
||||
val effectiveTime = task.dueTime ?: defaultTime
|
||||
|
||||
val dueMs = effectiveDate.atTime(effectiveTime).toInstant(timeZone).toEpochMilliseconds()
|
||||
|
||||
println("ReminderScheduler: task=${task.text} effectiveDate=$effectiveDate effectiveTime=$effectiveTime dueMs=$dueMs nowMillis=$nowMillis future=${dueMs > nowMillis}")
|
||||
|
||||
// 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
|
||||
val offsetMs = durationToMillis(task.reminder)
|
||||
val reminderMs = dueMs - offsetMs
|
||||
if (reminderMs > nowMillis) {
|
||||
alarms.add(ScheduledAlarm("${task.id}_remind", task.text, "reminder: ${task.reminder}", reminderMs))
|
||||
val label = buildReminderLabel(task, effectiveDate, effectiveTime)
|
||||
alarms.add(ScheduledAlarm("${task.id}_remind", task.text, label, reminderMs, task.priority ?: 0))
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
if (dueMs > nowMillis) {
|
||||
val label = buildDueLabel(task, effectiveDate, effectiveTime)
|
||||
alarms.add(ScheduledAlarm(task.id, task.text, label, dueMs, task.priority ?: 0))
|
||||
}
|
||||
}
|
||||
|
||||
return alarms
|
||||
}
|
||||
|
||||
private fun durationToMillis(d: ReminderDuration): Long = when (d.unit) {
|
||||
ReminderUnit.MIN -> d.value * 60_000L
|
||||
ReminderUnit.HR -> d.value * 3_600_000L
|
||||
ReminderUnit.DAY -> d.value * 86_400_000L
|
||||
ReminderUnit.WEEK -> d.value * 604_800_000L
|
||||
}
|
||||
|
||||
private fun buildReminderLabel(task: Task, date: kotlinx.datetime.LocalDate, time: LocalTime): String {
|
||||
val timeStr = "${time.hour.toString().padStart(2, '0')}:${time.minute.toString().padStart(2, '0')}"
|
||||
val priority = task.priority?.let { " · p$it" } ?: ""
|
||||
val tags = task.lists.joinToString("") { " #$it" }
|
||||
return "Due $date $timeStr$priority$tags"
|
||||
}
|
||||
|
||||
private fun buildDueLabel(task: Task, date: kotlinx.datetime.LocalDate, time: LocalTime): String {
|
||||
val timeStr = "${time.hour.toString().padStart(2, '0')}:${time.minute.toString().padStart(2, '0')}"
|
||||
val priority = task.priority?.let { " · p$it" } ?: ""
|
||||
val tags = task.lists.joinToString("") { " #$it" }
|
||||
return "Now · $date $timeStr$priority$tags"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +147,14 @@ fun SettingsScreen(
|
||||
viewModel.setWeekStartDay((weekStart + 1) % 7)
|
||||
}
|
||||
|
||||
val syncInterval by viewModel.syncInterval.collectAsState()
|
||||
SettingToggle("auto sync", "${syncInterval} min", accent, MaterialTheme.colorScheme.onSurfaceVariant) {
|
||||
val options = listOf(1, 2, 5, 10, 15, 30, 60)
|
||||
val idx = options.indexOf(syncInterval)
|
||||
viewModel.setSyncInterval(options[(idx + 1) % options.size])
|
||||
}
|
||||
Text("how often to fetch from server", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
SectionHeader("notifications")
|
||||
Spacer(Modifier.height(6.dp))
|
||||
@@ -165,6 +173,15 @@ fun SettingsScreen(
|
||||
)
|
||||
}
|
||||
Text("get notified when tasks are due or overdue", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
|
||||
val defaultNotifyTime by viewModel.defaultNotifyTime.collectAsState()
|
||||
SettingToggle("default notify time", defaultNotifyTime, accent, MaterialTheme.colorScheme.onSurfaceVariant) {
|
||||
val options = listOf("08:00", "09:00", "12:00", "17:00", "18:00", "20:00", "21:00")
|
||||
val idx = options.indexOf(defaultNotifyTime)
|
||||
viewModel.setDefaultNotifyTime(options[(idx + 1) % options.size])
|
||||
}
|
||||
Text("when a task has a date but no time", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
|
||||
Text(
|
||||
"check reminders now",
|
||||
fontSize = 13.sp, color = accent,
|
||||
|
||||
@@ -45,6 +45,12 @@ class SettingsViewModel(
|
||||
val weekStartDay: StateFlow<Int> = tokenStore.weekStartDay
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
|
||||
|
||||
val defaultNotifyTime: StateFlow<String> = tokenStore.defaultNotifyTime
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "20:00")
|
||||
|
||||
val syncInterval: StateFlow<Int> = tokenStore.syncInterval
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 5)
|
||||
|
||||
init {
|
||||
viewModelScope.launch { authRepository.validateToken() }
|
||||
}
|
||||
@@ -73,6 +79,14 @@ class SettingsViewModel(
|
||||
viewModelScope.launch { tokenStore.saveWeekStartDay(day) }
|
||||
}
|
||||
|
||||
fun setDefaultNotifyTime(time: String) {
|
||||
viewModelScope.launch { tokenStore.saveDefaultNotifyTime(time) }
|
||||
}
|
||||
|
||||
fun setSyncInterval(minutes: Int) {
|
||||
viewModelScope.launch { tokenStore.saveSyncInterval(minutes) }
|
||||
}
|
||||
|
||||
fun getExportJson(onResult: (String) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val memos = memoRepository.observeMemos().first()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
package com.avinal.memos.util
|
||||
|
||||
expect fun triggerReminderCheck()
|
||||
expect fun setLiveMemosProvider(provider: () -> List<com.avinal.memos.domain.Memo>)
|
||||
|
||||
@@ -17,6 +17,8 @@ class TokenStore(private val dataStore: DataStore<Preferences>) {
|
||||
val defaultVisibility: Flow<String> = dataStore.data.map { it[KEY_DEFAULT_VIS] ?: "PRIVATE" }
|
||||
val defaultReminder: Flow<String> = dataStore.data.map { it[KEY_DEFAULT_REMINDER] ?: "" }
|
||||
val weekStartDay: Flow<Int> = dataStore.data.map { (it[KEY_WEEK_START] ?: "0").toIntOrNull() ?: 0 }
|
||||
val defaultNotifyTime: Flow<String> = dataStore.data.map { it[KEY_DEFAULT_NOTIFY_TIME] ?: "20:00" }
|
||||
val syncInterval: Flow<Int> = dataStore.data.map { (it[KEY_SYNC_INTERVAL] ?: "5").toIntOrNull() ?: 5 }
|
||||
|
||||
suspend fun saveCredentials(serverUrl: String, token: String) {
|
||||
dataStore.edit { prefs ->
|
||||
@@ -49,6 +51,14 @@ class TokenStore(private val dataStore: DataStore<Preferences>) {
|
||||
dataStore.edit { it[KEY_WEEK_START] = day.toString() }
|
||||
}
|
||||
|
||||
suspend fun saveDefaultNotifyTime(time: String) {
|
||||
dataStore.edit { it[KEY_DEFAULT_NOTIFY_TIME] = time }
|
||||
}
|
||||
|
||||
suspend fun saveSyncInterval(minutes: Int) {
|
||||
dataStore.edit { it[KEY_SYNC_INTERVAL] = minutes.toString() }
|
||||
}
|
||||
|
||||
suspend fun clear() {
|
||||
dataStore.edit {
|
||||
val theme = it[KEY_THEME]
|
||||
@@ -68,5 +78,7 @@ class TokenStore(private val dataStore: DataStore<Preferences>) {
|
||||
private val KEY_DEFAULT_VIS = stringPreferencesKey("default_visibility")
|
||||
private val KEY_DEFAULT_REMINDER = stringPreferencesKey("default_reminder")
|
||||
private val KEY_WEEK_START = stringPreferencesKey("week_start_day")
|
||||
private val KEY_DEFAULT_NOTIFY_TIME = stringPreferencesKey("default_notify_time")
|
||||
private val KEY_SYNC_INTERVAL = stringPreferencesKey("sync_interval")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,183 +17,120 @@ 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 val defaultTime = LocalTime(20, 0)
|
||||
|
||||
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",
|
||||
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,
|
||||
)
|
||||
reminder = reminder, priority = priority)
|
||||
|
||||
@Test
|
||||
fun completedTaskProducesNoAlarms() {
|
||||
val alarms = ReminderScheduler.computeAlarms(listOf(task(completed = true)), nowMillis, tz, emptySet())
|
||||
assertTrue(alarms.isEmpty())
|
||||
}
|
||||
private fun compute(tasks: List<Task>, now: Long = nowMillis) =
|
||||
ReminderScheduler.computeAlarms(tasks, now, tz, emptySet(), defaultTime)
|
||||
|
||||
@Test
|
||||
fun taskWithNoDateProducesNoAlarms() {
|
||||
val alarms = ReminderScheduler.computeAlarms(listOf(task(date = null)), nowMillis, tz, emptySet())
|
||||
assertTrue(alarms.isEmpty())
|
||||
}
|
||||
@Test fun completedNoAlarms() { assertTrue(compute(listOf(task(completed = true))).isEmpty()) }
|
||||
@Test fun noDateNoTimeNoAlarms() { assertTrue(compute(listOf(task(date = null, time = null))).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())
|
||||
@Test fun dateOnlyUsesDefaultTime() {
|
||||
val alarms = compute(listOf(task()))
|
||||
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()
|
||||
val expected = dueDate.atTime(defaultTime).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 dateAndTimeUsesExactTime() {
|
||||
val alarms = compute(listOf(task(time = LocalTime(15, 0))))
|
||||
assertEquals(1, alarms.size)
|
||||
assertEquals(dueDate.atTime(LocalTime(15, 0)).toInstant(tz).toEpochMilliseconds(), alarms[0].triggerAtMillis)
|
||||
}
|
||||
|
||||
@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() {
|
||||
@Test fun reminderOffset30min() {
|
||||
val t = task(time = LocalTime(15, 0), reminder = ReminderDuration(30, ReminderUnit.MIN))
|
||||
val alarms = ReminderScheduler.computeAlarms(listOf(t), nowMillis, tz, emptySet())
|
||||
val alarms = compute(listOf(t))
|
||||
assertEquals(2, alarms.size)
|
||||
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() {
|
||||
@Test fun reminderOffset1hr() {
|
||||
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)
|
||||
val reminder = compute(listOf(t)).first { it.taskId.endsWith("_remind") }
|
||||
assertEquals(dueDate.atTime(LocalTime(10, 0)).toInstant(tz).toEpochMilliseconds() - 3_600_000L, reminder.triggerAtMillis)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reminderDurationOffset1day() {
|
||||
@Test fun reminderOffset1day() {
|
||||
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)
|
||||
val reminder = compute(listOf(t), earlyNow).first { it.taskId.endsWith("_remind") }
|
||||
assertEquals(dueDate.atTime(defaultTime).toInstant(tz).toEpochMilliseconds() - 86_400_000L, reminder.triggerAtMillis)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reminderDurationOffset1week() {
|
||||
@Test fun reminderOffset1week() {
|
||||
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)
|
||||
val reminder = compute(listOf(t), earlyNow).first { it.taskId.endsWith("_remind") }
|
||||
assertEquals(dueDate.atTime(defaultTime).toInstant(tz).toEpochMilliseconds() - 604_800_000L, reminder.triggerAtMillis)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reminderWithDueTimeProducesBothAlarms() {
|
||||
@Test fun reminderAndDueTimeBothFire() {
|
||||
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
|
||||
val alarms = compute(listOf(t))
|
||||
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
|
||||
@Test fun pastAlarmsNotScheduled() {
|
||||
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())
|
||||
assertTrue(compute(listOf(task(time = LocalTime(10, 0))), lateNow).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pastReminderNotScheduledButDueTimeStillIs() {
|
||||
// now is after the reminder time but before due time
|
||||
@Test fun pastReminderButFutureDue() {
|
||||
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
|
||||
val alarms = compute(listOf(t), midNow)
|
||||
assertEquals(1, alarms.size)
|
||||
assertEquals("t1", alarms[0].taskId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multipleTasksProduceCorrectAlarms() {
|
||||
@Test fun multipleTasks() {
|
||||
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" })
|
||||
assertEquals(2, compute(tasks).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun alarmLabelsCorrect() {
|
||||
@Test fun dueLabelIncludesPriorityAndTags() {
|
||||
val t = task(time = LocalTime(14, 0), priority = 1).copy(lists = listOf("work"))
|
||||
val alarm = compute(listOf(t)).first { it.taskId == "t1" }
|
||||
assertTrue(alarm.label.contains("p1"))
|
||||
assertTrue(alarm.label.contains("#work"))
|
||||
}
|
||||
|
||||
@Test fun reminderLabelIncludesDateAndTime() {
|
||||
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"))
|
||||
val alarm = compute(listOf(t)).first { it.taskId.endsWith("_remind") }
|
||||
assertTrue(alarm.label.contains("Due"))
|
||||
assertTrue(alarm.label.contains("14:00"))
|
||||
}
|
||||
|
||||
@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() {
|
||||
@Test fun taskTextPreserved() {
|
||||
val t = task().copy(text = "Buy groceries")
|
||||
val alarms = ReminderScheduler.computeAlarms(listOf(t), nowMillis, tz, emptySet())
|
||||
assertTrue(alarms.all { it.taskText == "Buy groceries" })
|
||||
assertTrue(compute(listOf(t)).all { it.taskText == "Buy groceries" })
|
||||
}
|
||||
|
||||
@Test fun customDefaultTime() {
|
||||
val alarms = ReminderScheduler.computeAlarms(
|
||||
listOf(task()), nowMillis, tz, emptySet(), LocalTime(9, 0)
|
||||
)
|
||||
val expected = dueDate.atTime(LocalTime(9, 0)).toInstant(tz).toEpochMilliseconds()
|
||||
assertEquals(expected, alarms[0].triggerAtMillis)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
package com.avinal.memos.util
|
||||
|
||||
actual fun triggerReminderCheck() {
|
||||
// TODO: iOS notification check
|
||||
}
|
||||
actual fun triggerReminderCheck() {}
|
||||
actual fun setLiveMemosProvider(provider: () -> List<com.avinal.memos.domain.Memo>) {}
|
||||
|
||||
Reference in New Issue
Block a user