163 lines
5.9 KiB
Dart
163 lines
5.9 KiB
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),
|
|
);
|
|
}
|
|
|
|
/// Re-fetch events for the current range without clearing state (no flicker).
|
|
Future<void> refresh() async {
|
|
final current = state.value;
|
|
if (current == null) return;
|
|
final events = await ref.read(eventsApiProvider).getEvents(
|
|
current.loadedRange.start,
|
|
current.loadedRange.end,
|
|
);
|
|
state = AsyncData(current.copyWith(eventsByDay: _groupByDay(events)));
|
|
}
|
|
|
|
/// 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;
|
|
}
|