docs: add Android Calendar screen design spec
This commit is contained in:
@@ -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<CalendarState>`) holds a `Map<DateTime, List<CalendarEvent>>` 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=<iso>&to=<iso>
|
||||
Future<List<CalendarEvent>> getEvents(DateTime from, DateTime to) async { ... }
|
||||
|
||||
// POST /api/events
|
||||
Future<CalendarEvent> createEvent(Map<String, dynamic> payload) async { ... }
|
||||
|
||||
// PATCH /api/events/{id}
|
||||
Future<CalendarEvent> updateEvent(int id, Map<String, dynamic> fields) async { ... }
|
||||
|
||||
// DELETE /api/events/{id}
|
||||
Future<void> 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<EventsApi>((ref) =>
|
||||
EventsApi(ref.watch(dioProvider)));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## State Management
|
||||
|
||||
### `CalendarState`
|
||||
```dart
|
||||
class CalendarState {
|
||||
final Map<DateTime, List<CalendarEvent>> eventsByDay; // keys: midnight local time
|
||||
final DateTime selectedDay;
|
||||
final DateTime focusedMonth;
|
||||
final DateTimeRange loadedRange;
|
||||
}
|
||||
```
|
||||
|
||||
### `CalendarNotifier extends AsyncNotifier<CalendarState>`
|
||||
|
||||
- **`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
|
||||
Reference in New Issue
Block a user