diff --git a/docs/superpowers/specs/2026-04-06-android-calendar-screen-design.md b/docs/superpowers/specs/2026-04-06-android-calendar-screen-design.md new file mode 100644 index 0000000..2aaaf3d --- /dev/null +++ b/docs/superpowers/specs/2026-04-06-android-calendar-screen-design.md @@ -0,0 +1,199 @@ +# Android Calendar Screen Design + +**Goal:** Build the Android Calendar screen — a month-strip + daily agenda view with full event CRUD (create, edit, delete) including a simple recurrence picker. + +**Architecture:** `CalendarNotifier` (`AsyncNotifier`) holds a `Map>` keyed by date-only values, selected day, focused month, and loaded date range. The screen uses `table_calendar` for the month strip and a `ListView` for the daily agenda. Create/edit/delete is handled by `EventFormSheet`, a scrollable modal bottom sheet. The existing `/api/events` backend is used unchanged. + +**Tech Stack:** Flutter, Riverpod AsyncNotifier, Dio, GoRouter, `table_calendar` package + +--- + +## Files + +### New +| File | Responsibility | +|------|---------------| +| `lib/data/models/calendar_event.dart` | `CalendarEvent` model + `fromJson` | +| `lib/data/api/events_api.dart` | `getEvents`, `createEvent`, `updateEvent`, `deleteEvent` Dio calls | +| `lib/providers/calendar_provider.dart` | `CalendarState`, `CalendarNotifier`, `calendarProvider` | +| `lib/screens/calendar/calendar_screen.dart` | Calendar screen UI (month strip + agenda) | +| `lib/screens/calendar/event_form_sheet.dart` | Create/edit modal bottom sheet | + +### Modified +| File | Change | +|------|--------| +| `lib/providers/api_client_provider.dart` | Add `eventsApiProvider` | +| `lib/app.dart` | Replace Calendar stub route with `CalendarScreen()` | +| `test/widget_test.dart` | Add `CalendarEvent.fromJson` tests | +| `pubspec.yaml` | Add `table_calendar` dependency | + +--- + +## Data Model + +### `CalendarEvent` +```dart +class CalendarEvent { + final int id; + final String title; + final DateTime startDt; + final DateTime? endDt; + final bool allDay; + final String description; + final String location; + final String color; + final String? recurrence; // raw RRULE string, e.g. "FREQ=WEEKLY" + final int? projectId; + final int? reminderMinutes; +} +``` + +`fromJson` maps: `id`, `title`, `start_dt` / `end_dt` (ISO string → `DateTime.parse`), `all_day`, `description`, `location`, `color`, `recurrence` (nullable), `project_id` (nullable), `reminder_minutes` (nullable). + +Date normalization helper — used throughout to build map keys: +```dart +DateTime dateOnly(DateTime dt) => DateTime(dt.year, dt.month, dt.day); +``` + +--- + +## API Layer + +### `events_api.dart` + +```dart +class EventsApi { + final Dio _dio; + const EventsApi(this._dio); + + // GET /api/events?from=&to= + Future> getEvents(DateTime from, DateTime to) async { ... } + + // POST /api/events + Future createEvent(Map payload) async { ... } + + // PATCH /api/events/{id} + Future updateEvent(int id, Map fields) async { ... } + + // DELETE /api/events/{id} + Future deleteEvent(int id) async { ... } +} +``` + +All methods catch `DioException` and rethrow via `dioToApp(e)` (same pattern as `NewsApi`). + +### `api_client_provider.dart` addition +```dart +final eventsApiProvider = Provider((ref) => + EventsApi(ref.watch(dioProvider))); +``` + +--- + +## State Management + +### `CalendarState` +```dart +class CalendarState { + final Map> eventsByDay; // keys: midnight local time + final DateTime selectedDay; + final DateTime focusedMonth; + final DateTimeRange loadedRange; +} +``` + +### `CalendarNotifier extends AsyncNotifier` + +- **`build()`**: fetches events for `[firstDayOfMonth - 1 month, lastDayOfMonth + 1 month]` for the current month; populates `eventsByDay`; sets `selectedDay` to today; sets `loadedRange` to the fetched range. + +- **`selectDay(DateTime day)`**: synchronous state update — updates `selectedDay` and `focusedMonth` to `DateTime(day.year, day.month)`. No API call. + +- **`loadMonth(DateTime month)`**: updates `focusedMonth`. If `month` is already within `loadedRange`, no-op (state update only). Otherwise fetches events for that month, merges new items into `eventsByDay`, extends `loadedRange`. + +- **`addEvent(CalendarEvent event)`**: inserts the event into `eventsByDay` under `dateOnly(event.startDt)`. Synchronous local mutation after successful API call. + +- **`updateEvent(CalendarEvent updated)`**: removes old entry by `id` from its old date bucket (scanned), inserts under `dateOnly(updated.startDt)`. Synchronous local mutation. + +- **`removeEvent(int id, DateTime date)`**: removes from `eventsByDay[dateOnly(date)]` by id. Synchronous local mutation. + +All three mutation methods accept the server-returned `CalendarEvent` — the screen calls the API first, then passes the result to the notifier. + +--- + +## Screen Behaviour + +### `CalendarScreen` (`ConsumerStatefulWidget`) + +**AppBar**: title "Calendar". + +**Month strip** (`TableCalendar`): +- Format: `CalendarFormat.month` +- Selected day highlighted with primary color +- Days with events show a dot indicator +- `onDaySelected`: calls `notifier.selectDay(day)` +- `onPageChanged`: calls `notifier.loadMonth(month)` + +**Agenda list** (`ListView.builder`): +- Items: `state.eventsByDay[dateOnly(state.selectedDay)] ?? []` +- Each `EventTile` shows: color dot, title, time string ("All day" if `allDay`, otherwise formatted start time) +- Tap → opens `EventFormSheet` in edit mode +- Empty: centered "No events" message + +**FAB** (`FloatingActionButton`): opens `EventFormSheet` in create mode with `startDt` pre-set to `selectedDay` at current time (rounded to nearest hour) + +**Initial loading**: `CircularProgressIndicator` centered. Error: message + "Retry" button calls `ref.invalidate(calendarProvider)`. + +--- + +## Event Form Sheet + +### `EventFormSheet` (shown via `showModalBottomSheet(isScrollControlled: true, useSafeArea: true)`) + +Accepts `CalendarEvent? event` (null = create mode) and `DateTime? initialDate` (used in create mode). + +**Fields:** +| Field | Widget | Notes | +|-------|--------|-------| +| Title | `TextField` | Required | +| All-day | `SwitchListTile` | Hides time pickers when on | +| Start date | `ListTile` → `showDatePicker` | | +| Start time | `ListTile` → `showTimePicker` | Hidden when all-day | +| End date | `ListTile` → `showDatePicker` | Optional, clearable | +| End time | `ListTile` → `showTimePicker` | Hidden when all-day | +| Repeat | `DropdownButton` | None / Daily / Weekly / Monthly / Yearly | +| Description | `TextField` multiline | Optional | +| Location | `TextField` | Optional | +| Color | Row of `InkWell` color chips | 6 preset colors + clear (empty string) | + +**Repeat → RRULE mapping:** +| UI value | RRULE stored | +|----------|-------------| +| None | `null` | +| Daily | `FREQ=DAILY` | +| Weekly | `FREQ=WEEKLY` | +| Monthly | `FREQ=MONTHLY` | +| Yearly | `FREQ=YEARLY` | + +Existing events with an unrecognized RRULE string (does not match the 5 patterns above) display "Custom (read-only)" and the dropdown is disabled. The raw RRULE is preserved unchanged on save. + +**Save flow:** +1. Validate title non-empty +2. Build payload map from form state +3. Call `EventsApi.createEvent` or `EventsApi.updateEvent` +4. On success: call `notifier.addEvent(result)` or `notifier.updateEvent(result)`, pop sheet +5. On error: show SnackBar "Failed to save event." + +**Delete flow (edit mode only):** +1. Show `AlertDialog` "Delete this event?" +2. On confirm: call `EventsApi.deleteEvent(event.id)` +3. On success: call `notifier.removeEvent(event.id, event.startDt)`, pop sheet +4. On error: show SnackBar "Failed to delete event." + +--- + +## What This Does NOT Include + +- Recurrence instance editing ("edit this event only" vs "all events") — backend does not support this distinction +- Reminder/notification editing — `reminder_minutes` field not exposed (backend stores it but no push notification is sent from events) +- Project association — `project_id` field not exposed in the form +- CalDAV sync trigger — available via `POST /api/events/sync` but not surfaced in this screen