# Android Calendar Screen Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build the Android Calendar screen — a `table_calendar` month strip + daily agenda with full event CRUD including a simple recurrence picker (None / Daily / Weekly / Monthly / Yearly). **Architecture:** `CalendarNotifier` (`AsyncNotifier`) holds `Map>` keyed by midnight-local dates, plus selected day, focused month, and loaded date range. The screen renders a `TableCalendar` month strip and a `ListView` agenda. `EventFormSheet` (Task 4) is created before `CalendarScreen` (Task 5) so imports resolve cleanly. **Tech Stack:** Flutter, Riverpod AsyncNotifier, Dio, `table_calendar: ^3.1.2` --- ## Files | File | Action | Responsibility | |------|--------|---------------| | `lib/data/models/calendar_event.dart` | Create | `CalendarEvent` model + `fromJson` + `dateOnly` helper | | `lib/data/api/events_api.dart` | Create | `getEvents`, `createEvent`, `updateEvent`, `deleteEvent` | | `lib/providers/api_client_provider.dart` | Modify | Add `eventsApiProvider` | | `lib/providers/calendar_provider.dart` | Create | `CalendarState`, `CalendarNotifier`, `calendarProvider` | | `lib/screens/calendar/event_form_sheet.dart` | Create | Create/edit/delete modal bottom sheet (no dependency on calendar_screen) | | `lib/screens/calendar/calendar_screen.dart` | Create | Month strip + agenda screen (imports event_form_sheet) | | `lib/app.dart` | Modify | Replace Calendar stub route with `CalendarScreen()` | | `test/widget_test.dart` | Modify | Add `CalendarEvent.fromJson` tests | | `pubspec.yaml` | Modify | Add `table_calendar: ^3.1.2` | --- ### Task 1: `table_calendar` package + `CalendarEvent` model + tests **Files:** - Modify: `pubspec.yaml` - Create: `lib/data/models/calendar_event.dart` - Modify: `test/widget_test.dart` - [ ] **Step 1: Add `table_calendar` to `pubspec.yaml`** Add `table_calendar: ^3.1.2` after `just_audio: ^0.9.39` in the `dependencies` section: ```yaml just_audio: ^0.9.39 table_calendar: ^3.1.2 ``` - [ ] **Step 2: Fetch packages** ```bash cd /home/bvandeusen/Nextcloud/Projects/fabled_app flutter pub get ``` Expected: `Resolving dependencies... Got dependencies!` - [ ] **Step 3: Write failing tests** Add this import at the top of `test/widget_test.dart` (after the existing imports): ```dart import 'package:fabled_app/data/models/calendar_event.dart'; ``` Add these test groups inside `void main() {` before the closing `}`: ```dart group('CalendarEvent.fromJson', () { test('parses all fields', () { final json = { 'id': 10, 'title': 'Team meeting', 'start_dt': '2026-04-07T09:00:00+00:00', 'end_dt': '2026-04-07T10:00:00+00:00', 'all_day': false, 'description': 'Weekly sync', 'location': 'Room 4', 'color': '#6366F1', 'recurrence': 'FREQ=WEEKLY', 'project_id': 3, 'reminder_minutes': 15, }; final event = CalendarEvent.fromJson(json); expect(event.id, equals(10)); expect(event.title, equals('Team meeting')); expect(event.startDt, equals(DateTime.parse('2026-04-07T09:00:00+00:00'))); expect(event.endDt, equals(DateTime.parse('2026-04-07T10:00:00+00:00'))); expect(event.allDay, isFalse); expect(event.description, equals('Weekly sync')); expect(event.location, equals('Room 4')); expect(event.color, equals('#6366F1')); expect(event.recurrence, equals('FREQ=WEEKLY')); expect(event.projectId, equals(3)); expect(event.reminderMinutes, equals(15)); }); test('handles null optional fields', () { final json = { 'id': 11, 'title': 'Birthday', 'start_dt': '2026-05-01T00:00:00+00:00', 'end_dt': null, 'all_day': true, 'description': '', 'location': '', 'color': '', 'recurrence': null, 'project_id': null, 'reminder_minutes': null, }; final event = CalendarEvent.fromJson(json); expect(event.endDt, isNull); expect(event.allDay, isTrue); expect(event.recurrence, isNull); expect(event.projectId, isNull); expect(event.reminderMinutes, isNull); }); }); group('dateOnly', () { test('strips time from datetime', () { final dt = DateTime(2026, 4, 7, 14, 30, 45); expect(dateOnly(dt), equals(DateTime(2026, 4, 7))); }); }); ``` - [ ] **Step 4: Run tests — expect FAIL** ```bash cd /home/bvandeusen/Nextcloud/Projects/fabled_app flutter test ``` Expected: FAIL — `CalendarEvent` and `dateOnly` not defined yet. - [ ] **Step 5: Create `lib/data/models/calendar_event.dart`** ```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; final int? projectId; final int? reminderMinutes; const CalendarEvent({ required this.id, required this.title, required this.startDt, this.endDt, required this.allDay, required this.description, required this.location, required this.color, this.recurrence, this.projectId, this.reminderMinutes, }); factory CalendarEvent.fromJson(Map json) => CalendarEvent( id: json['id'] as int, title: json['title'] as String? ?? '', startDt: DateTime.parse(json['start_dt'] as String), endDt: json['end_dt'] != null ? DateTime.parse(json['end_dt'] as String) : null, allDay: json['all_day'] as bool? ?? false, description: json['description'] as String? ?? '', location: json['location'] as String? ?? '', color: json['color'] as String? ?? '', recurrence: json['recurrence'] as String?, projectId: json['project_id'] as int?, reminderMinutes: json['reminder_minutes'] as int?, ); } /// Strips time from a DateTime, returning midnight local. /// Used as map keys in CalendarState.eventsByDay. DateTime dateOnly(DateTime dt) => DateTime(dt.year, dt.month, dt.day); ``` - [ ] **Step 6: Run tests — expect PASS** ```bash cd /home/bvandeusen/Nextcloud/Projects/fabled_app flutter test ``` Expected: All tests passed (22 total — 19 existing + 3 new). - [ ] **Step 7: Commit** ```bash git add pubspec.yaml pubspec.lock lib/data/models/calendar_event.dart test/widget_test.dart git commit -m "feat: add table_calendar package and CalendarEvent model with tests" ``` --- ### Task 2: Events API layer **Files:** - Create: `lib/data/api/events_api.dart` - Modify: `lib/providers/api_client_provider.dart` - [ ] **Step 1: Create `lib/data/api/events_api.dart`** ```dart import 'package:dio/dio.dart'; import '../models/calendar_event.dart'; import 'api_client.dart'; class EventsApi { final Dio _dio; const EventsApi(this._dio); /// GET /api/events?from=&to= Future> getEvents(DateTime from, DateTime to) async { try { final response = await _dio.get( '/api/events', queryParameters: { 'from': from.toUtc().toIso8601String(), 'to': to.toUtc().toIso8601String(), }, ); final list = response.data as List; return list .map((e) => CalendarEvent.fromJson(e as Map)) .toList(); } on DioException catch (e) { throw dioToApp(e); } } /// POST /api/events Future createEvent(Map payload) async { try { final response = await _dio.post('/api/events', data: payload); return CalendarEvent.fromJson(response.data as Map); } on DioException catch (e) { throw dioToApp(e); } } /// PATCH /api/events/{id} Future updateEvent( int id, Map fields) async { try { final response = await _dio.patch('/api/events/$id', data: fields); return CalendarEvent.fromJson(response.data as Map); } on DioException catch (e) { throw dioToApp(e); } } /// DELETE /api/events/{id} Future deleteEvent(int id) async { try { await _dio.delete('/api/events/$id'); } on DioException catch (e) { throw dioToApp(e); } } } ``` - [ ] **Step 2: Add `eventsApiProvider` to `lib/providers/api_client_provider.dart`** Add the import after the `news_api.dart` import line: ```dart import '../data/api/events_api.dart'; ``` Add the provider after the `newsApiProvider` block at the end of the file: ```dart final eventsApiProvider = Provider((ref) { return EventsApi(ref.watch(dioProvider)); }); ``` - [ ] **Step 3: Verify** ```bash cd /home/bvandeusen/Nextcloud/Projects/fabled_app flutter analyze lib/data/api/events_api.dart lib/providers/api_client_provider.dart ``` Expected: `No issues found!` - [ ] **Step 4: Commit** ```bash git add lib/data/api/events_api.dart lib/providers/api_client_provider.dart git commit -m "feat: add EventsApi and eventsApiProvider" ``` --- ### Task 3: Calendar provider **Files:** - Create: `lib/providers/calendar_provider.dart` - [ ] **Step 1: Create `lib/providers/calendar_provider.dart`** ```dart import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../data/models/calendar_event.dart'; import 'api_client_provider.dart'; // ─── CalendarState ──────────────────────────────────────────────────────────── class CalendarState { final Map> eventsByDay; final DateTime selectedDay; final DateTime focusedMonth; final DateTimeRange loadedRange; const CalendarState({ required this.eventsByDay, required this.selectedDay, required this.focusedMonth, required this.loadedRange, }); CalendarState copyWith({ Map>? eventsByDay, DateTime? selectedDay, DateTime? focusedMonth, DateTimeRange? loadedRange, }) { return CalendarState( eventsByDay: eventsByDay ?? this.eventsByDay, selectedDay: selectedDay ?? this.selectedDay, focusedMonth: focusedMonth ?? this.focusedMonth, loadedRange: loadedRange ?? this.loadedRange, ); } } // ─── CalendarNotifier ───────────────────────────────────────────────────────── final calendarProvider = AsyncNotifierProvider(CalendarNotifier.new); class CalendarNotifier extends AsyncNotifier { @override Future build() async { final now = DateTime.now(); final today = dateOnly(now); // Fetch current month ± 1 month as the initial window. final from = DateTime(now.year, now.month - 1, 1); final to = DateTime(now.year, now.month + 2, 0, 23, 59, 59); final events = await ref.watch(eventsApiProvider).getEvents(from, to); return CalendarState( eventsByDay: _groupByDay(events), selectedDay: today, focusedMonth: DateTime(now.year, now.month), loadedRange: DateTimeRange(start: from, end: to), ); } /// Synchronously updates selectedDay and focusedMonth. No API call. void selectDay(DateTime day) { final current = state.value; if (current == null) return; state = AsyncData(current.copyWith( selectedDay: dateOnly(day), focusedMonth: DateTime(day.year, day.month), )); } /// Updates focusedMonth. Fetches events for [month] if not already loaded. Future loadMonth(DateTime month) async { final current = state.value; if (current == null) return; final focused = DateTime(month.year, month.month); // Always update focusedMonth so TableCalendar shows the right page. state = AsyncData(current.copyWith(focusedMonth: focused)); // Skip fetch if the first day of [month] is within the already-loaded range. final monthStart = DateTime(month.year, month.month, 1); if (!monthStart.isBefore(current.loadedRange.start) && !monthStart.isAfter(current.loadedRange.end)) return; try { final from = DateTime(month.year, month.month, 1); final to = DateTime(month.year, month.month + 1, 0, 23, 59, 59); final events = await ref.read(eventsApiProvider).getEvents(from, to); final s = state.value!; final merged = Map>.from(s.eventsByDay); for (final e in events) { final key = dateOnly(e.startDt); (merged[key] ??= []).add(e); } final newStart = from.isBefore(s.loadedRange.start) ? from : s.loadedRange.start; final newEnd = to.isAfter(s.loadedRange.end) ? to : s.loadedRange.end; state = AsyncData(s.copyWith( eventsByDay: merged, loadedRange: DateTimeRange(start: newStart, end: newEnd), )); } catch (_) { // Failures are silent — already-loaded months remain visible. } } /// Inserts [event] into eventsByDay after a successful createEvent API call. void addEvent(CalendarEvent event) { final current = state.value; if (current == null) return; final key = dateOnly(event.startDt); final updated = Map>.from(current.eventsByDay); (updated[key] ??= []).add(event); state = AsyncData(current.copyWith(eventsByDay: updated)); } /// Replaces the old entry for [updated.id] with [updated] after a successful /// updateEvent API call. Scans all buckets to handle date changes. void updateEvent(CalendarEvent updated) { final current = state.value; if (current == null) return; final byDay = Map>.from(current.eventsByDay); for (final key in byDay.keys) { byDay[key] = byDay[key]!.where((e) => e.id != updated.id).toList(); } final newKey = dateOnly(updated.startDt); (byDay[newKey] ??= []).add(updated); state = AsyncData(current.copyWith(eventsByDay: byDay)); } /// Removes event [id] from [date]'s bucket after a successful deleteEvent /// API call. void removeEvent(int id, DateTime date) { final current = state.value; if (current == null) return; final key = dateOnly(date); final byDay = Map>.from(current.eventsByDay); byDay[key] = (byDay[key] ?? []).where((e) => e.id != id).toList(); state = AsyncData(current.copyWith(eventsByDay: byDay)); } } Map> _groupByDay(List events) { final byDay = >{}; for (final e in events) { final key = dateOnly(e.startDt); (byDay[key] ??= []).add(e); } return byDay; } ``` - [ ] **Step 2: Verify** ```bash cd /home/bvandeusen/Nextcloud/Projects/fabled_app flutter analyze lib/providers/calendar_provider.dart ``` Expected: `No issues found!` - [ ] **Step 3: Commit** ```bash git add lib/providers/calendar_provider.dart git commit -m "feat: add CalendarNotifier and calendarProvider" ``` --- ### Task 4: Event form sheet **Files:** - Create: `lib/screens/calendar/event_form_sheet.dart` This file has no dependency on `calendar_screen.dart` and is created first so Task 5 imports resolve cleanly. - [ ] **Step 1: Create `lib/screens/calendar/event_form_sheet.dart`** Create the directory `lib/screens/calendar/` if needed, then write this file: ```dart import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../data/models/calendar_event.dart'; import '../../providers/api_client_provider.dart'; import '../../providers/calendar_provider.dart'; class EventFormSheet extends ConsumerStatefulWidget { /// null = create mode; non-null = edit mode. final CalendarEvent? event; /// Pre-fills start date in create mode. Ignored in edit mode. final DateTime? initialDate; final CalendarNotifier notifier; const EventFormSheet({ super.key, required this.event, required this.initialDate, required this.notifier, }); @override ConsumerState createState() => _EventFormSheetState(); } class _EventFormSheetState extends ConsumerState { late final TextEditingController _titleCtrl; late final TextEditingController _descCtrl; late final TextEditingController _locationCtrl; late DateTime _startDt; DateTime? _endDt; late bool _allDay; String? _recurrence; // null = None; one of the FREQ= strings otherwise String _color = ''; bool _saving = false; bool get _isCreate => widget.event == null; /// Maps RRULE string (or null) to display label for the dropdown. static const Map _knownRrules = { null: 'None', 'FREQ=DAILY': 'Daily', 'FREQ=WEEKLY': 'Weekly', 'FREQ=MONTHLY': 'Monthly', 'FREQ=YEARLY': 'Yearly', }; /// True when editing an event whose RRULE is not one of the 5 known patterns. bool get _isCustomRrule => widget.event?.recurrence != null && !_knownRrules.containsKey(widget.event!.recurrence); static const List _presetColors = [ '#EF4444', '#F59E0B', '#10B981', '#6366F1', '#8B5CF6', '#EC4899', ]; @override void initState() { super.initState(); final e = widget.event; _titleCtrl = TextEditingController(text: e?.title ?? ''); _descCtrl = TextEditingController(text: e?.description ?? ''); _locationCtrl = TextEditingController(text: e?.location ?? ''); if (e != null) { _startDt = e.startDt.toLocal(); _endDt = e.endDt?.toLocal(); _allDay = e.allDay; // Custom RRULEs are displayed as read-only; leave _recurrence = null. _recurrence = _isCustomRrule ? null : e.recurrence; _color = e.color; } else { final base = widget.initialDate ?? DateTime.now(); final now = DateTime.now(); final hour = now.minute >= 30 ? (now.hour + 1) % 24 : now.hour; _startDt = DateTime(base.year, base.month, base.day, hour); _allDay = false; _recurrence = null; } } @override void dispose() { _titleCtrl.dispose(); _descCtrl.dispose(); _locationCtrl.dispose(); super.dispose(); } // ── Save ────────────────────────────────────────────────────────────────── Future _save() async { if (_titleCtrl.text.trim().isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Title is required.')), ); return; } setState(() => _saving = true); try { // Preserve unrecognised RRULEs unchanged on save. final rrule = _isCustomRrule ? widget.event!.recurrence : _recurrence; final payload = { 'title': _titleCtrl.text.trim(), 'start_dt': _startDt.toUtc().toIso8601String(), if (_endDt != null) 'end_dt': _endDt!.toUtc().toIso8601String(), 'all_day': _allDay, 'description': _descCtrl.text.trim(), 'location': _locationCtrl.text.trim(), 'color': _color, 'recurrence': rrule, }; final api = ref.read(eventsApiProvider); if (_isCreate) { final created = await api.createEvent(payload); widget.notifier.addEvent(created); } else { final updated = await api.updateEvent(widget.event!.id, payload); widget.notifier.updateEvent(updated); } if (mounted) Navigator.of(context).pop(); } catch (_) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Failed to save event.')), ); } } finally { if (mounted) setState(() => _saving = false); } } // ── Delete ──────────────────────────────────────────────────────────────── Future _delete() async { final confirmed = await showDialog( context: context, builder: (_) => AlertDialog( title: const Text('Delete this event?'), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel'), ), TextButton( onPressed: () => Navigator.of(context).pop(true), child: Text( 'Delete', style: TextStyle(color: Theme.of(context).colorScheme.error), ), ), ], ), ); if (confirmed != true || !mounted) return; setState(() => _saving = true); try { await ref.read(eventsApiProvider).deleteEvent(widget.event!.id); widget.notifier.removeEvent(widget.event!.id, widget.event!.startDt); if (mounted) Navigator.of(context).pop(); } catch (_) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Failed to delete event.')), ); } } finally { if (mounted) setState(() => _saving = false); } } // ── Date/time pickers ───────────────────────────────────────────────────── Future _pickStartDate() async { final d = await showDatePicker( context: context, initialDate: _startDt, firstDate: DateTime(2000), lastDate: DateTime(2100), ); if (d != null) { setState(() { _startDt = DateTime( d.year, d.month, d.day, _startDt.hour, _startDt.minute); }); } } Future _pickStartTime() async { final t = await showTimePicker( context: context, initialTime: TimeOfDay.fromDateTime(_startDt), ); if (t != null) { setState(() { _startDt = DateTime( _startDt.year, _startDt.month, _startDt.day, t.hour, t.minute); }); } } Future _pickEndDate() async { final d = await showDatePicker( context: context, initialDate: _endDt ?? _startDt, firstDate: DateTime(2000), lastDate: DateTime(2100), ); if (d != null) { setState(() { final prev = _endDt ?? _startDt; _endDt = DateTime(d.year, d.month, d.day, prev.hour, prev.minute); }); } } Future _pickEndTime() async { final t = await showTimePicker( context: context, initialTime: TimeOfDay.fromDateTime(_endDt ?? _startDt), ); if (t != null) { setState(() { final prev = _endDt ?? _startDt; _endDt = DateTime( prev.year, prev.month, prev.day, t.hour, t.minute); }); } } // ── Formatting helpers ──────────────────────────────────────────────────── String _fmtDate(DateTime dt) { const months = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', ]; return '${months[dt.month - 1]} ${dt.day}, ${dt.year}'; } String _fmtTime(DateTime dt) => '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; // ── Build ───────────────────────────────────────────────────────────────── @override Widget build(BuildContext context) { return Padding( padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Header Row( children: [ Text( _isCreate ? 'New Event' : 'Edit Event', style: Theme.of(context).textTheme.titleLarge, ), const Spacer(), if (!_isCreate) IconButton( icon: const Icon(Icons.delete_outline), color: Theme.of(context).colorScheme.error, onPressed: _saving ? null : _delete, ), ], ), const SizedBox(height: 16), // Title TextField( controller: _titleCtrl, decoration: const InputDecoration( labelText: 'Title', border: OutlineInputBorder(), ), textCapitalization: TextCapitalization.sentences, ), const SizedBox(height: 12), // All-day toggle SwitchListTile( title: const Text('All day'), value: _allDay, onChanged: (v) => setState(() => _allDay = v), contentPadding: EdgeInsets.zero, ), // Start date ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.calendar_today_outlined), title: Text(_fmtDate(_startDt)), subtitle: const Text('Start date'), onTap: _pickStartDate, ), // Start time (hidden when all-day) if (!_allDay) ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.access_time_outlined), title: Text(_fmtTime(_startDt)), subtitle: const Text('Start time'), onTap: _pickStartTime, ), // End date ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.event_outlined), title: Text( _endDt != null ? _fmtDate(_endDt!) : 'No end date'), subtitle: const Text('End date'), onTap: _pickEndDate, trailing: _endDt != null ? IconButton( icon: const Icon(Icons.clear), onPressed: () => setState(() => _endDt = null), ) : null, ), // End time (hidden when all-day or no end date) if (!_allDay && _endDt != null) ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.access_time_outlined), title: Text(_fmtTime(_endDt!)), subtitle: const Text('End time'), onTap: _pickEndTime, ), const SizedBox(height: 8), // Repeat — show read-only tile for custom RRULEs if (_isCustomRrule) ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.repeat_outlined), title: const Text('Custom (read-only)'), subtitle: Text(widget.event!.recurrence ?? ''), ) else Row( children: [ const Icon(Icons.repeat_outlined), const SizedBox(width: 16), DropdownButton( value: _recurrence, underline: const SizedBox.shrink(), items: _knownRrules.entries .map((entry) => DropdownMenuItem( value: entry.key, child: Text(entry.value), )) .toList(), onChanged: (v) => setState(() => _recurrence = v), ), ], ), const SizedBox(height: 12), // Description TextField( controller: _descCtrl, decoration: const InputDecoration( labelText: 'Description', border: OutlineInputBorder(), ), maxLines: 3, textCapitalization: TextCapitalization.sentences, ), const SizedBox(height: 12), // Location TextField( controller: _locationCtrl, decoration: const InputDecoration( labelText: 'Location', border: OutlineInputBorder(), ), ), const SizedBox(height: 16), // Color chips Text('Color', style: Theme.of(context).textTheme.labelMedium), const SizedBox(height: 8), Row( children: [ // "No color" chip GestureDetector( onTap: () => setState(() => _color = ''), child: Container( width: 32, height: 32, margin: const EdgeInsets.only(right: 8), decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( color: _color.isEmpty ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.outline, width: _color.isEmpty ? 2 : 1, ), ), child: const Icon(Icons.block, size: 16), ), ), ..._presetColors.map((hex) { final c = Color(int.parse(hex.replaceFirst('#', '0xFF'))); return GestureDetector( onTap: () => setState(() => _color = hex), child: Container( width: 32, height: 32, margin: const EdgeInsets.only(right: 8), decoration: BoxDecoration( color: c, shape: BoxShape.circle, border: _color == hex ? Border.all( color: Theme.of(context) .colorScheme .primary, width: 2, ) : null, ), ), ); }), ], ), const SizedBox(height: 24), // Save button SizedBox( width: double.infinity, child: FilledButton( onPressed: _saving ? null : _save, child: _saving ? const SizedBox( height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2), ) : Text(_isCreate ? 'Create' : 'Save'), ), ), ], ), ), ); } } ``` - [ ] **Step 2: Verify** ```bash cd /home/bvandeusen/Nextcloud/Projects/fabled_app flutter analyze lib/screens/calendar/event_form_sheet.dart ``` Expected: `No issues found!` - [ ] **Step 3: Commit** ```bash git add lib/screens/calendar/event_form_sheet.dart git commit -m "feat: add EventFormSheet with CRUD, recurrence picker, and color chips" ``` --- ### Task 5: Calendar screen **Files:** - Create: `lib/screens/calendar/calendar_screen.dart` `EventFormSheet` from Task 4 already exists — this file imports it cleanly. - [ ] **Step 1: Create `lib/screens/calendar/calendar_screen.dart`** ```dart import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:table_calendar/table_calendar.dart'; import '../../data/models/calendar_event.dart'; import '../../providers/calendar_provider.dart'; import 'event_form_sheet.dart'; class CalendarScreen extends ConsumerWidget { const CalendarScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final calAsync = ref.watch(calendarProvider); final notifier = ref.read(calendarProvider.notifier); return Scaffold( appBar: AppBar(title: const Text('Calendar')), body: calAsync.when( loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const Text('Could not load calendar.'), const SizedBox(height: 12), FilledButton.tonal( onPressed: () => ref.invalidate(calendarProvider), child: const Text('Retry'), ), ], ), ), data: (cal) => Column( children: [ TableCalendar( firstDay: DateTime(2020), lastDay: DateTime(2030), focusedDay: cal.focusedMonth, selectedDayPredicate: (day) => isSameDay(day, cal.selectedDay), eventLoader: (day) => cal.eventsByDay[dateOnly(day)] ?? [], calendarFormat: CalendarFormat.month, headerStyle: const HeaderStyle( formatButtonVisible: false, titleCentered: true, ), calendarStyle: CalendarStyle( selectedDecoration: BoxDecoration( color: Theme.of(context).colorScheme.primary, shape: BoxShape.circle, ), todayDecoration: BoxDecoration( color: Theme.of(context) .colorScheme .primary .withValues(alpha: 0.3), shape: BoxShape.circle, ), markerDecoration: BoxDecoration( color: Theme.of(context).colorScheme.secondary, shape: BoxShape.circle, ), ), onDaySelected: (selected, _) => notifier.selectDay(selected), onPageChanged: (focused) => notifier.loadMonth(focused), ), const Divider(height: 1), Expanded( child: _AgendaList( events: cal.eventsByDay[dateOnly(cal.selectedDay)] ?? [], notifier: notifier, ), ), ], ), ), floatingActionButton: calAsync.hasValue ? FloatingActionButton( onPressed: () => showModalBottomSheet( context: context, isScrollControlled: true, useSafeArea: true, builder: (_) => EventFormSheet( event: null, initialDate: calAsync.value!.selectedDay, notifier: notifier, ), ), child: const Icon(Icons.add), ) : null, ); } } // ── Agenda list ─────────────────────────────────────────────────────────────── class _AgendaList extends StatelessWidget { final List events; final CalendarNotifier notifier; const _AgendaList({required this.events, required this.notifier}); @override Widget build(BuildContext context) { if (events.isEmpty) { return const Center(child: Text('No events')); } return ListView.builder( padding: const EdgeInsets.symmetric(vertical: 8), itemCount: events.length, itemBuilder: (_, i) => _EventTile(event: events[i], notifier: notifier), ); } } // ── Event tile ──────────────────────────────────────────────────────────────── class _EventTile extends StatelessWidget { final CalendarEvent event; final CalendarNotifier notifier; const _EventTile({required this.event, required this.notifier}); Color _dotColor(BuildContext context) { if (event.color.isEmpty) return Theme.of(context).colorScheme.primary; try { return Color(int.parse(event.color.replaceFirst('#', '0xFF'))); } catch (_) { return Theme.of(context).colorScheme.primary; } } String _timeLabel() { if (event.allDay) return 'All day'; final h = event.startDt.hour.toString().padLeft(2, '0'); final m = event.startDt.minute.toString().padLeft(2, '0'); return '$h:$m'; } @override Widget build(BuildContext context) { return ListTile( leading: CircleAvatar( radius: 6, backgroundColor: _dotColor(context), ), title: Text(event.title), subtitle: Text(_timeLabel()), onTap: () => showModalBottomSheet( context: context, isScrollControlled: true, useSafeArea: true, builder: (_) => EventFormSheet( event: event, initialDate: null, notifier: notifier, ), ), ); } } ``` - [ ] **Step 2: Verify** ```bash cd /home/bvandeusen/Nextcloud/Projects/fabled_app flutter analyze lib/screens/calendar/calendar_screen.dart ``` Expected: `No issues found!` - [ ] **Step 3: Commit** ```bash git add lib/screens/calendar/calendar_screen.dart git commit -m "feat: add CalendarScreen with month strip and agenda list" ``` --- ### Task 6: Wire route and final verification **Files:** - Modify: `lib/app.dart` - [ ] **Step 1: Add import to `lib/app.dart`** Add this import alongside the other screen imports: ```dart import 'screens/calendar/calendar_screen.dart'; ``` - [ ] **Step 2: Replace the Calendar stub route in `lib/app.dart`** Find: ```dart GoRoute( path: Routes.calendar, builder: (_, _) => Scaffold( appBar: AppBar(title: const Text('Calendar')), body: const Center(child: Text('Calendar — coming soon')), ), ), ``` Replace with: ```dart GoRoute( path: Routes.calendar, builder: (_, _) => const CalendarScreen(), ), ``` - [ ] **Step 3: Full analyze and test** ```bash cd /home/bvandeusen/Nextcloud/Projects/fabled_app flutter analyze flutter test ``` Expected: `No issues found!` and all tests passed (22 total). - [ ] **Step 4: Commit** ```bash git add lib/app.dart git commit -m "feat: wire Calendar route to CalendarScreen" ```