7.7 KiB
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
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:
DateTime dateOnly(DateTime dt) => DateTime(dt.year, dt.month, dt.day);
API Layer
events_api.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
final eventsApiProvider = Provider<EventsApi>((ref) =>
EventsApi(ref.watch(dioProvider)));
State Management
CalendarState
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; populateseventsByDay; setsselectedDayto today; setsloadedRangeto the fetched range. -
selectDay(DateTime day): synchronous state update — updatesselectedDayandfocusedMonthtoDateTime(day.year, day.month). No API call. -
loadMonth(DateTime month): updatesfocusedMonth. Ifmonthis already withinloadedRange, no-op (state update only). Otherwise fetches events for that month, merges new items intoeventsByDay, extendsloadedRange. -
addEvent(CalendarEvent event): inserts the event intoeventsByDayunderdateOnly(event.startDt). Synchronous local mutation after successful API call. -
updateEvent(CalendarEvent updated): removes old entry byidfrom its old date bucket (scanned), inserts underdateOnly(updated.startDt). Synchronous local mutation. -
removeEvent(int id, DateTime date): removes fromeventsByDay[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: callsnotifier.selectDay(day)onPageChanged: callsnotifier.loadMonth(month)
Agenda list (ListView.builder):
- Items:
state.eventsByDay[dateOnly(state.selectedDay)] ?? [] - Each
EventTileshows: color dot, title, time string ("All day" ifallDay, otherwise formatted start time) - Tap → opens
EventFormSheetin 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:
- Validate title non-empty
- Build payload map from form state
- Call
EventsApi.createEventorEventsApi.updateEvent - On success: call
notifier.addEvent(result)ornotifier.updateEvent(result), pop sheet - On error: show SnackBar "Failed to save event."
Delete flow (edit mode only):
- Show
AlertDialog"Delete this event?" - On confirm: call
EventsApi.deleteEvent(event.id) - On success: call
notifier.removeEvent(event.id, event.startDt), pop sheet - 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_minutesfield not exposed (backend stores it but no push notification is sent from events) - Project association —
project_idfield not exposed in the form - CalDAV sync trigger — available via
POST /api/events/syncbut not surfaced in this screen