From 844ef2f048864f9d8dd8c82a5a3de7ad704f52d6 Mon Sep 17 00:00:00 2001 From: ulibte Date: Sat, 7 Feb 2026 01:57:44 -0300 Subject: [PATCH 01/13] Added onReminderClick to open reminder settings from the base note. Updated the base note to display the most relevant reminder. Added visual differences for elapsed reminders on the base note: strikethrough text, 50% opacity. --- .../activity/ConfigureWidgetActivity.kt | 4 ++ .../activity/main/fragment/NotallyFragment.kt | 19 ++++++ .../activity/note/PickNoteActivity.kt | 4 ++ .../presentation/view/main/BaseNoteVH.kt | 30 ++++++++- .../presentation/view/misc/ItemListener.kt | 2 + .../main/res/layout/recycler_base_note.xml | 62 +++++++++---------- 6 files changed, 86 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/com/philkes/notallyx/presentation/activity/ConfigureWidgetActivity.kt b/app/src/main/java/com/philkes/notallyx/presentation/activity/ConfigureWidgetActivity.kt index be19a89a..17541733 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/activity/ConfigureWidgetActivity.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/activity/ConfigureWidgetActivity.kt @@ -40,4 +40,8 @@ class ConfigureWidgetActivity : PickNoteActivity() { finish() } } + + override fun onReminderClick(position: Int) { + onClick(position) + } } diff --git a/app/src/main/java/com/philkes/notallyx/presentation/activity/main/fragment/NotallyFragment.kt b/app/src/main/java/com/philkes/notallyx/presentation/activity/main/fragment/NotallyFragment.kt index ba7fdbe6..9d041dc6 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/activity/main/fragment/NotallyFragment.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/activity/main/fragment/NotallyFragment.kt @@ -34,6 +34,7 @@ import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.EX import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.EXTRA_SELECTED_BASE_NOTE import com.philkes.notallyx.presentation.activity.note.EditListActivity import com.philkes.notallyx.presentation.activity.note.EditNoteActivity +import com.philkes.notallyx.presentation.activity.note.reminders.RemindersActivity import com.philkes.notallyx.presentation.getQuantityString import com.philkes.notallyx.presentation.hideKeyboard import com.philkes.notallyx.presentation.movedToResId @@ -151,6 +152,24 @@ abstract class NotallyFragment : Fragment(), ItemListener { } } + override fun onReminderClick(position: Int) { + if (model.actionMode.isEnabled()) { + onClick(position) + return + } + if (position != -1) { + notesAdapter?.getItem(position)?.let { item -> + if (item is BaseNote) { + val intent = + Intent(requireContext(), RemindersActivity::class.java).apply { + putExtra(RemindersActivity.NOTE_ID, item.id) + } + startActivity(intent) + } + } + } + } + override fun onLongClick(position: Int) { if (position != -1) { if (model.actionMode.selectedNotes.isNotEmpty()) { diff --git a/app/src/main/java/com/philkes/notallyx/presentation/activity/note/PickNoteActivity.kt b/app/src/main/java/com/philkes/notallyx/presentation/activity/note/PickNoteActivity.kt index 2c1d99fc..416a2c93 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/activity/note/PickNoteActivity.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/activity/note/PickNoteActivity.kt @@ -154,4 +154,8 @@ open class PickNoteActivity : LockedActivity(), ItemLis const val EXTRA_PICKED_NOTE_TITLE = "notallyx.intent.extra.PICKED_NOTE_TITLE" const val EXTRA_PICKED_NOTE_TYPE = "notallyx.intent.extra.PICKED_NOTE_TYPE" } + + override fun onReminderClick(position: Int) { + onClick(position) + } } diff --git a/app/src/main/java/com/philkes/notallyx/presentation/view/main/BaseNoteVH.kt b/app/src/main/java/com/philkes/notallyx/presentation/view/main/BaseNoteVH.kt index 333b8e70..a0d7aa56 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/view/main/BaseNoteVH.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/view/main/BaseNoteVH.kt @@ -1,5 +1,6 @@ package com.philkes.notallyx.presentation.view.main +import android.graphics.Paint import android.graphics.drawable.Drawable import android.util.TypedValue import android.view.View.GONE @@ -24,7 +25,7 @@ import com.philkes.notallyx.data.model.FileAttachment import com.philkes.notallyx.data.model.ListItem import com.philkes.notallyx.data.model.SpanRepresentation import com.philkes.notallyx.data.model.Type -import com.philkes.notallyx.data.model.hasUpcomingNotification +import com.philkes.notallyx.data.model.toText import com.philkes.notallyx.databinding.RecyclerBaseNoteBinding import com.philkes.notallyx.presentation.applySpans import com.philkes.notallyx.presentation.bindLabels @@ -41,6 +42,7 @@ import com.philkes.notallyx.presentation.viewmodel.preference.DateFormat import com.philkes.notallyx.presentation.viewmodel.preference.NotesSortBy import com.philkes.notallyx.presentation.viewmodel.preference.TextSize import java.io.File +import java.util.Date data class BaseNoteVHPreferences( val textSize: TextSize, @@ -87,6 +89,8 @@ class BaseNoteVH( listener.onLongClick(absoluteAdapterPosition) return@setOnLongClickListener true } + + ReminderChip.setOnClickListener { listener.onReminderClick(absoluteAdapterPosition) } } } @@ -159,7 +163,7 @@ class BaseNoteVH( } setColor(baseNote.color) - binding.RemindersView.isVisible = baseNote.reminders.any { it.hasUpcomingNotification() } + setupReminderChip(baseNote) } private fun bindNote(baseNote: BaseNote, keyword: String) { @@ -397,4 +401,26 @@ class BaseNoteVH( 0, ) } + + private fun setupReminderChip(baseNote: BaseNote) { + val now = Date(System.currentTimeMillis()) + val displayReminder = + baseNote.reminders.filter { it.dateTime > now }.minByOrNull { it.dateTime } + ?: baseNote.reminders.maxByOrNull { it.dateTime } + + if (displayReminder == null) { + binding.ReminderChip.visibility = GONE + return + } + displayReminder.let { reminder -> + binding.ReminderChip.apply { + visibility = VISIBLE + text = reminder.dateTime.toText() + val isElapsed = reminder.dateTime < now + alpha = if (isElapsed) 0.5f else 1.0f + paintFlags = + if (isElapsed) paintFlags or Paint.STRIKE_THRU_TEXT_FLAG else paintFlags + } + } + } } diff --git a/app/src/main/java/com/philkes/notallyx/presentation/view/misc/ItemListener.kt b/app/src/main/java/com/philkes/notallyx/presentation/view/misc/ItemListener.kt index 512a88ab..f3d62e71 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/view/misc/ItemListener.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/view/misc/ItemListener.kt @@ -5,4 +5,6 @@ interface ItemListener { fun onClick(position: Int) fun onLongClick(position: Int) + + fun onReminderClick(position: Int) } diff --git a/app/src/main/res/layout/recycler_base_note.xml b/app/src/main/res/layout/recycler_base_note.xml index 5ec8b804..056697d7 100644 --- a/app/src/main/res/layout/recycler_base_note.xml +++ b/app/src/main/res/layout/recycler_base_note.xml @@ -73,39 +73,35 @@ android:clickable="false" app:layout_constraintTop_toBottomOf="@id/Message" /> - - - + - - + + app:layout_constraintTop_toBottomOf="@id/Title" /> Date: Sat, 7 Feb 2026 02:53:11 -0300 Subject: [PATCH 02/13] onReminderClick optional --- .../notallyx/presentation/activity/ConfigureWidgetActivity.kt | 4 ---- .../notallyx/presentation/activity/note/PickNoteActivity.kt | 4 ---- .../philkes/notallyx/presentation/view/misc/ItemListener.kt | 2 +- 3 files changed, 1 insertion(+), 9 deletions(-) diff --git a/app/src/main/java/com/philkes/notallyx/presentation/activity/ConfigureWidgetActivity.kt b/app/src/main/java/com/philkes/notallyx/presentation/activity/ConfigureWidgetActivity.kt index 17541733..be19a89a 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/activity/ConfigureWidgetActivity.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/activity/ConfigureWidgetActivity.kt @@ -40,8 +40,4 @@ class ConfigureWidgetActivity : PickNoteActivity() { finish() } } - - override fun onReminderClick(position: Int) { - onClick(position) - } } diff --git a/app/src/main/java/com/philkes/notallyx/presentation/activity/note/PickNoteActivity.kt b/app/src/main/java/com/philkes/notallyx/presentation/activity/note/PickNoteActivity.kt index 416a2c93..2c1d99fc 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/activity/note/PickNoteActivity.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/activity/note/PickNoteActivity.kt @@ -154,8 +154,4 @@ open class PickNoteActivity : LockedActivity(), ItemLis const val EXTRA_PICKED_NOTE_TITLE = "notallyx.intent.extra.PICKED_NOTE_TITLE" const val EXTRA_PICKED_NOTE_TYPE = "notallyx.intent.extra.PICKED_NOTE_TYPE" } - - override fun onReminderClick(position: Int) { - onClick(position) - } } diff --git a/app/src/main/java/com/philkes/notallyx/presentation/view/misc/ItemListener.kt b/app/src/main/java/com/philkes/notallyx/presentation/view/misc/ItemListener.kt index f3d62e71..1062b64b 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/view/misc/ItemListener.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/view/misc/ItemListener.kt @@ -6,5 +6,5 @@ interface ItemListener { fun onLongClick(position: Int) - fun onReminderClick(position: Int) + fun onReminderClick(position: Int) {} } From 4acdae39f8fdd6cac64470b3d6c6800a10416d11 Mon Sep 17 00:00:00 2001 From: ulibte Date: Sat, 7 Feb 2026 20:28:40 -0300 Subject: [PATCH 03/13] clear the STRIKE_THRU_TEXT_FLAG when the reminder is not elapsed Set the ReminderChip to default visibility GONE and marginStart from 10dp to 16dp --- .../com/philkes/notallyx/presentation/view/main/BaseNoteVH.kt | 3 ++- app/src/main/res/layout/recycler_base_note.xml | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/philkes/notallyx/presentation/view/main/BaseNoteVH.kt b/app/src/main/java/com/philkes/notallyx/presentation/view/main/BaseNoteVH.kt index a0d7aa56..f65484b7 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/view/main/BaseNoteVH.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/view/main/BaseNoteVH.kt @@ -419,7 +419,8 @@ class BaseNoteVH( val isElapsed = reminder.dateTime < now alpha = if (isElapsed) 0.5f else 1.0f paintFlags = - if (isElapsed) paintFlags or Paint.STRIKE_THRU_TEXT_FLAG else paintFlags + if (isElapsed) paintFlags or Paint.STRIKE_THRU_TEXT_FLAG + else paintFlags and Paint.STRIKE_THRU_TEXT_FLAG.inv() } } } diff --git a/app/src/main/res/layout/recycler_base_note.xml b/app/src/main/res/layout/recycler_base_note.xml index 056697d7..f357977a 100644 --- a/app/src/main/res/layout/recycler_base_note.xml +++ b/app/src/main/res/layout/recycler_base_note.xml @@ -78,8 +78,9 @@ style="@style/Widget.MaterialComponents.Chip.Action" android:layout_width="wrap_content" android:layout_height="36dp" - android:layout_marginStart="10dp" + android:layout_marginStart="16dp" android:paddingVertical="6dp" + android:visibility="gone" android:clickable="true" android:focusable="true" android:textAppearance="?attr/textAppearanceLabelSmall" From 8929a864fc97e0df0ed458b7de7d4d476aaea33f Mon Sep 17 00:00:00 2001 From: ulibte Date: Mon, 9 Feb 2026 16:04:45 -0300 Subject: [PATCH 04/13] Note selection by long-pressing the reminder chip. Tools namespace to show reminder chip in Layout Editor. --- .../notallyx/presentation/view/main/BaseNoteVH.kt | 4 ++++ app/src/main/res/layout/recycler_base_note.xml | 10 +++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/philkes/notallyx/presentation/view/main/BaseNoteVH.kt b/app/src/main/java/com/philkes/notallyx/presentation/view/main/BaseNoteVH.kt index f65484b7..7b11c392 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/view/main/BaseNoteVH.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/view/main/BaseNoteVH.kt @@ -91,6 +91,10 @@ class BaseNoteVH( } ReminderChip.setOnClickListener { listener.onReminderClick(absoluteAdapterPosition) } + ReminderChip.setOnLongClickListener { + listener.onLongClick(absoluteAdapterPosition) + return@setOnLongClickListener true + } } } diff --git a/app/src/main/res/layout/recycler_base_note.xml b/app/src/main/res/layout/recycler_base_note.xml index f357977a..865dd430 100644 --- a/app/src/main/res/layout/recycler_base_note.xml +++ b/app/src/main/res/layout/recycler_base_note.xml @@ -1,5 +1,7 @@ - + app:layout_constraintStart_toStartOf="parent" + tools:visibility="visible" + tools:text="00/00/0000 00:00" + /> Date: Mon, 16 Feb 2026 18:15:08 +0100 Subject: [PATCH 05/13] Move ReminderChip below Title and take repetition into account --- .../presentation/view/main/BaseNoteVH.kt | 28 ++++++------ .../main/res/layout/recycler_base_note.xml | 45 ++++++++++--------- app/src/main/res/values-v26/themes.xml | 20 +++++++++ 3 files changed, 56 insertions(+), 37 deletions(-) create mode 100644 app/src/main/res/values-v26/themes.xml diff --git a/app/src/main/java/com/philkes/notallyx/presentation/view/main/BaseNoteVH.kt b/app/src/main/java/com/philkes/notallyx/presentation/view/main/BaseNoteVH.kt index 7b11c392..208ee91d 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/view/main/BaseNoteVH.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/view/main/BaseNoteVH.kt @@ -25,6 +25,7 @@ import com.philkes.notallyx.data.model.FileAttachment import com.philkes.notallyx.data.model.ListItem import com.philkes.notallyx.data.model.SpanRepresentation import com.philkes.notallyx.data.model.Type +import com.philkes.notallyx.data.model.findNextNotificationDate import com.philkes.notallyx.data.model.toText import com.philkes.notallyx.databinding.RecyclerBaseNoteBinding import com.philkes.notallyx.presentation.applySpans @@ -408,24 +409,21 @@ class BaseNoteVH( private fun setupReminderChip(baseNote: BaseNote) { val now = Date(System.currentTimeMillis()) - val displayReminder = - baseNote.reminders.filter { it.dateTime > now }.minByOrNull { it.dateTime } - ?: baseNote.reminders.maxByOrNull { it.dateTime } - - if (displayReminder == null) { + val mostRecentNotificationDate = + baseNote.reminders.findNextNotificationDate() + ?: baseNote.reminders.maxOfOrNull { it.dateTime } + if (mostRecentNotificationDate == null) { binding.ReminderChip.visibility = GONE return } - displayReminder.let { reminder -> - binding.ReminderChip.apply { - visibility = VISIBLE - text = reminder.dateTime.toText() - val isElapsed = reminder.dateTime < now - alpha = if (isElapsed) 0.5f else 1.0f - paintFlags = - if (isElapsed) paintFlags or Paint.STRIKE_THRU_TEXT_FLAG - else paintFlags and Paint.STRIKE_THRU_TEXT_FLAG.inv() - } + binding.ReminderChip.apply { + visibility = VISIBLE + text = mostRecentNotificationDate.toText() + val isElapsed = mostRecentNotificationDate < now + alpha = if (isElapsed) 0.5f else 1.0f + paintFlags = + if (isElapsed) paintFlags or Paint.STRIKE_THRU_TEXT_FLAG + else paintFlags and Paint.STRIKE_THRU_TEXT_FLAG.inv() } } } diff --git a/app/src/main/res/layout/recycler_base_note.xml b/app/src/main/res/layout/recycler_base_note.xml index 865dd430..a13607a3 100644 --- a/app/src/main/res/layout/recycler_base_note.xml +++ b/app/src/main/res/layout/recycler_base_note.xml @@ -75,38 +75,39 @@ android:clickable="false" app:layout_constraintTop_toBottomOf="@id/Message" /> - - + app:layout_constraintTop_toBottomOf="@id/Space" /> + + + app:layout_constraintTop_toBottomOf="@id/ReminderChip" /> + + + + \ No newline at end of file From 1bc8e51944896f8f4322749fabc61126ab6baa39 Mon Sep 17 00:00:00 2001 From: ulibte Date: Wed, 18 Feb 2026 15:27:52 -0300 Subject: [PATCH 06/13] Restore notifications on phone reboot --- app/src/main/AndroidManifest.xml | 1 + .../philkes/notallyx/data/model/Converters.kt | 4 +- .../philkes/notallyx/data/model/Reminder.kt | 7 +- .../note/reminders/ReminderReceiver.kt | 93 +++++++++++++++++-- 4 files changed, 96 insertions(+), 9 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bbf32d67..c8ca55d2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,6 +12,7 @@ + diff --git a/app/src/main/java/com/philkes/notallyx/data/model/Converters.kt b/app/src/main/java/com/philkes/notallyx/data/model/Converters.kt index af465417..29d06509 100644 --- a/app/src/main/java/com/philkes/notallyx/data/model/Converters.kt +++ b/app/src/main/java/com/philkes/notallyx/data/model/Converters.kt @@ -147,6 +147,7 @@ object Converters { put("id", reminder.id) // Store date as long timestamp put("dateTime", reminder.dateTime.time) // Store date as long timestamp put("repetition", reminder.repetition?.let { repetitionToJsonObject(it) }) + put("isNotificationVisible", reminder.isNotificationVisible) } } return JSONArray(objects) @@ -159,7 +160,8 @@ object Converters { val id = jsonObject.getLong("id") val dateTime = Date(jsonObject.getLong("dateTime")) val repetition = jsonObject.getSafeString("repetition")?.let { jsonToRepetition(it) } - Reminder(id, dateTime, repetition) + val isNotificationVisible = jsonObject.getSafeBoolean("isNotificationVisible") + Reminder(id, dateTime, repetition, isNotificationVisible) } } diff --git a/app/src/main/java/com/philkes/notallyx/data/model/Reminder.kt b/app/src/main/java/com/philkes/notallyx/data/model/Reminder.kt index 00419468..ba151e63 100644 --- a/app/src/main/java/com/philkes/notallyx/data/model/Reminder.kt +++ b/app/src/main/java/com/philkes/notallyx/data/model/Reminder.kt @@ -5,7 +5,12 @@ import java.util.Date import kotlinx.parcelize.Parcelize @Parcelize -data class Reminder(var id: Long, var dateTime: Date, var repetition: Repetition?) : Parcelable +data class Reminder( + var id: Long, + var dateTime: Date, + var repetition: Repetition?, + var isNotificationVisible: Boolean = false, +) : Parcelable @Parcelize data class Repetition(var value: Int, var unit: RepetitionTimeUnit) : Parcelable diff --git a/app/src/main/java/com/philkes/notallyx/presentation/activity/note/reminders/ReminderReceiver.kt b/app/src/main/java/com/philkes/notallyx/presentation/activity/note/reminders/ReminderReceiver.kt index 14189281..683f2661 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/activity/note/reminders/ReminderReceiver.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/activity/note/reminders/ReminderReceiver.kt @@ -3,6 +3,7 @@ package com.philkes.notallyx.presentation.activity.note.reminders import android.app.AlarmManager import android.app.Application import android.app.NotificationManager +import android.app.PendingIntent import android.content.BroadcastReceiver import android.content.Context import android.content.Intent @@ -12,6 +13,7 @@ import androidx.core.app.NotificationCompat import androidx.core.content.getSystemService import com.philkes.notallyx.R import com.philkes.notallyx.data.NotallyDatabase +import com.philkes.notallyx.data.dao.BaseNoteDao import com.philkes.notallyx.data.model.Reminder import com.philkes.notallyx.utils.canScheduleAlarms import com.philkes.notallyx.utils.cancelReminder @@ -46,9 +48,12 @@ class ReminderReceiver : BroadcastReceiver() { val noteId = intent.getLongExtra(EXTRA_NOTE_ID, -1L) notify(context, noteId, reminderId) } else { + val baseNoteDao = getDatabase(context).getBaseNoteDao() when { - canScheduleExactAlarms && intent.action == Intent.ACTION_BOOT_COMPLETED -> + canScheduleExactAlarms && intent.action == Intent.ACTION_BOOT_COMPLETED -> { rescheduleAlarms(context) + restoreRemindersNotifications(context, baseNoteDao) + } intent.action == AlarmManager.ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED -> { @@ -58,6 +63,14 @@ class ReminderReceiver : BroadcastReceiver() { cancelAlarms(context) } } + intent.action == ACTION_NOTIFICATION_DISMISSED -> { + val noteId = intent.getLongExtra(EXTRA_NOTE_ID, -1L) + val reminderId = intent.getLongExtra(EXTRA_REMINDER_ID, -1L) + Log.d(TAG, "Notification dismissed for note: $noteId") + CoroutineScope(Dispatchers.IO).launch { + setIsNotificationVisible(false, baseNoteDao, noteId, reminderId) + } + } } } } @@ -65,8 +78,7 @@ class ReminderReceiver : BroadcastReceiver() { private fun notify(context: Context, noteId: Long, reminderId: Long) { Log.d(TAG, "notify: noteId: $noteId reminderId: $reminderId") CoroutineScope(Dispatchers.IO).launch { - val database = - NotallyDatabase.getDatabase(context.applicationContext as Application, false).value + val database = getDatabase(context) val manager = context.getSystemService()!! if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { manager.createChannelIfNotExists( @@ -86,12 +98,19 @@ class ReminderReceiver : BroadcastReceiver() { context.getString(R.string.open_note), context.getOpenNotePendingIntent(note), ) + .setDeleteIntent(getDeletePendingIntent(context, noteId, reminderId)) .build() note.reminders .find { it.id == reminderId } ?.let { reminder: Reminder -> manager.notify(note.id.toString(), reminderId.toInt(), notification) context.scheduleReminder(note.id, reminder, forceRepetition = true) + setIsNotificationVisible( + true, + database.getBaseNoteDao(), + note.id, + reminderId, + ) } } } @@ -99,8 +118,7 @@ class ReminderReceiver : BroadcastReceiver() { private fun rescheduleAlarms(context: Context) { CoroutineScope(Dispatchers.IO).launch { - val database = - NotallyDatabase.getDatabase(context.applicationContext as Application, false).value + val database = getDatabase(context) val now = Date() val noteReminders = database.getBaseNoteDao().getAllReminders() val noteRemindersWithFutureNotify = @@ -120,8 +138,7 @@ class ReminderReceiver : BroadcastReceiver() { private fun cancelAlarms(context: Context) { CoroutineScope(Dispatchers.IO).launch { - val database = - NotallyDatabase.getDatabase(context.applicationContext as Application, false).value + val database = getDatabase(context) val noteReminders = database.getBaseNoteDao().getAllReminders() val noteRemindersWithFutureNotify = noteReminders.flatMap { (noteId, reminders) -> @@ -134,6 +151,66 @@ class ReminderReceiver : BroadcastReceiver() { } } + private suspend fun setIsNotificationVisible( + isNotificationVisible: Boolean, + baseNoteDao: BaseNoteDao, + noteId: Long, + reminderId: Long, + ) { + val note = baseNoteDao.get(noteId) ?: return + val currentReminders = note.reminders.toMutableList() + val index = currentReminders.indexOfFirst { it.id == reminderId } + if (index != -1) { + if (currentReminders[index].isNotificationVisible != isNotificationVisible) { + currentReminders[index] = + currentReminders[index].copy(isNotificationVisible = isNotificationVisible) + baseNoteDao.updateReminders(noteId, currentReminders) + } + } + } + + private fun getDeletePendingIntent( + context: Context, + noteId: Long, + reminderId: Long, + ): PendingIntent { + val deleteIntent = + Intent(context, ReminderReceiver::class.java).apply { + action = ACTION_NOTIFICATION_DISMISSED + putExtra(EXTRA_NOTE_ID, noteId) + putExtra(EXTRA_REMINDER_ID, reminderId) + } + + val deletePendingIntent = + PendingIntent.getBroadcast( + context, + "$noteId-$reminderId".hashCode(), + deleteIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + return deletePendingIntent + } + + fun restoreRemindersNotifications(context: Context, baseNoteDao: BaseNoteDao) { + CoroutineScope(Dispatchers.IO).launch { + val allNotes = baseNoteDao.getAllNotes() + allNotes.forEach { note -> + val now = Date(System.currentTimeMillis()) + val mostRecentReminder = + note.reminders + .filter { it.dateTime <= now } // Only reminders that have already passed + .maxByOrNull { it.dateTime } ?: return@forEach + if (mostRecentReminder.isNotificationVisible) { + notify(context, note.id, mostRecentReminder.id) + } + } + } + } + + private fun getDatabase(context: Context): NotallyDatabase { + return NotallyDatabase.getDatabase(context.applicationContext as Application, false).value + } + companion object { private const val TAG = "ReminderReceiver" @@ -141,5 +218,7 @@ class ReminderReceiver : BroadcastReceiver() { const val EXTRA_REMINDER_ID = "notallyx.intent.extra.REMINDER_ID" const val EXTRA_NOTE_ID = "notallyx.intent.extra.NOTE_ID" + const val ACTION_NOTIFICATION_DISMISSED = + "com.philkes.notallyx.ACTION_NOTIFICATION_DISMISSED" } } From d39f1806b0fb5eb423e51158cec0bb199d02a2bb Mon Sep 17 00:00:00 2001 From: ulibte Date: Fri, 20 Feb 2026 23:23:37 -0300 Subject: [PATCH 07/13] fix: update ModelExtensionsTest.kt to include isNotificationVisible of the Reminder --- .../com/philkes/notallyx/data/model/ModelExtensionsTest.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/test/kotlin/com/philkes/notallyx/data/model/ModelExtensionsTest.kt b/app/src/test/kotlin/com/philkes/notallyx/data/model/ModelExtensionsTest.kt index 1971ac6f..ea4e2a51 100644 --- a/app/src/test/kotlin/com/philkes/notallyx/data/model/ModelExtensionsTest.kt +++ b/app/src/test/kotlin/com/philkes/notallyx/data/model/ModelExtensionsTest.kt @@ -59,7 +59,8 @@ class ModelExtensionsTest { { "id": 0, "dateTime": 1742822940000, - "repetition": "{\"value\":1,\"unit\":\"DAYS\"}" + "repetition": "{\"value\":1,\"unit\":\"DAYS\"}", + "isNotificationVisible": false } ], "viewMode": "READ_ONLY" @@ -125,6 +126,7 @@ class ModelExtensionsTest { { "reminders": [{ "dateTime": 1743253506957, + "isNotificationVisible": false, "id": 1, "repetition": { "unit": "WEEKS", From f2f70ad1af0a2ad4895587d7281c7165a3c791f1 Mon Sep 17 00:00:00 2001 From: ulibte Date: Sat, 21 Feb 2026 15:33:08 -0300 Subject: [PATCH 08/13] ReminderReceiver.kt: created goAsyncScope to use goAsync converted multiple functions to suspended --- .../note/reminders/ReminderReceiver.kt | 212 +++++++++--------- 1 file changed, 106 insertions(+), 106 deletions(-) diff --git a/app/src/main/java/com/philkes/notallyx/presentation/activity/note/reminders/ReminderReceiver.kt b/app/src/main/java/com/philkes/notallyx/presentation/activity/note/reminders/ReminderReceiver.kt index 683f2661..11ecfccd 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/activity/note/reminders/ReminderReceiver.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/activity/note/reminders/ReminderReceiver.kt @@ -13,7 +13,6 @@ import androidx.core.app.NotificationCompat import androidx.core.content.getSystemService import com.philkes.notallyx.R import com.philkes.notallyx.data.NotallyDatabase -import com.philkes.notallyx.data.dao.BaseNoteDao import com.philkes.notallyx.data.model.Reminder import com.philkes.notallyx.utils.canScheduleAlarms import com.philkes.notallyx.utils.cancelReminder @@ -40,123 +39,115 @@ class ReminderReceiver : BroadcastReceiver() { return } val canScheduleExactAlarms = context.canScheduleAlarms() - if (intent.action == null) { - if (!canScheduleExactAlarms) { - return - } - val reminderId = intent.getLongExtra(EXTRA_REMINDER_ID, -1L) - val noteId = intent.getLongExtra(EXTRA_NOTE_ID, -1L) - notify(context, noteId, reminderId) - } else { - val baseNoteDao = getDatabase(context).getBaseNoteDao() - when { - canScheduleExactAlarms && intent.action == Intent.ACTION_BOOT_COMPLETED -> { - rescheduleAlarms(context) - restoreRemindersNotifications(context, baseNoteDao) + goAsyncScope { + if (intent.action == null) { + if (!canScheduleExactAlarms) { + return@goAsyncScope } + val reminderId = intent.getLongExtra(EXTRA_REMINDER_ID, -1L) + val noteId = intent.getLongExtra(EXTRA_NOTE_ID, -1L) + notify(context, noteId, reminderId) + } else { + when { + intent.action == Intent.ACTION_BOOT_COMPLETED -> { + if (canScheduleExactAlarms) { + rescheduleAlarms(context) + } + restoreRemindersNotifications(context) + } - intent.action == - AlarmManager.ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED -> { - if (canScheduleExactAlarms) { - rescheduleAlarms(context) - } else { - cancelAlarms(context) + intent.action == + AlarmManager.ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED -> { + if (canScheduleExactAlarms) { + rescheduleAlarms(context) + } else { + cancelAlarms(context) + } } - } - intent.action == ACTION_NOTIFICATION_DISMISSED -> { - val noteId = intent.getLongExtra(EXTRA_NOTE_ID, -1L) - val reminderId = intent.getLongExtra(EXTRA_REMINDER_ID, -1L) - Log.d(TAG, "Notification dismissed for note: $noteId") - CoroutineScope(Dispatchers.IO).launch { - setIsNotificationVisible(false, baseNoteDao, noteId, reminderId) + + intent.action == ACTION_NOTIFICATION_DISMISSED -> { + val noteId = intent.getLongExtra(EXTRA_NOTE_ID, -1L) + val reminderId = intent.getLongExtra(EXTRA_REMINDER_ID, -1L) + Log.d(TAG, "Notification dismissed for note: $noteId") + setIsNotificationVisible(false, context, noteId, reminderId) } } } } } - private fun notify(context: Context, noteId: Long, reminderId: Long) { + private suspend fun notify(context: Context, noteId: Long, reminderId: Long) { Log.d(TAG, "notify: noteId: $noteId reminderId: $reminderId") - CoroutineScope(Dispatchers.IO).launch { - val database = getDatabase(context) - val manager = context.getSystemService()!! - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - manager.createChannelIfNotExists( - NOTIFICATION_CHANNEL_ID, - importance = NotificationManager.IMPORTANCE_HIGH, - ) - } - database.getBaseNoteDao().get(noteId)?.let { note -> - val notification = - NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID) - .setSmallIcon(R.drawable.notebook) - .setContentTitle(note.title) // Set title from intent - .setContentText(note.body.truncate(200)) // Set content text from intent - .setPriority(NotificationCompat.PRIORITY_HIGH) - .addAction( - R.drawable.visibility, - context.getString(R.string.open_note), - context.getOpenNotePendingIntent(note), - ) - .setDeleteIntent(getDeletePendingIntent(context, noteId, reminderId)) - .build() - note.reminders - .find { it.id == reminderId } - ?.let { reminder: Reminder -> - manager.notify(note.id.toString(), reminderId.toInt(), notification) - context.scheduleReminder(note.id, reminder, forceRepetition = true) - setIsNotificationVisible( - true, - database.getBaseNoteDao(), - note.id, - reminderId, - ) - } - } + val database = getDatabase(context) + val manager = context.getSystemService()!! + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + manager.createChannelIfNotExists( + NOTIFICATION_CHANNEL_ID, + importance = NotificationManager.IMPORTANCE_HIGH, + ) + } + database.getBaseNoteDao().get(noteId)?.let { note -> + val notification = + NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(R.drawable.notebook) + .setContentTitle(note.title) // Set title from intent + .setContentText(note.body.truncate(200)) // Set content text from intent + .setPriority(NotificationCompat.PRIORITY_HIGH) + .addAction( + R.drawable.visibility, + context.getString(R.string.open_note), + context.getOpenNotePendingIntent(note), + ) + .setDeleteIntent(getDeletePendingIntent(context, noteId, reminderId)) + .build() + note.reminders + .find { it.id == reminderId } + ?.let { reminder: Reminder -> + manager.notify(note.id.toString(), reminderId.toInt(), notification) + context.scheduleReminder(note.id, reminder, forceRepetition = true) + setIsNotificationVisible(true, context, note.id, reminderId) + } } } - private fun rescheduleAlarms(context: Context) { - CoroutineScope(Dispatchers.IO).launch { - val database = getDatabase(context) - val now = Date() - val noteReminders = database.getBaseNoteDao().getAllReminders() - val noteRemindersWithFutureNotify = - noteReminders.flatMap { (noteId, reminders) -> - reminders - .filter { reminder -> - reminder.repetition != null || reminder.dateTime.after(now) - } - .map { reminder -> Pair(noteId, reminder) } - } - Log.d(TAG, "rescheduleAlarms: ${noteRemindersWithFutureNotify.size} alarms") - noteRemindersWithFutureNotify.forEach { (noteId, reminder) -> - context.scheduleReminder(noteId, reminder) + private suspend fun rescheduleAlarms(context: Context) { + val database = getDatabase(context) + val now = Date() + val noteReminders = database.getBaseNoteDao().getAllReminders() + val noteRemindersWithFutureNotify = + noteReminders.flatMap { (noteId, reminders) -> + reminders + .filter { reminder -> + reminder.repetition != null || reminder.dateTime.after(now) + } + .map { reminder -> Pair(noteId, reminder) } } + Log.d(TAG, "rescheduleAlarms: ${noteRemindersWithFutureNotify.size} alarms") + noteRemindersWithFutureNotify.forEach { (noteId, reminder) -> + context.scheduleReminder(noteId, reminder) } } - private fun cancelAlarms(context: Context) { - CoroutineScope(Dispatchers.IO).launch { - val database = getDatabase(context) - val noteReminders = database.getBaseNoteDao().getAllReminders() - val noteRemindersWithFutureNotify = - noteReminders.flatMap { (noteId, reminders) -> - reminders.map { reminder -> Pair(noteId, reminder.id) } - } - Log.d(TAG, "cancelAlarms: ${noteRemindersWithFutureNotify.size} alarms") - noteRemindersWithFutureNotify.forEach { (noteId, reminderId) -> - context.cancelReminder(noteId, reminderId) + private suspend fun cancelAlarms(context: Context) { + val database = getDatabase(context) + val noteReminders = database.getBaseNoteDao().getAllReminders() + val noteRemindersWithFutureNotify = + noteReminders.flatMap { (noteId, reminders) -> + reminders.map { reminder -> Pair(noteId, reminder.id) } } + Log.d(TAG, "cancelAlarms: ${noteRemindersWithFutureNotify.size} alarms") + noteRemindersWithFutureNotify.forEach { (noteId, reminderId) -> + context.cancelReminder(noteId, reminderId) } } private suspend fun setIsNotificationVisible( isNotificationVisible: Boolean, - baseNoteDao: BaseNoteDao, + context: Context, noteId: Long, reminderId: Long, ) { + val baseNoteDao = getDatabase(context).getBaseNoteDao() val note = baseNoteDao.get(noteId) ?: return val currentReminders = note.reminders.toMutableList() val index = currentReminders.indexOfFirst { it.id == reminderId } @@ -180,29 +171,27 @@ class ReminderReceiver : BroadcastReceiver() { putExtra(EXTRA_NOTE_ID, noteId) putExtra(EXTRA_REMINDER_ID, reminderId) } - val deletePendingIntent = PendingIntent.getBroadcast( context, - "$noteId-$reminderId".hashCode(), + noteId.toInt(), deleteIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) return deletePendingIntent } - fun restoreRemindersNotifications(context: Context, baseNoteDao: BaseNoteDao) { - CoroutineScope(Dispatchers.IO).launch { - val allNotes = baseNoteDao.getAllNotes() - allNotes.forEach { note -> - val now = Date(System.currentTimeMillis()) - val mostRecentReminder = - note.reminders - .filter { it.dateTime <= now } // Only reminders that have already passed - .maxByOrNull { it.dateTime } ?: return@forEach - if (mostRecentReminder.isNotificationVisible) { - notify(context, note.id, mostRecentReminder.id) - } + private suspend fun restoreRemindersNotifications(context: Context) { + val baseNoteDao = getDatabase(context).getBaseNoteDao() + val allNotes = baseNoteDao.getAllNotes() + allNotes.forEach { note -> + val now = Date(System.currentTimeMillis()) + val mostRecentReminder = + note.reminders + .filter { it.dateTime <= now } // Only reminders that have already passed + .maxByOrNull { it.dateTime } ?: return@forEach + if (mostRecentReminder.isNotificationVisible) { + notify(context, note.id, mostRecentReminder.id) } } } @@ -211,6 +200,17 @@ class ReminderReceiver : BroadcastReceiver() { return NotallyDatabase.getDatabase(context.applicationContext as Application, false).value } + private fun goAsyncScope(codeBlock: suspend CoroutineScope.() -> Unit) { + val pendingResult = goAsync() + CoroutineScope(Dispatchers.IO).launch { + try { + codeBlock() + } finally { + pendingResult.finish() + } + } + } + companion object { private const val TAG = "ReminderReceiver" From eb25e382b57f7c4c391712e422a56ce785f221eb Mon Sep 17 00:00:00 2001 From: ulibte Date: Sat, 21 Feb 2026 17:15:51 -0300 Subject: [PATCH 09/13] on the action ACTION_BOOT_COMPLETED rescheduleAlarms is already called, so no need to restoreReminderNotifications to schedule again --- .../activity/note/reminders/ReminderReceiver.kt | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/philkes/notallyx/presentation/activity/note/reminders/ReminderReceiver.kt b/app/src/main/java/com/philkes/notallyx/presentation/activity/note/reminders/ReminderReceiver.kt index 11ecfccd..d051037c 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/activity/note/reminders/ReminderReceiver.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/activity/note/reminders/ReminderReceiver.kt @@ -76,7 +76,12 @@ class ReminderReceiver : BroadcastReceiver() { } } - private suspend fun notify(context: Context, noteId: Long, reminderId: Long) { + private suspend fun notify( + context: Context, + noteId: Long, + reminderId: Long, + schedule: Boolean = true, + ) { Log.d(TAG, "notify: noteId: $noteId reminderId: $reminderId") val database = getDatabase(context) val manager = context.getSystemService()!! @@ -104,7 +109,8 @@ class ReminderReceiver : BroadcastReceiver() { .find { it.id == reminderId } ?.let { reminder: Reminder -> manager.notify(note.id.toString(), reminderId.toInt(), notification) - context.scheduleReminder(note.id, reminder, forceRepetition = true) + if (schedule) + context.scheduleReminder(note.id, reminder, forceRepetition = true) setIsNotificationVisible(true, context, note.id, reminderId) } } @@ -191,7 +197,7 @@ class ReminderReceiver : BroadcastReceiver() { .filter { it.dateTime <= now } // Only reminders that have already passed .maxByOrNull { it.dateTime } ?: return@forEach if (mostRecentReminder.isNotificationVisible) { - notify(context, note.id, mostRecentReminder.id) + notify(context, note.id, mostRecentReminder.id, schedule = false) } } } From d5302a6b1ae926a1dae815c86fdf090215802eb5 Mon Sep 17 00:00:00 2001 From: ulibte Date: Sat, 21 Feb 2026 18:31:01 -0300 Subject: [PATCH 10/13] Replace noteId.toInt() with a hash of both noteId and reminderId --- .../presentation/activity/note/reminders/ReminderReceiver.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/philkes/notallyx/presentation/activity/note/reminders/ReminderReceiver.kt b/app/src/main/java/com/philkes/notallyx/presentation/activity/note/reminders/ReminderReceiver.kt index d051037c..78f2e651 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/activity/note/reminders/ReminderReceiver.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/activity/note/reminders/ReminderReceiver.kt @@ -180,7 +180,7 @@ class ReminderReceiver : BroadcastReceiver() { val deletePendingIntent = PendingIntent.getBroadcast( context, - noteId.toInt(), + "$noteId-$reminderId".hashCode(), deleteIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) From 96e0d162b2971e06dbd0fb3b81e4a732c054f32d Mon Sep 17 00:00:00 2001 From: Felipe Viana <62855944+ulibte@users.noreply.github.com> Date: Sat, 21 Feb 2026 18:53:07 -0300 Subject: [PATCH 11/13] Apply suggestion from @coderabbitai[bot] Comments removed Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../presentation/activity/note/reminders/ReminderReceiver.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/philkes/notallyx/presentation/activity/note/reminders/ReminderReceiver.kt b/app/src/main/java/com/philkes/notallyx/presentation/activity/note/reminders/ReminderReceiver.kt index 78f2e651..30a1b419 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/activity/note/reminders/ReminderReceiver.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/activity/note/reminders/ReminderReceiver.kt @@ -95,8 +95,8 @@ class ReminderReceiver : BroadcastReceiver() { val notification = NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID) .setSmallIcon(R.drawable.notebook) - .setContentTitle(note.title) // Set title from intent - .setContentText(note.body.truncate(200)) // Set content text from intent + .setContentTitle(note.title) + .setContentText(note.body.truncate(200)) .setPriority(NotificationCompat.PRIORITY_HIGH) .addAction( R.drawable.visibility, From 2dfbe0da351529e3c6ff1c3bb3b8d4472b028a43 Mon Sep 17 00:00:00 2001 From: ulibte Date: Sat, 21 Feb 2026 19:38:55 -0300 Subject: [PATCH 12/13] setIsNotificationVisible to true before showing the notification --- .../presentation/activity/note/reminders/ReminderReceiver.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/philkes/notallyx/presentation/activity/note/reminders/ReminderReceiver.kt b/app/src/main/java/com/philkes/notallyx/presentation/activity/note/reminders/ReminderReceiver.kt index 30a1b419..ab2eb9c4 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/activity/note/reminders/ReminderReceiver.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/activity/note/reminders/ReminderReceiver.kt @@ -108,10 +108,10 @@ class ReminderReceiver : BroadcastReceiver() { note.reminders .find { it.id == reminderId } ?.let { reminder: Reminder -> + setIsNotificationVisible(true, context, note.id, reminderId) manager.notify(note.id.toString(), reminderId.toInt(), notification) if (schedule) context.scheduleReminder(note.id, reminder, forceRepetition = true) - setIsNotificationVisible(true, context, note.id, reminderId) } } } From 9df3460c13fb33f05acd1981b16306de1c5d3c4f Mon Sep 17 00:00:00 2001 From: Crustack Date: Sun, 22 Feb 2026 13:26:39 +0100 Subject: [PATCH 13/13] Also consider next repetition in restoreRemindersNotifications --- .../notallyx/data/model/ModelExtensions.kt | 55 +++++- .../note/reminders/ReminderReceiver.kt | 12 +- .../notallyx/data/model/ReminderTest.kt | 180 +++++++++++++++++- 3 files changed, 232 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/philkes/notallyx/data/model/ModelExtensions.kt b/app/src/main/java/com/philkes/notallyx/data/model/ModelExtensions.kt index ae432523..1c30a50c 100644 --- a/app/src/main/java/com/philkes/notallyx/data/model/ModelExtensions.kt +++ b/app/src/main/java/com/philkes/notallyx/data/model/ModelExtensions.kt @@ -360,6 +360,31 @@ fun RepetitionTimeUnit.toCalendarField(): Int { } } +fun Reminder.lastNotification(before: Date = Date()): Date? { + if (before.before(dateTime) || before == dateTime) { + return null + } + if (repetition == null) { + return dateTime + } + + val calendar = dateTime.toCalendar() + val field = repetition!!.unit.toCalendarField() + val value = repetition!!.value + + var last = calendar.time + // Increment until we are at or after 'before' + while (true) { + calendar.add(field, value) + if (calendar.time.after(before) || calendar.time == before) { + break + } + last = calendar.time + } + + return last +} + fun Reminder.nextNotification(from: Date = Date()): Date? { if (from.before(dateTime)) { return dateTime @@ -367,12 +392,19 @@ fun Reminder.nextNotification(from: Date = Date()): Date? { if (repetition == null) { return null } - val timeDifferenceMillis: Long = from.time - dateTime.time - val intervalsPassed = timeDifferenceMillis / repetition!!.toMillis() - val unitsUntilNext = ((repetition!!.value) * (intervalsPassed + 1)).toInt() - val reminderStart = dateTime.toCalendar() - reminderStart.add(repetition!!.unit.toCalendarField(), unitsUntilNext) - return reminderStart.time + val calendar = dateTime.toCalendar() + val field = repetition!!.unit.toCalendarField() + val value = repetition!!.value + + // If from is exactly at dateTime, we want the next one + while (true) { + calendar.add(field, value) + if (calendar.time.after(from)) { + break + } + } + + return calendar.time } fun Reminder.nextRepetition(from: Date = Date()): Date? { @@ -398,7 +430,16 @@ fun Collection.hasAnyUpcomingNotifications(): Boolean { } fun Collection.findNextNotificationDate(): Date? { - return mapNotNull { it.nextNotification() }.minByOrNull { it } + return mapNotNull { it.nextNotification() }.minOrNull() +} + +fun Collection.findLastNotificationDate(before: Date = Date()): Date? { + return mapNotNull { it.lastNotification(before) }.maxOrNull() +} + +fun Collection.findLastNotified(before: Date = Date()): Reminder? { + return filter { it.lastNotification(before) != null } + .maxByOrNull { it.lastNotification(before)!! } } fun Date.toCalendar() = Calendar.getInstance().apply { timeInMillis = this@toCalendar.time } diff --git a/app/src/main/java/com/philkes/notallyx/presentation/activity/note/reminders/ReminderReceiver.kt b/app/src/main/java/com/philkes/notallyx/presentation/activity/note/reminders/ReminderReceiver.kt index ab2eb9c4..50c32dcb 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/activity/note/reminders/ReminderReceiver.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/activity/note/reminders/ReminderReceiver.kt @@ -14,6 +14,7 @@ import androidx.core.content.getSystemService import com.philkes.notallyx.R import com.philkes.notallyx.data.NotallyDatabase import com.philkes.notallyx.data.model.Reminder +import com.philkes.notallyx.data.model.findLastNotified import com.philkes.notallyx.utils.canScheduleAlarms import com.philkes.notallyx.utils.cancelReminder import com.philkes.notallyx.utils.createChannelIfNotExists @@ -68,7 +69,10 @@ class ReminderReceiver : BroadcastReceiver() { intent.action == ACTION_NOTIFICATION_DISMISSED -> { val noteId = intent.getLongExtra(EXTRA_NOTE_ID, -1L) val reminderId = intent.getLongExtra(EXTRA_REMINDER_ID, -1L) - Log.d(TAG, "Notification dismissed for note: $noteId") + Log.d( + TAG, + "Notification dismissed for noteId: $noteId, reminderId: $reminderId", + ) setIsNotificationVisible(false, context, noteId, reminderId) } } @@ -191,11 +195,7 @@ class ReminderReceiver : BroadcastReceiver() { val baseNoteDao = getDatabase(context).getBaseNoteDao() val allNotes = baseNoteDao.getAllNotes() allNotes.forEach { note -> - val now = Date(System.currentTimeMillis()) - val mostRecentReminder = - note.reminders - .filter { it.dateTime <= now } // Only reminders that have already passed - .maxByOrNull { it.dateTime } ?: return@forEach + val mostRecentReminder = note.reminders.findLastNotified() ?: return@forEach if (mostRecentReminder.isNotificationVisible) { notify(context, note.id, mostRecentReminder.id, schedule = false) } diff --git a/app/src/test/kotlin/com/philkes/notallyx/data/model/ReminderTest.kt b/app/src/test/kotlin/com/philkes/notallyx/data/model/ReminderTest.kt index 69fd07fc..5e0ad7cd 100644 --- a/app/src/test/kotlin/com/philkes/notallyx/data/model/ReminderTest.kt +++ b/app/src/test/kotlin/com/philkes/notallyx/data/model/ReminderTest.kt @@ -18,9 +18,9 @@ class ReminderTest { @Test fun testNextRepetition() { - val repetitionStart = Calendar.getInstance().apply { set(2000, 0, 1, 0, 0, 0) } + val repetitionStart = calendar(2000, 0, 1) val reminder = Reminder(0, repetitionStart.time, Repetition(1, RepetitionTimeUnit.YEARS)) - val from = Calendar.getInstance().apply { set(2004, 6, 3, 3, 1, 2) }.time + val from = calendar(2004, 6, 3, 3, 1, 2).time val actual = reminder.nextRepetition(from)!!.time @@ -29,6 +29,182 @@ class ReminderTest { assertEquals(expected, actual) } + @Test + fun testNextNotification() { + val start = calendar(2023, 0, 1, 10) + val reminderNoRep = Reminder(0, start.time, null) + + // Before start, no rep -> returns start + assertEquals(start.time, reminderNoRep.nextNotification(calendar(2022, 11, 31, 10).time)) + // After start, no rep -> returns null + assertEquals(null, reminderNoRep.nextNotification(calendar(2023, 0, 1, 10, 0, 1).time)) + + val reminderRep = Reminder(0, start.time, Repetition(1, RepetitionTimeUnit.DAYS)) + // Before start, with rep -> returns start + assertEquals(start.time, reminderRep.nextNotification(calendar(2022, 11, 31, 10).time)) + // Exactly at start, with rep -> returns next (1 day later) + val expectedAfterStart = start.copy().apply { add(Calendar.DAY_OF_MONTH, 1) }.time + assertEquals(expectedAfterStart, reminderRep.nextNotification(start.time)) + // After start, with rep -> returns next + val fromAfterStart = start.copy().apply { add(Calendar.HOUR, 5) }.time + assertEquals(expectedAfterStart, reminderRep.nextNotification(fromAfterStart)) + } + + @Test + fun testLastNotification() { + val start = calendar(2023, 0, 1, 10) + val reminderNoRep = Reminder(0, start.time, null) + + // Before start, no rep -> returns null + assertEquals(null, reminderNoRep.lastNotification(calendar(2022, 11, 31, 10).time)) + // Exactly at start, no rep -> returns null (per code before or == start) + assertEquals(null, reminderNoRep.lastNotification(start.time)) + // After start, no rep -> returns start + val afterStart = start.copy().apply { add(Calendar.SECOND, 1) }.time + assertEquals(start.time, reminderNoRep.lastNotification(afterStart)) + + val reminderRep = Reminder(0, start.time, Repetition(1, RepetitionTimeUnit.HOURS)) + // Before start, with rep -> returns null + assertEquals(null, reminderRep.lastNotification(calendar(2023, 0, 1, 9).time)) + // After start, with rep -> returns last + val afterStartRep = start.copy().apply { add(Calendar.MINUTE, 30) }.time + assertEquals(start.time, reminderRep.lastNotification(afterStartRep)) + + // Exactly on a spike (2 hours after start) -> should return the one BEFORE (1 hour after + // start) + val twoHoursAfter = start.copy().apply { add(Calendar.HOUR, 2) }.time + val oneHourAfter = start.copy().apply { add(Calendar.HOUR, 1) }.time + assertEquals(oneHourAfter, reminderRep.lastNotification(twoHoursAfter)) + + // Multiple intervals + val threeHoursAndHalf = + start + .copy() + .apply { + add(Calendar.HOUR, 3) + add(Calendar.MINUTE, 30) + } + .time + val threeHoursAfter = start.copy().apply { add(Calendar.HOUR, 3) }.time + assertEquals(threeHoursAfter, reminderRep.lastNotification(threeHoursAndHalf)) + } + + @Test + fun testFindNextNotificationDate() { + val nextYear = Calendar.getInstance().get(Calendar.YEAR) + 1 + val r1 = Reminder(1, calendar(nextYear, 0, 1, 11).time, null) + val r2 = Reminder(2, calendar(nextYear, 0, 1, 12).time, null) + + val reminders = listOf(r1, r2) + val nextDate = reminders.findNextNotificationDate() + assertEquals(r1.dateTime, nextDate) + } + + @Test + fun testFindLastNotificationDate() { + val r1 = Reminder(1, calendar(2020, 0, 1, 10).time, null) + val r2 = Reminder(2, calendar(2021, 0, 1, 10).time, null) + + val reminders = listOf(r1, r2) + val lastDate = reminders.findLastNotificationDate() + // Both are in the past, so lastNotification() for both should be their dateTime. + // findLastNotificationDate should return the MAXIMUM of them. + assertEquals(r2.dateTime, lastDate) + } + + @Test + fun testFindLastNotified() { + val r1 = Reminder(1, calendar(2020, 0, 1, 10).time, null) + val r2 = Reminder(2, calendar(2021, 0, 1, 10).time, null) + + val reminders = listOf(r1, r2) + val lastNotified = reminders.findLastNotified() + // r2 was last notified later than r1 + assertEquals(r2, lastNotified) + + // Case where before is specified + val before = calendar(2020, 6, 1, 10).time + val lastNotifiedBefore = reminders.findLastNotified(before) + // Only r1 was notified before 2020-06-01 + assertEquals(r1, lastNotifiedBefore) + } + + @Test + fun testRepetitionsWithDifferentUnits() { + val start = calendar(2023, 0, 1, 10) + + // MINUTES + val reminderMinutes = Reminder(0, start.time, Repetition(30, RepetitionTimeUnit.MINUTES)) + val after35Min = start.copy().apply { add(Calendar.MINUTE, 35) }.time + // Expected is 10:30, because 10:00 + 30m = 10:30, which is before 10:35 + assertEquals( + start.copy().apply { add(Calendar.MINUTE, 30) }.time, + reminderMinutes.lastNotification(after35Min), + ) + val after65Min = start.copy().apply { add(Calendar.MINUTE, 65) }.time + val expectedMin = start.copy().apply { add(Calendar.MINUTE, 60) }.time + assertEquals(expectedMin, reminderMinutes.lastNotification(after65Min)) + + // WEEKS + val reminderWeeks = Reminder(0, start.time, Repetition(1, RepetitionTimeUnit.WEEKS)) + val after1Week = + start + .copy() + .apply { + add(Calendar.WEEK_OF_YEAR, 1) + add(Calendar.SECOND, 1) + } + .time + assertEquals( + start.copy().apply { add(Calendar.WEEK_OF_YEAR, 1) }.time, + reminderWeeks.lastNotification(after1Week), + ) + + // MONTHS + val reminderMonths = Reminder(0, start.time, Repetition(1, RepetitionTimeUnit.MONTHS)) + val after1Month = + start + .copy() + .apply { + add(Calendar.MONTH, 1) + add(Calendar.SECOND, 1) + } + .time + assertEquals( + start.copy().apply { add(Calendar.MONTH, 1) }.time, + reminderMonths.lastNotification(after1Month), + ) + + // YEARS + val reminderYears = Reminder(0, start.time, Repetition(1, RepetitionTimeUnit.YEARS)) + val after1Year = + start + .copy() + .apply { + add(Calendar.YEAR, 1) + add(Calendar.SECOND, 1) + } + .time + assertEquals( + start.copy().apply { add(Calendar.YEAR, 1) }.time, + reminderYears.lastNotification(after1Year), + ) + } + + private fun calendar( + year: Int, + month: Int, + day: Int, + hourOfDay: Int = 0, + minute: Int = 0, + second: Int = 0, + millis: Int = 0, + ): Calendar = + Calendar.getInstance().apply { + set(year, month, day, hourOfDay, minute, second) + set(Calendar.MILLISECOND, millis) + } + private fun Calendar.copy(): Calendar { val calendar = Calendar.getInstance() calendar.timeInMillis = timeInMillis