From c4dca6d4ed153f9b39c5c43911e094b556fef5ef Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 6 Apr 2026 10:28:53 -0400 Subject: [PATCH] feat: add CalendarNotifier and calendarProvider --- lib/providers/calendar_provider.dart | 151 +++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 lib/providers/calendar_provider.dart diff --git a/lib/providers/calendar_provider.dart b/lib/providers/calendar_provider.dart new file mode 100644 index 0000000..c3c8951 --- /dev/null +++ b/lib/providers/calendar_provider.dart @@ -0,0 +1,151 @@ +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; +}