Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions lib/i18n/en-US.i18n.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion lib/i18n/strings.g.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions lib/i18n/strings_en_US.g.dart
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ class TranslationsEnUs extends Translations with BaseTranslations<AppLocale, Tra
@override late final _TranslationsIntroEnUs intro = _TranslationsIntroEnUs._(_root);
@override late final _TranslationsLoginEnUs login = _TranslationsLoginEnUs._(_root);
@override late final _TranslationsNavEnUs nav = _TranslationsNavEnUs._(_root);
@override late final _TranslationsCalendarEnUs calendar = _TranslationsCalendarEnUs._(_root);
@override late final _TranslationsCourseTableEnUs courseTable = _TranslationsCourseTableEnUs._(_root);
@override late final _TranslationsProfileEnUs profile = _TranslationsProfileEnUs._(_root);
@override late final _TranslationsEnrollmentStatusEnUs enrollmentStatus = _TranslationsEnrollmentStatusEnUs._(_root);
Expand Down Expand Up @@ -130,9 +131,20 @@ class _TranslationsNavEnUs extends TranslationsNavZhTw {
@override String get courseTable => '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);
Expand Down Expand Up @@ -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',
Expand Down
18 changes: 18 additions & 0 deletions lib/i18n/strings_zh_TW.g.dart
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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' => '一',
Expand Down
3 changes: 3 additions & 0 deletions lib/i18n/zh-TW.i18n.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ nav:
courseTable: 課表
scores: 成績
portal: 傳送門
calendar: 行事曆
profile: 我
calendar:
month: 月
courseTable:
notFound: 找不到課表
dayOfWeek(map):
Expand Down
11 changes: 11 additions & 0 deletions lib/router/app_router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -105,6 +107,15 @@ GoRouter createAppRouter({
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: AppRoutes.calendar,
pageBuilder: (context, state) =>
const NoTransitionPage(child: CalendarScreen()),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
Expand Down
135 changes: 135 additions & 0 deletions lib/screens/main/calendar/calendar_providers.dart
Original file line number Diff line number Diff line change
@@ -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<DateTime> {
@override
DateTime build() => DateTime.now();
void updateDate(DateTime day) => state = day;
}

final calendarFocusedDayProvider = NotifierProvider<DateTimeNotifier, DateTime>(
DateTimeNotifier.new,
);

final calendarSelectedDayProvider =
NotifierProvider<DateTimeNotifier, DateTime>(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<DateTimeRange> {
@override
DateTimeRange build() => threeMonthWindow(DateTime.now());
void updateRange(DateTimeRange range) => state = range;
}

final calendarRangeProvider =
NotifierProvider<CalendarRangeNotifier, DateTimeRange>(
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<Map<int, List<CalendarEvent>>>((
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 = <int, List<CalendarEvent>>{};
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;
});
Loading
Loading