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/plans/2026-04-06-android-calendar-screen.md
T

38 KiB

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<CalendarState>) holds Map<DateTime, List<CalendarEvent>> 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:

  just_audio: ^0.9.39
  table_calendar: ^3.1.2
  • Step 2: Fetch packages
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):

import 'package:fabled_app/data/models/calendar_event.dart';

Add these test groups inside void main() { before the closing }:

  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
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
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<String, dynamic> 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
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
flutter test

Expected: All tests passed (22 total — 19 existing + 3 new).

  • Step 7: Commit
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

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=<iso>&to=<iso>
  Future<List<CalendarEvent>> 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<dynamic>;
      return list
          .map((e) => CalendarEvent.fromJson(e as Map<String, dynamic>))
          .toList();
    } on DioException catch (e) {
      throw dioToApp(e);
    }
  }

  /// POST /api/events
  Future<CalendarEvent> createEvent(Map<String, dynamic> payload) async {
    try {
      final response = await _dio.post('/api/events', data: payload);
      return CalendarEvent.fromJson(response.data as Map<String, dynamic>);
    } on DioException catch (e) {
      throw dioToApp(e);
    }
  }

  /// PATCH /api/events/{id}
  Future<CalendarEvent> updateEvent(
      int id, Map<String, dynamic> fields) async {
    try {
      final response = await _dio.patch('/api/events/$id', data: fields);
      return CalendarEvent.fromJson(response.data as Map<String, dynamic>);
    } on DioException catch (e) {
      throw dioToApp(e);
    }
  }

  /// DELETE /api/events/{id}
  Future<void> 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:

import '../data/api/events_api.dart';

Add the provider after the newsApiProvider block at the end of the file:

final eventsApiProvider = Provider<EventsApi>((ref) {
  return EventsApi(ref.watch(dioProvider));
});
  • Step 3: Verify
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
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

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<DateTime, List<CalendarEvent>> 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<DateTime, List<CalendarEvent>>? 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, CalendarState>(CalendarNotifier.new);

class CalendarNotifier extends AsyncNotifier<CalendarState> {
  @override
  Future<CalendarState> 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<void> 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<DateTime, List<CalendarEvent>>.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<DateTime, List<CalendarEvent>>.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<DateTime, List<CalendarEvent>>.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<DateTime, List<CalendarEvent>>.from(current.eventsByDay);
    byDay[key] = (byDay[key] ?? []).where((e) => e.id != id).toList();
    state = AsyncData(current.copyWith(eventsByDay: byDay));
  }
}

Map<DateTime, List<CalendarEvent>> _groupByDay(List<CalendarEvent> events) {
  final byDay = <DateTime, List<CalendarEvent>>{};
  for (final e in events) {
    final key = dateOnly(e.startDt);
    (byDay[key] ??= []).add(e);
  }
  return byDay;
}
  • Step 2: Verify
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
flutter analyze lib/providers/calendar_provider.dart

Expected: No issues found!

  • Step 3: Commit
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:

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<EventFormSheet> createState() => _EventFormSheetState();
}

class _EventFormSheetState extends ConsumerState<EventFormSheet> {
  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<String?, String> _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<String> _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<void> _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 = <String, dynamic>{
        '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<void> _delete() async {
    final confirmed = await showDialog<bool>(
      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<void> _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<void> _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<void> _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<void> _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<String?>(
                    value: _recurrence,
                    underline: const SizedBox.shrink(),
                    items: _knownRrules.entries
                        .map((entry) => DropdownMenuItem<String?>(
                              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
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
flutter analyze lib/screens/calendar/event_form_sheet.dart

Expected: No issues found!

  • Step 3: Commit
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
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<CalendarEvent>(
              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<CalendarEvent> 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
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
flutter analyze lib/screens/calendar/calendar_screen.dart

Expected: No issues found!

  • Step 3: Commit
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:

import 'screens/calendar/calendar_screen.dart';
  • Step 2: Replace the Calendar stub route in lib/app.dart

Find:

      GoRoute(
        path: Routes.calendar,
        builder: (_, _) => Scaffold(
          appBar: AppBar(title: const Text('Calendar')),
          body: const Center(child: Text('Calendar — coming soon')),
        ),
      ),

Replace with:

      GoRoute(
        path: Routes.calendar,
        builder: (_, _) => const CalendarScreen(),
      ),
  • Step 3: Full analyze and test
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
flutter analyze
flutter test

Expected: No issues found! and all tests passed (22 total).

  • Step 4: Commit
git add lib/app.dart
git commit -m "feat: wire Calendar route to CalendarScreen"