# 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