Skip to content

Commit a75aca0

Browse files
authored
feat: snooze all pill filters 215 (#219)
* docs: plan refs #215 * feat: snooze all filter by pills fixes #215 * fix: validator code review feedback ll code review issues addressed. Here's a summary of the fixes: **`MainActivityModern.kt`:** - Removed duplicate `saveFilterState()`/`restoreFilterState()` implementation - Now uses `FilterState.toBundle()` and `FilterState.fromBundle()` directly - Stores under `Consts.INTENT_FILTER_STATE` key to avoid any potential key collision - Removed unused constants (`STATE_CALENDAR_IDS`, `STATE_CALENDAR_NULL`, `STATE_STATUS_FILTERS`, `STATE_TIME_FILTER`) **`FilterState.kt`:** - Changed `toDisplayString()` to show "0 calendars" when `selectedCalendarIds` is an empty set - Empty set is a valid filter state that shows 0 events (none selected) - Removed the `if (count > 0)` check **`FilterState.kt`:** - Changed from `when` with `else -> null` to exhaustive `when` without `else` - Now the compiler will catch any future enum additions at compile time - Explicitly handles `TimeFilter.ALL` case with an empty block **`FilterStateTest.kt`** - Added 15 new tests: - Bundle serialization round-trip (all fields, null calendars, empty calendars, empty status, all time filters, all status options) - `hasActiveFilters()` (default, calendar filter, empty calendar, status filter, time filter) - `toDisplayString()` (no filters, calendar count, empty set, status names, multiple status, time filter, combined) **`ApplicationControllerCoreRobolectricTest.kt`** - Added 7 new tests: - `snoozeAllEvents` with no filter - `snoozeAllEvents` with status filter - `snoozeAllEvents` with calendar filter - `snoozeAllEvents` with search AND filter combined - `snoozeAllEvents` with empty calendar filter (none selected) - `snoozeAllEvents` with null filterState (backward compatibility) - `snoozeAllEvents` with time filter * fix: build * fix: better empty state * fix: much better empty state * fix: legit test breaks * fix: flaky test * fix: do stuff with fragment after detachment
1 parent 76a1ec7 commit a75aca0

14 files changed

Lines changed: 1168 additions & 73 deletions

File tree

android/app/src/androidTest/java/com/github/quarck/calnotify/ui/SnoozeAllActivityTest.kt

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ class SnoozeAllActivityTest : BaseUltronTest() {
148148
@Test
149149
fun clicking_preset_button_triggers_snooze() {
150150
fixture.mockApplicationController()
151-
every { ApplicationController.snoozeAllEvents(any(), any(), any(), any(), any()) } returns mockk(relaxed = true)
151+
every { ApplicationController.snoozeAllEvents(any(), any(), any(), any(), any(), any()) } returns mockk(relaxed = true)
152152

153153
val event = fixture.createEvent(title = "Snooze Me")
154154
val scenario = fixture.launchSnoozeActivityForEvent(event)
@@ -161,7 +161,7 @@ class SnoozeAllActivityTest : BaseUltronTest() {
161161

162162
// Verify snoozeAllEvents was called (SnoozeAllActivity always uses snoozeAllEvents)
163163
verify(timeout = 2000) {
164-
ApplicationController.snoozeAllEvents(any(), any(), any(), any(), any())
164+
ApplicationController.snoozeAllEvents(any(), any(), any(), any(), any(), any())
165165
}
166166

167167
scenario.close()
@@ -170,7 +170,7 @@ class SnoozeAllActivityTest : BaseUltronTest() {
170170
@Test
171171
fun clicking_preset_in_snooze_all_mode_triggers_snooze_all() {
172172
fixture.mockApplicationController()
173-
every { ApplicationController.snoozeAllEvents(any(), any(), any(), any(), any()) } returns mockk(relaxed = true)
173+
every { ApplicationController.snoozeAllEvents(any(), any(), any(), any(), any(), any()) } returns mockk(relaxed = true)
174174

175175
fixture.seedEvents(3)
176176
val scenario = fixture.launchSnoozeAllActivity()
@@ -183,7 +183,7 @@ class SnoozeAllActivityTest : BaseUltronTest() {
183183

184184
// Verify snoozeAllEvents was called
185185
verify(timeout = 2000) {
186-
ApplicationController.snoozeAllEvents(any(), any(), any(), any(), any())
186+
ApplicationController.snoozeAllEvents(any(), any(), any(), any(), any(), any())
187187
}
188188

189189
scenario.close()
@@ -260,7 +260,7 @@ class SnoozeAllActivityTest : BaseUltronTest() {
260260
@Test
261261
fun snooze_finishes_activity() {
262262
fixture.mockApplicationController()
263-
every { ApplicationController.snoozeAllEvents(any(), any(), any(), any(), any()) } returns mockk(relaxed = true)
263+
every { ApplicationController.snoozeAllEvents(any(), any(), any(), any(), any(), any()) } returns mockk(relaxed = true)
264264

265265
val event = fixture.createEvent()
266266
val scenario = fixture.launchSnoozeActivityForEvent(event)

android/app/src/main/java/com/github/quarck/calnotify/Consts.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ object Consts {
7070
const val INTENT_SNOOZE_ALL_KEY = "snooze_all"
7171
const val INTENT_SEARCH_QUERY = "search_query"
7272
const val INTENT_SEARCH_QUERY_EVENT_COUNT = "search_query_event_count"
73+
const val INTENT_FILTER_STATE = "filter_state"
7374
const val INTENT_SNOOZE_ALL_COLLAPSED_KEY = "snooze_all_collapsed"
7475
const val INTENT_DISMISS_ALL_KEY = "dismiss_all"
7576

android/app/src/main/java/com/github/quarck/calnotify/app/ApplicationController.kt

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import com.github.quarck.calnotify.utils.CNPlusClockInterface
5757
import com.github.quarck.calnotify.utils.CNPlusSystemClock
5858

5959
import com.github.quarck.calnotify.dismissedeventsstorage.EventDismissResult
60+
import com.github.quarck.calnotify.ui.FilterState
6061
import expo.modules.mymodule.JsRescheduleConfirmationObject
6162
import kotlinx.serialization.json.Json
6263

@@ -922,12 +923,30 @@ object ApplicationController : ApplicationControllerInterface, EventMovedHandler
922923
return snoozeEvents(context, { it.displayStatus == EventDisplayStatus.DisplayedCollapsed }, snoozeDelay, isChange, onlySnoozeVisible)
923924
}
924925

925-
fun snoozeAllEvents(context: Context, snoozeDelay: Long, isChange: Boolean, onlySnoozeVisible: Boolean, searchQuery: String? = null): SnoozeResult? {
926+
fun snoozeAllEvents(
927+
context: Context,
928+
snoozeDelay: Long,
929+
isChange: Boolean,
930+
onlySnoozeVisible: Boolean,
931+
searchQuery: String? = null,
932+
filterState: FilterState? = null
933+
): SnoozeResult? {
934+
val now = clock.currentTimeMillis()
926935
return snoozeEvents(context, { event ->
927-
searchQuery?.let { query ->
936+
// Search query filter (existing behavior)
937+
val matchesSearch = searchQuery?.let { query ->
928938
event.title.contains(query, ignoreCase = true) ||
929939
event.desc.contains(query, ignoreCase = true)
930940
} ?: true
941+
942+
// FilterState filters (new)
943+
val matchesFilter = filterState?.let { filter ->
944+
filter.matchesCalendar(event) &&
945+
filter.matchesStatus(event) &&
946+
filter.matchesTime(event, now)
947+
} ?: true
948+
949+
matchesSearch && matchesFilter
931950
}, snoozeDelay, isChange, onlySnoozeVisible)
932951
}
933952

android/app/src/main/java/com/github/quarck/calnotify/ui/ActiveEventsFragment.kt

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,34 @@ class ActiveEventsFragment : Fragment(), EventListCallback, SearchableFragment {
191191
}
192192

193193
private fun updateEmptyState() {
194-
emptyView.visibility = if (adapter.itemCount == 0) View.VISIBLE else View.GONE
194+
if (!isAdded) return // Fragment detached, skip update
195+
196+
val isEmpty = adapter.itemCount == 0
197+
emptyView.visibility = if (isEmpty) View.VISIBLE else View.GONE
198+
199+
if (isEmpty) {
200+
val filterState = getFilterState()
201+
val searchQuery = getSearchQuery()
202+
val hasSearch = !searchQuery.isNullOrEmpty()
203+
val hasFilter = filterState.hasActiveFilters()
204+
205+
val tabName = getString(R.string.nav_active)
206+
val itemType = getString(R.string.notifications_lowercase)
207+
val baseMessage = getString(R.string.empty_active)
208+
val message = when {
209+
hasSearch && hasFilter -> {
210+
val filterDesc = filterState.toDisplayString(requireContext()) ?: ""
211+
getString(R.string.empty_state_with_search_and_filters, tabName, itemType, searchQuery, filterDesc)
212+
}
213+
hasSearch -> getString(R.string.empty_state_with_search, tabName, itemType, searchQuery)
214+
hasFilter -> {
215+
val filterDesc = filterState.toDisplayString(requireContext()) ?: ""
216+
getString(R.string.empty_state_with_filters, tabName, itemType, filterDesc)
217+
}
218+
else -> baseMessage
219+
}
220+
emptyView.text = message
221+
}
195222
}
196223

197224
// EventListCallback implementation

android/app/src/main/java/com/github/quarck/calnotify/ui/DismissedEventsFragment.kt

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,34 @@ class DismissedEventsFragment : Fragment(), DismissedEventListCallback, Searchab
187187
}
188188

189189
private fun updateEmptyState() {
190-
emptyView.visibility = if (adapter.itemCount == 0) View.VISIBLE else View.GONE
190+
if (!isAdded) return // Fragment detached, skip update
191+
192+
val isEmpty = adapter.itemCount == 0
193+
emptyView.visibility = if (isEmpty) View.VISIBLE else View.GONE
194+
195+
if (isEmpty) {
196+
val filterState = getFilterState()
197+
val searchQuery = getSearchQuery()
198+
val hasSearch = !searchQuery.isNullOrEmpty()
199+
val hasFilter = filterState.hasActiveFilters()
200+
201+
val tabName = getString(R.string.nav_dismissed)
202+
val itemType = getString(R.string.events_lowercase)
203+
val baseMessage = getString(R.string.empty_dismissed)
204+
val message = when {
205+
hasSearch && hasFilter -> {
206+
val filterDesc = filterState.toDisplayString(requireContext()) ?: ""
207+
getString(R.string.empty_state_with_search_and_filters, tabName, itemType, searchQuery, filterDesc)
208+
}
209+
hasSearch -> getString(R.string.empty_state_with_search, tabName, itemType, searchQuery)
210+
hasFilter -> {
211+
val filterDesc = filterState.toDisplayString(requireContext()) ?: ""
212+
getString(R.string.empty_state_with_filters, tabName, itemType, filterDesc)
213+
}
214+
else -> baseMessage
215+
}
216+
emptyView.text = message
217+
}
191218
}
192219

193220
// DismissedEventListCallback implementation

android/app/src/main/java/com/github/quarck/calnotify/ui/FilterState.kt

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919

2020
package com.github.quarck.calnotify.ui
2121

22+
import android.content.Context
23+
import android.os.Bundle
24+
import com.github.quarck.calnotify.R
2225
import com.github.quarck.calnotify.calendar.EventAlertRecord
2326
import com.github.quarck.calnotify.dismissedeventsstorage.DismissedEventAlertRecord
2427
import com.github.quarck.calnotify.utils.DateTimeUtils
@@ -38,6 +41,97 @@ data class FilterState(
3841
val statusFilters: Set<StatusOption> = emptySet(), // empty = show all (no filter)
3942
val timeFilter: TimeFilter = TimeFilter.ALL
4043
) {
44+
45+
companion object {
46+
private const val BUNDLE_CALENDAR_IDS = "filter_calendar_ids"
47+
private const val BUNDLE_CALENDAR_NULL = "filter_calendar_null"
48+
private const val BUNDLE_STATUS_FILTERS = "filter_status"
49+
private const val BUNDLE_TIME_FILTER = "filter_time"
50+
51+
/** Deserialize FilterState from a Bundle */
52+
fun fromBundle(bundle: Bundle?): FilterState {
53+
if (bundle == null) return FilterState()
54+
55+
val calendarIds: Set<Long>? = if (bundle.getBoolean(BUNDLE_CALENDAR_NULL, false)) {
56+
null
57+
} else {
58+
bundle.getLongArray(BUNDLE_CALENDAR_IDS)?.toSet()
59+
}
60+
61+
val statusFilters = bundle.getIntArray(BUNDLE_STATUS_FILTERS)
62+
?.toList()
63+
?.mapNotNull { StatusOption.entries.getOrNull(it) }
64+
?.toSet() ?: emptySet()
65+
66+
val timeFilter = TimeFilter.entries.getOrNull(
67+
bundle.getInt(BUNDLE_TIME_FILTER, 0)
68+
) ?: TimeFilter.ALL
69+
70+
return FilterState(
71+
selectedCalendarIds = calendarIds,
72+
statusFilters = statusFilters,
73+
timeFilter = timeFilter
74+
)
75+
}
76+
}
77+
78+
/** Serialize FilterState to a Bundle for Intent passing */
79+
fun toBundle(): Bundle = Bundle().apply {
80+
selectedCalendarIds?.let {
81+
putLongArray(BUNDLE_CALENDAR_IDS, it.toLongArray())
82+
} ?: putBoolean(BUNDLE_CALENDAR_NULL, true)
83+
84+
putIntArray(BUNDLE_STATUS_FILTERS, statusFilters.map { it.ordinal }.toIntArray())
85+
putInt(BUNDLE_TIME_FILTER, timeFilter.ordinal)
86+
}
87+
88+
/** Check if any filters are active */
89+
fun hasActiveFilters(): Boolean {
90+
return selectedCalendarIds != null ||
91+
statusFilters.isNotEmpty() ||
92+
timeFilter != TimeFilter.ALL
93+
}
94+
95+
/**
96+
* Generate human-readable description of active filters for UI display.
97+
* Returns null if no filters are active.
98+
*/
99+
fun toDisplayString(context: Context): String? {
100+
val parts = mutableListOf<String>()
101+
102+
// Calendar filter (null = no filter, non-null = specific calendars selected)
103+
// Empty set means "none selected" which is a valid filter state (shows 0 events)
104+
if (selectedCalendarIds != null) {
105+
val count = selectedCalendarIds.size
106+
parts.add(context.resources.getQuantityString(
107+
R.plurals.filter_calendar_summary, count, count
108+
))
109+
}
110+
111+
// Status filters (show individual names)
112+
if (statusFilters.isNotEmpty()) {
113+
val names = statusFilters.map { option ->
114+
when (option) {
115+
StatusOption.SNOOZED -> context.getString(R.string.filter_status_snoozed)
116+
StatusOption.ACTIVE -> context.getString(R.string.filter_status_active)
117+
StatusOption.MUTED -> context.getString(R.string.filter_status_muted)
118+
StatusOption.RECURRING -> context.getString(R.string.filter_status_recurring)
119+
}
120+
}
121+
parts.add(names.joinToString(", "))
122+
}
123+
124+
// Time filter (exhaustive when to catch future enum additions at compile time)
125+
when (timeFilter) {
126+
TimeFilter.ALL -> { /* No display for "all" */ }
127+
TimeFilter.STARTED_TODAY -> parts.add(context.getString(R.string.filter_time_started_today))
128+
TimeFilter.STARTED_THIS_WEEK -> parts.add(context.getString(R.string.filter_time_started_this_week))
129+
TimeFilter.PAST -> parts.add(context.getString(R.string.filter_time_past))
130+
TimeFilter.STARTED_THIS_MONTH -> parts.add(context.getString(R.string.filter_time_started_this_month))
131+
}
132+
133+
return if (parts.isEmpty()) null else parts.joinToString(", ")
134+
}
41135
/** Check if an event matches current status filters (empty set = match all) */
42136
fun matchesStatus(event: EventAlertRecord): Boolean {
43137
if (statusFilters.isEmpty()) return true // No filter = show all

android/app/src/main/java/com/github/quarck/calnotify/ui/MainActivityModern.kt

Lines changed: 22 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import android.view.Menu
2929
import android.view.MenuItem
3030
import android.view.View
3131
import android.widget.PopupMenu
32+
import android.widget.Toast
3233
import androidx.appcompat.widget.SearchView
3334
import androidx.appcompat.widget.Toolbar
3435
import androidx.core.view.ViewCompat
@@ -86,45 +87,16 @@ class MainActivityModern : MainActivityBase() {
8687
}
8788

8889
private fun saveFilterState(outState: Bundle) {
89-
// Save calendar filter (null = all, empty = none, set = specific)
90-
filterState.selectedCalendarIds?.let {
91-
outState.putLongArray(STATE_CALENDAR_IDS, it.toLongArray())
92-
} ?: outState.putBoolean(STATE_CALENDAR_NULL, true)
93-
94-
// Save status filters as ordinal array
95-
outState.putIntArray(STATE_STATUS_FILTERS, filterState.statusFilters.map { it.ordinal }.toIntArray())
96-
97-
// Save time filter
98-
outState.putInt(STATE_TIME_FILTER, filterState.timeFilter.ordinal)
90+
// Use FilterState's serialization (stored under INTENT_FILTER_STATE key to avoid conflicts)
91+
outState.putBundle(Consts.INTENT_FILTER_STATE, filterState.toBundle())
9992

10093
// Save current destination to detect recreation vs tab switch
10194
currentDestinationId?.let { outState.putInt(STATE_DESTINATION_ID, it) }
10295
}
10396

10497
private fun restoreFilterState(savedState: Bundle) {
105-
// Restore calendar filter
106-
val calendarIds: Set<Long>? = if (savedState.getBoolean(STATE_CALENDAR_NULL, false)) {
107-
null
108-
} else {
109-
savedState.getLongArray(STATE_CALENDAR_IDS)?.toSet()
110-
}
111-
112-
// Restore status filters
113-
val statusFilters = savedState.getIntArray(STATE_STATUS_FILTERS)
114-
?.toList()
115-
?.mapNotNull { ordinal -> StatusOption.entries.getOrNull(ordinal) }
116-
?.toSet() ?: emptySet()
117-
118-
// Restore time filter
119-
val timeFilter = TimeFilter.entries.getOrNull(
120-
savedState.getInt(STATE_TIME_FILTER, 0)
121-
) ?: TimeFilter.ALL
122-
123-
filterState = FilterState(
124-
selectedCalendarIds = calendarIds,
125-
statusFilters = statusFilters,
126-
timeFilter = timeFilter
127-
)
98+
// Use FilterState's deserialization
99+
filterState = FilterState.fromBundle(savedState.getBundle(Consts.INTENT_FILTER_STATE))
128100

129101
// Restore destination ID to detect recreation vs tab switch
130102
if (savedState.containsKey(STATE_DESTINATION_ID)) {
@@ -256,9 +228,7 @@ class MainActivityModern : MainActivityBase() {
256228
// Show snooze all only for fragments that support it (Active events)
257229
val snoozeAllMenuItem = menu.findItem(R.id.action_snooze_all)
258230
val supportsSnoozeAll = currentFragment?.supportsSnoozeAll() == true
259-
val hasEvents = (currentFragment?.getDisplayedEventCount() ?: 0) > 0
260231
snoozeAllMenuItem?.isVisible = supportsSnoozeAll
261-
snoozeAllMenuItem?.isEnabled = hasEvents
262232
if (supportsSnoozeAll) {
263233
snoozeAllMenuItem?.title = resources.getString(
264234
if (currentFragment?.hasActiveEvents() == true) R.string.snooze_all else R.string.change_all
@@ -338,13 +308,29 @@ class MainActivityModern : MainActivityBase() {
338308
val isChange = fragment?.hasActiveEvents() != true
339309
val searchQuery = fragment?.getSearchQuery()
340310
val eventCount = fragment?.getDisplayedEventCount() ?: 0
311+
val currentFilterState = getCurrentFilterState()
312+
313+
if (eventCount == 0) {
314+
// Show feedback when no events match filters/search
315+
val hasSearch = !searchQuery.isNullOrEmpty()
316+
val hasFilter = currentFilterState.hasActiveFilters()
317+
val message = when {
318+
hasSearch && hasFilter -> getString(R.string.snooze_all_no_events_search_and_filters)
319+
hasSearch -> getString(R.string.snooze_all_no_events_search)
320+
hasFilter -> getString(R.string.snooze_all_no_events_filters)
321+
else -> getString(R.string.snooze_all_no_events)
322+
}
323+
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
324+
return true
325+
}
341326

342327
startActivity(
343328
Intent(this, SnoozeAllActivity::class.java)
344329
.putExtra(Consts.INTENT_SNOOZE_ALL_IS_CHANGE, isChange)
345330
.putExtra(Consts.INTENT_SNOOZE_FROM_MAIN_ACTIVITY, true)
346331
.putExtra(Consts.INTENT_SEARCH_QUERY, searchQuery)
347332
.putExtra(Consts.INTENT_SEARCH_QUERY_EVENT_COUNT, eventCount)
333+
.putExtra(Consts.INTENT_FILTER_STATE, currentFilterState.toBundle())
348334
.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
349335
)
350336
}
@@ -644,11 +630,7 @@ class MainActivityModern : MainActivityBase() {
644630
/** Max length for combined calendar names before showing "+N" */
645631
private const val MAX_COMBINED_CALENDAR_CHIP_LENGTH = 25
646632

647-
// SavedInstanceState keys for filter persistence across rotation
648-
private const val STATE_CALENDAR_IDS = "filter_calendar_ids"
649-
private const val STATE_CALENDAR_NULL = "filter_calendar_null"
650-
private const val STATE_STATUS_FILTERS = "filter_status"
651-
private const val STATE_TIME_FILTER = "filter_time"
633+
// SavedInstanceState key for destination (filter state uses FilterState.toBundle())
652634
private const val STATE_DESTINATION_ID = "filter_destination_id"
653635
}
654636
}

0 commit comments

Comments
 (0)