From 8d0b4f363366109eaa183105702a219753211205 Mon Sep 17 00:00:00 2001 From: William Harris Date: Thu, 5 Mar 2026 00:23:02 +0000 Subject: [PATCH 1/5] docs: plan --- docs/README.md | 1 + docs/dev_todo/snoozed_until_filter_pill.md | 342 +++++++++++++++++++++ 2 files changed, 343 insertions(+) create mode 100644 docs/dev_todo/snoozed_until_filter_pill.md diff --git a/docs/README.md b/docs/README.md index d1fac1af..7bf4247a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -61,6 +61,7 @@ Features and changes under consideration: - [Milestone 2: Pre-Actions](dev_todo/event_lookahead_milestone2_pre_actions.md) - Pre-mute, pre-snooze, pre-dismiss βœ… - [Milestone 3: Filter Pills](dev_todo/event_lookahead_milestone3_filter_pills.md) - Status, Time, Calendar filters 🚧 - [Upcoming Time Filter](dev_todo/upcoming_time_filter.md) - Time filter for Upcoming tab ([#216](https://github.com/williscool/CalendarNotification/issues/216)) + - [Snoozed Until Filter Pill](dev_todo/snoozed_until_filter_pill.md) - Filter chip by snooze wake time ([#255](https://github.com/williscool/CalendarNotification/issues/255)) - [Deprecated Features Removal](dev_todo/deprecated_features.md) - QuietHours, CalendarEditor - [Android Modernization](dev_todo/android_modernization.md) - Coroutines, Hilt DI opportunities - [Raise Min SDK](dev_todo/raise_min_sdk.md) - API 24 β†’ 26+ considerations diff --git a/docs/dev_todo/snoozed_until_filter_pill.md b/docs/dev_todo/snoozed_until_filter_pill.md new file mode 100644 index 00000000..4ee88620 --- /dev/null +++ b/docs/dev_todo/snoozed_until_filter_pill.md @@ -0,0 +1,342 @@ +# Snoozed Until Filter Pill / Chip + +**Parent Doc:** [event_lookahead_milestone3_filter_pills.md](./event_lookahead_milestone3_filter_pills.md) +**GitHub Issue:** [#255](https://github.com/williscool/CalendarNotification/issues/255) + +## Overview + +Add a "Snoozed Until" filter chip to the Active tab that works like the existing Time filter chip, but filters on `event.snoozedUntil` instead of `event.instanceStartTime`. + +## Current State + +| Feature | Status | +|---------|--------| +| Time filter chip (Active/Dismissed) | βœ… Filters on `instanceStartTime` / `instanceEndTime` | +| Status filter chip | βœ… Can filter to show only snoozed events | +| Snoozed Until filter chip | ❌ Not implemented (this feature) | + +### Existing Pattern to Follow + +The Time filter has a clean, well-tested pattern: + +1. `TimeFilter` enum in `FilterState.kt` β€” filter options + `matches()` logic +2. `TimeFilterBottomSheet` β€” bottom sheet UI with radio buttons + Apply +3. `FilterState.timeFilter` field β€” holds the current selection +4. `FilterType.TIME` β€” enables/disables the filter in `filterEvents()` +5. `MainActivityModern.addTimeChip()` β€” creates the chip, wires up the bottom sheet +6. `FilterStateTest` β€” comprehensive tests for matching + serialization + +The Snoozed Until filter follows this exact pattern. + +## Design Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Which tabs | **Active only** | Only active events can be snoozed (`snoozedUntil > 0`). Dismissed/Upcoming events don't have meaningful snooze times. | +| Non-snoozed events | **Excluded when filter active** | When filter is not ALL, events with `snoozedUntil == 0` don't match β€” they have no snooze time to filter on. | +| Tab-specific options | **No tab variation** | Unlike Time filter (which hides PAST on Dismissed, MONTH on Active), this only appears on Active so no tab logic needed. | +| Bottom sheet vs dropdown | **Bottom sheet** | Matches Time filter UX. Single-select with Apply button. | +| Interaction with Status filter | **Independent** | User can combine Status=Snoozed + SnoozedUntil=Today to see "events snoozed until today". Or use SnoozedUntil alone (implicitly shows only snoozed events since non-snoozed are excluded). | + +## UI Vision + +### Active Tab Chip Row (after this change) + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ [ Calendar β–Ό ] [ Status β–Ό ] [ Time β–Ό ] [ Snoozed Until β–Ό ] β†’ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Snoozed Until Bottom Sheet + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Filter by Snoozed Until β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β—‹ All β”‚ +β”‚ β—‹ Snoozed until today β”‚ +β”‚ β—‹ Snoozed until this week β”‚ +β”‚ β—‹ Snoozed until this month β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ [ APPLY ] β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## Filter Definitions + +### SnoozedUntilFilter Options + +| Option | Logic | Notes | +|--------|-------|-------| +| ALL | No filter (default) | Shows all events regardless of snooze status | +| SNOOZED_UNTIL_TODAY | `snoozedUntil > 0 && isToday(snoozedUntil, now)` | Events waking up today | +| SNOOZED_UNTIL_THIS_WEEK | `snoozedUntil > 0 && isThisWeek(snoozedUntil, now)` | Events waking up this week | +| SNOOZED_UNTIL_THIS_MONTH | `snoozedUntil > 0 && isThisMonth(snoozedUntil, now)` | Events waking up this month | + +All non-ALL options implicitly require `snoozedUntil > 0`, so non-snoozed events are excluded. + +Uses the same `DateTimeUtils.isToday()`, `isThisWeek()`, `isThisMonth()` helpers that `TimeFilter` already uses. + +--- + +## Implementation Phases + +### Phase 1: SnoozedUntilFilter Enum + FilterState Integration + +**Goal:** Add the filter logic and state management. + +**Files to modify:** +- `FilterState.kt` + +**Changes:** + +1. Add `SNOOZED_UNTIL` to `FilterType` enum: + +```kotlin +enum class FilterType { + CALENDAR, STATUS, TIME, SNOOZED_UNTIL +} +``` + +2. Add `SnoozedUntilFilter` enum (parallel to `TimeFilter`): + +```kotlin +enum class SnoozedUntilFilter { + ALL, + SNOOZED_UNTIL_TODAY, + SNOOZED_UNTIL_THIS_WEEK, + SNOOZED_UNTIL_THIS_MONTH; + + fun matches(event: EventAlertRecord, now: Long): Boolean { + if (this == ALL) return true + if (event.snoozedUntil == 0L) return false + return when (this) { + ALL -> true + SNOOZED_UNTIL_TODAY -> DateTimeUtils.isToday(event.snoozedUntil, now) + SNOOZED_UNTIL_THIS_WEEK -> DateTimeUtils.isThisWeek(event.snoozedUntil, now) + SNOOZED_UNTIL_THIS_MONTH -> DateTimeUtils.isThisMonth(event.snoozedUntil, now) + } + } +} +``` + +3. Add `snoozedUntilFilter` field to `FilterState`: + +```kotlin +data class FilterState( + val selectedCalendarIds: Set? = null, + val statusFilters: Set = emptySet(), + val timeFilter: TimeFilter = TimeFilter.ALL, + val snoozedUntilFilter: SnoozedUntilFilter = SnoozedUntilFilter.ALL +) +``` + +4. Add `matchesSnoozedUntil()` method to `FilterState`: + +```kotlin +fun matchesSnoozedUntil(event: EventAlertRecord, now: Long): Boolean { + return snoozedUntilFilter.matches(event, now) +} +``` + +5. Update `filterEvents()` to include `SNOOZED_UNTIL`: + +```kotlin +(FilterType.SNOOZED_UNTIL !in apply || matchesSnoozedUntil(event, now)) +``` + +6. Update `hasActiveFilters()`: + +```kotlin +fun hasActiveFilters(): Boolean { + return selectedCalendarIds != null || + statusFilters.isNotEmpty() || + timeFilter != TimeFilter.ALL || + snoozedUntilFilter != SnoozedUntilFilter.ALL +} +``` + +7. Update `toDisplayString()` β€” add snoozed until section. + +8. Update `toBundle()` / `fromBundle()` β€” add `BUNDLE_SNOOZED_UNTIL_FILTER` serialization (same pattern as `BUNDLE_TIME_FILTER`). + +9. Update `filterEvents()` default `apply` set for active events to include `FilterType.SNOOZED_UNTIL`. + +--- + +### Phase 2: Tests for SnoozedUntilFilter + +**Goal:** Add tests before building the UI. + +**Files to modify:** +- `FilterStateTest.kt` + +**Tests to add** (mirroring existing `TimeFilter` tests): + +``` +SnoozedUntilFilter ALL matches all events +SnoozedUntilFilter ALL matches non-snoozed events +SnoozedUntilFilter SNOOZED_UNTIL_TODAY matches events snoozed until today +SnoozedUntilFilter SNOOZED_UNTIL_TODAY excludes non-snoozed events +SnoozedUntilFilter SNOOZED_UNTIL_THIS_WEEK matches events snoozed until this week +SnoozedUntilFilter SNOOZED_UNTIL_THIS_MONTH matches events snoozed until this month +FilterState matchesSnoozedUntil uses snoozedUntilFilter +FilterState default has ALL snoozedUntilFilter +toBundle and fromBundle round-trip snoozedUntilFilter +toBundle and fromBundle round-trip all snoozedUntilFilter values +hasActiveFilters returns true when snoozedUntilFilter is not ALL +toDisplayString shows snoozed until filter when not ALL +``` + +--- + +### Phase 3: Bottom Sheet UI + +**Goal:** Create the bottom sheet for selecting snoozed until filter options. + +**New files:** +- `SnoozedUntilFilterBottomSheet.kt` (parallel to `TimeFilterBottomSheet.kt`) +- `layout/bottom_sheet_snoozed_until_filter.xml` (parallel to `bottom_sheet_time_filter.xml`) + +**Strings to add** (in `strings.xml`): + +```xml +Snoozed Until +All +Snoozed until today +Snoozed until this week +Snoozed until this month +``` + +The bottom sheet follows the exact same pattern as `TimeFilterBottomSheet`: +- `BottomSheetDialogFragment` with `RadioGroup` +- `Fragment Result API` for communicating selection back +- `REQUEST_KEY` / `RESULT_FILTER` constants + +--- + +### Phase 4: Wire Up Chip in MainActivityModern + +**Goal:** Add the chip to the Active tab and connect it to the bottom sheet. + +**Files to modify:** +- `MainActivityModern.kt` + +**Changes:** + +1. Add `addSnoozedUntilChip()` method (parallel to `addTimeChip()`): + - Creates chip with current filter text + - Shows `SnoozedUntilFilterBottomSheet` on click + +2. Add `getSnoozedUntilChipText()` method (parallel to `getTimeChipText()`). + +3. Add `showSnoozedUntilFilterBottomSheet()` method. + +4. Add fragment result listener in `setupFilterResultListeners()` for `SnoozedUntilFilterBottomSheet.REQUEST_KEY`. + +5. Update `updateFilterChipsForCurrentTab()` β€” add `addSnoozedUntilChip()` to the Active tab case: + +```kotlin +R.id.activeEventsFragment -> { + addCalendarChip() + addStatusChip() + addTimeChip(TimeFilterBottomSheet.TabType.ACTIVE) + addSnoozedUntilChip() // NEW +} +``` + +--- + +### Phase 5: Apply Filter in Fragment + +**Goal:** Active events fragment applies the snoozed until filter. + +**Files to modify:** +- `ActiveEventsFragment.kt` + +The fragment's `loadEvents()` already calls `filterState.filterEvents()` which applies all `FilterType`s in its `apply` set. Just need to add `FilterType.SNOOZED_UNTIL` to the active tab's default apply set (done in Phase 1). + +--- + +## Files Summary + +### New Files + +| File | Phase | Purpose | +|------|-------|---------| +| `SnoozedUntilFilterBottomSheet.kt` | 3 | Bottom sheet for snoozed until filter | +| `bottom_sheet_snoozed_until_filter.xml` | 3 | Bottom sheet layout | + +### Modified Files + +| File | Phase | Changes | +|------|-------|---------| +| `FilterState.kt` | 1 | Add `SnoozedUntilFilter` enum, `FilterType.SNOOZED_UNTIL`, `FilterState.snoozedUntilFilter` field, matching/serialization | +| `FilterStateTest.kt` | 2 | Tests for SnoozedUntilFilter matching + serialization | +| `strings.xml` | 3 | Add snoozed until filter strings | +| `MainActivityModern.kt` | 4 | Add chip, bottom sheet wiring, result listener | +| `ActiveEventsFragment.kt` | 5 | Include `SNOOZED_UNTIL` in filter apply set (if not already default) | + +### Unchanged Files + +| File | Reason | +|------|--------| +| `TimeFilterBottomSheet.kt` | Separate filter, no changes needed | +| `DismissedEventsFragment.kt` | No snoozed until chip on Dismissed tab | +| `UpcomingEventsFragment.kt` | No snoozed until chip on Upcoming tab | +| `SnoozeAllActivity.kt` | Future work β€” see snooze_all_filter_pills.md | + +--- + +## Testing Strategy + +### Unit Tests (Phase 2) + +All in `FilterStateTest.kt` β€” Robolectric tests matching the existing pattern: + +- `SnoozedUntilFilter.matches()` for each enum value +- Non-snoozed event exclusion (snoozedUntil == 0) +- `FilterState.matchesSnoozedUntil()` delegation +- Bundle round-trip serialization +- `hasActiveFilters()` / `toDisplayString()` integration + +### Manual Testing Checklist + +- [ ] "Snoozed Until" chip appears only on Active tab +- [ ] Chip does NOT appear on Upcoming or Dismissed tabs +- [ ] Tapping chip opens bottom sheet with 4 radio options +- [ ] Selecting option + Apply filters the event list +- [ ] Non-snoozed events are hidden when filter is not "All" +- [ ] Chip text updates to reflect current selection +- [ ] Switching tabs clears the snoozed until filter +- [ ] Filter survives app backgrounding (via `onSaveInstanceState`) +- [ ] Filter clears on app restart +- [ ] Combines correctly with Status filter (e.g., Status=Snoozed + SnoozedUntil=Today) +- [ ] Combines correctly with Time filter and Calendar filter +- [ ] "Snoozed until today" correctly includes events snoozed until later today +- [ ] "Snoozed until this week" correctly uses locale-aware week boundaries + +--- + +## Edge Cases + +| Scenario | Expected Behavior | +|----------|-------------------| +| No snoozed events, filter set to "Today" | Empty list (all events filtered out) | +| Event snoozed until exactly midnight boundary | `isToday()` handles this correctly (same as Time filter) | +| Filter active, then event snooze expires | Event disappears from filtered list on next refresh | +| All filters combined (Calendar + Status + Time + SnoozedUntil) | AND logic across all filter types | +| Filter set, then switch to Dismissed tab and back | Filter cleared (same as all other filters) | + +--- + +## Implementation Order + +1. **Phase 1** β€” `SnoozedUntilFilter` enum + `FilterState` integration +2. **Phase 2** β€” Tests (run before UI work) +3. **Phase 3** β€” Bottom sheet UI + strings +4. **Phase 4** β€” Wire up chip in `MainActivityModern` +5. **Phase 5** β€” Verify fragment filtering works (may be zero changes if default apply set is updated in Phase 1) + +Each phase is independently testable. Phases 1-2 are purely logic/tests with no UI changes. From f58c95d1b2f7e70da7a26264bf76438708ddfa01 Mon Sep 17 00:00:00 2001 From: William Harris Date: Thu, 5 Mar 2026 01:54:53 +0000 Subject: [PATCH 2/5] docs: new features --- docs/dev_todo/snoozed_until_filter_pill.md | 468 ++++++++++++++------- 1 file changed, 317 insertions(+), 151 deletions(-) diff --git a/docs/dev_todo/snoozed_until_filter_pill.md b/docs/dev_todo/snoozed_until_filter_pill.md index 4ee88620..ad8da0e3 100644 --- a/docs/dev_todo/snoozed_until_filter_pill.md +++ b/docs/dev_todo/snoozed_until_filter_pill.md @@ -5,7 +5,7 @@ ## Overview -Add a "Snoozed Until" filter chip to the Active tab that works like the existing Time filter chip, but filters on `event.snoozedUntil` instead of `event.instanceStartTime`. +Add a "Snoozed Until" filter chip to the Active tab that filters events based on when their snooze expires (`event.snoozedUntil`). Features configurable interval presets, a before/after direction toggle, and custom period / specific date-time pickers β€” mirroring patterns from the Upcoming Time Filter and View Event snooze dialogs. ## Current State @@ -13,30 +13,36 @@ Add a "Snoozed Until" filter chip to the Active tab that works like the existing |---------|--------| | Time filter chip (Active/Dismissed) | βœ… Filters on `instanceStartTime` / `instanceEndTime` | | Status filter chip | βœ… Can filter to show only snoozed events | +| Upcoming time filter | βœ… Configurable presets, persisted to Settings | +| View Event "For a custom period" | βœ… `TimeIntervalPickerController` + quick presets dialog | +| View Event "Until a specific time and date" | βœ… `DatePicker` β†’ `TimePicker` two-step flow | | Snoozed Until filter chip | ❌ Not implemented (this feature) | -### Existing Pattern to Follow +### Existing Patterns to Reuse -The Time filter has a clean, well-tested pattern: - -1. `TimeFilter` enum in `FilterState.kt` β€” filter options + `matches()` logic -2. `TimeFilterBottomSheet` β€” bottom sheet UI with radio buttons + Apply -3. `FilterState.timeFilter` field β€” holds the current selection -4. `FilterType.TIME` β€” enables/disables the filter in `filterEvents()` -5. `MainActivityModern.addTimeChip()` β€” creates the chip, wires up the bottom sheet -6. `FilterStateTest` β€” comprehensive tests for matching + serialization - -The Snoozed Until filter follows this exact pattern. +| Pattern | Source | How we use it | +|---------|--------|---------------| +| Dynamic radio button presets | `UpcomingTimeFilterBottomSheet` | Generate interval options from configurable presets | +| Preset parsing/formatting | `PreferenceUtils.parseSnoozePresets()` / `formatPresetHumanReadable()` | Parse `"12h, 1d, 3d, 7d, 4w"` format | +| Custom period preference | `UpcomingTimePresetPreferenceX` / `SnoozePresetPreferenceX` | Settings UI for configuring presets | +| Duration picker | `TimeIntervalPickerController` + `dialog_interval_picker.xml` | "For a custom period" option | +| Date + time picker | `dialog_date_picker.xml` β†’ `dialog_time_picker.xml` | "Until a specific time and date" option | +| Fragment Result API | `TimeFilterBottomSheet`, `UpcomingTimeFilterBottomSheet` | Communicate selection back to activity | +| In-memory filter state | `FilterState` + Bundle serialization | Filter clears on tab switch, survives rotation | ## Design Decisions | Decision | Choice | Rationale | |----------|--------|-----------| -| Which tabs | **Active only** | Only active events can be snoozed (`snoozedUntil > 0`). Dismissed/Upcoming events don't have meaningful snooze times. | -| Non-snoozed events | **Excluded when filter active** | When filter is not ALL, events with `snoozedUntil == 0` don't match β€” they have no snooze time to filter on. | -| Tab-specific options | **No tab variation** | Unlike Time filter (which hides PAST on Dismissed, MONTH on Active), this only appears on Active so no tab logic needed. | -| Bottom sheet vs dropdown | **Bottom sheet** | Matches Time filter UX. Single-select with Apply button. | -| Interaction with Status filter | **Independent** | User can combine Status=Snoozed + SnoozedUntil=Today to see "events snoozed until today". Or use SnoozedUntil alone (implicitly shows only snoozed events since non-snoozed are excluded). | +| Which tabs | **Active only** | Only active events can be snoozed. Dismissed/Upcoming don't have meaningful `snoozedUntil`. | +| Non-snoozed events | **Excluded when filter active** | Events with `snoozedUntil == 0` don't match any non-ALL filter β€” they have no snooze time to filter on. | +| Preset intervals | **Configurable via Settings** | Follow `UpcomingTimeFilterBottomSheet` pattern β€” presets parsed from comma-separated string using `PreferenceUtils`. | +| Default presets | **12h, 1d, 3d, 7d, 4w** | Practical defaults for "show events waking up within X". | +| Settings location | **Navigation & UI β†’ Active Events** (new category) | Parallel to the existing "Upcoming Events" category. | +| Filter persistence | **In-memory (`FilterState`)** | Same as Time filter β€” clears on tab switch, survives rotation. Presets live in Settings, but the current _selection_ is in-memory. | +| Before/After | **Toggle in bottom sheet** | "Before" = `snoozedUntil <= now + interval`. "After" = `snoozedUntil > now + interval`. | +| Custom period | **Reuse `TimeIntervalPickerController`** | Same widget used by View Event Activity's "For a custom period". | +| Specific time | **Reuse `DatePicker` β†’ `TimePicker` flow** | Same two-step flow used by View Event Activity's "Until a specific time and date". | ## UI Vision @@ -54,169 +60,288 @@ The Snoozed Until filter follows this exact pattern. β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Filter by Snoozed Until β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ [ ● Before | β—‹ After ] ← toggle β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ β—‹ All β”‚ -β”‚ β—‹ Snoozed until today β”‚ -β”‚ β—‹ Snoozed until this week β”‚ -β”‚ β—‹ Snoozed until this month β”‚ +β”‚ ───────────────────────────────── β”‚ +β”‚ β—‹ 12 hours β”‚ +β”‚ ● 1 day ← current β”‚ +β”‚ β—‹ 3 days β”‚ +β”‚ β—‹ 7 days β”‚ +β”‚ β—‹ 4 weeks β”‚ +β”‚ ───────────────────────────────── β”‚ +β”‚ β—‹ For a custom period... β”‚ +β”‚ β—‹ Until a specific time and date... β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ [ APPLY ] β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` -## Filter Definitions - -### SnoozedUntilFilter Options +**Behavior:** -| Option | Logic | Notes | -|--------|-------|-------| -| ALL | No filter (default) | Shows all events regardless of snooze status | -| SNOOZED_UNTIL_TODAY | `snoozedUntil > 0 && isToday(snoozedUntil, now)` | Events waking up today | -| SNOOZED_UNTIL_THIS_WEEK | `snoozedUntil > 0 && isThisWeek(snoozedUntil, now)` | Events waking up this week | -| SNOOZED_UNTIL_THIS_MONTH | `snoozedUntil > 0 && isThisMonth(snoozedUntil, now)` | Events waking up this month | +- **Before + preset**: Show events where `snoozedUntil <= now + preset` (waking up within X) +- **After + preset**: Show events where `snoozedUntil > now + preset` (won't wake up for at least X) +- **Before + specific time**: Show events where `snoozedUntil <= specificTime` +- **After + specific time**: Show events where `snoozedUntil > specificTime` +- **All**: No filter regardless of direction toggle -All non-ALL options implicitly require `snoozedUntil > 0`, so non-snoozed events are excluded. +**Custom period flow:** Selecting "For a custom period..." opens the `TimeIntervalPickerController` dialog (same as snooze). The entered duration replaces the preset selection. -Uses the same `DateTimeUtils.isToday()`, `isThisWeek()`, `isThisMonth()` helpers that `TimeFilter` already uses. +**Specific time flow:** Selecting "Until a specific time and date..." opens the DatePicker β†’ TimePicker two-step flow (same as snooze). The chosen absolute timestamp replaces the preset selection. ---- - -## Implementation Phases +### Chip Text Examples -### Phase 1: SnoozedUntilFilter Enum + FilterState Integration +| State | Chip Text | +|-------|-----------| +| No filter | "Snoozed Until" | +| Before 1 day | "Snoozed ≀ 1 day" | +| After 7 days | "Snoozed > 7 days" | +| Before specific time | "Snoozed ≀ Mar 15 3:00 PM" | +| After custom 6h | "Snoozed > 6 hours" | -**Goal:** Add the filter logic and state management. +--- -**Files to modify:** -- `FilterState.kt` +## Filter Definitions -**Changes:** +### SnoozedUntilFilterConfig -1. Add `SNOOZED_UNTIL` to `FilterType` enum: +Unlike the simple `TimeFilter` enum, this filter needs to represent multiple modes and a direction. Stored as a data class in `FilterState`: ```kotlin -enum class FilterType { - CALENDAR, STATUS, TIME, SNOOZED_UNTIL +data class SnoozedUntilFilterConfig( + val mode: SnoozedUntilFilterMode = SnoozedUntilFilterMode.ALL, + val direction: FilterDirection = FilterDirection.BEFORE, + val valueMillis: Long = 0L +) + +enum class SnoozedUntilFilterMode { + ALL, // no filter (default) + PRESET, // from configurable interval presets; valueMillis = duration + CUSTOM_PERIOD, // user-entered duration; valueMillis = duration + SPECIFIC_TIME // user-picked date+time; valueMillis = absolute timestamp } + +enum class FilterDirection { BEFORE, AFTER } ``` -2. Add `SnoozedUntilFilter` enum (parallel to `TimeFilter`): +### Matching Logic ```kotlin -enum class SnoozedUntilFilter { - ALL, - SNOOZED_UNTIL_TODAY, - SNOOZED_UNTIL_THIS_WEEK, - SNOOZED_UNTIL_THIS_MONTH; - - fun matches(event: EventAlertRecord, now: Long): Boolean { - if (this == ALL) return true - if (event.snoozedUntil == 0L) return false - return when (this) { - ALL -> true - SNOOZED_UNTIL_TODAY -> DateTimeUtils.isToday(event.snoozedUntil, now) - SNOOZED_UNTIL_THIS_WEEK -> DateTimeUtils.isThisWeek(event.snoozedUntil, now) - SNOOZED_UNTIL_THIS_MONTH -> DateTimeUtils.isThisMonth(event.snoozedUntil, now) - } +fun matches(event: EventAlertRecord, now: Long): Boolean { + if (mode == ALL) return true + if (event.snoozedUntil == 0L) return false + + val threshold = when (mode) { + ALL -> return true + PRESET, CUSTOM_PERIOD -> now + valueMillis + SPECIFIC_TIME -> valueMillis + } + + return when (direction) { + BEFORE -> event.snoozedUntil <= threshold + AFTER -> event.snoozedUntil > threshold } } ``` -3. Add `snoozedUntilFilter` field to `FilterState`: +### Bundle Serialization + +Three values to persist: `mode.ordinal` (Int), `direction.ordinal` (Int), `valueMillis` (Long). Same pattern as other `FilterState` fields. + +--- + +## Settings: Active Events Category + +New category in `navigation_preferences.xml`, parallel to the existing "Upcoming Events" category: + +```xml + + + + + +``` + +### Settings Properties ```kotlin -data class FilterState( - val selectedCalendarIds: Set? = null, - val statusFilters: Set = emptySet(), - val timeFilter: TimeFilter = TimeFilter.ALL, - val snoozedUntilFilter: SnoozedUntilFilter = SnoozedUntilFilter.ALL -) +// In Settings.kt +private const val SNOOZED_UNTIL_PRESETS_KEY = "pref_snoozed_until_presets" +const val DEFAULT_SNOOZED_UNTIL_PRESETS = "12h, 1d, 3d, 7d, 4w" + +val snoozedUntilPresetsRaw: String + get() = getString(SNOOZED_UNTIL_PRESETS_KEY, DEFAULT_SNOOZED_UNTIL_PRESETS) + +val snoozedUntilPresets: LongArray + get() { + val ret = PreferenceUtils.parseSnoozePresets(snoozedUntilPresetsRaw) + ?: PreferenceUtils.parseSnoozePresets(DEFAULT_SNOOZED_UNTIL_PRESETS) + ?: return longArrayOf() + return ret.filter { it > 0 }.toLongArray() + } ``` -4. Add `matchesSnoozedUntil()` method to `FilterState`: +Uses the same `PreferenceUtils.parseSnoozePresets()` / `formatPresetHumanReadable()` infrastructure as snooze presets and upcoming time presets. No max lookahead clamping needed (unlike upcoming, there's no scan window limit for snoozed events). + +The `SnoozedUntilPresetPreferenceX` can either reuse or extend the existing `UpcomingTimePresetPreferenceX` / `SnoozePresetPreferenceX` pattern β€” custom `DialogPreference` with EditText, parsing via `PreferenceUtils`. + +--- + +## Implementation Phases + +### Phase 1: Settings Infrastructure + +**Goal:** Add configurable presets and the Active Events settings category. + +**Files to modify:** +- `Settings.kt` β€” Add `snoozedUntilPresetsRaw`, `snoozedUntilPresets` +- `navigation_preferences.xml` β€” Add "Active Events" category with preset preference +- `strings.xml` β€” Add `active_events_category`, `snoozed_until_presets_title`, `snoozed_until_presets_summary` + +**New files:** +- `SnoozedUntilPresetPreferenceX.kt` β€” Custom preference (or generalize the existing `UpcomingTimePresetPreferenceX`) + +--- + +### Phase 2: Filter State Model + +**Goal:** Add `SnoozedUntilFilterConfig` to `FilterState` with matching, serialization, and display. + +**Files to modify:** +- `FilterState.kt` + +**Changes:** + +1. Add `SNOOZED_UNTIL` to `FilterType` enum: ```kotlin -fun matchesSnoozedUntil(event: EventAlertRecord, now: Long): Boolean { - return snoozedUntilFilter.matches(event, now) +enum class FilterType { + CALENDAR, STATUS, TIME, SNOOZED_UNTIL } ``` -5. Update `filterEvents()` to include `SNOOZED_UNTIL`: +2. Add `FilterDirection` enum and `SnoozedUntilFilterMode` enum. + +3. Add `SnoozedUntilFilterConfig` data class with `matches()` method. + +4. Add `snoozedUntilFilter: SnoozedUntilFilterConfig` field to `FilterState`. + +5. Add `matchesSnoozedUntil()` method to `FilterState`. + +6. Update `filterEvents()` inline fun to include `SNOOZED_UNTIL`: ```kotlin (FilterType.SNOOZED_UNTIL !in apply || matchesSnoozedUntil(event, now)) ``` -6. Update `hasActiveFilters()`: +7. Update `hasActiveFilters()`: ```kotlin -fun hasActiveFilters(): Boolean { - return selectedCalendarIds != null || - statusFilters.isNotEmpty() || - timeFilter != TimeFilter.ALL || - snoozedUntilFilter != SnoozedUntilFilter.ALL -} +snoozedUntilFilter.mode != SnoozedUntilFilterMode.ALL ``` -7. Update `toDisplayString()` β€” add snoozed until section. +8. Update `toDisplayString()` β€” show direction + value (e.g., "Snoozed ≀ 1 day"). -8. Update `toBundle()` / `fromBundle()` β€” add `BUNDLE_SNOOZED_UNTIL_FILTER` serialization (same pattern as `BUNDLE_TIME_FILTER`). +9. Update `toBundle()` / `fromBundle()` β€” serialize `mode.ordinal`, `direction.ordinal`, `valueMillis`. -9. Update `filterEvents()` default `apply` set for active events to include `FilterType.SNOOZED_UNTIL`. +10. Update active events `filterEvents()` default apply set to include `FilterType.SNOOZED_UNTIL`. --- -### Phase 2: Tests for SnoozedUntilFilter +### Phase 3: Tests -**Goal:** Add tests before building the UI. +**Goal:** Tests before building UI. **Files to modify:** - `FilterStateTest.kt` -**Tests to add** (mirroring existing `TimeFilter` tests): +**Tests to add:** ``` -SnoozedUntilFilter ALL matches all events -SnoozedUntilFilter ALL matches non-snoozed events -SnoozedUntilFilter SNOOZED_UNTIL_TODAY matches events snoozed until today -SnoozedUntilFilter SNOOZED_UNTIL_TODAY excludes non-snoozed events -SnoozedUntilFilter SNOOZED_UNTIL_THIS_WEEK matches events snoozed until this week -SnoozedUntilFilter SNOOZED_UNTIL_THIS_MONTH matches events snoozed until this month -FilterState matchesSnoozedUntil uses snoozedUntilFilter -FilterState default has ALL snoozedUntilFilter -toBundle and fromBundle round-trip snoozedUntilFilter -toBundle and fromBundle round-trip all snoozedUntilFilter values -hasActiveFilters returns true when snoozedUntilFilter is not ALL -toDisplayString shows snoozed until filter when not ALL +SnoozedUntilFilter: +- ALL matches all events including non-snoozed +- ALL matches non-snoozed events (snoozedUntil == 0) +- PRESET BEFORE matches events snoozed until within interval +- PRESET BEFORE excludes events snoozed until beyond interval +- PRESET BEFORE excludes non-snoozed events +- PRESET AFTER matches events snoozed until beyond interval +- PRESET AFTER excludes events snoozed until within interval +- PRESET AFTER excludes non-snoozed events +- CUSTOM_PERIOD works same as PRESET (both use now + valueMillis) +- SPECIFIC_TIME BEFORE matches events snoozed until before timestamp +- SPECIFIC_TIME BEFORE excludes events snoozed until after timestamp +- SPECIFIC_TIME AFTER matches events snoozed until after timestamp +- SPECIFIC_TIME AFTER excludes events snoozed until before timestamp +- boundary: event.snoozedUntil == threshold with BEFORE matches (<=) +- boundary: event.snoozedUntil == threshold with AFTER does not match (>) + +FilterState integration: +- matchesSnoozedUntil delegates to config +- default has ALL snoozedUntilFilter +- hasActiveFilters true when snoozedUntilFilter is not ALL +- toDisplayString shows snoozed until when not ALL + +Serialization: +- toBundle/fromBundle round-trip for each mode +- toBundle/fromBundle round-trip preserves direction +- toBundle/fromBundle round-trip preserves valueMillis +- fromBundle with null returns default ``` --- -### Phase 3: Bottom Sheet UI +### Phase 4: Bottom Sheet UI -**Goal:** Create the bottom sheet for selecting snoozed until filter options. +**Goal:** Create the bottom sheet with dynamic presets, before/after toggle, and custom options. **New files:** -- `SnoozedUntilFilterBottomSheet.kt` (parallel to `TimeFilterBottomSheet.kt`) -- `layout/bottom_sheet_snoozed_until_filter.xml` (parallel to `bottom_sheet_time_filter.xml`) +- `SnoozedUntilFilterBottomSheet.kt` +- `layout/bottom_sheet_snoozed_until_filter.xml` + +**Bottom sheet structure** (follows `UpcomingTimeFilterBottomSheet` dynamic pattern): + +1. **Header** β€” "Filter by Snoozed Until" +2. **Before/After toggle** β€” `RadioGroup` or `MaterialButtonToggleGroup` with two options +3. **"All" radio button** β€” clears filter +4. **Divider** +5. **Dynamic preset radio buttons** β€” generated from `settings.snoozedUntilPresets` using `PreferenceUtils.formatPresetHumanReadable()` (same as `UpcomingTimeFilterBottomSheet`) +6. **Divider** +7. **"For a custom period..." radio button** β€” on select, opens `TimeIntervalPickerController` dialog (reuse from `ViewEventActivityNoRecents.customSnoozeShowDialog`) +8. **"Until a specific time and date..." radio button** β€” on select, opens DatePicker β†’ TimePicker flow (reuse from `ViewEventActivityNoRecents.snoozeUntilShowDatePickerDialog`) +9. **Apply button** β€” sends result via Fragment Result API + +**Result bundle:** -**Strings to add** (in `strings.xml`): +```kotlin +companion object { + const val REQUEST_KEY = "snoozed_until_filter_request" + const val RESULT_MODE = "result_mode" // SnoozedUntilFilterMode ordinal + const val RESULT_DIRECTION = "result_direction" // FilterDirection ordinal + const val RESULT_VALUE = "result_value" // Long β€” duration or timestamp +} +``` + +**Strings to add:** ```xml Snoozed Until All -Snoozed until today -Snoozed until this week -Snoozed until this month +Before +After +For a custom period… +Until a specific time and date… ``` -The bottom sheet follows the exact same pattern as `TimeFilterBottomSheet`: -- `BottomSheetDialogFragment` with `RadioGroup` -- `Fragment Result API` for communicating selection back -- `REQUEST_KEY` / `RESULT_FILTER` constants - --- -### Phase 4: Wire Up Chip in MainActivityModern +### Phase 5: Wire Up Chip in MainActivityModern **Goal:** Add the chip to the Active tab and connect it to the bottom sheet. @@ -225,17 +350,20 @@ The bottom sheet follows the exact same pattern as `TimeFilterBottomSheet`: **Changes:** -1. Add `addSnoozedUntilChip()` method (parallel to `addTimeChip()`): - - Creates chip with current filter text - - Shows `SnoozedUntilFilterBottomSheet` on click +1. `addSnoozedUntilChip()` β€” creates chip, shows `SnoozedUntilFilterBottomSheet` on click. -2. Add `getSnoozedUntilChipText()` method (parallel to `getTimeChipText()`). +2. `getSnoozedUntilChipText()` β€” returns text based on current `filterState.snoozedUntilFilter`: + - ALL β†’ "Snoozed Until" + - BEFORE + preset/custom β†’ "Snoozed ≀ {formatted duration}" + - AFTER + preset/custom β†’ "Snoozed > {formatted duration}" + - BEFORE + specific β†’ "Snoozed ≀ {formatted date}" + - AFTER + specific β†’ "Snoozed > {formatted date}" -3. Add `showSnoozedUntilFilterBottomSheet()` method. +3. `showSnoozedUntilFilterBottomSheet()` β€” instantiates and shows the bottom sheet. -4. Add fragment result listener in `setupFilterResultListeners()` for `SnoozedUntilFilterBottomSheet.REQUEST_KEY`. +4. Fragment result listener in `setupFilterResultListeners()` for `SnoozedUntilFilterBottomSheet.REQUEST_KEY` β€” builds `SnoozedUntilFilterConfig` from result bundle, updates `filterState`, refreshes chips + fragment. -5. Update `updateFilterChipsForCurrentTab()` β€” add `addSnoozedUntilChip()` to the Active tab case: +5. Update `updateFilterChipsForCurrentTab()`: ```kotlin R.id.activeEventsFragment -> { @@ -248,14 +376,14 @@ R.id.activeEventsFragment -> { --- -### Phase 5: Apply Filter in Fragment +### Phase 6: Verify Fragment Filtering -**Goal:** Active events fragment applies the snoozed until filter. +**Goal:** Ensure Active events fragment applies the snoozed until filter. -**Files to modify:** +**Files to verify:** - `ActiveEventsFragment.kt` -The fragment's `loadEvents()` already calls `filterState.filterEvents()` which applies all `FilterType`s in its `apply` set. Just need to add `FilterType.SNOOZED_UNTIL` to the active tab's default apply set (done in Phase 1). +The fragment's `loadEvents()` calls `filterState.filterEvents()` which applies all `FilterType`s in its `apply` set. Adding `FilterType.SNOOZED_UNTIL` to the active tab's default apply set (done in Phase 2) should be sufficient. If the active tab currently uses an explicit set that doesn't include `SNOOZED_UNTIL`, update it. --- @@ -265,57 +393,90 @@ The fragment's `loadEvents()` already calls `filterState.filterEvents()` which a | File | Phase | Purpose | |------|-------|---------| -| `SnoozedUntilFilterBottomSheet.kt` | 3 | Bottom sheet for snoozed until filter | -| `bottom_sheet_snoozed_until_filter.xml` | 3 | Bottom sheet layout | +| `SnoozedUntilFilterBottomSheet.kt` | 4 | Bottom sheet with presets, toggle, custom pickers | +| `bottom_sheet_snoozed_until_filter.xml` | 4 | Bottom sheet layout | +| `SnoozedUntilPresetPreferenceX.kt` | 1 | Custom Settings preference for presets (or generalize existing) | ### Modified Files | File | Phase | Changes | |------|-------|---------| -| `FilterState.kt` | 1 | Add `SnoozedUntilFilter` enum, `FilterType.SNOOZED_UNTIL`, `FilterState.snoozedUntilFilter` field, matching/serialization | -| `FilterStateTest.kt` | 2 | Tests for SnoozedUntilFilter matching + serialization | -| `strings.xml` | 3 | Add snoozed until filter strings | -| `MainActivityModern.kt` | 4 | Add chip, bottom sheet wiring, result listener | -| `ActiveEventsFragment.kt` | 5 | Include `SNOOZED_UNTIL` in filter apply set (if not already default) | +| `Settings.kt` | 1 | `snoozedUntilPresetsRaw`, `snoozedUntilPresets` | +| `navigation_preferences.xml` | 1 | New "Active Events" category with preset preference | +| `FilterState.kt` | 2 | `SnoozedUntilFilterConfig`, `FilterDirection`, `SnoozedUntilFilterMode`, `FilterType.SNOOZED_UNTIL`, matching/serialization | +| `FilterStateTest.kt` | 3 | Tests for all matching modes, directions, serialization | +| `strings.xml` | 1, 4 | Settings strings, bottom sheet strings, chip text strings | +| `MainActivityModern.kt` | 5 | Chip, bottom sheet wiring, result listener | +| `ActiveEventsFragment.kt` | 6 | Include `SNOOZED_UNTIL` in filter apply set (if needed) | ### Unchanged Files | File | Reason | |------|--------| -| `TimeFilterBottomSheet.kt` | Separate filter, no changes needed | +| `TimeFilterBottomSheet.kt` | Separate filter, no changes | +| `UpcomingTimeFilterBottomSheet.kt` | Reference pattern only, no changes | +| `TimeIntervalPickerController.kt` | Reused as-is from bottom sheet | +| `dialog_interval_picker.xml` | Reused as-is | +| `dialog_date_picker.xml` | Reused as-is | +| `dialog_time_picker.xml` | Reused as-is | | `DismissedEventsFragment.kt` | No snoozed until chip on Dismissed tab | | `UpcomingEventsFragment.kt` | No snoozed until chip on Upcoming tab | -| `SnoozeAllActivity.kt` | Future work β€” see snooze_all_filter_pills.md | --- ## Testing Strategy -### Unit Tests (Phase 2) +### Unit Tests (Phase 3) + +All in `FilterStateTest.kt` β€” Robolectric tests following existing patterns. + +**SnoozedUntilFilterConfig.matches():** +- ALL mode β€” matches everything (snoozed and non-snoozed) +- Non-snoozed events (snoozedUntil == 0) excluded for all non-ALL modes +- PRESET + BEFORE β€” snoozedUntil <= now + duration +- PRESET + AFTER β€” snoozedUntil > now + duration +- CUSTOM_PERIOD β€” same threshold logic as PRESET +- SPECIFIC_TIME + BEFORE β€” snoozedUntil <= absolute timestamp +- SPECIFIC_TIME + AFTER β€” snoozedUntil > absolute timestamp +- Boundary: snoozedUntil == threshold β†’ BEFORE matches, AFTER doesn't + +**FilterState integration:** +- `matchesSnoozedUntil()` delegates correctly +- Default constructor has ALL mode +- `hasActiveFilters()` detects non-ALL snoozedUntilFilter -All in `FilterStateTest.kt` β€” Robolectric tests matching the existing pattern: +**Bundle serialization:** +- Round-trip each mode (ALL, PRESET, CUSTOM_PERIOD, SPECIFIC_TIME) +- Round-trip both directions (BEFORE, AFTER) +- Round-trip preserves `valueMillis` +- Null bundle returns default -- `SnoozedUntilFilter.matches()` for each enum value -- Non-snoozed event exclusion (snoozedUntil == 0) -- `FilterState.matchesSnoozedUntil()` delegation -- Bundle round-trip serialization -- `hasActiveFilters()` / `toDisplayString()` integration +**Settings presets (separate test or existing PreferenceUtils tests):** +- Parse `"12h, 1d, 3d, 7d, 4w"` correctly +- Fallback to defaults on invalid input +- Negative values filtered out ### Manual Testing Checklist - [ ] "Snoozed Until" chip appears only on Active tab - [ ] Chip does NOT appear on Upcoming or Dismissed tabs -- [ ] Tapping chip opens bottom sheet with 4 radio options -- [ ] Selecting option + Apply filters the event list +- [ ] Tapping chip opens bottom sheet with toggle + presets + custom options +- [ ] Before/After toggle switches direction +- [ ] Preset radio buttons match configured intervals from Settings +- [ ] Selecting preset + Apply filters the event list +- [ ] "For a custom period..." opens duration picker dialog +- [ ] Entering custom duration and Apply filters correctly +- [ ] "Until a specific time and date..." opens DatePicker β†’ TimePicker +- [ ] Picking a specific date/time and Apply filters correctly - [ ] Non-snoozed events are hidden when filter is not "All" -- [ ] Chip text updates to reflect current selection +- [ ] Chip text updates with direction symbol (≀ / >) and value +- [ ] "All" option clears the filter regardless of toggle state - [ ] Switching tabs clears the snoozed until filter - [ ] Filter survives app backgrounding (via `onSaveInstanceState`) - [ ] Filter clears on app restart -- [ ] Combines correctly with Status filter (e.g., Status=Snoozed + SnoozedUntil=Today) -- [ ] Combines correctly with Time filter and Calendar filter -- [ ] "Snoozed until today" correctly includes events snoozed until later today -- [ ] "Snoozed until this week" correctly uses locale-aware week boundaries +- [ ] Changing presets in Settings β†’ Navigation & UI β†’ Active Events updates the bottom sheet +- [ ] Invalid presets in Settings fall back to defaults +- [ ] Combines correctly with Status, Time, and Calendar filters --- @@ -323,20 +484,25 @@ All in `FilterStateTest.kt` β€” Robolectric tests matching the existing pattern: | Scenario | Expected Behavior | |----------|-------------------| -| No snoozed events, filter set to "Today" | Empty list (all events filtered out) | -| Event snoozed until exactly midnight boundary | `isToday()` handles this correctly (same as Time filter) | -| Filter active, then event snooze expires | Event disappears from filtered list on next refresh | +| No snoozed events, filter active | Empty list (all events filtered out) | +| Event snoozedUntil exactly at threshold | BEFORE matches (≀), AFTER doesn't (>) | +| Specific time in the past | Valid β€” shows events snoozed until before/after that past time | +| Custom period of 0 | Effectively `now` β€” BEFORE shows events waking up now or earlier, AFTER shows all future snoozes | | All filters combined (Calendar + Status + Time + SnoozedUntil) | AND logic across all filter types | | Filter set, then switch to Dismissed tab and back | Filter cleared (same as all other filters) | +| Presets changed in Settings while bottom sheet open | Bottom sheet reads presets on create β€” won't update mid-dialog, next open picks up changes | +| SPECIFIC_TIME mode + rotation | `valueMillis` (absolute timestamp) preserved via Bundle β€” still correct after rotation | +| PRESET/CUSTOM_PERIOD mode + time passes | Threshold is `now + duration` β€” recalculated each time `matches()` is called, so filter stays relative | --- ## Implementation Order -1. **Phase 1** β€” `SnoozedUntilFilter` enum + `FilterState` integration -2. **Phase 2** β€” Tests (run before UI work) -3. **Phase 3** β€” Bottom sheet UI + strings -4. **Phase 4** β€” Wire up chip in `MainActivityModern` -5. **Phase 5** β€” Verify fragment filtering works (may be zero changes if default apply set is updated in Phase 1) +1. **Phase 1** β€” Settings infrastructure (presets + Active Events category) +2. **Phase 2** β€” `SnoozedUntilFilterConfig` + `FilterState` integration +3. **Phase 3** β€” Tests (run before UI work) +4. **Phase 4** β€” Bottom sheet UI (dynamic presets, toggle, custom pickers) +5. **Phase 5** β€” Wire up chip in `MainActivityModern` +6. **Phase 6** β€” Verify fragment filtering -Each phase is independently testable. Phases 1-2 are purely logic/tests with no UI changes. +Phases 1-3 are purely logic/settings/tests with no UI changes. Phase 4 is the bulk of the UI work. Phases 5-6 wire everything together. From 44373c294964d92c90c3d57663e1a726d31e3414 Mon Sep 17 00:00:00 2001 From: William Harris Date: Thu, 5 Mar 2026 02:10:40 +0000 Subject: [PATCH 3/5] feat: draw the rest of the fucking owl --- .../com/github/quarck/calnotify/Settings.kt | 18 + .../prefs/NavigationSettingsFragmentX.kt | 21 +- .../prefs/SnoozedUntilPresetPreferenceX.kt | 136 ++++++ .../github/quarck/calnotify/ui/FilterState.kt | 91 +++- .../quarck/calnotify/ui/MainActivityModern.kt | 64 ++- .../ui/SnoozedUntilFilterBottomSheet.kt | 352 ++++++++++++++++ .../bottom_sheet_snoozed_until_filter.xml | 53 +++ .../layout/dialog_snoozed_until_presets.xml | 35 ++ android/app/src/main/res/values/strings.xml | 17 + .../main/res/xml/navigation_preferences.xml | 13 + .../quarck/calnotify/ui/FilterStateTest.kt | 389 ++++++++++++++++++ 11 files changed, 1175 insertions(+), 14 deletions(-) create mode 100644 android/app/src/main/java/com/github/quarck/calnotify/prefs/SnoozedUntilPresetPreferenceX.kt create mode 100644 android/app/src/main/java/com/github/quarck/calnotify/ui/SnoozedUntilFilterBottomSheet.kt create mode 100644 android/app/src/main/res/layout/bottom_sheet_snoozed_until_filter.xml create mode 100644 android/app/src/main/res/layout/dialog_snoozed_until_presets.xml diff --git a/android/app/src/main/java/com/github/quarck/calnotify/Settings.kt b/android/app/src/main/java/com/github/quarck/calnotify/Settings.kt index 971b32ca..1df40d91 100644 --- a/android/app/src/main/java/com/github/quarck/calnotify/Settings.kt +++ b/android/app/src/main/java/com/github/quarck/calnotify/Settings.kt @@ -501,6 +501,21 @@ class Settings(context: Context) : PersistentStorageBase(context), SettingsInter .toLongArray() } + /** Raw configurable snoozed-until filter presets string (e.g., "12h, 1d, 3d, 7d, 4w") */ + val snoozedUntilPresetsRaw: String + get() = getString(SNOOZED_UNTIL_PRESETS_KEY, DEFAULT_SNOOZED_UNTIL_PRESETS) + + /** Parsed snoozed-until filter presets in milliseconds. Filters out negative values. */ + val snoozedUntilPresets: LongArray + get() { + val ret = PreferenceUtils.parseSnoozePresets(snoozedUntilPresetsRaw) + ?: PreferenceUtils.parseSnoozePresets(DEFAULT_SNOOZED_UNTIL_PRESETS) + ?: return longArrayOf() + return ret.filter { it > 0 } + .take(MAX_SNOOZED_UNTIL_PRESETS) + .toLongArray() + } + /** Max calendars to show in calendar filter. 0 = no limit (show all). */ val calendarFilterMaxItems: Int get() = getString(CALENDAR_FILTER_MAX_ITEMS_KEY, DEFAULT_CALENDAR_FILTER_MAX_ITEMS.toString()) @@ -613,6 +628,7 @@ class Settings(context: Context) : PersistentStorageBase(context), SettingsInter private const val UPCOMING_EVENTS_FIXED_HOURS_KEY = "upcoming_events_fixed_hours" private const val UPCOMING_FIXED_LOOKAHEAD_MILLIS_KEY = "upcoming_fixed_lookahead_millis" private const val UPCOMING_TIME_PRESETS_KEY = "pref_upcoming_time_presets" + private const val SNOOZED_UNTIL_PRESETS_KEY = "pref_snoozed_until_presets" private const val CALENDAR_FILTER_MAX_ITEMS_KEY = "calendar_filter_max_items" private const val CALENDAR_FILTER_SHOW_SEARCH_KEY = "calendar_filter_show_search" private const val CALENDAR_FILTER_SHOW_IDS_KEY = "calendar_filter_show_ids" @@ -638,6 +654,8 @@ class Settings(context: Context) : PersistentStorageBase(context), SettingsInter internal const val MAX_UPCOMING_TIME_PRESETS = 10 internal const val MAX_LOOKAHEAD_DAYS = 30L internal const val MAX_LOOKAHEAD_MILLIS = MAX_LOOKAHEAD_DAYS * Consts.DAY_IN_MILLISECONDS + internal const val DEFAULT_SNOOZED_UNTIL_PRESETS = "12h, 1d, 3d, 7d, 4w" + internal const val MAX_SNOOZED_UNTIL_PRESETS = 10 /** Default max calendars in filter (0 = no limit) */ internal const val DEFAULT_CALENDAR_FILTER_MAX_ITEMS = 20 } diff --git a/android/app/src/main/java/com/github/quarck/calnotify/prefs/NavigationSettingsFragmentX.kt b/android/app/src/main/java/com/github/quarck/calnotify/prefs/NavigationSettingsFragmentX.kt index 6134738b..bd354b11 100644 --- a/android/app/src/main/java/com/github/quarck/calnotify/prefs/NavigationSettingsFragmentX.kt +++ b/android/app/src/main/java/com/github/quarck/calnotify/prefs/NavigationSettingsFragmentX.kt @@ -60,13 +60,20 @@ class NavigationSettingsFragmentX : PreferenceFragmentCompat() { } override fun onDisplayPreferenceDialog(preference: Preference) { - if (preference is UpcomingTimePresetPreferenceX) { - val dialogFragment = UpcomingTimePresetPreferenceX.Dialog.newInstance(preference.key) - @Suppress("DEPRECATION") - dialogFragment.setTargetFragment(this, 0) - dialogFragment.show(parentFragmentManager, DIALOG_FRAGMENT_TAG) - } else { - super.onDisplayPreferenceDialog(preference) + when (preference) { + is UpcomingTimePresetPreferenceX -> { + val dialogFragment = UpcomingTimePresetPreferenceX.Dialog.newInstance(preference.key) + @Suppress("DEPRECATION") + dialogFragment.setTargetFragment(this, 0) + dialogFragment.show(parentFragmentManager, DIALOG_FRAGMENT_TAG) + } + is SnoozedUntilPresetPreferenceX -> { + val dialogFragment = SnoozedUntilPresetPreferenceX.Dialog.newInstance(preference.key) + @Suppress("DEPRECATION") + dialogFragment.setTargetFragment(this, 0) + dialogFragment.show(parentFragmentManager, DIALOG_FRAGMENT_TAG) + } + else -> super.onDisplayPreferenceDialog(preference) } } diff --git a/android/app/src/main/java/com/github/quarck/calnotify/prefs/SnoozedUntilPresetPreferenceX.kt b/android/app/src/main/java/com/github/quarck/calnotify/prefs/SnoozedUntilPresetPreferenceX.kt new file mode 100644 index 00000000..c5c17eaa --- /dev/null +++ b/android/app/src/main/java/com/github/quarck/calnotify/prefs/SnoozedUntilPresetPreferenceX.kt @@ -0,0 +1,136 @@ +// +// Calendar Notifications Plus +// Copyright (C) 2025 William Harris (wharris+cnplus@upscalews.com) +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software Foundation, +// Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +// + +package com.github.quarck.calnotify.prefs + +import android.app.AlertDialog +import android.content.Context +import android.os.Bundle +import android.util.AttributeSet +import android.view.View +import android.widget.EditText +import android.widget.TextView +import androidx.preference.DialogPreference +import androidx.preference.PreferenceDialogFragmentCompat +import com.github.quarck.calnotify.R +import com.github.quarck.calnotify.Settings + +class SnoozedUntilPresetPreferenceX @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = androidx.preference.R.attr.dialogPreferenceStyle, + defStyleRes: Int = 0 +) : DialogPreference(context, attrs, defStyleAttr, defStyleRes) { + + var presetValue: String = Settings.DEFAULT_SNOOZED_UNTIL_PRESETS + private set + + init { + dialogLayoutResource = R.layout.dialog_snoozed_until_presets + positiveButtonText = context.getString(android.R.string.ok) + negativeButtonText = context.getString(android.R.string.cancel) + } + + fun persistPreset(value: String) { + presetValue = value + persistString(value) + notifyChanged() + } + + override fun onSetInitialValue(defaultValue: Any?) { + presetValue = getPersistedString((defaultValue as? String) ?: Settings.DEFAULT_SNOOZED_UNTIL_PRESETS) + } + + override fun onGetDefaultValue(a: android.content.res.TypedArray, index: Int): Any? { + return a.getString(index) + } + + class Dialog : PreferenceDialogFragmentCompat() { + private var edit: EditText? = null + + override fun onBindDialogView(view: View) { + super.onBindDialogView(view) + + val pref = preference as SnoozedUntilPresetPreferenceX + + val label = view.findViewById(R.id.text_label_snoozed_until_presets) + label?.text = getString(R.string.dialog_snoozed_until_presets_label, Settings.MAX_SNOOZED_UNTIL_PRESETS) + + edit = view.findViewById(R.id.edit_text_snoozed_until_presets) + edit?.setText(pref.presetValue) + } + + override fun onDialogClosed(positiveResult: Boolean) { + if (!positiveResult) return + val value = edit?.text?.toString() ?: return + + val result = PreferenceUtils.normalizePresetInput( + value, Settings.DEFAULT_SNOOZED_UNTIL_PRESETS + ) { it > 0 } + + if (result != null) { + val pref = preference as SnoozedUntilPresetPreferenceX + if (pref.callChangeListener(result.value)) { + pref.persistPreset(result.value) + } + + if (result.droppedCount > 0) { + showMessage(R.string.warning_presets_invalid_removed) + } + + val parsed = PreferenceUtils.parseSnoozePresets(result.value) + if (parsed != null && parsed.size > Settings.MAX_SNOOZED_UNTIL_PRESETS) { + showFormattedMessage(R.string.error_too_many_snoozed_until_presets, Settings.MAX_SNOOZED_UNTIL_PRESETS) + } + } else { + showMessage(R.string.error_cannot_parse_preset) + } + } + + private fun showMessage(id: Int) { + val context = requireContext() + AlertDialog.Builder(context) + .setMessage(context.getString(id)) + .setCancelable(false) + .setPositiveButton(android.R.string.ok) { _, _ -> } + .create() + .show() + } + + private fun showFormattedMessage(id: Int, vararg args: Any) { + val context = requireContext() + AlertDialog.Builder(context) + .setMessage(context.getString(id, *args)) + .setCancelable(false) + .setPositiveButton(android.R.string.ok) { _, _ -> } + .create() + .show() + } + + companion object { + fun newInstance(key: String): Dialog { + val fragment = Dialog() + val args = Bundle(1) + args.putString(ARG_KEY, key) + fragment.arguments = args + return fragment + } + } + } +} 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 fda65b5c..225530a1 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 @@ -30,7 +30,52 @@ import com.github.quarck.calnotify.utils.DateTimeUtils * Which filters to apply when filtering events. */ enum class FilterType { - CALENDAR, STATUS, TIME + CALENDAR, STATUS, TIME, SNOOZED_UNTIL +} + +/** + * Direction for the snoozed-until filter threshold. + */ +enum class FilterDirection { + BEFORE, // snoozedUntil <= threshold + AFTER // snoozedUntil > threshold +} + +/** + * Mode for the snoozed-until filter. + */ +enum class SnoozedUntilFilterMode { + ALL, // no filter (default) + PRESET, // from configurable interval presets; valueMillis = duration + CUSTOM_PERIOD, // user-entered duration; valueMillis = duration + SPECIFIC_TIME // user-picked date+time; valueMillis = absolute timestamp +} + +/** + * Configuration for the snoozed-until filter. Unlike the simple TimeFilter enum, + * this supports multiple modes (presets, custom duration, specific time) and a + * before/after direction toggle. + */ +data class SnoozedUntilFilterConfig( + val mode: SnoozedUntilFilterMode = SnoozedUntilFilterMode.ALL, + val direction: FilterDirection = FilterDirection.BEFORE, + val valueMillis: Long = 0L +) { + fun matches(event: EventAlertRecord, now: Long): Boolean { + if (mode == SnoozedUntilFilterMode.ALL) return true + if (event.snoozedUntil == 0L) return false + + val threshold = when (mode) { + SnoozedUntilFilterMode.ALL -> return true + SnoozedUntilFilterMode.PRESET, SnoozedUntilFilterMode.CUSTOM_PERIOD -> now + valueMillis + SnoozedUntilFilterMode.SPECIFIC_TIME -> valueMillis + } + + return when (direction) { + FilterDirection.BEFORE -> event.snoozedUntil <= threshold + FilterDirection.AFTER -> event.snoozedUntil > threshold + } + } } /** @@ -39,7 +84,8 @@ enum class FilterType { data class FilterState( val selectedCalendarIds: Set? = null, // null = no filter (all), empty = none, set = specific val statusFilters: Set = emptySet(), // empty = show all (no filter) - val timeFilter: TimeFilter = TimeFilter.ALL + val timeFilter: TimeFilter = TimeFilter.ALL, + val snoozedUntilFilter: SnoozedUntilFilterConfig = SnoozedUntilFilterConfig() ) { companion object { @@ -47,6 +93,9 @@ data class FilterState( private const val BUNDLE_CALENDAR_NULL = "filter_calendar_null" private const val BUNDLE_STATUS_FILTERS = "filter_status" private const val BUNDLE_TIME_FILTER = "filter_time" + private const val BUNDLE_SNOOZED_UNTIL_MODE = "filter_snoozed_until_mode" + private const val BUNDLE_SNOOZED_UNTIL_DIRECTION = "filter_snoozed_until_direction" + private const val BUNDLE_SNOOZED_UNTIL_VALUE = "filter_snoozed_until_value" /** Deserialize FilterState from a Bundle */ fun fromBundle(bundle: Bundle?): FilterState { @@ -67,10 +116,21 @@ data class FilterState( bundle.getInt(BUNDLE_TIME_FILTER, 0) ) ?: TimeFilter.ALL + val snoozedUntilFilter = SnoozedUntilFilterConfig( + mode = SnoozedUntilFilterMode.entries.getOrNull( + bundle.getInt(BUNDLE_SNOOZED_UNTIL_MODE, 0) + ) ?: SnoozedUntilFilterMode.ALL, + direction = FilterDirection.entries.getOrNull( + bundle.getInt(BUNDLE_SNOOZED_UNTIL_DIRECTION, 0) + ) ?: FilterDirection.BEFORE, + valueMillis = bundle.getLong(BUNDLE_SNOOZED_UNTIL_VALUE, 0L) + ) + return FilterState( selectedCalendarIds = calendarIds, statusFilters = statusFilters, - timeFilter = timeFilter + timeFilter = timeFilter, + snoozedUntilFilter = snoozedUntilFilter ) } } @@ -83,13 +143,17 @@ data class FilterState( putIntArray(BUNDLE_STATUS_FILTERS, statusFilters.map { it.ordinal }.toIntArray()) putInt(BUNDLE_TIME_FILTER, timeFilter.ordinal) + putInt(BUNDLE_SNOOZED_UNTIL_MODE, snoozedUntilFilter.mode.ordinal) + putInt(BUNDLE_SNOOZED_UNTIL_DIRECTION, snoozedUntilFilter.direction.ordinal) + putLong(BUNDLE_SNOOZED_UNTIL_VALUE, snoozedUntilFilter.valueMillis) } /** Check if any filters are active */ fun hasActiveFilters(): Boolean { return selectedCalendarIds != null || statusFilters.isNotEmpty() || - timeFilter != TimeFilter.ALL + timeFilter != TimeFilter.ALL || + snoozedUntilFilter.mode != SnoozedUntilFilterMode.ALL } /** @@ -130,6 +194,15 @@ data class FilterState( TimeFilter.STARTED_THIS_MONTH -> parts.add(context.getString(R.string.filter_time_started_this_month)) } + // Snoozed until filter + if (snoozedUntilFilter.mode != SnoozedUntilFilterMode.ALL) { + val symbol = when (snoozedUntilFilter.direction) { + FilterDirection.BEFORE -> "\u2264" // ≀ + FilterDirection.AFTER -> ">" + } + parts.add(context.getString(R.string.filter_snoozed_until_display, symbol)) + } + return if (parts.isEmpty()) null else parts.joinToString(", ") } /** Check if an event matches current status filters (empty set = match all) */ @@ -143,6 +216,11 @@ data class FilterState( return timeFilter.matches(event, now) } + /** Check if an event matches current snoozed-until filter */ + fun matchesSnoozedUntil(event: EventAlertRecord, now: Long): Boolean { + return snoozedUntilFilter.matches(event, now) + } + /** Check if an event matches current calendar filter (null = all, empty = none) */ fun matchesCalendar(event: EventAlertRecord): Boolean { if (selectedCalendarIds == null) return true // No filter = show all @@ -165,7 +243,8 @@ data class FilterState( val event = eventExtractor(item) (FilterType.CALENDAR !in apply || matchesCalendar(event)) && (FilterType.STATUS !in apply || matchesStatus(event)) && - (FilterType.TIME !in apply || matchesTime(event, now)) + (FilterType.TIME !in apply || matchesTime(event, now)) && + (FilterType.SNOOZED_UNTIL !in apply || matchesSnoozedUntil(event, now)) }.toTypedArray() } @@ -173,7 +252,7 @@ data class FilterState( fun filterEvents( events: List, now: Long, - apply: Set = setOf(FilterType.CALENDAR, FilterType.STATUS, FilterType.TIME) + apply: Set = setOf(FilterType.CALENDAR, FilterType.STATUS, FilterType.TIME, FilterType.SNOOZED_UNTIL) ): Array { return filterEvents(events, now, apply) { it } } 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 441c2af2..13d2f786 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 @@ -400,10 +400,11 @@ class MainActivityModern : MainActivityBase() { when (currentDestination) { R.id.activeEventsFragment -> { - // Active tab: Calendar, Status, Time + // Active tab: Calendar, Status, Time, Snoozed Until addCalendarChip() addStatusChip() addTimeChip(TimeFilterBottomSheet.TabType.ACTIVE) + addSnoozedUntilChip() } R.id.upcomingEventsFragment -> { addCalendarChip() @@ -634,6 +635,51 @@ class MainActivityModern : MainActivityBase() { bottomSheet.show(supportFragmentManager, "UpcomingTimeFilterBottomSheet") } + // === Snoozed Until Filter Chip === + + private fun addSnoozedUntilChip() { + val materialContext = ContextThemeWrapper(this, com.google.android.material.R.style.Theme_MaterialComponents_DayNight) + val chip = Chip(materialContext).apply { + text = getSnoozedUntilChipText() + isCheckable = false + isChipIconVisible = false + isCloseIconVisible = true + closeIcon = getDrawable(R.drawable.ic_arrow_drop_down) + setOnClickListener { showSnoozedUntilFilterBottomSheet() } + setOnCloseIconClickListener { showSnoozedUntilFilterBottomSheet() } + } + chipGroup?.addView(chip) + } + + private fun getSnoozedUntilChipText(): String { + val config = filterState.snoozedUntilFilter + if (config.mode == SnoozedUntilFilterMode.ALL) { + return getString(R.string.filter_snoozed_until) + } + val symbol = when (config.direction) { + FilterDirection.BEFORE -> "\u2264" // ≀ + FilterDirection.AFTER -> ">" + } + val valueText = when (config.mode) { + SnoozedUntilFilterMode.ALL -> "" + SnoozedUntilFilterMode.PRESET, SnoozedUntilFilterMode.CUSTOM_PERIOD -> + PreferenceUtils.formatPresetHumanReadable(this, config.valueMillis) + SnoozedUntilFilterMode.SPECIFIC_TIME -> + android.text.format.DateUtils.formatDateTime( + this, config.valueMillis, + android.text.format.DateUtils.FORMAT_SHOW_DATE or + android.text.format.DateUtils.FORMAT_SHOW_TIME or + android.text.format.DateUtils.FORMAT_ABBREV_ALL + ) + } + return "$symbol $valueText" + } + + private fun showSnoozedUntilFilterBottomSheet() { + val bottomSheet = SnoozedUntilFilterBottomSheet.newInstance(filterState.snoozedUntilFilter) + bottomSheet.show(supportFragmentManager, "SnoozedUntilFilterBottomSheet") + } + /** Setup Fragment Result listeners for bottom sheets (survives config changes) */ private fun setupFilterResultListeners() { // Time filter result @@ -674,6 +720,22 @@ class MainActivityModern : MainActivityBase() { updateFilterChipsForCurrentTab() notifyCurrentFragmentFilterChanged() } + + // Snoozed until filter result + supportFragmentManager.setFragmentResultListener( + SnoozedUntilFilterBottomSheet.REQUEST_KEY, this + ) { _, bundle -> + val mode = SnoozedUntilFilterMode.entries.getOrNull( + bundle.getInt(SnoozedUntilFilterBottomSheet.RESULT_MODE, 0) + ) ?: SnoozedUntilFilterMode.ALL + val direction = FilterDirection.entries.getOrNull( + bundle.getInt(SnoozedUntilFilterBottomSheet.RESULT_DIRECTION, 0) + ) ?: FilterDirection.BEFORE + val value = bundle.getLong(SnoozedUntilFilterBottomSheet.RESULT_VALUE, 0L) + filterState = filterState.copy(snoozedUntilFilter = SnoozedUntilFilterConfig(mode, direction, value)) + updateFilterChipsForCurrentTab() + notifyCurrentFragmentFilterChanged() + } } // === Selection Mode Coordination === diff --git a/android/app/src/main/java/com/github/quarck/calnotify/ui/SnoozedUntilFilterBottomSheet.kt b/android/app/src/main/java/com/github/quarck/calnotify/ui/SnoozedUntilFilterBottomSheet.kt new file mode 100644 index 00000000..953a82f1 --- /dev/null +++ b/android/app/src/main/java/com/github/quarck/calnotify/ui/SnoozedUntilFilterBottomSheet.kt @@ -0,0 +1,352 @@ +// +// Calendar Notifications Plus +// Copyright (C) 2025 William Harris (wharris+cnplus@upscalews.com) +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software Foundation, +// Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +// + +package com.github.quarck.calnotify.ui + +import android.content.DialogInterface +import android.os.Bundle +import android.text.format.DateUtils +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.DatePicker +import android.widget.RadioButton +import android.widget.RadioGroup +import android.widget.TimePicker +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.core.os.bundleOf +import androidx.fragment.app.setFragmentResult +import com.github.quarck.calnotify.R +import com.github.quarck.calnotify.Settings +import com.github.quarck.calnotify.prefs.PreferenceUtils +import com.github.quarck.calnotify.utils.findOrThrow +import com.github.quarck.calnotify.utils.hourCompat +import com.github.quarck.calnotify.utils.minuteCompat +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import java.util.Calendar + +/** + * Bottom sheet for filtering active events by snoozed-until time. + * Features configurable interval presets, before/after toggle, + * and custom period / specific date-time pickers. + */ +class SnoozedUntilFilterBottomSheet : BottomSheetDialogFragment() { + + private val ALL_RADIO_ID = View.generateViewId() + private val CUSTOM_PERIOD_RADIO_ID = View.generateViewId() + private val SPECIFIC_TIME_RADIO_ID = View.generateViewId() + + private var customPeriodMillis: Long = 0L + private var specificTimeMillis: Long = 0L + + private val currentConfig: SnoozedUntilFilterConfig + get() { + val args = arguments ?: return SnoozedUntilFilterConfig() + return SnoozedUntilFilterConfig( + mode = SnoozedUntilFilterMode.entries.getOrNull( + args.getInt(ARG_MODE, 0) + ) ?: SnoozedUntilFilterMode.ALL, + direction = FilterDirection.entries.getOrNull( + args.getInt(ARG_DIRECTION, 0) + ) ?: FilterDirection.BEFORE, + valueMillis = args.getLong(ARG_VALUE, 0L) + ) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.bottom_sheet_snoozed_until_filter, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val settings = Settings(requireContext()) + val directionGroup = view.findViewById(R.id.direction_toggle) + val radioGroup = view.findViewById(R.id.snoozed_until_radio_group).apply { + isSaveEnabled = false + } + val applyButton = view.findViewById