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) {}