@@ -3,63 +3,133 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
33import 'package:tattoo/database/database.dart' ;
44import 'package:tattoo/repositories/calendar_repository.dart' ;
55
6- class CalendarFocusedDayNotifier extends Notifier <DateTime > {
6+ // ---------------------------------------------------------------------------
7+ // Shared notifier — used for both focused-day and selected-day providers.
8+ // ---------------------------------------------------------------------------
9+
10+ class DateTimeNotifier extends Notifier <DateTime > {
711 @override
812 DateTime build () => DateTime .now ();
913 void updateDate (DateTime day) => state = day;
1014}
1115
12- final calendarFocusedDayProvider = NotifierProvider <CalendarFocusedDayNotifier , DateTime >(
13- CalendarFocusedDayNotifier .new ,
16+ final calendarFocusedDayProvider = NotifierProvider <DateTimeNotifier , DateTime >(
17+ DateTimeNotifier .new ,
1418);
1519
20+ final calendarSelectedDayProvider =
21+ NotifierProvider <DateTimeNotifier , DateTime >(DateTimeNotifier .new );
22+
23+ // ---------------------------------------------------------------------------
24+ // Calendar event display helpers
25+ // ---------------------------------------------------------------------------
26+
27+ extension CalendarEventX on CalendarEvent {
28+ /// The inclusive last day/time of this event for display and bucketing.
29+ ///
30+ /// For all-day events the portal stores `end` as the exclusive next-day
31+ /// midnight (e.g. an event on Jan 5 has end = Jan 6 00:00:00). We subtract
32+ /// 1 ms so that bucketing treats Jan 5 as the last day, not Jan 6.
33+ ///
34+ /// For timed events the raw `end` is used as-is.
35+ ///
36+ /// Note: the DB overlap query in CalendarRepository._eventsOverlapping uses
37+ /// the raw `end` column, so boundary-events may appear in a wider DB slice
38+ /// than the bucketing below. This intentional mismatch is safe — the display
39+ /// layer only places events in the correct day buckets.
40+ ///
41+ /// Precondition: callers in [calendarEventsProvider] guard `event.start != null`
42+ /// before calling this getter.
43+ DateTime get displayEndDate {
44+ final e = end;
45+ if (e == null ) return start! ; // start is non-null at this call site
46+
47+ if (allDay) {
48+ // All-day end is the exclusive next-day midnight; subtract 1 ms to keep
49+ // it within the correct day.
50+ final startDay = DateTime (start! .year, start! .month, start! .day);
51+ final endDay = DateTime (e.year, e.month, e.day);
52+ return endDay.isAfter (startDay)
53+ ? endDay.subtract (const Duration (milliseconds: 1 ))
54+ : startDay;
55+ }
56+
57+ return e;
58+ }
59+ }
60+
61+ // ---------------------------------------------------------------------------
62+ // Range notifier — tracks the ±1-month window around the focused month.
63+ // ---------------------------------------------------------------------------
64+
65+ /// Returns a [DateTimeRange] spanning from the start of the month before
66+ /// [focus] to the end of the month two months after [focus] .
67+ ///
68+ /// Uses Dart's normalising DateTime constructor (day 0 = last day of the
69+ /// previous month) to keep the arithmetic simple and correct across year
70+ /// boundaries (e.g. month - 1 when month == 1 yields December of the
71+ /// previous year).
72+ DateTimeRange threeMonthWindow (DateTime focus) {
73+ return DateTimeRange (
74+ start: DateTime (focus.year, focus.month - 1 , 1 ),
75+ end: DateTime (focus.year, focus.month + 2 , 0 ),
76+ );
77+ }
78+
1679class CalendarRangeNotifier extends Notifier <DateTimeRange > {
1780 @override
18- DateTimeRange build () {
19- final now = DateTime .now ();
20- return DateTimeRange (
21- start: DateTime (now.year, now.month - 1 , 1 ),
22- end: DateTime (now.year, now.month + 2 , 0 ),
23- );
24- }
81+ DateTimeRange build () => threeMonthWindow (DateTime .now ());
2582 void updateRange (DateTimeRange range) => state = range;
2683}
2784
28- final calendarRangeProvider = NotifierProvider <CalendarRangeNotifier , DateTimeRange >(
29- CalendarRangeNotifier .new ,
30- );
85+ final calendarRangeProvider =
86+ NotifierProvider <CalendarRangeNotifier , DateTimeRange >(
87+ CalendarRangeNotifier .new ,
88+ );
89+
90+ // ---------------------------------------------------------------------------
91+ // Events provider — maps normalized date integers to event lists.
92+ // ---------------------------------------------------------------------------
93+
94+ /// Encodes a calendar date as a compact integer key to avoid the pitfall of
95+ /// using [DateTime] objects (which include a time component) as map keys.
96+ int dateKey (int year, int month, int day) => year * 10000 + month * 100 + day;
97+
98+ final calendarEventsProvider = FutureProvider <Map <int , List <CalendarEvent >>>((
99+ ref,
100+ ) async {
101+ final range = ref.watch (calendarRangeProvider);
102+ final repo = ref.watch (calendarRepositoryProvider);
31103
32- final calendarEventsProvider =
33- FutureProvider <Map <DateTime , List <CalendarEvent >>>((ref) async {
34- final range = ref.watch (calendarRangeProvider);
35- final repo = ref.watch (calendarRepositoryProvider);
36-
37- final events = await repo.getCalendar (
38- startDate: range.start,
39- endDate: range.end,
40- );
41-
42- final map = < DateTime , List <CalendarEvent >> {};
43- for (final event in events) {
44- if (event.start == null || event.end == null ) continue ;
45- var current = DateTime (
46- event.start! .year,
47- event.start! .month,
48- event.start! .day,
49- );
50- // NTUT sets the `end` epoch to exactly 00:00 on the day *after* the event finishes.
51- // We subtract 1 millisecond so that 'last' falls on the true final day.
52- final adjustedEnd = event.end! .subtract (const Duration (milliseconds: 1 ));
53-
54- // If start and end are exactly the same (a zero-duration event at midnight),
55- // fallback to current to ensure it gets added for at least one day.
56- final lastDay = adjustedEnd.isBefore (current) ? current : adjustedEnd;
57-
58- final last = DateTime (lastDay.year, lastDay.month, lastDay.day);
59- while (! current.isAfter (last)) {
60- map.putIfAbsent (current, () => []).add (event);
61- current = current.add (const Duration (days: 1 ));
62- }
63- }
64- return map;
65- });
104+ final events = await repo.getCalendar (
105+ startDate: range.start,
106+ endDate: range.end,
107+ );
108+
109+ final map = < int , List <CalendarEvent >> {};
110+ for (final event in events) {
111+ if (event.start == null ) continue ;
112+
113+ var current = DateTime (
114+ event.start! .year,
115+ event.start! .month,
116+ event.start! .day,
117+ );
118+ final adjustedEnd = event.displayEndDate;
119+
120+ // If start and end coincide (zero-duration event), emit for exactly one day.
121+ final lastDay = adjustedEnd.isBefore (current) ? current : adjustedEnd;
122+ final last = DateTime (lastDay.year, lastDay.month, lastDay.day);
123+
124+ while (! current.isAfter (last)) {
125+ map
126+ .putIfAbsent (
127+ dateKey (current.year, current.month, current.day),
128+ () => [],
129+ )
130+ .add (event);
131+ current = DateTime (current.year, current.month, current.day + 1 );
132+ }
133+ }
134+ return map;
135+ });
0 commit comments