This repository has been archived on 2026-06-02. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
FabledApp/docs/superpowers/specs/2026-04-06-android-calendar-screen-design.md

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; 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 ListTileshowDatePicker
Start time ListTileshowTimePicker Hidden when all-day
End date ListTileshowDatePicker Optional, clearable
End time ListTileshowTimePicker 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