diff --git a/lib/i18n/en-US.i18n.yaml b/lib/i18n/en-US.i18n.yaml index 068b52fd..942df1e7 100644 --- a/lib/i18n/en-US.i18n.yaml +++ b/lib/i18n/en-US.i18n.yaml @@ -48,7 +48,10 @@ nav: courseTable: Courses scores: Scores portal: Portals + calendar: Calendar profile: Me +calendar: + month: Month courseTable: notFound: Course table not found dayOfWeek(map): diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 38f79745..2de79ecb 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,7 +4,7 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 2 -/// Strings: 208 (104 per locale) +/// Strings: 212 (106 per locale) // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_en_US.g.dart b/lib/i18n/strings_en_US.g.dart index e6d8919e..bb02f73e 100644 --- a/lib/i18n/strings_en_US.g.dart +++ b/lib/i18n/strings_en_US.g.dart @@ -44,6 +44,7 @@ class TranslationsEnUs extends Translations with BaseTranslations 'Courses'; @override String get scores => 'Scores'; @override String get portal => 'Portals'; + @override String get calendar => 'Calendar'; @override String get profile => 'Me'; } +// Path: calendar +class _TranslationsCalendarEnUs extends TranslationsCalendarZhTw { + _TranslationsCalendarEnUs._(TranslationsEnUs root) : this._root = root, super.internal(root); + + final TranslationsEnUs _root; // ignore: unused_field + + // Translations + @override String get month => 'Month'; +} + // Path: courseTable class _TranslationsCourseTableEnUs extends TranslationsCourseTableZhTw { _TranslationsCourseTableEnUs._(TranslationsEnUs root) : this._root = root, super.internal(root); @@ -395,7 +407,9 @@ extension on TranslationsEnUs { 'nav.courseTable' => 'Courses', 'nav.scores' => 'Scores', 'nav.portal' => 'Portals', + 'nav.calendar' => 'Calendar', 'nav.profile' => 'Me', + 'calendar.month' => 'Month', 'courseTable.notFound' => 'Course table not found', 'courseTable.dayOfWeek.sunday' => 'Sun', 'courseTable.dayOfWeek.monday' => 'Mon', diff --git a/lib/i18n/strings_zh_TW.g.dart b/lib/i18n/strings_zh_TW.g.dart index d36d4a40..1f6595a3 100644 --- a/lib/i18n/strings_zh_TW.g.dart +++ b/lib/i18n/strings_zh_TW.g.dart @@ -45,6 +45,7 @@ class Translations with BaseTranslations { late final TranslationsIntroZhTw intro = TranslationsIntroZhTw.internal(_root); late final TranslationsLoginZhTw login = TranslationsLoginZhTw.internal(_root); late final TranslationsNavZhTw nav = TranslationsNavZhTw.internal(_root); + late final TranslationsCalendarZhTw calendar = TranslationsCalendarZhTw.internal(_root); late final TranslationsCourseTableZhTw courseTable = TranslationsCourseTableZhTw.internal(_root); late final TranslationsProfileZhTw profile = TranslationsProfileZhTw.internal(_root); late final TranslationsEnrollmentStatusZhTw enrollmentStatus = TranslationsEnrollmentStatusZhTw.internal(_root); @@ -186,10 +187,25 @@ class TranslationsNavZhTw { /// zh-TW: '傳送門' String get portal => '傳送門'; + /// zh-TW: '行事曆' + String get calendar => '行事曆'; + /// zh-TW: '我' String get profile => '我'; } +// Path: calendar +class TranslationsCalendarZhTw { + TranslationsCalendarZhTw.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// zh-TW: '月' + String get month => '月'; +} + // Path: courseTable class TranslationsCourseTableZhTw { TranslationsCourseTableZhTw.internal(this._root); @@ -580,7 +596,9 @@ extension on Translations { 'nav.courseTable' => '課表', 'nav.scores' => '成績', 'nav.portal' => '傳送門', + 'nav.calendar' => '行事曆', 'nav.profile' => '我', + 'calendar.month' => '月', 'courseTable.notFound' => '找不到課表', 'courseTable.dayOfWeek.sunday' => '日', 'courseTable.dayOfWeek.monday' => '一', diff --git a/lib/i18n/zh-TW.i18n.yaml b/lib/i18n/zh-TW.i18n.yaml index 53b8ba76..908901ea 100644 --- a/lib/i18n/zh-TW.i18n.yaml +++ b/lib/i18n/zh-TW.i18n.yaml @@ -48,7 +48,10 @@ nav: courseTable: 課表 scores: 成績 portal: 傳送門 + calendar: 行事曆 profile: 我 +calendar: + month: 月 courseTable: notFound: 找不到課表 dayOfWeek(map): diff --git a/lib/router/app_router.dart b/lib/router/app_router.dart index 1f397fbb..dbfb15a2 100644 --- a/lib/router/app_router.dart +++ b/lib/router/app_router.dart @@ -5,6 +5,7 @@ import 'package:tattoo/repositories/auth_repository.dart'; import 'package:tattoo/shells/animated_shell_container.dart'; import 'package:tattoo/screens/main/home_screen.dart'; import 'package:tattoo/screens/main/portal/portal_screen.dart'; +import 'package:tattoo/screens/main/calendar/calendar_screen.dart'; import 'package:tattoo/screens/main/profile/about_screen.dart'; import 'package:tattoo/screens/main/profile/profile_screen.dart'; import 'package:tattoo/screens/main/score/score_screen.dart'; @@ -19,6 +20,7 @@ abstract class AppRoutes { static const home = '/'; static const score = '/score'; static const portal = '/portal'; + static const calendar = '/calendar'; static const profile = '/profile'; static const intro = '/intro'; static const login = '/login'; @@ -105,6 +107,15 @@ GoRouter createAppRouter({ ), ], ), + StatefulShellBranch( + routes: [ + GoRoute( + path: AppRoutes.calendar, + pageBuilder: (context, state) => + const NoTransitionPage(child: CalendarScreen()), + ), + ], + ), StatefulShellBranch( routes: [ GoRoute( diff --git a/lib/screens/main/calendar/calendar_providers.dart b/lib/screens/main/calendar/calendar_providers.dart new file mode 100644 index 00000000..276b14b5 --- /dev/null +++ b/lib/screens/main/calendar/calendar_providers.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:tattoo/database/database.dart'; +import 'package:tattoo/repositories/calendar_repository.dart'; + +// --------------------------------------------------------------------------- +// Shared notifier — used for both focused-day and selected-day providers. +// --------------------------------------------------------------------------- + +class DateTimeNotifier extends Notifier { + @override + DateTime build() => DateTime.now(); + void updateDate(DateTime day) => state = day; +} + +final calendarFocusedDayProvider = NotifierProvider( + DateTimeNotifier.new, +); + +final calendarSelectedDayProvider = + NotifierProvider(DateTimeNotifier.new); + +// --------------------------------------------------------------------------- +// Calendar event display helpers +// --------------------------------------------------------------------------- + +extension CalendarEventX on CalendarEvent { + /// The inclusive last day/time of this event for display and bucketing. + /// + /// For all-day events the portal stores `end` as the exclusive next-day + /// midnight (e.g. an event on Jan 5 has end = Jan 6 00:00:00). We subtract + /// 1 ms so that bucketing treats Jan 5 as the last day, not Jan 6. + /// + /// For timed events the raw `end` is used as-is. + /// + /// Note: the DB overlap query in CalendarRepository._eventsOverlapping uses + /// the raw `end` column, so boundary-events may appear in a wider DB slice + /// than the bucketing below. This intentional mismatch is safe — the display + /// layer only places events in the correct day buckets. + /// + /// Precondition: callers in [calendarEventsProvider] guard `event.start != null` + /// before calling this getter. + DateTime get displayEndDate { + final e = end; + if (e == null) return start!; // start is non-null at this call site + + if (allDay) { + // All-day end is the exclusive next-day midnight; subtract 1 ms to keep + // it within the correct day. + final startDay = DateTime(start!.year, start!.month, start!.day); + final endDay = DateTime(e.year, e.month, e.day); + return endDay.isAfter(startDay) + ? endDay.subtract(const Duration(milliseconds: 1)) + : startDay; + } + + return e; + } +} + +// --------------------------------------------------------------------------- +// Range notifier — tracks the ±1-month window around the focused month. +// --------------------------------------------------------------------------- + +/// Returns a [DateTimeRange] spanning from the start of the month before +/// [focus] to the end of the month two months after [focus]. +/// +/// Uses Dart's normalising DateTime constructor (day 0 = last day of the +/// previous month) to keep the arithmetic simple and correct across year +/// boundaries (e.g. month - 1 when month == 1 yields December of the +/// previous year). +DateTimeRange threeMonthWindow(DateTime focus) { + return DateTimeRange( + start: DateTime(focus.year, focus.month - 1, 1), + end: DateTime(focus.year, focus.month + 2, 0), + ); +} + +class CalendarRangeNotifier extends Notifier { + @override + DateTimeRange build() => threeMonthWindow(DateTime.now()); + void updateRange(DateTimeRange range) => state = range; +} + +final calendarRangeProvider = + NotifierProvider( + CalendarRangeNotifier.new, + ); + +// --------------------------------------------------------------------------- +// Events provider — maps normalized date integers to event lists. +// --------------------------------------------------------------------------- + +/// Encodes a calendar date as a compact integer key to avoid the pitfall of +/// using [DateTime] objects (which include a time component) as map keys. +int dateKey(int year, int month, int day) => year * 10000 + month * 100 + day; + +final calendarEventsProvider = FutureProvider>>(( + ref, +) async { + final range = ref.watch(calendarRangeProvider); + final repo = ref.watch(calendarRepositoryProvider); + + final events = await repo.getCalendar( + startDate: range.start, + endDate: range.end, + ); + + final map = >{}; + for (final event in events) { + if (event.start == null) continue; + + var current = DateTime( + event.start!.year, + event.start!.month, + event.start!.day, + ); + final adjustedEnd = event.displayEndDate; + + // If start and end coincide (zero-duration event), emit for exactly one day. + final lastDay = adjustedEnd.isBefore(current) ? current : adjustedEnd; + final last = DateTime(lastDay.year, lastDay.month, lastDay.day); + + while (!current.isAfter(last)) { + map + .putIfAbsent( + dateKey(current.year, current.month, current.day), + () => [], + ) + .add(event); + current = DateTime(current.year, current.month, current.day + 1); + } + } + return map; +}); diff --git a/lib/screens/main/calendar/calendar_screen.dart b/lib/screens/main/calendar/calendar_screen.dart new file mode 100644 index 00000000..b661e57a --- /dev/null +++ b/lib/screens/main/calendar/calendar_screen.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:table_calendar/table_calendar.dart'; +import 'package:tattoo/i18n/strings.g.dart'; +import 'package:tattoo/screens/main/calendar/calendar_providers.dart'; +import 'package:tattoo/database/database.dart'; + +class CalendarScreen extends ConsumerWidget { + const CalendarScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final focusedDay = ref.watch(calendarFocusedDayProvider); + final selectedDay = ref.watch(calendarSelectedDayProvider); + final eventsAsyncValue = ref.watch(calendarEventsProvider); + + return Scaffold( + appBar: AppBar( + title: Text(t.nav.calendar), + ), + body: eventsAsyncValue.when( + skipLoadingOnReload: true, + data: (eventsMap) { + final selectedEvents = _getEventsForDay(eventsMap, selectedDay); + + return Column( + children: [ + TableCalendar( + firstDay: DateTime(2020, 1, 1), + lastDay: DateTime(2030, 12, 31), + focusedDay: focusedDay, + selectedDayPredicate: (day) => isSameDay(selectedDay, day), + onDaySelected: (newSelectedDay, newFocusedDay) { + ref + .read(calendarSelectedDayProvider.notifier) + .updateDate(newSelectedDay); + ref + .read(calendarFocusedDayProvider.notifier) + .updateDate(newFocusedDay); + }, + onPageChanged: (newFocusedDay) { + ref + .read(calendarFocusedDayProvider.notifier) + .updateDate(newFocusedDay); + + // Expand the fetch range when the user navigates near the + // current window boundary so events are always pre-loaded. + final range = ref.read(calendarRangeProvider); + final atStart = + (newFocusedDay.year == range.start.year && + newFocusedDay.month == range.start.month); + final atEnd = + (newFocusedDay.year == range.end.year && + newFocusedDay.month == range.end.month); + final outOfRange = + newFocusedDay.isBefore(range.start) || + newFocusedDay.isAfter(range.end); + + if (atStart || atEnd || outOfRange) { + ref + .read(calendarRangeProvider.notifier) + .updateRange(threeMonthWindow(newFocusedDay)); + } + }, + eventLoader: (day) => _getEventsForDay(eventsMap, day), + calendarFormat: CalendarFormat.month, + availableCalendarFormats: { + CalendarFormat.month: t.calendar.month, + }, + ), + const SizedBox(height: 8.0), + Expanded( + child: ListView.builder( + itemCount: selectedEvents.length, + itemBuilder: (context, index) { + final event = selectedEvents[index]; + final title = event.title ?? t.general.unknown; + final String formattedStart = event.start != null + ? _formatDate(event.start!) + : '?'; + final String formattedEnd = event.end != null + ? _formatDate(event.displayEndDate) + : formattedStart; + + final subtitleText = formattedStart == formattedEnd + ? formattedStart + : '$formattedStart – $formattedEnd'; + + return ListTile( + title: Text(title), + subtitle: Text(subtitleText), + trailing: event.place != null && event.place!.isNotEmpty + ? ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 120), + child: Text( + event.place!, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ) + : null, + ); + }, + ), + ), + ], + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => Center(child: Text(t.errors.occurred)), + ), + ); + } + + List _getEventsForDay( + Map> eventsMap, + DateTime day, + ) { + return eventsMap[dateKey(day.year, day.month, day.day)] ?? []; + } + + /// Formats a [DateTime] as `yyyy-MM-dd` using the `intl` package. + String _formatDate(DateTime date) => DateFormat('yyyy-MM-dd').format(date); +} diff --git a/lib/screens/main/home_screen.dart b/lib/screens/main/home_screen.dart index b5ba044c..7f043e84 100644 --- a/lib/screens/main/home_screen.dart +++ b/lib/screens/main/home_screen.dart @@ -39,6 +39,10 @@ class HomeScreen extends ConsumerWidget { icon: Icon(Icons.switch_access_shortcut_outlined), label: t.nav.portal, ), + NavigationDestination( + icon: Icon(Icons.calendar_month), + label: t.nav.calendar, + ), NavigationDestination( icon: Icon(Icons.account_circle), label: t.nav.profile, diff --git a/lib/services/portal/mock_portal_service.dart b/lib/services/portal/mock_portal_service.dart index 1e291dfb..afd76396 100644 --- a/lib/services/portal/mock_portal_service.dart +++ b/lib/services/portal/mock_portal_service.dart @@ -56,8 +56,8 @@ class MockPortalService implements PortalService { [ ( id: 60561, - start: DateTime.fromMillisecondsSinceEpoch(1753977600000), - end: DateTime.fromMillisecondsSinceEpoch(1754064000000), + start: DateTime(2025, 8, 1), // 1753977600000 + end: DateTime(2025, 8, 2), // 1754064000000 allDay: true, title: '114學年度第1學期開始', place: null, @@ -67,8 +67,8 @@ class MockPortalService implements PortalService { ), ( id: 60574, - start: DateTime.fromMillisecondsSinceEpoch(1757260800000), - end: DateTime.fromMillisecondsSinceEpoch(1757347200000), + start: DateTime(2025, 9, 8), // 1757260800000 + end: DateTime(2025, 9, 9), // 1757347200000 allDay: true, title: '開學暨註冊截止日、開學典禮', place: null, @@ -78,8 +78,8 @@ class MockPortalService implements PortalService { ), ( id: 60581, - start: DateTime.fromMillisecondsSinceEpoch(1759766400000), - end: DateTime.fromMillisecondsSinceEpoch(1759852800000), + start: DateTime(2025, 10, 7), // 1759766400000 + end: DateTime(2025, 10, 8), // 1759852800000 allDay: true, title: '期中撤選開始', place: null, @@ -89,8 +89,8 @@ class MockPortalService implements PortalService { ), ( id: 60582, - start: DateTime.fromMillisecondsSinceEpoch(1759766400000), - end: DateTime.fromMillisecondsSinceEpoch(1759852800000), + start: DateTime(2025, 10, 7), // 1759766400000 + end: DateTime(2025, 10, 8), // 1759852800000 allDay: true, title: '國文會考', place: null, @@ -100,8 +100,8 @@ class MockPortalService implements PortalService { ), ( id: 60589, - start: DateTime.fromMillisecondsSinceEpoch(1762099200000), - end: DateTime.fromMillisecondsSinceEpoch(1762617600000), + start: DateTime(2025, 11, 3), // 1762099200000 + end: DateTime(2025, 11, 9), // 1762617600000 allDay: true, title: '期中考試', place: null, @@ -111,8 +111,8 @@ class MockPortalService implements PortalService { ), ( id: 60591, - start: DateTime.fromMillisecondsSinceEpoch(1764259200000), - end: DateTime.fromMillisecondsSinceEpoch(1764320400000), + start: DateTime(2025, 11, 28, 17, 0), // 1764259200000 + end: DateTime(2025, 11, 29, 17, 0), // 1764320400000 allDay: false, title: '日間部期中撤選結束(17:00 截止)、休退學學生退1/3學雜費截止', place: null, @@ -122,8 +122,8 @@ class MockPortalService implements PortalService { ), ( id: 60603, - start: DateTime.fromMillisecondsSinceEpoch(1767542400000), - end: DateTime.fromMillisecondsSinceEpoch(1768060800000), + start: DateTime(2026, 1, 5), // 1767542400000 + end: DateTime(2026, 1, 11), // 1768060800000 allDay: true, title: '期末考試', place: null, @@ -133,8 +133,8 @@ class MockPortalService implements PortalService { ), ( id: 60605, - start: DateTime.fromMillisecondsSinceEpoch(1768147200000), - end: DateTime.fromMillisecondsSinceEpoch(1768233600000), + start: DateTime(2026, 1, 12), // 1768147200000 + end: DateTime(2026, 1, 13), // 1768233600000 allDay: true, title: '寒假開始、寒宿開始', place: null, diff --git a/lib/services/portal/ntut_portal_service.dart b/lib/services/portal/ntut_portal_service.dart index e12ac842..a26d4815 100644 --- a/lib/services/portal/ntut_portal_service.dart +++ b/lib/services/portal/ntut_portal_service.dart @@ -224,8 +224,23 @@ class NtutPortalService implements PortalService { final List events = jsonDecode(response.data); String? normalizeEmpty(String? value) => value?.isNotEmpty == true ? value : null; - DateTime? fromEpoch(int? ms) => - ms != null ? DateTime.fromMillisecondsSinceEpoch(ms) : null; + DateTime? fromEpoch(int? ms) { + if (ms == null) return null; + // NTUT API returns an epoch that exactly corresponds to UTC+8. + // By forcing it through UTC and adding 8 hours, we get the exact + // year/month/day/hour that the NTUT portal intended, regardless of + // the user's local timezone. We then create a device-local DateTime. + final utc = DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true); + final taipei = utc.add(const Duration(hours: 8)); + return DateTime( + taipei.year, + taipei.month, + taipei.day, + taipei.hour, + taipei.minute, + taipei.second, + ); + } return events .where( diff --git a/pubspec.lock b/pubspec.lock index 401127ce..cf7a8122 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1109,6 +1109,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + simple_gesture_detector: + dependency: transitive + description: + name: simple_gesture_detector + sha256: ba2cd5af24ff20a0b8d609cec3f40e5b0744d2a71804a2616ae086b9c19d19a3 + url: "https://pub.dev" + source: hosted + version: "0.2.1" skeletonizer: dependency: "direct main" description: @@ -1250,6 +1258,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + table_calendar: + dependency: "direct main" + description: + name: table_calendar + sha256: "0c0c6219878b363a2d5f40c7afb159d845f253d061dc3c822aa0d5fe0f721982" + url: "https://pub.dev" + source: hosted + version: "3.2.0" term_glyph: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 9c492850..3e4e6228 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -46,6 +46,7 @@ dependencies: firebase_crashlytics: ^5.0.7 firebase_analytics: ^12.1.2 auto_size_text: ^3.0.0 + table_calendar: ^3.2.0 dev_dependencies: flutter_test: