From cdb46eec089bb195ae45564dd54dad76a1706eb8 Mon Sep 17 00:00:00 2001 From: William Harris Date: Tue, 24 Feb 2026 05:17:27 +0000 Subject: [PATCH 1/9] docs: plan --- docs/README.md | 1 + ...event_lookahead_milestone3_filter_pills.md | 2 +- docs/dev_todo/upcoming_time_filter.md | 277 ++++++++++++++++++ 3 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 docs/dev_todo/upcoming_time_filter.md diff --git a/docs/README.md b/docs/README.md index 85847f63..d1fac1af 100644 --- a/docs/README.md +++ b/docs/README.md @@ -60,6 +60,7 @@ Features and changes under consideration: - [Events View Lookahead](dev_todo/events_view_lookahead.md) - Upcoming events tab, pre-actions, filter pills ⭐ *Active* - [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)) - [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/event_lookahead_milestone3_filter_pills.md b/docs/dev_todo/event_lookahead_milestone3_filter_pills.md index c43a7d62..f6444c89 100644 --- a/docs/dev_todo/event_lookahead_milestone3_filter_pills.md +++ b/docs/dev_todo/event_lookahead_milestone3_filter_pills.md @@ -920,7 +920,7 @@ Each phase is independently testable and deliverable. - "5 events matching Snoozed, Muted filters will be snoozed" - "3 events matching 'meeting', Snoozed filter will be snoozed" (search + filter) - Pattern: `X event(s) matching [search], [filter1, filter2, ...] will be snoozed` -- **Time filter for Upcoming tab** - Needs thoughtful integration with existing lookahead settings (fixed hours vs. day boundary mode). Could filter within existing lookahead or override/extend it. Implement after Calendar filter to learn from those patterns. +- **Time filter for Upcoming tab** - Spec'd out: [upcoming_time_filter.md](./upcoming_time_filter.md) ([#216](https://github.com/williscool/CalendarNotification/issues/216)). Unlike other time filters, this controls the lookahead window and persists to Settings. - **Fix test activity calendar creation** - The test activity (`TestActivityCalendarEvents` or similar) creates a new calendar each time it runs instead of reusing an existing "Test Calendar". This leads to hundreds of duplicate calendars over time (observed 718 "Test Calendar" entries). Should check for existing test calendar by name before creating a new one. ### potential diff --git a/docs/dev_todo/upcoming_time_filter.md b/docs/dev_todo/upcoming_time_filter.md new file mode 100644 index 00000000..1399f13d --- /dev/null +++ b/docs/dev_todo/upcoming_time_filter.md @@ -0,0 +1,277 @@ +# Feature: Time Filter for Upcoming Tab + +**GitHub Issue:** [#216](https://github.com/williscool/CalendarNotification/issues/216) +**Parent Doc:** [events_view_lookahead.md](./events_view_lookahead.md) +**Related:** [Milestone 3: Filter Pills](./event_lookahead_milestone3_filter_pills.md) (where this was deferred) + +## Background + +Milestone 3 added filter pills to all tabs but explicitly deferred the Time filter for the Upcoming tab: + +> Time filter for Upcoming tab is deferred - it needs thoughtful integration with the existing lookahead settings (fixed hours vs. day boundary mode). + +The Active and Dismissed tab Time filters are simple in-memory filters that operate *within* already-loaded events. The Upcoming tab is different: its "time filter" actually **controls the lookahead window** β€” it determines which events get fetched from MonitorStorage in the first place. + +## Design Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Filter persistence | **Persists to Settings** | Unlike other filter pills (in-memory), this controls the data query window. User explicitly said "changing this pill should be the same as changing the option in the settings UI" | +| Pill behavior | **Dual-purpose: mode + interval** | Shows Day Boundary as a special option, plus configurable fixed-hour intervals | +| Configurable intervals | **Snooze preset pattern** | Comma-separated string (e.g., `"4h, 8h, 12h, 24h"`) using `PreferenceUtils.parseSnoozePresets()` | +| Invalid config fallback | **Standard default list** | Same pattern as snooze presets: if parse fails, use defaults | +| Bottom sheet type | **New `UpcomingTimeFilterBottomSheet`** | Separate from existing `TimeFilterBottomSheet` β€” completely different behavior and options | +| Clear on tab switch? | **No** | Unlike other filters, this persists to Settings. It represents the user's preferred view window | + +## How It's Different From Other Time Filters + +| Aspect | Active/Dismissed Time Filter | Upcoming Time Filter | +|--------|------------------------------|----------------------| +| What it does | Filters already-loaded events by start time | Controls which events get fetched (lookahead window) | +| Persistence | In-memory, clears on tab switch & restart | Persists to Settings | +| Options | Started Today / This Week / Past / etc. | Day Boundary / 4h / 8h / 12h / 24h / 48h | +| Implementation | `FilterState.timeFilter` enum | Writes to `Settings.upcomingEventsMode` + `upcomingEventsFixedHours` | +| Shared with Settings UI | No | Yes β€” pill and Settings UI modify the same values | + +## UI Vision + +### Upcoming Time Filter Chip + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ [ Calendar β–Ό ] [ Status β–Ό ] [ 8 hours β–Ό ] β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +Chip text shows the current lookahead mode: +- `"Day boundary"` when in day boundary mode +- `"4 hours"` / `"8 hours"` / `"24 hours"` etc. when in fixed mode + +### Bottom Sheet + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Upcoming Window βœ• β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β—‹ Day boundary (4 AM) β”‚ +β”‚ ───────────────────────────────── β”‚ +β”‚ β—‹ 4 hours β”‚ +β”‚ ● 8 hours ← current β”‚ +β”‚ β—‹ 12 hours β”‚ +β”‚ β—‹ 24 hours β”‚ +β”‚ β—‹ 48 hours β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ [ APPLY ] β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +- Day boundary option shows the configured boundary hour (from Settings) +- Fixed hour options come from configurable presets (like snooze presets) +- A divider separates the day boundary option from fixed hour options +- Current selection is pre-checked + +## Configurable Intervals (Snooze Preset Pattern) + +### Settings Property + +```kotlin +// In Settings.kt +private const val UPCOMING_TIME_PRESETS_KEY = "pref_upcoming_time_presets" +const val DEFAULT_UPCOMING_TIME_PRESETS = "4h, 8h, 12h, 24h, 48h" + +val upcomingTimePresetsRaw: String + get() = getString(UPCOMING_TIME_PRESETS_KEY, DEFAULT_UPCOMING_TIME_PRESETS) + +val upcomingTimePresets: LongArray + get() { + var ret = PreferenceUtils.parseSnoozePresets(upcomingTimePresetsRaw) + if (ret == null) + ret = PreferenceUtils.parseSnoozePresets(DEFAULT_UPCOMING_TIME_PRESETS) + if (ret == null || ret.isEmpty()) + ret = longArrayOf( + 4 * Consts.HOUR_IN_MILLISECONDS, + 8 * Consts.HOUR_IN_MILLISECONDS, + 12 * Consts.HOUR_IN_MILLISECONDS, + 24 * Consts.HOUR_IN_MILLISECONDS, + 48 * Consts.HOUR_IN_MILLISECONDS + ) + return ret + } +``` + +### Settings UI + +Reuse the `SnoozePresetPreferenceX` pattern β€” custom `DialogPreference` with EditText: + +```xml + + +``` + +Either create a new `UpcomingTimePresetPreferenceX` (nearly identical to `SnoozePresetPreferenceX`), or make `SnoozePresetPreferenceX` more generic and reusable for both. The parsing is identical (`PreferenceUtils.parseSnoozePresets()`). + +## Implementation Plan + +### Phase 1: Settings Infrastructure + +Add the configurable time presets to `Settings.kt`. + +**Files to modify:** +- `Settings.kt` β€” Add `upcomingTimePresetsRaw`, `upcomingTimePresets`, and key/default constants + +**Tests:** +- Parse valid presets (reuses existing `PreferenceUtils` tests, but verify integration) +- Fallback when presets are invalid +- Fallback when presets are empty + +### Phase 2: Bottom Sheet UI + +Create `UpcomingTimeFilterBottomSheet` β€” a new bottom sheet that: +1. Shows "Day boundary (X AM)" as the first radio option +2. Shows configurable fixed-hour interval options below a divider +3. Pre-selects the current lookahead mode +4. On Apply, writes to `Settings.upcomingEventsMode` and `Settings.upcomingEventsFixedHours` +5. Returns result via Fragment Result API (like existing `TimeFilterBottomSheet`) + +**New files:** +- `UpcomingTimeFilterBottomSheet.kt` β€” The bottom sheet fragment +- `layout/bottom_sheet_upcoming_time_filter.xml` β€” Layout (or generate dynamically since options are configurable) + +**Design note:** Since the fixed-hour options are configurable, the radio buttons should be generated dynamically rather than using a static XML layout. The layout needs: +- A header row ("Upcoming Window" + close button) +- A `RadioGroup` that gets populated programmatically +- An Apply button + +### Phase 3: Wire Up the Chip + +Add the time chip to the Upcoming tab in `MainActivityModern.updateFilterChipsForCurrentTab()`. + +**Key behavior:** +- Chip text reflects current `Settings.upcomingEventsMode` and related values +- Tapping chip shows `UpcomingTimeFilterBottomSheet` +- On result: update Settings, refresh the Upcoming fragment +- Unlike other filter chips, this does NOT use `FilterState` β€” it writes directly to Settings + +**Files to modify:** +- `MainActivityModern.kt` β€” Add `addUpcomingTimeChip()`, handle result, update `updateFilterChipsForCurrentTab()` + +### Phase 4: Settings UI for Custom Presets + +Add configurable preset preference to the settings screen. + +**Files to modify/create:** +- `UpcomingTimePresetPreferenceX.kt` (or generalize `SnoozePresetPreferenceX`) +- `navigation_preferences.xml` β€” Add the preference entry +- `strings.xml` β€” Add string resources + +### Phase 5: Integration with UpcomingEventsFragment + +When the time filter changes (via pill or Settings UI), the Upcoming fragment needs to reload events with the new lookahead window. `UpcomingEventsProvider` already reads from `Settings` each time `getUpcomingEvents()` is called, so just triggering `loadEvents()` should work. + +**Verify:** +- Changing the pill triggers `UpcomingEventsFragment.loadEvents()` +- Changing Settings UI triggers reload when returning to the Upcoming tab (already handled by `onResume`) +- Empty state message updates to reflect the new window + +## String Resources + +```xml + +Upcoming Window +Day boundary (%s) +%d hours +Lookahead interval presets +Comma-separated intervals (e.g., 4h, 8h, 12h, 24h) +``` + +## Edge Cases + +### Invalid Custom Presets +If user enters garbage in the Settings preset field, `PreferenceUtils.parseSnoozePresets()` returns null and we fall back to the default list. The bottom sheet always has valid options. + +### Current Selection Not in Presets +If the user changes their presets and the currently active fixed hours value isn't in the new list: +- The bottom sheet shows all preset options, none pre-selected (or "Day boundary" selected as fallback) +- The current setting continues to work β€” `UpcomingEventsLookahead` doesn't care if the value came from a preset list + +### Day Boundary Hour Changes +If the user changes their day boundary hour in Settings, the pill text and bottom sheet update automatically on next view (they read from Settings each time). + +### Very Large Intervals +The `upcomingEventsFixedHours` is already clamped to `1..48` by `Settings.kt`. If a preset would exceed this, it gets clamped. We should validate presets to ensure they make sense (e.g., filter out values > 48h or < 1h at display time, or clamp when writing to Settings). + +## Files Summary + +### New Files + +| File | Phase | Purpose | +|------|-------|---------| +| `UpcomingTimeFilterBottomSheet.kt` | 2 | Bottom sheet for upcoming time/lookahead selection | +| `bottom_sheet_upcoming_time_filter.xml` | 2 | Layout (minimal β€” dynamic radio buttons) | +| `UpcomingTimePresetPreferenceX.kt` | 4 | Custom preference for configuring presets (or generalize existing) | + +### Modified Files + +| File | Phase | Changes | +|------|-------|---------| +| `Settings.kt` | 1 | Add `upcomingTimePresetsRaw`, `upcomingTimePresets` | +| `MainActivityModern.kt` | 3 | Add upcoming time chip, handle result, update chip text | +| `navigation_preferences.xml` | 4 | Add configurable preset preference | +| `strings.xml` | 2-4 | Add string resources | +| `event_lookahead_milestone3_filter_pills.md` | β€” | Move "Time filter for Upcoming tab" from Future to Planned/Done and link here | + +### Not Modified (but involved) + +| File | Reason | +|------|--------| +| `UpcomingEventsLookahead.kt` | Already reads from Settings β€” no changes needed | +| `UpcomingEventsProvider.kt` | Already uses `UpcomingEventsLookahead` β€” no changes needed | +| `UpcomingEventsFragment.kt` | Already reloads via `loadEvents()` β€” no changes needed (just needs to be triggered) | +| `FilterState.kt` | Not used β€” this filter writes to Settings, not FilterState | +| `TimeFilterBottomSheet.kt` | Not used β€” separate bottom sheet for Active/Dismissed tabs | + +## Testing Strategy + +### Unit Tests (Robolectric) + +``` +UpcomingTimeFilterTest.kt: +- upcomingTimePresets parses valid custom presets +- upcomingTimePresets falls back to defaults on invalid input +- upcomingTimePresets falls back to defaults on empty string +- upcomingTimePresets returns correct millisecond values +- selecting day boundary writes mode to Settings +- selecting fixed interval writes mode and hours to Settings +- chip text reflects day boundary mode +- chip text reflects fixed hours mode with correct value +- presets outside valid range are filtered/clamped +``` + +### Manual Testing Checklist + +- [ ] Upcoming tab shows time chip with current lookahead mode +- [ ] Tapping chip opens bottom sheet with Day Boundary + interval options +- [ ] Day boundary option shows configured boundary hour +- [ ] Selecting Day boundary and Apply switches lookahead mode +- [ ] Selecting a fixed interval and Apply switches mode + hours +- [ ] Event list refreshes after changing the lookahead window +- [ ] Chip text updates to reflect new selection +- [ ] Changing lookahead in Settings UI updates the chip on return +- [ ] Custom presets in Settings UI are reflected in the bottom sheet +- [ ] Invalid custom presets fall back to defaults +- [ ] Tab switch does NOT clear this filter (unlike other filters) +- [ ] Setting survives app restart + +## Implementation Order + +1. **Phase 1** β€” Settings infrastructure (smallest, enables testing) +2. **Phase 2** β€” Bottom sheet UI (core feature) +3. **Phase 3** β€” Wire up chip in MainActivityModern +4. **Phase 4** β€” Settings UI for custom presets (nice-to-have, can defer) +5. **Phase 5** β€” Verify integration (mostly verifying existing code paths work) + +Phases 1-3 deliver the core feature. Phase 4 adds the "configurable like snooze presets" enhancement. Phase 5 is verification/polish. From 93693b3b8a30f2813ea74eb6d207df812707cedc Mon Sep 17 00:00:00 2001 From: William Harris Date: Tue, 24 Feb 2026 05:35:26 +0000 Subject: [PATCH 2/9] docs: plan better --- docs/dev_todo/upcoming_time_filter.md | 179 +++++++++++++++++++++----- 1 file changed, 147 insertions(+), 32 deletions(-) diff --git a/docs/dev_todo/upcoming_time_filter.md b/docs/dev_todo/upcoming_time_filter.md index 1399f13d..5a3e521b 100644 --- a/docs/dev_todo/upcoming_time_filter.md +++ b/docs/dev_todo/upcoming_time_filter.md @@ -18,8 +18,10 @@ The Active and Dismissed tab Time filters are simple in-memory filters that oper |----------|--------|-----------| | Filter persistence | **Persists to Settings** | Unlike other filter pills (in-memory), this controls the data query window. User explicitly said "changing this pill should be the same as changing the option in the settings UI" | | Pill behavior | **Dual-purpose: mode + interval** | Shows Day Boundary as a special option, plus configurable fixed-hour intervals | -| Configurable intervals | **Snooze preset pattern** | Comma-separated string (e.g., `"4h, 8h, 12h, 24h"`) using `PreferenceUtils.parseSnoozePresets()` | +| Configurable intervals | **Snooze preset pattern** | Comma-separated string (e.g., `"4h, 8h, 1d, 3d, 1w"`) using `PreferenceUtils.parseSnoozePresets()` | | Invalid config fallback | **Standard default list** | Same pattern as snooze presets: if parse fails, use defaults | +| Max lookahead | **30 days (scan window)** | `manualCalWatchScanWindow` = 30 days. MonitorStorage has no data beyond this. Values > 30d are clamped | +| Week unit support | **Add `w` to PreferenceUtils** | Currently only supports `s`, `m`, `h`, `d`. Need `w` (weeks) for natural presets like `1w` | | Bottom sheet type | **New `UpcomingTimeFilterBottomSheet`** | Separate from existing `TimeFilterBottomSheet` β€” completely different behavior and options | | Clear on tab switch? | **No** | Unlike other filters, this persists to Settings. It represents the user's preferred view window | @@ -29,8 +31,8 @@ The Active and Dismissed tab Time filters are simple in-memory filters that oper |--------|------------------------------|----------------------| | What it does | Filters already-loaded events by start time | Controls which events get fetched (lookahead window) | | Persistence | In-memory, clears on tab switch & restart | Persists to Settings | -| Options | Started Today / This Week / Past / etc. | Day Boundary / 4h / 8h / 12h / 24h / 48h | -| Implementation | `FilterState.timeFilter` enum | Writes to `Settings.upcomingEventsMode` + `upcomingEventsFixedHours` | +| Options | Started Today / This Week / Past / etc. | Day Boundary / 4h / 8h / 1d / 3d / 1w | +| Implementation | `FilterState.timeFilter` enum | Writes to `Settings.upcomingEventsMode` + `upcomingEventsFixedLookaheadMillis` | | Shared with Settings UI | No | Yes β€” pill and Settings UI modify the same values | ## UI Vision @@ -45,7 +47,8 @@ The Active and Dismissed tab Time filters are simple in-memory filters that oper Chip text shows the current lookahead mode: - `"Day boundary"` when in day boundary mode -- `"4 hours"` / `"8 hours"` / `"24 hours"` etc. when in fixed mode +- `"4 hours"` / `"8 hours"` / `"1 day"` / `"1 week"` etc. when in fixed mode +- Uses `PreferenceUtils.formatSnoozePreset()` for display (human-readable) ### Bottom Sheet @@ -57,9 +60,9 @@ Chip text shows the current lookahead mode: β”‚ ───────────────────────────────── β”‚ β”‚ β—‹ 4 hours β”‚ β”‚ ● 8 hours ← current β”‚ -β”‚ β—‹ 12 hours β”‚ -β”‚ β—‹ 24 hours β”‚ -β”‚ β—‹ 48 hours β”‚ +β”‚ β—‹ 1 day β”‚ +β”‚ β—‹ 3 days β”‚ +β”‚ β—‹ 1 week β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ [ APPLY ] β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ @@ -70,6 +73,68 @@ Chip text shows the current lookahead mode: - A divider separates the day boundary option from fixed hour options - Current selection is pre-checked +## Prerequisite: Add Week Unit to PreferenceUtils + +The current `PreferenceUtils.parseSnoozePresets()` supports `s`, `m`, `h`, `d` but not weeks. The parser uses `str.takeLast(1)` (single character only), so multi-character units like `wk` won't work. + +**Add `w` (week) support:** + +```kotlin +// In PreferenceUtils.parseSnoozePresets() +when (unit) { + "s" -> num + "m" -> num * Consts.MINUTE_IN_SECONDS + "h" -> num * Consts.HOUR_IN_SECONDS + "d" -> num * Consts.DAY_IN_SECONDS + "w" -> num * Consts.DAY_IN_SECONDS * 7 // NEW + else -> throw Exception("Unknown unit $unit") +} + +// In PreferenceUtils.formatSnoozePreset() β€” add week formatting +internal fun formatSnoozePreset(value: Long): String { + val seconds = value / 1000L + if (seconds % (3600L * 24 * 7) == 0L) { // NEW - check weeks first + val weeks = seconds / (3600L * 24 * 7) + return "${weeks}w" + } + if (seconds % (3600L * 24) == 0L) { ... } + // ... rest unchanged +} +``` + +This benefits both snooze presets (users could configure `1w` snooze) and upcoming time presets. Existing presets are unaffected since `w` is a new unit. + +**Also add `WEEK_IN_SECONDS` to Consts if not present:** +```kotlin +const val WEEK_IN_SECONDS: Long = DAY_IN_SECONDS * 7 +``` + +## Lookahead Storage Change: Hours β†’ Milliseconds + +The current `upcomingEventsFixedHours: Int` (clamped to 1..48) is too restrictive for presets like `3d` or `1w`. Rather than trying to convert everything to hours, store the lookahead value in **milliseconds** (same as snooze presets internally). + +```kotlin +// NEW: replaces upcomingEventsFixedHours for the preset-based lookahead +private const val UPCOMING_FIXED_LOOKAHEAD_MILLIS_KEY = "upcoming_fixed_lookahead_millis" + +val upcomingEventsFixedLookaheadMillis: Long + get() { + val raw = getLong(UPCOMING_FIXED_LOOKAHEAD_MILLIS_KEY, -1L) + if (raw > 0) return raw.coerceAtMost(MAX_LOOKAHEAD_MILLIS) + // Migration: if old fixedHours setting exists, convert it + val legacyHours = upcomingEventsFixedHours + return (legacyHours.toLong() * Consts.HOUR_IN_MILLISECONDS).coerceAtMost(MAX_LOOKAHEAD_MILLIS) + } + set(value) = setLong(UPCOMING_FIXED_LOOKAHEAD_MILLIS_KEY, value.coerceAtMost(MAX_LOOKAHEAD_MILLIS)) + +// Scan window cap β€” MonitorStorage only has data this far ahead +internal const val MAX_LOOKAHEAD_MILLIS = 30L * Consts.DAY_IN_MILLISECONDS +``` + +`UpcomingEventsLookahead.calculateFixedEndTime()` then uses `upcomingEventsFixedLookaheadMillis` directly instead of multiplying hours. + +**Migration note:** The old `upcomingEventsFixedHours` property is kept for backward compat β€” if the new millis key has no value, we convert from the legacy hours value. The old ListPreference in Settings UI for fixed hours can be deprecated once the preset-based pill is in place. + ## Configurable Intervals (Snooze Preset Pattern) ### Settings Property @@ -77,7 +142,7 @@ Chip text shows the current lookahead mode: ```kotlin // In Settings.kt private const val UPCOMING_TIME_PRESETS_KEY = "pref_upcoming_time_presets" -const val DEFAULT_UPCOMING_TIME_PRESETS = "4h, 8h, 12h, 24h, 48h" +const val DEFAULT_UPCOMING_TIME_PRESETS = "4h, 8h, 1d, 3d, 1w" val upcomingTimePresetsRaw: String get() = getString(UPCOMING_TIME_PRESETS_KEY, DEFAULT_UPCOMING_TIME_PRESETS) @@ -91,14 +156,22 @@ val upcomingTimePresets: LongArray ret = longArrayOf( 4 * Consts.HOUR_IN_MILLISECONDS, 8 * Consts.HOUR_IN_MILLISECONDS, - 12 * Consts.HOUR_IN_MILLISECONDS, - 24 * Consts.HOUR_IN_MILLISECONDS, - 48 * Consts.HOUR_IN_MILLISECONDS + 1 * Consts.DAY_IN_MILLISECONDS, + 3 * Consts.DAY_IN_MILLISECONDS, + 7 * Consts.DAY_IN_MILLISECONDS ) - return ret + // Filter out negative values and cap at scan window + return ret.filter { it > 0 && it <= MAX_LOOKAHEAD_MILLIS }.toLongArray() } ``` +### Scan Window Constraint + +`Settings.manualCalWatchScanWindow` = 30 days. MonitorStorage won't have alerts beyond this, so: +- Presets > 30 days are filtered out at display time +- `upcomingEventsFixedLookaheadMillis` is clamped to `MAX_LOOKAHEAD_MILLIS` (30 days) +- The dialog help text mentions the 30-day limit + ### Settings UI Reuse the `SnoozePresetPreferenceX` pattern β€” custom `DialogPreference` with EditText: @@ -109,32 +182,56 @@ Reuse the `SnoozePresetPreferenceX` pattern β€” custom `DialogPreference` with E android:key="pref_upcoming_time_presets" android:title="@string/upcoming_time_presets_title" android:summary="@string/upcoming_time_presets_summary" - android:defaultValue="4h, 8h, 12h, 24h, 48h" /> + android:defaultValue="4h, 8h, 1d, 3d, 1w" /> ``` Either create a new `UpcomingTimePresetPreferenceX` (nearly identical to `SnoozePresetPreferenceX`), or make `SnoozePresetPreferenceX` more generic and reusable for both. The parsing is identical (`PreferenceUtils.parseSnoozePresets()`). +**Key difference from snooze preset dialog:** No negative values allowed. The help text should say: + +```xml +Comma-separated list of lookahead intervals\n\nSupported values like \'4h\', \'1d\' or \'1w\'\n\nMaximum 30 days (calendar scan limit)\n\nLeave empty to use defaults +``` + ## Implementation Plan +### Phase 0: Add Week Unit to PreferenceUtils + +Small, self-contained change that benefits both features. + +**Files to modify:** +- `PreferenceUtils.kt` β€” Add `"w"` case to `parseSnoozePresets()`, add week check to `formatSnoozePreset()` +- `Consts.kt` β€” Add `WEEK_IN_SECONDS` if not present + +**Tests:** +- `parseSnoozePresets("1w")` returns 7 days in millis +- `parseSnoozePresets("2w")` returns 14 days in millis +- `formatSnoozePreset(7 * DAY_IN_MILLISECONDS)` returns `"1w"` +- Existing snooze preset tests still pass (no regression) + ### Phase 1: Settings Infrastructure -Add the configurable time presets to `Settings.kt`. +Add the configurable time presets and milliseconds-based lookahead to `Settings.kt`. **Files to modify:** -- `Settings.kt` β€” Add `upcomingTimePresetsRaw`, `upcomingTimePresets`, and key/default constants +- `Settings.kt` β€” Add `upcomingTimePresetsRaw`, `upcomingTimePresets`, `upcomingEventsFixedLookaheadMillis`, `MAX_LOOKAHEAD_MILLIS` +- `UpcomingEventsLookahead.kt` β€” Use `upcomingEventsFixedLookaheadMillis` instead of `upcomingEventsFixedHours * HOUR_IN_MILLISECONDS` **Tests:** -- Parse valid presets (reuses existing `PreferenceUtils` tests, but verify integration) +- Parse valid presets including `w` unit - Fallback when presets are invalid - Fallback when presets are empty +- Negative values filtered out +- Values > 30 days filtered out +- Legacy migration: old `fixedHours=8` converts to `8 * HOUR_IN_MILLIS` ### Phase 2: Bottom Sheet UI Create `UpcomingTimeFilterBottomSheet` β€” a new bottom sheet that: 1. Shows "Day boundary (X AM)" as the first radio option -2. Shows configurable fixed-hour interval options below a divider +2. Shows configurable fixed interval options below a divider (from `upcomingTimePresets`) 3. Pre-selects the current lookahead mode -4. On Apply, writes to `Settings.upcomingEventsMode` and `Settings.upcomingEventsFixedHours` +4. On Apply, writes to `Settings.upcomingEventsMode` and `Settings.upcomingEventsFixedLookaheadMillis` 5. Returns result via Fragment Result API (like existing `TimeFilterBottomSheet`) **New files:** @@ -183,9 +280,9 @@ When the time filter changes (via pill or Settings UI), the Upcoming fragment ne Upcoming Window Day boundary (%s) -%d hours Lookahead interval presets -Comma-separated intervals (e.g., 4h, 8h, 12h, 24h) +Comma-separated intervals (e.g., 4h, 8h, 1d, 3d, 1w) +Comma-separated list of lookahead intervals\n\nSupported values like \'4h\', \'1d\' or \'1w\'\n\nMaximum 30 days (calendar scan limit)\n\nLeave empty to use defaults ``` ## Edge Cases @@ -202,7 +299,10 @@ If the user changes their presets and the currently active fixed hours value isn If the user changes their day boundary hour in Settings, the pill text and bottom sheet update automatically on next view (they read from Settings each time). ### Very Large Intervals -The `upcomingEventsFixedHours` is already clamped to `1..48` by `Settings.kt`. If a preset would exceed this, it gets clamped. We should validate presets to ensure they make sense (e.g., filter out values > 48h or < 1h at display time, or clamp when writing to Settings). +`upcomingEventsFixedLookaheadMillis` is clamped to `MAX_LOOKAHEAD_MILLIS` (30 days = `manualCalWatchScanWindow`). MonitorStorage has no data beyond this, so larger values would just show the same results as 30 days. The `upcomingTimePresets` getter filters out values > 30 days and negative values before they reach the bottom sheet. + +### Negative Values +Unlike snooze presets (which support `-5m` for "5 minutes before event"), upcoming time presets must all be positive. Negative values are filtered out by the `upcomingTimePresets` getter. The dialog help text does not mention negative value support. ## Files Summary @@ -218,17 +318,19 @@ The `upcomingEventsFixedHours` is already clamped to `1..48` by `Settings.kt`. I | File | Phase | Changes | |------|-------|---------| -| `Settings.kt` | 1 | Add `upcomingTimePresetsRaw`, `upcomingTimePresets` | -| `MainActivityModern.kt` | 3 | Add upcoming time chip, handle result, update chip text | +| `PreferenceUtils.kt` | 0 | Add `"w"` unit to parse/format | +| `Consts.kt` | 0 | Add `WEEK_IN_SECONDS` | +| `Settings.kt` | 1 | Add `upcomingTimePresetsRaw`, `upcomingTimePresets`, `upcomingEventsFixedLookaheadMillis`, `MAX_LOOKAHEAD_MILLIS` | +| `UpcomingEventsLookahead.kt` | 1 | Use `upcomingEventsFixedLookaheadMillis` instead of hours Γ— millis | +| `MainActivityModern.kt` | 3 | Add upcoming time chip, handle result, update `updateFilterChipsForCurrentTab()` | | `navigation_preferences.xml` | 4 | Add configurable preset preference | -| `strings.xml` | 2-4 | Add string resources | -| `event_lookahead_milestone3_filter_pills.md` | β€” | Move "Time filter for Upcoming tab" from Future to Planned/Done and link here | +| `strings.xml` | 0-4 | Add string resources | +| `event_lookahead_milestone3_filter_pills.md` | β€” | Already updated to link here | ### Not Modified (but involved) | File | Reason | |------|--------| -| `UpcomingEventsLookahead.kt` | Already reads from Settings β€” no changes needed | | `UpcomingEventsProvider.kt` | Already uses `UpcomingEventsLookahead` β€” no changes needed | | `UpcomingEventsFragment.kt` | Already reloads via `loadEvents()` β€” no changes needed (just needs to be triggered) | | `FilterState.kt` | Not used β€” this filter writes to Settings, not FilterState | @@ -239,16 +341,28 @@ The `upcomingEventsFixedHours` is already clamped to `1..48` by `Settings.kt`. I ### Unit Tests (Robolectric) ``` -UpcomingTimeFilterTest.kt: +PreferenceUtilsWeekUnitTest.kt (Phase 0): +- parseSnoozePresets "1w" returns 7 days in millis +- parseSnoozePresets "2w" returns 14 days in millis +- parseSnoozePresets mixed units "4h, 1d, 1w" parses correctly +- formatSnoozePreset(7 * DAY_IN_MILLIS) returns "1w" +- formatSnoozePreset(14 * DAY_IN_MILLIS) returns "2w" +- formatSnoozePreset(3 * DAY_IN_MILLIS) returns "3d" (not weeks) +- existing presets like "15m, 1h, 4h, 1d" still parse correctly + +UpcomingTimeFilterTest.kt (Phase 1): - upcomingTimePresets parses valid custom presets - upcomingTimePresets falls back to defaults on invalid input - upcomingTimePresets falls back to defaults on empty string - upcomingTimePresets returns correct millisecond values +- upcomingTimePresets filters out negative values +- upcomingTimePresets filters out values > 30 days +- upcomingEventsFixedLookaheadMillis clamped to MAX_LOOKAHEAD_MILLIS +- legacy migration: old fixedHours=8 converts correctly - selecting day boundary writes mode to Settings -- selecting fixed interval writes mode and hours to Settings +- selecting fixed interval writes mode and millis to Settings - chip text reflects day boundary mode -- chip text reflects fixed hours mode with correct value -- presets outside valid range are filtered/clamped +- chip text reflects fixed interval with correct human-readable text ``` ### Manual Testing Checklist @@ -268,10 +382,11 @@ UpcomingTimeFilterTest.kt: ## Implementation Order -1. **Phase 1** β€” Settings infrastructure (smallest, enables testing) +0. **Phase 0** β€” Add `w` (week) unit to `PreferenceUtils` (small, self-contained, benefits both features) +1. **Phase 1** β€” Settings infrastructure: presets, millis storage, lookahead update 2. **Phase 2** β€” Bottom sheet UI (core feature) 3. **Phase 3** β€” Wire up chip in MainActivityModern 4. **Phase 4** β€” Settings UI for custom presets (nice-to-have, can defer) 5. **Phase 5** β€” Verify integration (mostly verifying existing code paths work) -Phases 1-3 deliver the core feature. Phase 4 adds the "configurable like snooze presets" enhancement. Phase 5 is verification/polish. +Phases 0-3 deliver the core feature. Phase 4 adds the "configurable like snooze presets" enhancement. Phase 5 is verification/polish. From 12f4050248fadf0e09373cf25f6daf2c16f4e2e7 Mon Sep 17 00:00:00 2001 From: William Harris Date: Tue, 24 Feb 2026 05:42:42 +0000 Subject: [PATCH 3/9] docs: plan more --- docs/dev_todo/upcoming_time_filter.md | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/docs/dev_todo/upcoming_time_filter.md b/docs/dev_todo/upcoming_time_filter.md index 5a3e521b..fb75d284 100644 --- a/docs/dev_todo/upcoming_time_filter.md +++ b/docs/dev_todo/upcoming_time_filter.md @@ -128,7 +128,8 @@ val upcomingEventsFixedLookaheadMillis: Long set(value) = setLong(UPCOMING_FIXED_LOOKAHEAD_MILLIS_KEY, value.coerceAtMost(MAX_LOOKAHEAD_MILLIS)) // Scan window cap β€” MonitorStorage only has data this far ahead -internal const val MAX_LOOKAHEAD_MILLIS = 30L * Consts.DAY_IN_MILLISECONDS +internal const val MAX_LOOKAHEAD_DAYS = 30L // derived from manualCalWatchScanWindow +internal const val MAX_LOOKAHEAD_MILLIS = MAX_LOOKAHEAD_DAYS * Consts.DAY_IN_MILLISECONDS ``` `UpcomingEventsLookahead.calculateFixedEndTime()` then uses `upcomingEventsFixedLookaheadMillis` directly instead of multiplying hours. @@ -149,17 +150,9 @@ val upcomingTimePresetsRaw: String val upcomingTimePresets: LongArray get() { - var ret = PreferenceUtils.parseSnoozePresets(upcomingTimePresetsRaw) - if (ret == null) - ret = PreferenceUtils.parseSnoozePresets(DEFAULT_UPCOMING_TIME_PRESETS) - if (ret == null || ret.isEmpty()) - ret = longArrayOf( - 4 * Consts.HOUR_IN_MILLISECONDS, - 8 * Consts.HOUR_IN_MILLISECONDS, - 1 * Consts.DAY_IN_MILLISECONDS, - 3 * Consts.DAY_IN_MILLISECONDS, - 7 * Consts.DAY_IN_MILLISECONDS - ) + val ret = PreferenceUtils.parseSnoozePresets(upcomingTimePresetsRaw) + ?: PreferenceUtils.parseSnoozePresets(DEFAULT_UPCOMING_TIME_PRESETS) + ?: return longArrayOf() // should never happen β€” DEFAULT is a compile-time constant // Filter out negative values and cap at scan window return ret.filter { it > 0 && it <= MAX_LOOKAHEAD_MILLIS }.toLongArray() } @@ -190,7 +183,8 @@ Either create a new `UpcomingTimePresetPreferenceX` (nearly identical to `Snooze **Key difference from snooze preset dialog:** No negative values allowed. The help text should say: ```xml -Comma-separated list of lookahead intervals\n\nSupported values like \'4h\', \'1d\' or \'1w\'\n\nMaximum 30 days (calendar scan limit)\n\nLeave empty to use defaults +Comma-separated list of lookahead intervals\n\nSupported values like \'4h\', \'1d\' or \'1w\'\n\nMaximum %d days (calendar scan limit)\n\nLeave empty to use defaults + ``` ## Implementation Plan @@ -282,7 +276,8 @@ When the time filter changes (via pill or Settings UI), the Upcoming fragment ne Day boundary (%s) Lookahead interval presets Comma-separated intervals (e.g., 4h, 8h, 1d, 3d, 1w) -Comma-separated list of lookahead intervals\n\nSupported values like \'4h\', \'1d\' or \'1w\'\n\nMaximum 30 days (calendar scan limit)\n\nLeave empty to use defaults +Comma-separated list of lookahead intervals\n\nSupported values like \'4h\', \'1d\' or \'1w\'\n\nMaximum %d days (calendar scan limit)\n\nLeave empty to use defaults + ``` ## Edge Cases From 11c4a77406482685bcb05b4fac7c15807fd0ba03 Mon Sep 17 00:00:00 2001 From: William Harris Date: Tue, 24 Feb 2026 05:49:38 +0000 Subject: [PATCH 4/9] feat: draw the rest of the fucking owl --- .../com/github/quarck/calnotify/Consts.kt | 2 + .../com/github/quarck/calnotify/Settings.kt | 30 +++- .../prefs/NavigationSettingsFragmentX.kt | 16 +- .../quarck/calnotify/prefs/PreferenceUtils.kt | 50 ++++-- .../prefs/UpcomingTimePresetPreferenceX.kt | 128 ++++++++++++++++ .../quarck/calnotify/ui/MainActivityModern.kt | 52 ++++++- .../ui/UpcomingTimeFilterBottomSheet.kt | 144 ++++++++++++++++++ .../upcoming/UpcomingEventsLookahead.kt | 7 +- .../bottom_sheet_upcoming_time_filter.xml | 30 ++++ .../layout/dialog_upcoming_time_presets.xml | 35 +++++ android/app/src/main/res/values/strings.xml | 7 + .../main/res/xml/navigation_preferences.xml | 6 + 12 files changed, 489 insertions(+), 18 deletions(-) create mode 100644 android/app/src/main/java/com/github/quarck/calnotify/prefs/UpcomingTimePresetPreferenceX.kt create mode 100644 android/app/src/main/java/com/github/quarck/calnotify/ui/UpcomingTimeFilterBottomSheet.kt create mode 100644 android/app/src/main/res/layout/bottom_sheet_upcoming_time_filter.xml create mode 100644 android/app/src/main/res/layout/dialog_upcoming_time_presets.xml diff --git a/android/app/src/main/java/com/github/quarck/calnotify/Consts.kt b/android/app/src/main/java/com/github/quarck/calnotify/Consts.kt index 95d9e831..e3fcf22b 100644 --- a/android/app/src/main/java/com/github/quarck/calnotify/Consts.kt +++ b/android/app/src/main/java/com/github/quarck/calnotify/Consts.kt @@ -33,6 +33,8 @@ object Consts { const val DAY_IN_MILLISECONDS = 24L * 3600L * 1000L const val DAY_IN_SECONDS: Long = 3600L * 24 const val DAY_IN_MINUTES: Int = 60*24 + const val WEEK_IN_SECONDS: Long = DAY_IN_SECONDS * 7 + const val WEEK_IN_MILLISECONDS: Long = DAY_IN_MILLISECONDS * 7 const val HOUR_IN_SECONDS: Long = 3600L const val HOUR_IN_MILLISECONDS: Long = 3600L * 1000L const val MINUTE_IN_SECONDS: Long = 60L 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 5182d778..05be8247 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 @@ -469,13 +469,36 @@ class Settings(context: Context) : PersistentStorageBase(context), SettingsInter .coerceIn(MIN_DAY_BOUNDARY_HOUR, MAX_DAY_BOUNDARY_HOUR) set(value) = setString(UPCOMING_EVENTS_DAY_BOUNDARY_HOUR_KEY, value.coerceIn(MIN_DAY_BOUNDARY_HOUR, MAX_DAY_BOUNDARY_HOUR).toString()) - /** Fixed hours lookahead (1-48, default 8). Bounded to prevent misconfiguration. */ + /** Fixed hours lookahead (1-48, default 8). Legacy property β€” prefer upcomingEventsFixedLookaheadMillis. */ var upcomingEventsFixedHours: Int get() = (getString(UPCOMING_EVENTS_FIXED_HOURS_KEY, DEFAULT_UPCOMING_EVENTS_FIXED_HOURS.toString()) .toIntOrNull() ?: DEFAULT_UPCOMING_EVENTS_FIXED_HOURS) .coerceIn(MIN_FIXED_HOURS, MAX_FIXED_HOURS) set(value) = setString(UPCOMING_EVENTS_FIXED_HOURS_KEY, value.coerceIn(MIN_FIXED_HOURS, MAX_FIXED_HOURS).toString()) + /** Fixed lookahead in milliseconds. Clamped to MAX_LOOKAHEAD_MILLIS (scan window). + * Falls back to legacy upcomingEventsFixedHours if no millis value is stored. */ + var upcomingEventsFixedLookaheadMillis: Long + get() { + val raw = getLong(UPCOMING_FIXED_LOOKAHEAD_MILLIS_KEY, -1L) + if (raw > 0) return raw.coerceAtMost(MAX_LOOKAHEAD_MILLIS) + return (upcomingEventsFixedHours.toLong() * Consts.HOUR_IN_MILLISECONDS).coerceAtMost(MAX_LOOKAHEAD_MILLIS) + } + set(value) = setLong(UPCOMING_FIXED_LOOKAHEAD_MILLIS_KEY, value.coerceIn(1L, MAX_LOOKAHEAD_MILLIS)) + + /** Raw configurable upcoming time presets string (e.g., "4h, 8h, 1d, 3d, 1w") */ + val upcomingTimePresetsRaw: String + get() = getString(UPCOMING_TIME_PRESETS_KEY, DEFAULT_UPCOMING_TIME_PRESETS) + + /** Parsed upcoming time presets in milliseconds. Filters out negative and >30d values. */ + val upcomingTimePresets: LongArray + get() { + val ret = PreferenceUtils.parseSnoozePresets(upcomingTimePresetsRaw) + ?: PreferenceUtils.parseSnoozePresets(DEFAULT_UPCOMING_TIME_PRESETS) + ?: return longArrayOf() + return ret.filter { it > 0 && it <= MAX_LOOKAHEAD_MILLIS }.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()) @@ -586,6 +609,8 @@ class Settings(context: Context) : PersistentStorageBase(context), SettingsInter private const val UPCOMING_EVENTS_MODE_KEY = "upcoming_events_mode" private const val UPCOMING_EVENTS_DAY_BOUNDARY_HOUR_KEY = "upcoming_events_day_boundary_hour" 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 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" @@ -607,6 +632,9 @@ class Settings(context: Context) : PersistentStorageBase(context), SettingsInter internal const val MAX_DAY_BOUNDARY_HOUR = 10 // 10am (max slack for night owls) internal const val MIN_FIXED_HOURS = 1 internal const val MAX_FIXED_HOURS = 48 + internal const val DEFAULT_UPCOMING_TIME_PRESETS = "4h, 8h, 1d, 3d, 1w" + internal const val MAX_LOOKAHEAD_DAYS = 30L + internal const val MAX_LOOKAHEAD_MILLIS = MAX_LOOKAHEAD_DAYS * Consts.DAY_IN_MILLISECONDS /** 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 a99eb96a..d73a9472 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 @@ -27,6 +27,7 @@ import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat +import androidx.preference.PreferenceDialogFragmentCompat import com.github.quarck.calnotify.R import com.github.quarck.calnotify.Settings import com.github.quarck.calnotify.ui.MainActivityLegacy @@ -39,26 +40,35 @@ import com.github.quarck.calnotify.ui.MainActivityModern class NavigationSettingsFragmentX : PreferenceFragmentCompat() { companion object { - // Delay before restarting app to let user see the "Restarting..." toast private const val RESTART_DELAY_FOR_TOAST_VISIBILITY_MS = 500L + private const val DIALOG_FRAGMENT_TAG = "NavigationSettingsFragmentX.DIALOG" } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.navigation_preferences, rootKey) - // Set up click handler for "Switch to Classic View" button findPreference("switch_to_classic_view")?.setOnPreferenceClickListener { showSwitchToClassicViewDialog() true } - // Set up click handler for "Switch to New View" button findPreference("switch_to_new_view")?.setOnPreferenceClickListener { showSwitchToNewViewDialog() true } } + 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) + } + } + private fun showSwitchToClassicViewDialog() { val ctx = context ?: return AlertDialog.Builder(ctx) diff --git a/android/app/src/main/java/com/github/quarck/calnotify/prefs/PreferenceUtils.kt b/android/app/src/main/java/com/github/quarck/calnotify/prefs/PreferenceUtils.kt index da533bf5..c8b1a9cc 100644 --- a/android/app/src/main/java/com/github/quarck/calnotify/prefs/PreferenceUtils.kt +++ b/android/app/src/main/java/com/github/quarck/calnotify/prefs/PreferenceUtils.kt @@ -36,18 +36,23 @@ object PreferenceUtils { internal fun formatSnoozePreset(value: Long): String { val seconds = value / 1000L - if (seconds % (3600L * 24) == 0L) { - val days = seconds / (3600L * 24) + if (seconds % Consts.WEEK_IN_SECONDS == 0L) { + val weeks = seconds / Consts.WEEK_IN_SECONDS + return "${weeks}w" + } + + if (seconds % Consts.DAY_IN_SECONDS == 0L) { + val days = seconds / Consts.DAY_IN_SECONDS return "${days}d" } - if (seconds % 3600L == 0L) { - val hours = seconds / 3600L + if (seconds % Consts.HOUR_IN_SECONDS == 0L) { + val hours = seconds / Consts.HOUR_IN_SECONDS return "${hours}h" } - if (seconds % 60L == 0L) { - val minutes = seconds / 60L + if (seconds % Consts.MINUTE_IN_SECONDS == 0L) { + val minutes = seconds / Consts.MINUTE_IN_SECONDS return "${minutes}m" } @@ -70,9 +75,10 @@ object PreferenceUtils { val seconds = when (unit) { "s" -> num - "m" -> num * Consts.MINUTE_IN_SECONDS; - "h" -> num * Consts.HOUR_IN_SECONDS; - "d" -> num * Consts.DAY_IN_SECONDS; + "m" -> num * Consts.MINUTE_IN_SECONDS + "h" -> num * Consts.HOUR_IN_SECONDS + "d" -> num * Consts.DAY_IN_SECONDS + "w" -> num * Consts.WEEK_IN_SECONDS else -> throw Exception("Unknown unit ${unit}") } seconds * 1000L @@ -108,4 +114,30 @@ object PreferenceUtils { fun formatPattern(pattern: LongArray): String = pattern.map { p -> formatSnoozePreset(p) }.joinToString(", ") + + /** + * Format a millisecond duration as a human-readable label (e.g., "8 hours", "3 days", "1 week"). + * Used for display in bottom sheets and chips. + */ + fun formatPresetHumanReadable(millis: Long): String { + val seconds = millis / 1000L + + if (seconds % Consts.WEEK_IN_SECONDS == 0L) { + val weeks = seconds / Consts.WEEK_IN_SECONDS + return if (weeks == 1L) "1 week" else "$weeks weeks" + } + if (seconds % Consts.DAY_IN_SECONDS == 0L) { + val days = seconds / Consts.DAY_IN_SECONDS + return if (days == 1L) "1 day" else "$days days" + } + if (seconds % Consts.HOUR_IN_SECONDS == 0L) { + val hours = seconds / Consts.HOUR_IN_SECONDS + return if (hours == 1L) "1 hour" else "$hours hours" + } + if (seconds % Consts.MINUTE_IN_SECONDS == 0L) { + val minutes = seconds / Consts.MINUTE_IN_SECONDS + return if (minutes == 1L) "1 minute" else "$minutes minutes" + } + return "$seconds seconds" + } } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/quarck/calnotify/prefs/UpcomingTimePresetPreferenceX.kt b/android/app/src/main/java/com/github/quarck/calnotify/prefs/UpcomingTimePresetPreferenceX.kt new file mode 100644 index 00000000..b4470365 --- /dev/null +++ b/android/app/src/main/java/com/github/quarck/calnotify/prefs/UpcomingTimePresetPreferenceX.kt @@ -0,0 +1,128 @@ +// +// 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 UpcomingTimePresetPreferenceX @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_UPCOMING_TIME_PRESETS + private set + + init { + dialogLayoutResource = R.layout.dialog_upcoming_time_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_UPCOMING_TIME_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 UpcomingTimePresetPreferenceX + + val label = view.findViewById(R.id.text_label_upcoming_presets) + label?.text = getString(R.string.dialog_upcoming_time_presets_label, Settings.MAX_LOOKAHEAD_DAYS.toInt()) + + edit = view.findViewById(R.id.edit_text_upcoming_time_presets) + edit?.setText(pref.presetValue) + } + + override fun onDialogClosed(positiveResult: Boolean) { + if (positiveResult) { + val value = edit?.text?.toString() + + if (value != null) { + val presets = PreferenceUtils.parseSnoozePresets(value) + if (presets != null) { + // Filter out negative values + val validPresets = presets.filter { it > 0 } + val newValue = if (validPresets.isEmpty()) { + Settings.DEFAULT_UPCOMING_TIME_PRESETS + } else { + value.split(',') + .map { it.trim() } + .filter { it.isNotEmpty() } + .joinToString(", ") + } + + val pref = preference as UpcomingTimePresetPreferenceX + if (pref.callChangeListener(newValue)) { + pref.persistPreset(newValue) + } + } 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() + } + + 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/MainActivityModern.kt b/android/app/src/main/java/com/github/quarck/calnotify/ui/MainActivityModern.kt index e336a49a..553fdf7f 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 @@ -40,6 +40,9 @@ import androidx.navigation.ui.setupWithNavController import com.github.quarck.calnotify.BuildConfig import com.github.quarck.calnotify.Consts import com.github.quarck.calnotify.R +import com.github.quarck.calnotify.Settings +import com.github.quarck.calnotify.prefs.PreferenceUtils +import com.github.quarck.calnotify.upcoming.UpcomingEventsLookahead import com.github.quarck.calnotify.app.ApplicationController import com.github.quarck.calnotify.calendar.CalendarProvider import com.github.quarck.calnotify.calendar.EventAlertRecord @@ -392,9 +395,9 @@ class MainActivityModern : MainActivityBase() { addTimeChip(TimeFilterBottomSheet.TabType.ACTIVE) } R.id.upcomingEventsFragment -> { - // Upcoming tab: Calendar, Status (Time filter deferred - needs lookahead integration) addCalendarChip() addStatusChip() + addUpcomingTimeChip() } R.id.dismissedEventsFragment -> { // Dismissed tab: Calendar, Time @@ -590,6 +593,36 @@ class MainActivityModern : MainActivityBase() { bottomSheet.show(supportFragmentManager, "CalendarFilterBottomSheet") } + // === Upcoming Time Filter Chip === + + private fun addUpcomingTimeChip() { + val materialContext = ContextThemeWrapper(this, com.google.android.material.R.style.Theme_MaterialComponents_DayNight) + val chip = Chip(materialContext).apply { + text = getUpcomingTimeChipText() + isCheckable = false + isChipIconVisible = false + isCloseIconVisible = true + closeIcon = getDrawable(R.drawable.ic_arrow_drop_down) + setOnClickListener { showUpcomingTimeFilterBottomSheet() } + setOnCloseIconClickListener { showUpcomingTimeFilterBottomSheet() } + } + chipGroup?.addView(chip) + } + + private fun getUpcomingTimeChipText(): String { + val settings = Settings(this) + return if (settings.upcomingEventsMode == UpcomingEventsLookahead.MODE_DAY_BOUNDARY) { + getString(R.string.upcoming_events_mode_day_boundary) + } else { + PreferenceUtils.formatPresetHumanReadable(settings.upcomingEventsFixedLookaheadMillis) + } + } + + private fun showUpcomingTimeFilterBottomSheet() { + val bottomSheet = UpcomingTimeFilterBottomSheet.newInstance() + bottomSheet.show(supportFragmentManager, "UpcomingTimeFilterBottomSheet") + } + /** Setup Fragment Result listeners for bottom sheets (survives config changes) */ private fun setupFilterResultListeners() { // Time filter result @@ -613,6 +646,23 @@ class MainActivityModern : MainActivityBase() { updateFilterChipsForCurrentTab() notifyCurrentFragmentFilterChanged() } + + // Upcoming time filter result β€” writes to Settings, not FilterState + supportFragmentManager.setFragmentResultListener( + UpcomingTimeFilterBottomSheet.REQUEST_KEY, this + ) { _, bundle -> + val mode = bundle.getString(UpcomingTimeFilterBottomSheet.RESULT_MODE) ?: return@setFragmentResultListener + val settings = Settings(this) + settings.upcomingEventsMode = mode + if (mode == UpcomingEventsLookahead.MODE_FIXED) { + val millis = bundle.getLong(UpcomingTimeFilterBottomSheet.RESULT_MILLIS, -1L) + if (millis > 0) { + settings.upcomingEventsFixedLookaheadMillis = millis + } + } + updateFilterChipsForCurrentTab() + notifyCurrentFragmentFilterChanged() + } } // === Selection Mode Coordination === diff --git a/android/app/src/main/java/com/github/quarck/calnotify/ui/UpcomingTimeFilterBottomSheet.kt b/android/app/src/main/java/com/github/quarck/calnotify/ui/UpcomingTimeFilterBottomSheet.kt new file mode 100644 index 00000000..faa9a2c3 --- /dev/null +++ b/android/app/src/main/java/com/github/quarck/calnotify/ui/UpcomingTimeFilterBottomSheet.kt @@ -0,0 +1,144 @@ +// +// 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.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.RadioButton +import android.widget.RadioGroup +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.upcoming.UpcomingEventsLookahead +import com.google.android.material.bottomsheet.BottomSheetDialogFragment + +/** + * Bottom sheet for selecting the upcoming events lookahead window. + * Unlike the regular TimeFilterBottomSheet (in-memory filter), this persists to Settings + * because it controls which events get fetched from MonitorStorage. + * + * Options: + * - Day boundary mode (with configured boundary hour) + * - Configurable fixed interval presets (e.g., 4h, 8h, 1d, 3d, 1w) + */ +class UpcomingTimeFilterBottomSheet : BottomSheetDialogFragment() { + + private val DAY_BOUNDARY_RADIO_ID = View.generateViewId() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.bottom_sheet_upcoming_time_filter, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val settings = Settings(requireContext()) + val radioGroup = view.findViewById(R.id.upcoming_time_radio_group) + val applyButton = view.findViewById