diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml
index cb6fb12..71225df 100644
--- a/androidApp/src/main/AndroidManifest.xml
+++ b/androidApp/src/main/AndroidManifest.xml
@@ -6,6 +6,9 @@
+
+
+
) {
+ 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")
+ }
+}
diff --git a/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskAlarmReceiver.kt b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskAlarmReceiver.kt
index 528eece..4a8fe71 100644
--- a/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskAlarmReceiver.kt
+++ b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskAlarmReceiver.kt
@@ -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,
)
}
}
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 008d40d..3657758 100644
--- a/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskCheckWorker.kt
+++ b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskCheckWorker.kt
@@ -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(
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,
)
diff --git a/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskNotificationManager.kt b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskNotificationManager.kt
index 17d8b1a..76ca4c9 100644
--- a/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskNotificationManager.kt
+++ b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskNotificationManager.kt
@@ -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())
}
}
diff --git a/composeApp/src/androidMain/kotlin/com/avinal/memos/util/PlatformNotification.android.kt b/composeApp/src/androidMain/kotlin/com/avinal/memos/util/PlatformNotification.android.kt
index 18192c2..b84d718 100644
--- a/composeApp/src/androidMain/kotlin/com/avinal/memos/util/PlatformNotification.android.kt
+++ b/composeApp/src/androidMain/kotlin/com/avinal/memos/util/PlatformNotification.android.kt
@@ -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)? = null
+
+actual fun setLiveMemosProvider(provider: () -> List) {
+ 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)
+ }
}
diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/AppDependencies.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/AppDependencies.kt
index b1a3510..5c94546 100644
--- a/composeApp/src/commonMain/kotlin/com/avinal/memos/AppDependencies.kt
+++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/AppDependencies.kt
@@ -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) {}
+ }
}
diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/MemoRepository.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/MemoRepository.kt
index 0e14683..4aa24fb 100644
--- a/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/MemoRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/MemoRepository.kt
@@ -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> {
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
- }
}
diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/notifications/ReminderScheduler.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/notifications/ReminderScheduler.kt
index 75157ab..8659c05 100644
--- a/composeApp/src/commonMain/kotlin/com/avinal/memos/notifications/ReminderScheduler.kt
+++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/notifications/ReminderScheduler.kt
@@ -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,
nowMillis: Long,
timeZone: TimeZone,
- alreadyScheduledIds: Set,
+ alreadyScheduledIds: Set = emptySet(),
+ defaultTime: LocalTime = LocalTime(20, 0),
): List {
val alarms = mutableListOf()
-
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"
+ }
}
diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/settings/SettingsScreen.kt
index 6fa9bab..818fb59 100644
--- a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/settings/SettingsScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/settings/SettingsScreen.kt
@@ -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,
diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/settings/SettingsViewModel.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/settings/SettingsViewModel.kt
index 41e0775..1d66735 100644
--- a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/settings/SettingsViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/settings/SettingsViewModel.kt
@@ -45,6 +45,12 @@ class SettingsViewModel(
val weekStartDay: StateFlow = tokenStore.weekStartDay
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
+ val defaultNotifyTime: StateFlow = tokenStore.defaultNotifyTime
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "20:00")
+
+ val syncInterval: StateFlow = 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()
diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/util/PlatformNotification.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/util/PlatformNotification.kt
index 7e78e07..d06a5a1 100644
--- a/composeApp/src/commonMain/kotlin/com/avinal/memos/util/PlatformNotification.kt
+++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/util/PlatformNotification.kt
@@ -1,3 +1,4 @@
package com.avinal.memos.util
expect fun triggerReminderCheck()
+expect fun setLiveMemosProvider(provider: () -> List)
diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/util/TokenStore.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/util/TokenStore.kt
index b090595..f89daf6 100644
--- a/composeApp/src/commonMain/kotlin/com/avinal/memos/util/TokenStore.kt
+++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/util/TokenStore.kt
@@ -17,6 +17,8 @@ class TokenStore(private val dataStore: DataStore) {
val defaultVisibility: Flow = dataStore.data.map { it[KEY_DEFAULT_VIS] ?: "PRIVATE" }
val defaultReminder: Flow = dataStore.data.map { it[KEY_DEFAULT_REMINDER] ?: "" }
val weekStartDay: Flow = dataStore.data.map { (it[KEY_WEEK_START] ?: "0").toIntOrNull() ?: 0 }
+ val defaultNotifyTime: Flow = dataStore.data.map { it[KEY_DEFAULT_NOTIFY_TIME] ?: "20:00" }
+ val syncInterval: Flow = 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) {
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) {
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")
}
}
diff --git a/composeApp/src/commonTest/kotlin/com/avinal/memos/ReminderSchedulerTest.kt b/composeApp/src/commonTest/kotlin/com/avinal/memos/ReminderSchedulerTest.kt
index 2e022d0..3ad7d64 100644
--- a/composeApp/src/commonTest/kotlin/com/avinal/memos/ReminderSchedulerTest.kt
+++ b/composeApp/src/commonTest/kotlin/com/avinal/memos/ReminderSchedulerTest.kt
@@ -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, 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)
}
}
diff --git a/composeApp/src/iosMain/kotlin/com/avinal/memos/util/PlatformNotification.ios.kt b/composeApp/src/iosMain/kotlin/com/avinal/memos/util/PlatformNotification.ios.kt
index e2817c5..b7fd1c8 100644
--- a/composeApp/src/iosMain/kotlin/com/avinal/memos/util/PlatformNotification.ios.kt
+++ b/composeApp/src/iosMain/kotlin/com/avinal/memos/util/PlatformNotification.ios.kt
@@ -1,5 +1,4 @@
package com.avinal.memos.util
-actual fun triggerReminderCheck() {
- // TODO: iOS notification check
-}
+actual fun triggerReminderCheck() {}
+actual fun setLiveMemosProvider(provider: () -> List) {}