1236 lines
38 KiB
Markdown
1236 lines
38 KiB
Markdown
# 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:
|
|
|
|
```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<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**
|
|
|
|
```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=<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:
|
|
|
|
```dart
|
|
import '../data/api/events_api.dart';
|
|
```
|
|
|
|
Add the provider after the `newsApiProvider` block at the end of the file:
|
|
|
|
```dart
|
|
final eventsApiProvider = Provider<EventsApi>((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<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**
|
|
|
|
```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<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**
|
|
|
|
```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<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**
|
|
|
|
```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"
|
|
```
|