From 8a0837a843c340e480026cadd1695ec70ac4fc86 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 6 Apr 2026 10:17:29 -0400 Subject: [PATCH] docs: add Android Calendar screen implementation plan --- .../2026-04-06-android-calendar-screen.md | 1235 +++++++++++++++++ 1 file changed, 1235 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-06-android-calendar-screen.md diff --git a/docs/superpowers/plans/2026-04-06-android-calendar-screen.md b/docs/superpowers/plans/2026-04-06-android-calendar-screen.md new file mode 100644 index 0000000..8a57fdb --- /dev/null +++ b/docs/superpowers/plans/2026-04-06-android-calendar-screen.md @@ -0,0 +1,1235 @@ +# 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" +```