diff --git a/android/app/src/main/java/com/github/quarck/calnotify/app/ApplicationController.kt b/android/app/src/main/java/com/github/quarck/calnotify/app/ApplicationController.kt index fcae9937..d1323e62 100644 --- a/android/app/src/main/java/com/github/quarck/calnotify/app/ApplicationController.kt +++ b/android/app/src/main/java/com/github/quarck/calnotify/app/ApplicationController.kt @@ -943,19 +943,8 @@ object ApplicationController : ApplicationControllerInterface, EventMovedHandler ): SnoozeResult? { val now = clock.currentTimeMillis() return snoozeEvents(context, { event -> - // Pinned events are excluded from batch snooze !event.isPinned && - // Search query filter (existing behavior) - (searchQuery?.let { query -> - event.title.contains(query, ignoreCase = true) || - event.desc.contains(query, ignoreCase = true) - } ?: true) && - // FilterState filters - (filterState?.let { filter -> - filter.matchesCalendar(event) && - filter.matchesStatus(event) && - filter.matchesTime(event, now) - } ?: true) + FilterState.matchesSearchAndFilters(event, searchQuery, filterState, now) }, snoozeDelay, isChange, onlySnoozeVisible) } @@ -1122,12 +1111,17 @@ object ApplicationController : ApplicationControllerInterface, EventMovedHandler } } - fun muteAllVisibleEvents(context: Context) { - + fun muteAllVisibleEvents( + context: Context, + searchQuery: String? = null, + filterState: FilterState? = null + ) { + val now = clock.currentTimeMillis() getEventsStorage(context).use { db -> val eventsToMute = db.events.filter { - event -> (event.snoozedUntil == 0L) && event.isNotSpecial && !event.isTask + event -> (event.snoozedUntil == 0L) && event.isNotSpecial && !event.isTask && + FilterState.matchesSearchAndFilters(event, searchQuery, filterState, now) } if (eventsToMute.isNotEmpty()) { @@ -1146,10 +1140,16 @@ object ApplicationController : ApplicationControllerInterface, EventMovedHandler } } - fun pinAllVisibleEvents(context: Context) { + fun pinAllVisibleEvents( + context: Context, + searchQuery: String? = null, + filterState: FilterState? = null + ) { + val now = clock.currentTimeMillis() getEventsStorage(context).use { db -> val eventsToPin = db.events.filter { - event -> (event.snoozedUntil == 0L) && event.isNotSpecial && !event.isPinned + event -> (event.snoozedUntil == 0L) && event.isNotSpecial && !event.isPinned && + FilterState.matchesSearchAndFilters(event, searchQuery, filterState, now) } if (eventsToPin.isNotEmpty()) { val pinnedEvents = eventsToPin.map { it.isPinned = true; it } @@ -1158,9 +1158,16 @@ object ApplicationController : ApplicationControllerInterface, EventMovedHandler } } - fun unpinAllVisibleEvents(context: Context) { + fun unpinAllVisibleEvents( + context: Context, + searchQuery: String? = null, + filterState: FilterState? = null + ) { + val now = clock.currentTimeMillis() getEventsStorage(context).use { db -> - val eventsToUnpin = db.events.filter { event -> event.isPinned } + val eventsToUnpin = db.events.filter { event -> event.isPinned && + FilterState.matchesSearchAndFilters(event, searchQuery, filterState, now) + } if (eventsToUnpin.isNotEmpty()) { val unpinnedEvents = eventsToUnpin.map { it.isPinned = false; it } db.updateEvents(unpinnedEvents) diff --git a/android/app/src/main/java/com/github/quarck/calnotify/ui/FilterState.kt b/android/app/src/main/java/com/github/quarck/calnotify/ui/FilterState.kt index db767b83..3e153a58 100644 --- a/android/app/src/main/java/com/github/quarck/calnotify/ui/FilterState.kt +++ b/android/app/src/main/java/com/github/quarck/calnotify/ui/FilterState.kt @@ -90,6 +90,24 @@ data class FilterState( ) { companion object { + /** + * Checks if an event matches the given search query and filter state. + * Handles nullable parameters: null searchQuery or filterState means "match all". + */ + fun matchesSearchAndFilters( + event: EventAlertRecord, + searchQuery: String?, + filterState: FilterState?, + now: Long + ): Boolean { + if (searchQuery != null && + !event.title.contains(searchQuery, ignoreCase = true) && + !event.desc.contains(searchQuery, ignoreCase = true)) { + return false + } + return filterState?.matchesEvent(event, now) ?: true + } + private const val BUNDLE_CALENDAR_IDS = "filter_calendar_ids" private const val BUNDLE_CALENDAR_NULL = "filter_calendar_null" private const val BUNDLE_STATUS_FILTERS = "filter_status" @@ -232,7 +250,12 @@ data class FilterState( if (selectedCalendarIds == null) return true // No filter = show all return event.calendarId in selectedCalendarIds // Empty set = show none } - + + /** Check if an event matches all active filters */ + fun matchesEvent(event: EventAlertRecord, now: Long): Boolean { + return filterEvents(listOf(event), now).isNotEmpty() + } + /** * Filter events by specified filter types. * @param events List of events to filter diff --git a/android/app/src/main/java/com/github/quarck/calnotify/ui/MainActivityModern.kt b/android/app/src/main/java/com/github/quarck/calnotify/ui/MainActivityModern.kt index 9d44d0b2..7fad27bd 100644 --- a/android/app/src/main/java/com/github/quarck/calnotify/ui/MainActivityModern.kt +++ b/android/app/src/main/java/com/github/quarck/calnotify/ui/MainActivityModern.kt @@ -216,11 +216,14 @@ class MainActivityModern : MainActivityBase() { // Custom quiet hours is deprecated menu.findItem(R.id.action_custom_quiet_interval)?.isVisible = false + val hasFilters = filterState.hasActiveFilters() || !currentFragment?.getSearchQuery().isNullOrEmpty() + // Show mute all only for fragments that support it (Active events) val muteAllMenuItem = menu.findItem(R.id.action_mute_all) val supportsMuteAll = currentFragment?.supportsMuteAll() == true muteAllMenuItem?.isVisible = supportsMuteAll && settings.enableNotificationMute muteAllMenuItem?.isEnabled = currentFragment?.anyForMuteAll() == true + muteAllMenuItem?.title = getString(if (hasFilters) R.string.mute_all_filtered else R.string.mute_all) // Show dismiss all only for fragments that support it (Active events) val dismissAllMenuItem = menu.findItem(R.id.action_dismiss_all) @@ -233,9 +236,11 @@ class MainActivityModern : MainActivityBase() { val pinAllMenuItem = menu.findItem(R.id.action_pin_all) pinAllMenuItem?.isVisible = supportsPinAll pinAllMenuItem?.isEnabled = currentFragment?.anyForPinAll() == true + pinAllMenuItem?.title = getString(if (hasFilters) R.string.pin_all_filtered else R.string.pin_all) val unpinAllMenuItem = menu.findItem(R.id.action_unpin_all) unpinAllMenuItem?.isVisible = supportsPinAll unpinAllMenuItem?.isEnabled = currentFragment?.anyForUnpinAll() == true + unpinAllMenuItem?.title = getString(if (hasFilters) R.string.unpin_all_filtered else R.string.unpin_all) // Show snooze all only for fragments that support it (Active events) val snoozeAllMenuItem = menu.findItem(R.id.action_snooze_all) @@ -390,8 +395,9 @@ class MainActivityModern : MainActivityBase() { } private fun onMuteAll() { + val hasFilters = filterState.hasActiveFilters() || !getCurrentSearchableFragment()?.getSearchQuery().isNullOrEmpty() AlertDialog.Builder(this) - .setMessage(R.string.mute_all_events_question) + .setMessage(if (hasFilters) R.string.mute_all_filtered_events_question else R.string.mute_all_events_question) .setCancelable(false) .setPositiveButton(android.R.string.yes) { _, _ -> doMuteAll() @@ -402,14 +408,17 @@ class MainActivityModern : MainActivityBase() { } private fun doMuteAll() { - ApplicationController.muteAllVisibleEvents(this) + val searchQuery = getCurrentSearchableFragment()?.getSearchQuery() + val currentFilterState = getCurrentFilterState() + ApplicationController.muteAllVisibleEvents(this, searchQuery, currentFilterState) getCurrentSearchableFragment()?.onMuteAllComplete() invalidateOptionsMenu() } private fun onPinAll() { + val hasFilters = filterState.hasActiveFilters() || !getCurrentSearchableFragment()?.getSearchQuery().isNullOrEmpty() AlertDialog.Builder(this) - .setMessage(R.string.pin_all_events_question) + .setMessage(if (hasFilters) R.string.pin_all_filtered_events_question else R.string.pin_all_events_question) .setCancelable(false) .setPositiveButton(android.R.string.yes) { _, _ -> doPinAll() @@ -420,14 +429,17 @@ class MainActivityModern : MainActivityBase() { } private fun doPinAll() { - ApplicationController.pinAllVisibleEvents(this) + val searchQuery = getCurrentSearchableFragment()?.getSearchQuery() + val currentFilterState = getCurrentFilterState() + ApplicationController.pinAllVisibleEvents(this, searchQuery, currentFilterState) getCurrentSearchableFragment()?.onPinAllComplete() invalidateOptionsMenu() } private fun onUnpinAll() { + val hasFilters = filterState.hasActiveFilters() || !getCurrentSearchableFragment()?.getSearchQuery().isNullOrEmpty() AlertDialog.Builder(this) - .setMessage(R.string.unpin_all_events_question) + .setMessage(if (hasFilters) R.string.unpin_all_filtered_events_question else R.string.unpin_all_events_question) .setCancelable(false) .setPositiveButton(android.R.string.yes) { _, _ -> doUnpinAll() @@ -438,7 +450,9 @@ class MainActivityModern : MainActivityBase() { } private fun doUnpinAll() { - ApplicationController.unpinAllVisibleEvents(this) + val searchQuery = getCurrentSearchableFragment()?.getSearchQuery() + val currentFilterState = getCurrentFilterState() + ApplicationController.unpinAllVisibleEvents(this, searchQuery, currentFilterState) getCurrentSearchableFragment()?.onPinAllComplete() invalidateOptionsMenu() } diff --git a/android/app/src/main/java/com/github/quarck/calnotify/ui/ViewEventActivityNoRecents.kt b/android/app/src/main/java/com/github/quarck/calnotify/ui/ViewEventActivityNoRecents.kt index d7a2e784..fcee2a08 100644 --- a/android/app/src/main/java/com/github/quarck/calnotify/ui/ViewEventActivityNoRecents.kt +++ b/android/app/src/main/java/com/github/quarck/calnotify/ui/ViewEventActivityNoRecents.kt @@ -527,14 +527,16 @@ open class ViewEventActivityNoRecents : AppCompatActivity() { } private fun togglePinForEvent(pinned: Boolean) { - getEventsStorage(this).use { db -> - val current = db.getEvent(event.eventId, event.instanceStartTime) - if (current != null) { - current.isPinned = pinned - db.updateEvent(current) + event.isPinned = pinned + background { + getEventsStorage(this).use { db -> + val current = db.getEvent(event.eventId, event.instanceStartTime) + if (current != null) { + current.isPinned = pinned + db.updateEvent(current) + } } } - event.isPinned = pinned } private fun formatPreset(preset: Long): String { diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 623bb2b3..de71eec4 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -394,16 +394,22 @@ Un-Mute Mute - Mute all visible - Mute all visible events? (Tasks excluded) + Mute all + Mute all filtered + Mute all events? (Tasks excluded) + Mute all filtered events? (Tasks excluded) Notification is pinned Pin Unpin - Pin all visible + Pin all + Pin all filtered Unpin all - Pin all visible events?\nPinned events are excluded from batch snooze. + Unpin all filtered + Pin all events?\nPinned events are excluded from batch snooze. + Pin all filtered events?\nPinned events are excluded from batch snooze. Unpin all pinned events? + Unpin all filtered pinned events? Pinned Unpinned diff --git a/android/app/src/test/java/com/github/quarck/calnotify/app/ApplicationControllerCoreRobolectricTest.kt b/android/app/src/test/java/com/github/quarck/calnotify/app/ApplicationControllerCoreRobolectricTest.kt index 2e9ff30f..7e6e6735 100644 --- a/android/app/src/test/java/com/github/quarck/calnotify/app/ApplicationControllerCoreRobolectricTest.kt +++ b/android/app/src/test/java/com/github/quarck/calnotify/app/ApplicationControllerCoreRobolectricTest.kt @@ -84,6 +84,7 @@ class ApplicationControllerCoreRobolectricTest { private fun createTestEvent( eventId: Long = 1L, + calendarId: Long = 1L, instanceStartTime: Long = baseTime, snoozedUntil: Long = 0L, isMuted: Boolean = false, @@ -91,7 +92,7 @@ class ApplicationControllerCoreRobolectricTest { lastStatusChangeTime: Long = baseTime ): EventAlertRecord { val event = EventAlertRecord( - calendarId = 1L, + calendarId = calendarId, eventId = eventId, isAllDay = false, isRepeating = false, @@ -462,6 +463,87 @@ class ApplicationControllerCoreRobolectricTest { assertFalse("No events should be pinned after unpin all", events.any { it.isPinned }) } + // === filter-aware muteAllVisibleEvents tests === + + @Test + fun testMuteAllVisibleEvents_respectsSearchQuery() { + mockEventsStorage.addEvent(createTestEvent(eventId = 1).apply { title = "Meeting with Alice" }) + mockEventsStorage.addEvent(createTestEvent(eventId = 2).apply { title = "Lunch with Bob" }) + + ApplicationController.muteAllVisibleEvents(context, searchQuery = "Alice") + + val events = mockEventsStorage.events + assertTrue("Matching event should be muted", events.find { it.eventId == 1L }?.isMuted == true) + assertFalse("Non-matching event should NOT be muted", events.find { it.eventId == 2L }?.isMuted == true) + } + + @Test + fun testMuteAllVisibleEvents_respectsFilterState() { + mockEventsStorage.addEvent(createTestEvent(eventId = 1, calendarId = 10L)) + mockEventsStorage.addEvent(createTestEvent(eventId = 2, calendarId = 20L)) + + val filter = FilterState(selectedCalendarIds = setOf(10L)) + ApplicationController.muteAllVisibleEvents(context, filterState = filter) + + val events = mockEventsStorage.events + assertTrue("Filtered-in event should be muted", events.find { it.eventId == 1L }?.isMuted == true) + assertFalse("Filtered-out event should NOT be muted", events.find { it.eventId == 2L }?.isMuted == true) + } + + // === filter-aware pinAllVisibleEvents tests === + + @Test + fun testPinAllVisibleEvents_respectsSearchQuery() { + mockEventsStorage.addEvent(createTestEvent(eventId = 1).apply { title = "Meeting with Alice" }) + mockEventsStorage.addEvent(createTestEvent(eventId = 2).apply { title = "Lunch with Bob" }) + + ApplicationController.pinAllVisibleEvents(context, searchQuery = "Alice") + + val events = mockEventsStorage.events + assertTrue("Matching event should be pinned", events.find { it.eventId == 1L }?.isPinned == true) + assertFalse("Non-matching event should NOT be pinned", events.find { it.eventId == 2L }?.isPinned == true) + } + + @Test + fun testPinAllVisibleEvents_respectsFilterState() { + mockEventsStorage.addEvent(createTestEvent(eventId = 1, calendarId = 10L)) + mockEventsStorage.addEvent(createTestEvent(eventId = 2, calendarId = 20L)) + + val filter = FilterState(selectedCalendarIds = setOf(10L)) + ApplicationController.pinAllVisibleEvents(context, filterState = filter) + + val events = mockEventsStorage.events + assertTrue("Filtered-in event should be pinned", events.find { it.eventId == 1L }?.isPinned == true) + assertFalse("Filtered-out event should NOT be pinned", events.find { it.eventId == 2L }?.isPinned == true) + } + + // === filter-aware unpinAllVisibleEvents tests === + + @Test + fun testUnpinAllVisibleEvents_respectsSearchQuery() { + mockEventsStorage.addEvent(createTestEvent(eventId = 1).apply { isPinned = true; title = "Meeting with Alice" }) + mockEventsStorage.addEvent(createTestEvent(eventId = 2).apply { isPinned = true; title = "Lunch with Bob" }) + + ApplicationController.unpinAllVisibleEvents(context, searchQuery = "Alice") + + val events = mockEventsStorage.events + assertFalse("Matching event should be unpinned", events.find { it.eventId == 1L }?.isPinned == true) + assertTrue("Non-matching event should stay pinned", events.find { it.eventId == 2L }?.isPinned == true) + } + + @Test + fun testUnpinAllVisibleEvents_respectsFilterState() { + mockEventsStorage.addEvent(createTestEvent(eventId = 1, calendarId = 10L).apply { isPinned = true }) + mockEventsStorage.addEvent(createTestEvent(eventId = 2, calendarId = 20L).apply { isPinned = true }) + + val filter = FilterState(selectedCalendarIds = setOf(10L)) + ApplicationController.unpinAllVisibleEvents(context, filterState = filter) + + val events = mockEventsStorage.events + assertFalse("Filtered-in event should be unpinned", events.find { it.eventId == 1L }?.isPinned == true) + assertTrue("Filtered-out event should stay pinned", events.find { it.eventId == 2L }?.isPinned == true) + } + // === onEventAlarm tests === @Test