From dfcef4f9ceae26a667ec844bb0405ce49247c798 Mon Sep 17 00:00:00 2001 From: kevinlee-06 Date: Wed, 25 Mar 2026 11:01:08 +0800 Subject: [PATCH 1/6] feat: implement CalendarRepository with academic year caching --- lib/database/actions.dart | 6 + lib/database/database.dart | 1 + lib/database/database.g.dart | 1007 ++++++++++++++++++++- lib/database/schema.dart | 38 + lib/repositories/calendar_repository.dart | 145 +++ 5 files changed, 1194 insertions(+), 3 deletions(-) create mode 100644 lib/repositories/calendar_repository.dart diff --git a/lib/database/actions.dart b/lib/database/actions.dart index 56c7574b..c7de668c 100644 --- a/lib/database/actions.dart +++ b/lib/database/actions.dart @@ -3,6 +3,11 @@ import 'package:tattoo/database/database.dart'; /// Reusable database operations shared across repositories. extension DatabaseActions on AppDatabase { + /// Returns the authenticated user's profile. + Future getUser() { + return select(users).getSingleOrNull(); + } + /// Drops and recreates all tables, fully resetting the database. Future deleteEverything() async { await transaction(() async { @@ -32,6 +37,7 @@ extension DatabaseActions on AppDatabase { fetchedAt: Value(null), semestersFetchedAt: Value(null), scoreDataFetchedAt: Value(null), + calendarFetchedAt: Value(null), ), ); }); diff --git a/lib/database/database.dart b/lib/database/database.dart index 2770d2ab..629de19f 100644 --- a/lib/database/database.dart +++ b/lib/database/database.dart @@ -48,6 +48,7 @@ final databaseProvider = Provider((ref) { UserSemesterSummaryTutors, UserSemesterSummaryCadreRoles, UserSemesterRankings, + CalendarEvents, ], views: [ CourseTableSlots, diff --git a/lib/database/database.g.dart b/lib/database/database.g.dart index 69178b71..2135fb35 100644 --- a/lib/database/database.g.dart +++ b/lib/database/database.g.dart @@ -169,6 +169,18 @@ class $UsersTable extends Users with TableInfo<$UsersTable, User> { type: DriftSqlType.dateTime, requiredDuringInsert: false, ); + static const VerificationMeta _calendarFetchedAtMeta = const VerificationMeta( + 'calendarFetchedAt', + ); + @override + late final GeneratedColumn calendarFetchedAt = + GeneratedColumn( + 'calendar_fetched_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); @override List get $columns => [ id, @@ -186,6 +198,7 @@ class $UsersTable extends Users with TableInfo<$UsersTable, User> { passwordExpiresInDays, semestersFetchedAt, scoreDataFetchedAt, + calendarFetchedAt, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -315,6 +328,15 @@ class $UsersTable extends Users with TableInfo<$UsersTable, User> { ), ); } + if (data.containsKey('calendar_fetched_at')) { + context.handle( + _calendarFetchedAtMeta, + calendarFetchedAt.isAcceptableOrUnknown( + data['calendar_fetched_at']!, + _calendarFetchedAtMeta, + ), + ); + } return context; } @@ -384,6 +406,10 @@ class $UsersTable extends Users with TableInfo<$UsersTable, User> { DriftSqlType.dateTime, data['${effectivePrefix}score_data_fetched_at'], ), + calendarFetchedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}calendar_fetched_at'], + ), ); } @@ -448,6 +474,9 @@ class User extends DataClass implements Insertable { /// When score-related academic data was last fetched from student query. final DateTime? scoreDataFetchedAt; + + /// When the academic calendar was last fetched from the portal. + final DateTime? calendarFetchedAt; const User({ required this.id, this.fetchedAt, @@ -464,6 +493,7 @@ class User extends DataClass implements Insertable { this.passwordExpiresInDays, this.semestersFetchedAt, this.scoreDataFetchedAt, + this.calendarFetchedAt, }); @override Map toColumns(bool nullToAbsent) { @@ -503,6 +533,9 @@ class User extends DataClass implements Insertable { if (!nullToAbsent || scoreDataFetchedAt != null) { map['score_data_fetched_at'] = Variable(scoreDataFetchedAt); } + if (!nullToAbsent || calendarFetchedAt != null) { + map['calendar_fetched_at'] = Variable(calendarFetchedAt); + } return map; } @@ -543,6 +576,9 @@ class User extends DataClass implements Insertable { scoreDataFetchedAt: scoreDataFetchedAt == null && nullToAbsent ? const Value.absent() : Value(scoreDataFetchedAt), + calendarFetchedAt: calendarFetchedAt == null && nullToAbsent + ? const Value.absent() + : Value(calendarFetchedAt), ); } @@ -573,6 +609,9 @@ class User extends DataClass implements Insertable { scoreDataFetchedAt: serializer.fromJson( json['scoreDataFetchedAt'], ), + calendarFetchedAt: serializer.fromJson( + json['calendarFetchedAt'], + ), ); } @override @@ -594,6 +633,7 @@ class User extends DataClass implements Insertable { 'passwordExpiresInDays': serializer.toJson(passwordExpiresInDays), 'semestersFetchedAt': serializer.toJson(semestersFetchedAt), 'scoreDataFetchedAt': serializer.toJson(scoreDataFetchedAt), + 'calendarFetchedAt': serializer.toJson(calendarFetchedAt), }; } @@ -613,6 +653,7 @@ class User extends DataClass implements Insertable { Value passwordExpiresInDays = const Value.absent(), Value semestersFetchedAt = const Value.absent(), Value scoreDataFetchedAt = const Value.absent(), + Value calendarFetchedAt = const Value.absent(), }) => User( id: id ?? this.id, fetchedAt: fetchedAt.present ? fetchedAt.value : this.fetchedAt, @@ -635,6 +676,9 @@ class User extends DataClass implements Insertable { scoreDataFetchedAt: scoreDataFetchedAt.present ? scoreDataFetchedAt.value : this.scoreDataFetchedAt, + calendarFetchedAt: calendarFetchedAt.present + ? calendarFetchedAt.value + : this.calendarFetchedAt, ); User copyWithCompanion(UsersCompanion data) { return User( @@ -667,6 +711,9 @@ class User extends DataClass implements Insertable { scoreDataFetchedAt: data.scoreDataFetchedAt.present ? data.scoreDataFetchedAt.value : this.scoreDataFetchedAt, + calendarFetchedAt: data.calendarFetchedAt.present + ? data.calendarFetchedAt.value + : this.calendarFetchedAt, ); } @@ -687,7 +734,8 @@ class User extends DataClass implements Insertable { ..write('email: $email, ') ..write('passwordExpiresInDays: $passwordExpiresInDays, ') ..write('semestersFetchedAt: $semestersFetchedAt, ') - ..write('scoreDataFetchedAt: $scoreDataFetchedAt') + ..write('scoreDataFetchedAt: $scoreDataFetchedAt, ') + ..write('calendarFetchedAt: $calendarFetchedAt') ..write(')')) .toString(); } @@ -709,6 +757,7 @@ class User extends DataClass implements Insertable { passwordExpiresInDays, semestersFetchedAt, scoreDataFetchedAt, + calendarFetchedAt, ); @override bool operator ==(Object other) => @@ -728,7 +777,8 @@ class User extends DataClass implements Insertable { other.email == this.email && other.passwordExpiresInDays == this.passwordExpiresInDays && other.semestersFetchedAt == this.semestersFetchedAt && - other.scoreDataFetchedAt == this.scoreDataFetchedAt); + other.scoreDataFetchedAt == this.scoreDataFetchedAt && + other.calendarFetchedAt == this.calendarFetchedAt); } class UsersCompanion extends UpdateCompanion { @@ -747,6 +797,7 @@ class UsersCompanion extends UpdateCompanion { final Value passwordExpiresInDays; final Value semestersFetchedAt; final Value scoreDataFetchedAt; + final Value calendarFetchedAt; const UsersCompanion({ this.id = const Value.absent(), this.fetchedAt = const Value.absent(), @@ -763,6 +814,7 @@ class UsersCompanion extends UpdateCompanion { this.passwordExpiresInDays = const Value.absent(), this.semestersFetchedAt = const Value.absent(), this.scoreDataFetchedAt = const Value.absent(), + this.calendarFetchedAt = const Value.absent(), }); UsersCompanion.insert({ this.id = const Value.absent(), @@ -780,6 +832,7 @@ class UsersCompanion extends UpdateCompanion { this.passwordExpiresInDays = const Value.absent(), this.semestersFetchedAt = const Value.absent(), this.scoreDataFetchedAt = const Value.absent(), + this.calendarFetchedAt = const Value.absent(), }) : studentId = Value(studentId), nameZh = Value(nameZh), avatarFilename = Value(avatarFilename), @@ -800,6 +853,7 @@ class UsersCompanion extends UpdateCompanion { Expression? passwordExpiresInDays, Expression? semestersFetchedAt, Expression? scoreDataFetchedAt, + Expression? calendarFetchedAt, }) { return RawValuesInsertable({ if (id != null) 'id': id, @@ -820,6 +874,7 @@ class UsersCompanion extends UpdateCompanion { 'semesters_fetched_at': semestersFetchedAt, if (scoreDataFetchedAt != null) 'score_data_fetched_at': scoreDataFetchedAt, + if (calendarFetchedAt != null) 'calendar_fetched_at': calendarFetchedAt, }); } @@ -839,6 +894,7 @@ class UsersCompanion extends UpdateCompanion { Value? passwordExpiresInDays, Value? semestersFetchedAt, Value? scoreDataFetchedAt, + Value? calendarFetchedAt, }) { return UsersCompanion( id: id ?? this.id, @@ -857,6 +913,7 @@ class UsersCompanion extends UpdateCompanion { passwordExpiresInDays ?? this.passwordExpiresInDays, semestersFetchedAt: semestersFetchedAt ?? this.semestersFetchedAt, scoreDataFetchedAt: scoreDataFetchedAt ?? this.scoreDataFetchedAt, + calendarFetchedAt: calendarFetchedAt ?? this.calendarFetchedAt, ); } @@ -914,6 +971,9 @@ class UsersCompanion extends UpdateCompanion { scoreDataFetchedAt.value, ); } + if (calendarFetchedAt.present) { + map['calendar_fetched_at'] = Variable(calendarFetchedAt.value); + } return map; } @@ -934,7 +994,8 @@ class UsersCompanion extends UpdateCompanion { ..write('email: $email, ') ..write('passwordExpiresInDays: $passwordExpiresInDays, ') ..write('semestersFetchedAt: $semestersFetchedAt, ') - ..write('scoreDataFetchedAt: $scoreDataFetchedAt') + ..write('scoreDataFetchedAt: $scoreDataFetchedAt, ') + ..write('calendarFetchedAt: $calendarFetchedAt') ..write(')')) .toString(); } @@ -9538,6 +9599,628 @@ class UserSemesterRankingsCompanion } } +class $CalendarEventsTable extends CalendarEvents + with TableInfo<$CalendarEventsTable, CalendarEvent> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $CalendarEventsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + static const VerificationMeta _portalIdMeta = const VerificationMeta( + 'portalId', + ); + @override + late final GeneratedColumn portalId = GeneratedColumn( + 'portal_id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways('UNIQUE'), + ); + static const VerificationMeta _startMeta = const VerificationMeta('start'); + @override + late final GeneratedColumn start = GeneratedColumn( + 'start', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _endMeta = const VerificationMeta('end'); + @override + late final GeneratedColumn end = GeneratedColumn( + 'end', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _allDayMeta = const VerificationMeta('allDay'); + @override + late final GeneratedColumn allDay = GeneratedColumn( + 'all_day', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("all_day" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); + static const VerificationMeta _titleMeta = const VerificationMeta('title'); + @override + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _placeMeta = const VerificationMeta('place'); + @override + late final GeneratedColumn place = GeneratedColumn( + 'place', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _contentMeta = const VerificationMeta( + 'content', + ); + @override + late final GeneratedColumn content = GeneratedColumn( + 'content', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _ownerNameMeta = const VerificationMeta( + 'ownerName', + ); + @override + late final GeneratedColumn ownerName = GeneratedColumn( + 'owner_name', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _creatorNameMeta = const VerificationMeta( + 'creatorName', + ); + @override + late final GeneratedColumn creatorName = GeneratedColumn( + 'creator_name', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + portalId, + start, + end, + allDay, + title, + place, + content, + ownerName, + creatorName, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'calendar_events'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('portal_id')) { + context.handle( + _portalIdMeta, + portalId.isAcceptableOrUnknown(data['portal_id']!, _portalIdMeta), + ); + } else if (isInserting) { + context.missing(_portalIdMeta); + } + if (data.containsKey('start')) { + context.handle( + _startMeta, + start.isAcceptableOrUnknown(data['start']!, _startMeta), + ); + } + if (data.containsKey('end')) { + context.handle( + _endMeta, + end.isAcceptableOrUnknown(data['end']!, _endMeta), + ); + } + if (data.containsKey('all_day')) { + context.handle( + _allDayMeta, + allDay.isAcceptableOrUnknown(data['all_day']!, _allDayMeta), + ); + } + if (data.containsKey('title')) { + context.handle( + _titleMeta, + title.isAcceptableOrUnknown(data['title']!, _titleMeta), + ); + } + if (data.containsKey('place')) { + context.handle( + _placeMeta, + place.isAcceptableOrUnknown(data['place']!, _placeMeta), + ); + } + if (data.containsKey('content')) { + context.handle( + _contentMeta, + content.isAcceptableOrUnknown(data['content']!, _contentMeta), + ); + } + if (data.containsKey('owner_name')) { + context.handle( + _ownerNameMeta, + ownerName.isAcceptableOrUnknown(data['owner_name']!, _ownerNameMeta), + ); + } + if (data.containsKey('creator_name')) { + context.handle( + _creatorNameMeta, + creatorName.isAcceptableOrUnknown( + data['creator_name']!, + _creatorNameMeta, + ), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + CalendarEvent map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return CalendarEvent( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + portalId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}portal_id'], + )!, + start: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}start'], + ), + end: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}end'], + ), + allDay: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}all_day'], + )!, + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + ), + place: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}place'], + ), + content: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}content'], + ), + ownerName: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}owner_name'], + ), + creatorName: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}creator_name'], + ), + ); + } + + @override + $CalendarEventsTable createAlias(String alias) { + return $CalendarEventsTable(attachedDatabase, alias); + } +} + +class CalendarEvent extends DataClass implements Insertable { + /// Auto-incrementing primary key. + final int id; + + /// Unique event ID from NTUT Portal. + /// + /// Nullable in DTO, but we expect it for syncing. We use our own auto-increment + /// ID as the primary key and this as a unique constraint to avoid duplicates. + final int portalId; + + /// Event start time. + final DateTime? start; + + /// Event end time. + final DateTime? end; + + /// Whether this is an all-day event. + final bool allDay; + + /// Event title / description. + final String? title; + + /// Event location. + final String? place; + + /// Event content / details. + final String? content; + + /// Owner name (e.g., "學校行事曆"). + final String? ownerName; + + /// Creator name (e.g., "教務處"). + final String? creatorName; + const CalendarEvent({ + required this.id, + required this.portalId, + this.start, + this.end, + required this.allDay, + this.title, + this.place, + this.content, + this.ownerName, + this.creatorName, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['portal_id'] = Variable(portalId); + if (!nullToAbsent || start != null) { + map['start'] = Variable(start); + } + if (!nullToAbsent || end != null) { + map['end'] = Variable(end); + } + map['all_day'] = Variable(allDay); + if (!nullToAbsent || title != null) { + map['title'] = Variable(title); + } + if (!nullToAbsent || place != null) { + map['place'] = Variable(place); + } + if (!nullToAbsent || content != null) { + map['content'] = Variable(content); + } + if (!nullToAbsent || ownerName != null) { + map['owner_name'] = Variable(ownerName); + } + if (!nullToAbsent || creatorName != null) { + map['creator_name'] = Variable(creatorName); + } + return map; + } + + CalendarEventsCompanion toCompanion(bool nullToAbsent) { + return CalendarEventsCompanion( + id: Value(id), + portalId: Value(portalId), + start: start == null && nullToAbsent + ? const Value.absent() + : Value(start), + end: end == null && nullToAbsent ? const Value.absent() : Value(end), + allDay: Value(allDay), + title: title == null && nullToAbsent + ? const Value.absent() + : Value(title), + place: place == null && nullToAbsent + ? const Value.absent() + : Value(place), + content: content == null && nullToAbsent + ? const Value.absent() + : Value(content), + ownerName: ownerName == null && nullToAbsent + ? const Value.absent() + : Value(ownerName), + creatorName: creatorName == null && nullToAbsent + ? const Value.absent() + : Value(creatorName), + ); + } + + factory CalendarEvent.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return CalendarEvent( + id: serializer.fromJson(json['id']), + portalId: serializer.fromJson(json['portalId']), + start: serializer.fromJson(json['start']), + end: serializer.fromJson(json['end']), + allDay: serializer.fromJson(json['allDay']), + title: serializer.fromJson(json['title']), + place: serializer.fromJson(json['place']), + content: serializer.fromJson(json['content']), + ownerName: serializer.fromJson(json['ownerName']), + creatorName: serializer.fromJson(json['creatorName']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'portalId': serializer.toJson(portalId), + 'start': serializer.toJson(start), + 'end': serializer.toJson(end), + 'allDay': serializer.toJson(allDay), + 'title': serializer.toJson(title), + 'place': serializer.toJson(place), + 'content': serializer.toJson(content), + 'ownerName': serializer.toJson(ownerName), + 'creatorName': serializer.toJson(creatorName), + }; + } + + CalendarEvent copyWith({ + int? id, + int? portalId, + Value start = const Value.absent(), + Value end = const Value.absent(), + bool? allDay, + Value title = const Value.absent(), + Value place = const Value.absent(), + Value content = const Value.absent(), + Value ownerName = const Value.absent(), + Value creatorName = const Value.absent(), + }) => CalendarEvent( + id: id ?? this.id, + portalId: portalId ?? this.portalId, + start: start.present ? start.value : this.start, + end: end.present ? end.value : this.end, + allDay: allDay ?? this.allDay, + title: title.present ? title.value : this.title, + place: place.present ? place.value : this.place, + content: content.present ? content.value : this.content, + ownerName: ownerName.present ? ownerName.value : this.ownerName, + creatorName: creatorName.present ? creatorName.value : this.creatorName, + ); + CalendarEvent copyWithCompanion(CalendarEventsCompanion data) { + return CalendarEvent( + id: data.id.present ? data.id.value : this.id, + portalId: data.portalId.present ? data.portalId.value : this.portalId, + start: data.start.present ? data.start.value : this.start, + end: data.end.present ? data.end.value : this.end, + allDay: data.allDay.present ? data.allDay.value : this.allDay, + title: data.title.present ? data.title.value : this.title, + place: data.place.present ? data.place.value : this.place, + content: data.content.present ? data.content.value : this.content, + ownerName: data.ownerName.present ? data.ownerName.value : this.ownerName, + creatorName: data.creatorName.present + ? data.creatorName.value + : this.creatorName, + ); + } + + @override + String toString() { + return (StringBuffer('CalendarEvent(') + ..write('id: $id, ') + ..write('portalId: $portalId, ') + ..write('start: $start, ') + ..write('end: $end, ') + ..write('allDay: $allDay, ') + ..write('title: $title, ') + ..write('place: $place, ') + ..write('content: $content, ') + ..write('ownerName: $ownerName, ') + ..write('creatorName: $creatorName') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + portalId, + start, + end, + allDay, + title, + place, + content, + ownerName, + creatorName, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is CalendarEvent && + other.id == this.id && + other.portalId == this.portalId && + other.start == this.start && + other.end == this.end && + other.allDay == this.allDay && + other.title == this.title && + other.place == this.place && + other.content == this.content && + other.ownerName == this.ownerName && + other.creatorName == this.creatorName); +} + +class CalendarEventsCompanion extends UpdateCompanion { + final Value id; + final Value portalId; + final Value start; + final Value end; + final Value allDay; + final Value title; + final Value place; + final Value content; + final Value ownerName; + final Value creatorName; + const CalendarEventsCompanion({ + this.id = const Value.absent(), + this.portalId = const Value.absent(), + this.start = const Value.absent(), + this.end = const Value.absent(), + this.allDay = const Value.absent(), + this.title = const Value.absent(), + this.place = const Value.absent(), + this.content = const Value.absent(), + this.ownerName = const Value.absent(), + this.creatorName = const Value.absent(), + }); + CalendarEventsCompanion.insert({ + this.id = const Value.absent(), + required int portalId, + this.start = const Value.absent(), + this.end = const Value.absent(), + this.allDay = const Value.absent(), + this.title = const Value.absent(), + this.place = const Value.absent(), + this.content = const Value.absent(), + this.ownerName = const Value.absent(), + this.creatorName = const Value.absent(), + }) : portalId = Value(portalId); + static Insertable custom({ + Expression? id, + Expression? portalId, + Expression? start, + Expression? end, + Expression? allDay, + Expression? title, + Expression? place, + Expression? content, + Expression? ownerName, + Expression? creatorName, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (portalId != null) 'portal_id': portalId, + if (start != null) 'start': start, + if (end != null) 'end': end, + if (allDay != null) 'all_day': allDay, + if (title != null) 'title': title, + if (place != null) 'place': place, + if (content != null) 'content': content, + if (ownerName != null) 'owner_name': ownerName, + if (creatorName != null) 'creator_name': creatorName, + }); + } + + CalendarEventsCompanion copyWith({ + Value? id, + Value? portalId, + Value? start, + Value? end, + Value? allDay, + Value? title, + Value? place, + Value? content, + Value? ownerName, + Value? creatorName, + }) { + return CalendarEventsCompanion( + id: id ?? this.id, + portalId: portalId ?? this.portalId, + start: start ?? this.start, + end: end ?? this.end, + allDay: allDay ?? this.allDay, + title: title ?? this.title, + place: place ?? this.place, + content: content ?? this.content, + ownerName: ownerName ?? this.ownerName, + creatorName: creatorName ?? this.creatorName, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (portalId.present) { + map['portal_id'] = Variable(portalId.value); + } + if (start.present) { + map['start'] = Variable(start.value); + } + if (end.present) { + map['end'] = Variable(end.value); + } + if (allDay.present) { + map['all_day'] = Variable(allDay.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (place.present) { + map['place'] = Variable(place.value); + } + if (content.present) { + map['content'] = Variable(content.value); + } + if (ownerName.present) { + map['owner_name'] = Variable(ownerName.value); + } + if (creatorName.present) { + map['creator_name'] = Variable(creatorName.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('CalendarEventsCompanion(') + ..write('id: $id, ') + ..write('portalId: $portalId, ') + ..write('start: $start, ') + ..write('end: $end, ') + ..write('allDay: $allDay, ') + ..write('title: $title, ') + ..write('place: $place, ') + ..write('content: $content, ') + ..write('ownerName: $ownerName, ') + ..write('creatorName: $creatorName') + ..write(')')) + .toString(); + } +} + class CourseTableSlot extends DataClass { final int id; final String number; @@ -10659,6 +11342,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { $UserSemesterSummaryCadreRolesTable(this); late final $UserSemesterRankingsTable userSemesterRankings = $UserSemesterRankingsTable(this); + late final $CalendarEventsTable calendarEvents = $CalendarEventsTable(this); late final $CourseTableSlotsView courseTableSlots = $CourseTableSlotsView( this, ); @@ -10730,6 +11414,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { userSemesterSummaryTutors, userSemesterSummaryCadreRoles, userSemesterRankings, + calendarEvents, courseTableSlots, scoreDetails, userAcademicSummaries, @@ -10847,6 +11532,7 @@ typedef $$UsersTableCreateCompanionBuilder = Value passwordExpiresInDays, Value semestersFetchedAt, Value scoreDataFetchedAt, + Value calendarFetchedAt, }); typedef $$UsersTableUpdateCompanionBuilder = UsersCompanion Function({ @@ -10865,6 +11551,7 @@ typedef $$UsersTableUpdateCompanionBuilder = Value passwordExpiresInDays, Value semestersFetchedAt, Value scoreDataFetchedAt, + Value calendarFetchedAt, }); final class $$UsersTableReferences @@ -11002,6 +11689,11 @@ class $$UsersTableFilterComposer extends Composer<_$AppDatabase, $UsersTable> { builder: (column) => ColumnFilters(column), ); + ColumnFilters get calendarFetchedAt => $composableBuilder( + column: $table.calendarFetchedAt, + builder: (column) => ColumnFilters(column), + ); + Expression scoresRefs( Expression Function($$ScoresTableFilterComposer f) f, ) { @@ -11137,6 +11829,11 @@ class $$UsersTableOrderingComposer column: $table.scoreDataFetchedAt, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get calendarFetchedAt => $composableBuilder( + column: $table.calendarFetchedAt, + builder: (column) => ColumnOrderings(column), + ); } class $$UsersTableAnnotationComposer @@ -11207,6 +11904,11 @@ class $$UsersTableAnnotationComposer builder: (column) => column, ); + GeneratedColumn get calendarFetchedAt => $composableBuilder( + column: $table.calendarFetchedAt, + builder: (column) => column, + ); + Expression scoresRefs( Expression Function($$ScoresTableAnnotationComposer a) f, ) { @@ -11305,6 +12007,7 @@ class $$UsersTableTableManager Value passwordExpiresInDays = const Value.absent(), Value semestersFetchedAt = const Value.absent(), Value scoreDataFetchedAt = const Value.absent(), + Value calendarFetchedAt = const Value.absent(), }) => UsersCompanion( id: id, fetchedAt: fetchedAt, @@ -11321,6 +12024,7 @@ class $$UsersTableTableManager passwordExpiresInDays: passwordExpiresInDays, semestersFetchedAt: semestersFetchedAt, scoreDataFetchedAt: scoreDataFetchedAt, + calendarFetchedAt: calendarFetchedAt, ), createCompanionCallback: ({ @@ -11339,6 +12043,7 @@ class $$UsersTableTableManager Value passwordExpiresInDays = const Value.absent(), Value semestersFetchedAt = const Value.absent(), Value scoreDataFetchedAt = const Value.absent(), + Value calendarFetchedAt = const Value.absent(), }) => UsersCompanion.insert( id: id, fetchedAt: fetchedAt, @@ -11355,6 +12060,7 @@ class $$UsersTableTableManager passwordExpiresInDays: passwordExpiresInDays, semestersFetchedAt: semestersFetchedAt, scoreDataFetchedAt: scoreDataFetchedAt, + calendarFetchedAt: calendarFetchedAt, ), withReferenceMapper: (p0) => p0 .map( @@ -21224,6 +21930,299 @@ typedef $$UserSemesterRankingsTableProcessedTableManager = UserSemesterRanking, PrefetchHooks Function({bool summary}) >; +typedef $$CalendarEventsTableCreateCompanionBuilder = + CalendarEventsCompanion Function({ + Value id, + required int portalId, + Value start, + Value end, + Value allDay, + Value title, + Value place, + Value content, + Value ownerName, + Value creatorName, + }); +typedef $$CalendarEventsTableUpdateCompanionBuilder = + CalendarEventsCompanion Function({ + Value id, + Value portalId, + Value start, + Value end, + Value allDay, + Value title, + Value place, + Value content, + Value ownerName, + Value creatorName, + }); + +class $$CalendarEventsTableFilterComposer + extends Composer<_$AppDatabase, $CalendarEventsTable> { + $$CalendarEventsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get portalId => $composableBuilder( + column: $table.portalId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get start => $composableBuilder( + column: $table.start, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get end => $composableBuilder( + column: $table.end, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get allDay => $composableBuilder( + column: $table.allDay, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get place => $composableBuilder( + column: $table.place, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get content => $composableBuilder( + column: $table.content, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get ownerName => $composableBuilder( + column: $table.ownerName, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get creatorName => $composableBuilder( + column: $table.creatorName, + builder: (column) => ColumnFilters(column), + ); +} + +class $$CalendarEventsTableOrderingComposer + extends Composer<_$AppDatabase, $CalendarEventsTable> { + $$CalendarEventsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get portalId => $composableBuilder( + column: $table.portalId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get start => $composableBuilder( + column: $table.start, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get end => $composableBuilder( + column: $table.end, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get allDay => $composableBuilder( + column: $table.allDay, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get place => $composableBuilder( + column: $table.place, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get content => $composableBuilder( + column: $table.content, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get ownerName => $composableBuilder( + column: $table.ownerName, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get creatorName => $composableBuilder( + column: $table.creatorName, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$CalendarEventsTableAnnotationComposer + extends Composer<_$AppDatabase, $CalendarEventsTable> { + $$CalendarEventsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get portalId => + $composableBuilder(column: $table.portalId, builder: (column) => column); + + GeneratedColumn get start => + $composableBuilder(column: $table.start, builder: (column) => column); + + GeneratedColumn get end => + $composableBuilder(column: $table.end, builder: (column) => column); + + GeneratedColumn get allDay => + $composableBuilder(column: $table.allDay, builder: (column) => column); + + GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); + + GeneratedColumn get place => + $composableBuilder(column: $table.place, builder: (column) => column); + + GeneratedColumn get content => + $composableBuilder(column: $table.content, builder: (column) => column); + + GeneratedColumn get ownerName => + $composableBuilder(column: $table.ownerName, builder: (column) => column); + + GeneratedColumn get creatorName => $composableBuilder( + column: $table.creatorName, + builder: (column) => column, + ); +} + +class $$CalendarEventsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $CalendarEventsTable, + CalendarEvent, + $$CalendarEventsTableFilterComposer, + $$CalendarEventsTableOrderingComposer, + $$CalendarEventsTableAnnotationComposer, + $$CalendarEventsTableCreateCompanionBuilder, + $$CalendarEventsTableUpdateCompanionBuilder, + ( + CalendarEvent, + BaseReferences<_$AppDatabase, $CalendarEventsTable, CalendarEvent>, + ), + CalendarEvent, + PrefetchHooks Function() + > { + $$CalendarEventsTableTableManager( + _$AppDatabase db, + $CalendarEventsTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$CalendarEventsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$CalendarEventsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$CalendarEventsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value portalId = const Value.absent(), + Value start = const Value.absent(), + Value end = const Value.absent(), + Value allDay = const Value.absent(), + Value title = const Value.absent(), + Value place = const Value.absent(), + Value content = const Value.absent(), + Value ownerName = const Value.absent(), + Value creatorName = const Value.absent(), + }) => CalendarEventsCompanion( + id: id, + portalId: portalId, + start: start, + end: end, + allDay: allDay, + title: title, + place: place, + content: content, + ownerName: ownerName, + creatorName: creatorName, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required int portalId, + Value start = const Value.absent(), + Value end = const Value.absent(), + Value allDay = const Value.absent(), + Value title = const Value.absent(), + Value place = const Value.absent(), + Value content = const Value.absent(), + Value ownerName = const Value.absent(), + Value creatorName = const Value.absent(), + }) => CalendarEventsCompanion.insert( + id: id, + portalId: portalId, + start: start, + end: end, + allDay: allDay, + title: title, + place: place, + content: content, + ownerName: ownerName, + creatorName: creatorName, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$CalendarEventsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $CalendarEventsTable, + CalendarEvent, + $$CalendarEventsTableFilterComposer, + $$CalendarEventsTableOrderingComposer, + $$CalendarEventsTableAnnotationComposer, + $$CalendarEventsTableCreateCompanionBuilder, + $$CalendarEventsTableUpdateCompanionBuilder, + ( + CalendarEvent, + BaseReferences<_$AppDatabase, $CalendarEventsTable, CalendarEvent>, + ), + CalendarEvent, + PrefetchHooks Function() + >; class $AppDatabaseManager { final _$AppDatabase _db; @@ -21283,4 +22282,6 @@ class $AppDatabaseManager { ); $$UserSemesterRankingsTableTableManager get userSemesterRankings => $$UserSemesterRankingsTableTableManager(_db, _db.userSemesterRankings); + $$CalendarEventsTableTableManager get calendarEvents => + $$CalendarEventsTableTableManager(_db, _db.calendarEvents); } diff --git a/lib/database/schema.dart b/lib/database/schema.dart index 08920973..a9fd2cd6 100644 --- a/lib/database/schema.dart +++ b/lib/database/schema.dart @@ -98,6 +98,9 @@ class Users extends Table with AutoIncrementId, Fetchable { /// When score-related academic data was last fetched from student query. late final scoreDataFetchedAt = dateTime().nullable()(); + + /// When the academic calendar was last fetched from the portal. + late final calendarFetchedAt = dateTime().nullable()(); } /// Student seen in an I-School Plus course roster. @@ -670,3 +673,38 @@ class Materials extends Table with AutoIncrementId { {courseOffering, href}, ]; } + +/// Academic calendar events from the NTUT portal. +/// +/// Data source: PortalService.getCalendar() +class CalendarEvents extends Table with AutoIncrementId { + /// Unique event ID from NTUT Portal. + /// + /// Nullable in DTO, but we expect it for syncing. We use our own auto-increment + /// ID as the primary key and this as a unique constraint to avoid duplicates. + late final portalId = integer().unique()(); + + /// Event start time. + late final start = dateTime().nullable()(); + + /// Event end time. + late final end = dateTime().nullable()(); + + /// Whether this is an all-day event. + late final allDay = boolean().withDefault(const Constant(false))(); + + /// Event title / description. + late final title = text().nullable()(); + + /// Event location. + late final place = text().nullable()(); + + /// Event content / details. + late final content = text().nullable()(); + + /// Owner name (e.g., "學校行事曆"). + late final ownerName = text().nullable()(); + + /// Creator name (e.g., "教務處"). + late final creatorName = text().nullable()(); +} diff --git a/lib/repositories/calendar_repository.dart b/lib/repositories/calendar_repository.dart new file mode 100644 index 00000000..83bb6889 --- /dev/null +++ b/lib/repositories/calendar_repository.dart @@ -0,0 +1,145 @@ +import 'package:drift/drift.dart'; +import 'package:riverpod/riverpod.dart'; +import 'package:tattoo/database/database.dart'; +import 'package:tattoo/repositories/auth_repository.dart'; +import 'package:tattoo/services/portal/portal_service.dart'; +import 'package:tattoo/utils/fetch_with_ttl.dart'; + +/// Provides the [CalendarRepository] instance. +final calendarRepositoryProvider = Provider((ref) { + // Clear repository state when the session ends + ref.watch(sessionProvider); + + return CalendarRepository( + portalService: ref.watch(portalServiceProvider), + database: ref.watch(databaseProvider), + authRepository: ref.watch(authRepositoryProvider), + ); +}); + +/// Manages academic calendar events from the NTUT portal. +class CalendarRepository { + final PortalService _portalService; + final AppDatabase _database; + final AuthRepository _authRepository; + + CalendarRepository({ + required PortalService portalService, + required AppDatabase database, + required AuthRepository authRepository, + }) : _portalService = portalService, + _database = database, + _authRepository = authRepository; + + /// Gets academic calendar events for the given date range. + /// + /// Returns cached data if fresh (within TTL). Set [refresh] to `true` to + /// bypass TTL (pull-to-refresh). + Future> getCalendar({ + required DateTime startDate, + required DateTime endDate, + bool refresh = false, + }) async { + final user = await _database.getUser(); + final cached = + await (_database.select(_database.calendarEvents) + ..where((e) { + // Include events that overlap with the range + return e.start.isSmallerOrEqualValue(endDate) & + e.end.isGreaterOrEqualValue(startDate); + }) + ..orderBy([(e) => OrderingTerm.asc(e.start)])) + .get(); + + return fetchWithTtl>( + // If we have any cached data for this range, pass it to TTL check. + // Wide-range fetching ensures that if we have partial data, we likely + // have the full academic year cached. + cached: cached.isEmpty ? null : cached, + getFetchedAt: (_) => user?.calendarFetchedAt, + fetchFromNetwork: () => _fetchCalendarFromNetwork(startDate, endDate), + refresh: refresh, + ); + } + + Future> _fetchCalendarFromNetwork( + DateTime startDate, + DateTime endDate, + ) async { + // Recommendation 1: Fetch a wide range (full academic year) to ensure a + // complete cache for the entire year, preventing partial range bugs. + // NTUT academic years run from Aug 1 to July 31. + final wideStartDate = startDate.month < 8 + ? DateTime(startDate.year - 1, 8, 1) + : DateTime(startDate.year, 8, 1); + final wideEndDate = DateTime(wideStartDate.year + 1, 7, 31); + + final dtos = await _authRepository.withAuth( + () => _portalService.getCalendar(wideStartDate, wideEndDate), + ); + + await _database.transaction(() async { + final portalIds = dtos.map((e) => e.id).whereType().toSet(); + + for (final dto in dtos) { + final id = dto.id; + if (id == null) + continue; // Recommendation 2: Handle non-nullable portalId + + await _database + .into(_database.calendarEvents) + .insert( + CalendarEventsCompanion.insert( + portalId: Value(id), + start: Value(dto.start), + end: Value(dto.end), + allDay: Value(dto.allDay), + title: Value(dto.title), + place: Value(dto.place), + content: Value(dto.content), + ownerName: Value(dto.ownerName), + creatorName: Value(dto.creatorName), + ), + onConflict: DoUpdate( + (old) => CalendarEventsCompanion( + start: Value(dto.start), + end: Value(dto.end), + allDay: Value(dto.allDay), + title: Value(dto.title), + place: Value(dto.place), + content: Value(dto.content), + ownerName: Value(dto.ownerName), + creatorName: Value(dto.creatorName), + ), + target: [_database.calendarEvents.portalId], + ), + ); + } + + // Recommendation 3: Sync by deleting events in the fetched range that + // are no longer present on the portal. + await (_database.delete(_database.calendarEvents)..where((e) { + return e.start.isGreaterOrEqualValue(wideStartDate) & + e.end.isSmallerOrEqualValue(wideEndDate) & + e.portalId.isNotIn(portalIds); + })) + .go(); + + // Update the global fetch timestamp for the calendar + await _database + .update(_database.users) + .write( + UsersCompanion(calendarFetchedAt: Value(DateTime.now())), + ); + }); + + // Re-fetch from DB to return only the originally requested range + return (_database.select(_database.calendarEvents) + ..where((e) { + return e.start.isSmallerOrEqualValue(endDate) & + e.end.isGreaterOrEqualValue(startDate); + }) + ..orderBy([(e) => OrderingTerm.asc(e.start)])) + .get(); + } +} From af7bc6856bcd21c9437c995187f7b4c66596ec40 Mon Sep 17 00:00:00 2001 From: kevinlee-06 Date: Wed, 25 Mar 2026 11:15:39 +0800 Subject: [PATCH 2/6] fix: Use `isBiggerOrEqualValue` for date comparisons and directly assign `portalId` during event insertion. --- lib/repositories/calendar_repository.dart | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/repositories/calendar_repository.dart b/lib/repositories/calendar_repository.dart index 83bb6889..0e4ae993 100644 --- a/lib/repositories/calendar_repository.dart +++ b/lib/repositories/calendar_repository.dart @@ -46,7 +46,7 @@ class CalendarRepository { ..where((e) { // Include events that overlap with the range return e.start.isSmallerOrEqualValue(endDate) & - e.end.isGreaterOrEqualValue(startDate); + e.end.isBiggerOrEqualValue(startDate); }) ..orderBy([(e) => OrderingTerm.asc(e.start)])) .get(); @@ -83,14 +83,15 @@ class CalendarRepository { for (final dto in dtos) { final id = dto.id; - if (id == null) + if (id == null) { continue; // Recommendation 2: Handle non-nullable portalId + } await _database .into(_database.calendarEvents) .insert( CalendarEventsCompanion.insert( - portalId: Value(id), + portalId: id, start: Value(dto.start), end: Value(dto.end), allDay: Value(dto.allDay), @@ -119,7 +120,7 @@ class CalendarRepository { // Recommendation 3: Sync by deleting events in the fetched range that // are no longer present on the portal. await (_database.delete(_database.calendarEvents)..where((e) { - return e.start.isGreaterOrEqualValue(wideStartDate) & + return e.start.isBiggerOrEqualValue(wideStartDate) & e.end.isSmallerOrEqualValue(wideEndDate) & e.portalId.isNotIn(portalIds); })) @@ -137,7 +138,7 @@ class CalendarRepository { return (_database.select(_database.calendarEvents) ..where((e) { return e.start.isSmallerOrEqualValue(endDate) & - e.end.isGreaterOrEqualValue(startDate); + e.end.isBiggerOrEqualValue(startDate); }) ..orderBy([(e) => OrderingTerm.asc(e.start)])) .get(); From c03b62601e49b80e05b6eacfacca50c22aede4a5 Mon Sep 17 00:00:00 2001 From: kevinlee-06 Date: Fri, 27 Mar 2026 10:51:49 +0800 Subject: [PATCH 3/6] refactor: refine calendar event caching logic to use `calendarFetchedAt` as the authoritative source and update it per user. --- lib/repositories/calendar_repository.dart | 38 +++++++++++++---------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/lib/repositories/calendar_repository.dart b/lib/repositories/calendar_repository.dart index 0e4ae993..eea9ee19 100644 --- a/lib/repositories/calendar_repository.dart +++ b/lib/repositories/calendar_repository.dart @@ -41,6 +41,11 @@ class CalendarRepository { bool refresh = false, }) async { final user = await _database.getUser(); + // calendarFetchedAt is authoritative for cache presence. The range-filtered + // query below may return an empty list even when the full academic year is + // cached (e.g. a future date range), so we must not use cached.isEmpty as + // the nil-check — that would cause spurious network fetches. + final hasCachedData = user?.calendarFetchedAt != null; final cached = await (_database.select(_database.calendarEvents) ..where((e) { @@ -52,10 +57,7 @@ class CalendarRepository { .get(); return fetchWithTtl>( - // If we have any cached data for this range, pass it to TTL check. - // Wide-range fetching ensures that if we have partial data, we likely - // have the full academic year cached. - cached: cached.isEmpty ? null : cached, + cached: hasCachedData ? cached : null, getFetchedAt: (_) => user?.calendarFetchedAt, fetchFromNetwork: () => _fetchCalendarFromNetwork(startDate, endDate), refresh: refresh, @@ -66,26 +68,31 @@ class CalendarRepository { DateTime startDate, DateTime endDate, ) async { - // Recommendation 1: Fetch a wide range (full academic year) to ensure a - // complete cache for the entire year, preventing partial range bugs. + // Fetch a wide range (full academic year) to ensure a complete cache for + // the entire year, preventing partial range bugs on subsequent calls. // NTUT academic years run from Aug 1 to July 31. final wideStartDate = startDate.month < 8 ? DateTime(startDate.year - 1, 8, 1) : DateTime(startDate.year, 8, 1); final wideEndDate = DateTime(wideStartDate.year + 1, 7, 31); + // No SSO needed — getCalendar uses the portal session established at login. final dtos = await _authRepository.withAuth( () => _portalService.getCalendar(wideStartDate, wideEndDate), ); + // getUser() is non-null here because this repository is session-scoped + // and only reachable after a successful login. + final userId = (await _database.getUser())!.id; + await _database.transaction(() async { final portalIds = dtos.map((e) => e.id).whereType().toSet(); for (final dto in dtos) { final id = dto.id; - if (id == null) { - continue; // Recommendation 2: Handle non-nullable portalId - } + // portalId is nullable in the DTO (NTUT servers occasionally omit it). + // Skip events without an ID — we can't sync or deduplicate them. + if (id == null) continue; await _database .into(_database.calendarEvents) @@ -117,8 +124,7 @@ class CalendarRepository { ); } - // Recommendation 3: Sync by deleting events in the fetched range that - // are no longer present on the portal. + // Sync: delete events in the fetched range that are no longer on the portal. await (_database.delete(_database.calendarEvents)..where((e) { return e.start.isBiggerOrEqualValue(wideStartDate) & e.end.isSmallerOrEqualValue(wideEndDate) & @@ -126,12 +132,10 @@ class CalendarRepository { })) .go(); - // Update the global fetch timestamp for the calendar - await _database - .update(_database.users) - .write( - UsersCompanion(calendarFetchedAt: Value(DateTime.now())), - ); + // Update the fetch timestamp for this user only. + await (_database.update(_database.users) + ..where((u) => u.id.equals(userId))) + .write(UsersCompanion(calendarFetchedAt: Value(DateTime.now()))); }); // Re-fetch from DB to return only the originally requested range From 6268b518d59af3064c9bceeb6661089c075fc291 Mon Sep 17 00:00:00 2001 From: kevinlee-06 Date: Fri, 27 Mar 2026 11:00:55 +0800 Subject: [PATCH 4/6] refactor: refactor calendar event fetching to pass `userId` directly, improve `wideEndDate` precision, and prevent accidental deletion with empty `portalIds`. --- lib/repositories/calendar_repository.dart | 30 ++++++++++++++--------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/lib/repositories/calendar_repository.dart b/lib/repositories/calendar_repository.dart index eea9ee19..aa7b9d1e 100644 --- a/lib/repositories/calendar_repository.dart +++ b/lib/repositories/calendar_repository.dart @@ -59,12 +59,16 @@ class CalendarRepository { return fetchWithTtl>( cached: hasCachedData ? cached : null, getFetchedAt: (_) => user?.calendarFetchedAt, - fetchFromNetwork: () => _fetchCalendarFromNetwork(startDate, endDate), + // user is non-null here: this repo is session-scoped so getUser() always + // returns a row while a session is active. + fetchFromNetwork: () => + _fetchCalendarFromNetwork(user!.id, startDate, endDate), refresh: refresh, ); } Future> _fetchCalendarFromNetwork( + int userId, DateTime startDate, DateTime endDate, ) async { @@ -74,17 +78,15 @@ class CalendarRepository { final wideStartDate = startDate.month < 8 ? DateTime(startDate.year - 1, 8, 1) : DateTime(startDate.year, 8, 1); - final wideEndDate = DateTime(wideStartDate.year + 1, 7, 31); + // Use end-of-day so events ending on July 31 after 00:00 are included in + // the sync window. + final wideEndDate = DateTime(wideStartDate.year + 1, 7, 31, 23, 59, 59); // No SSO needed — getCalendar uses the portal session established at login. final dtos = await _authRepository.withAuth( () => _portalService.getCalendar(wideStartDate, wideEndDate), ); - // getUser() is non-null here because this repository is session-scoped - // and only reachable after a successful login. - final userId = (await _database.getUser())!.id; - await _database.transaction(() async { final portalIds = dtos.map((e) => e.id).whereType().toSet(); @@ -125,12 +127,16 @@ class CalendarRepository { } // Sync: delete events in the fetched range that are no longer on the portal. - await (_database.delete(_database.calendarEvents)..where((e) { - return e.start.isBiggerOrEqualValue(wideStartDate) & - e.end.isSmallerOrEqualValue(wideEndDate) & - e.portalId.isNotIn(portalIds); - })) - .go(); + // Guard against an empty set — isNotIn([]) generates "NOT IN ()" which + // SQLite treats as always true, wiping all events in the range. + if (portalIds.isNotEmpty) { + await (_database.delete(_database.calendarEvents)..where((e) { + return e.start.isBiggerOrEqualValue(wideStartDate) & + e.end.isSmallerOrEqualValue(wideEndDate) & + e.portalId.isNotIn(portalIds); + })) + .go(); + } // Update the fetch timestamp for this user only. await (_database.update(_database.users) From d5bf83a12c30183c082f7c205400d7c1df1f2072 Mon Sep 17 00:00:00 2001 From: kevinlee-06 Date: Fri, 27 Mar 2026 11:12:02 +0800 Subject: [PATCH 5/6] fix: robustify `CalendarEventDto` ID parsing to handle string values and clarify field nullability in documentation --- lib/services/portal/ntut_portal_service.dart | 6 +++++- lib/services/portal/portal_service.dart | 15 ++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/lib/services/portal/ntut_portal_service.dart b/lib/services/portal/ntut_portal_service.dart index a272160b..e12ac842 100644 --- a/lib/services/portal/ntut_portal_service.dart +++ b/lib/services/portal/ntut_portal_service.dart @@ -234,7 +234,11 @@ class NtutPortalService implements PortalService { ) .map( (e) => ( - id: e['id'], + id: switch (e['id']) { + final int i => i, + final String s => int.tryParse(s), + _ => null, + }, start: fromEpoch(e['calStart']), end: fromEpoch(e['calEnd']), allDay: e['allDay'] == '1', diff --git a/lib/services/portal/portal_service.dart b/lib/services/portal/portal_service.dart index c00f68f8..36bdca0b 100644 --- a/lib/services/portal/portal_service.dart +++ b/lib/services/portal/portal_service.dart @@ -24,16 +24,25 @@ typedef UserDto = ({ /// Represents a calendar event from the NTUT Portal. /// -/// Weekend markers (isHoliday with empty title) are filtered out by -/// [PortalService.getCalendar]. +/// Weekend markers (`isHoliday == '1'`) are filtered out by +/// [PortalService.getCalendar] before mapping to this type. +/// Some non-holiday events may still have a null [id]. typedef CalendarEventDto = ({ - /// Event ID. + /// Event ID from the portal. + /// + /// Null for some portal events (not just weekends — those are already + /// filtered). Events without an ID are skipped by [CalendarRepository] + /// since they cannot be synced or deduplicated. int? id, /// Event start time. + /// + /// Null when the portal omits timing information for this event. DateTime? start, /// Event end time. + /// + /// Null when the portal omits timing information for this event. DateTime? end, /// Whether this is an all-day event. From 247dc88598339e98f715b921e6fb653b71ad5a0a Mon Sep 17 00:00:00 2001 From: kevinlee-06 Date: Fri, 27 Mar 2026 12:17:21 +0800 Subject: [PATCH 6/6] refactor: extract calendar event query into helper, use batch inserts for network sync, and refine event deletion logic. --- lib/repositories/calendar_repository.dart | 131 +++++++++++++--------- 1 file changed, 75 insertions(+), 56 deletions(-) diff --git a/lib/repositories/calendar_repository.dart b/lib/repositories/calendar_repository.dart index aa7b9d1e..f59662ae 100644 --- a/lib/repositories/calendar_repository.dart +++ b/lib/repositories/calendar_repository.dart @@ -1,3 +1,4 @@ +import 'dart:developer'; import 'package:drift/drift.dart'; import 'package:riverpod/riverpod.dart'; import 'package:tattoo/database/database.dart'; @@ -41,32 +42,41 @@ class CalendarRepository { bool refresh = false, }) async { final user = await _database.getUser(); + if (user == null) return []; + // calendarFetchedAt is authoritative for cache presence. The range-filtered // query below may return an empty list even when the full academic year is // cached (e.g. a future date range), so we must not use cached.isEmpty as // the nil-check — that would cause spurious network fetches. - final hasCachedData = user?.calendarFetchedAt != null; - final cached = - await (_database.select(_database.calendarEvents) - ..where((e) { - // Include events that overlap with the range - return e.start.isSmallerOrEqualValue(endDate) & - e.end.isBiggerOrEqualValue(startDate); - }) - ..orderBy([(e) => OrderingTerm.asc(e.start)])) - .get(); + final hasCachedData = user.calendarFetchedAt != null; + final cached = hasCachedData + ? await _eventsOverlapping(startDate, endDate).get() + : null; return fetchWithTtl>( - cached: hasCachedData ? cached : null, - getFetchedAt: (_) => user?.calendarFetchedAt, - // user is non-null here: this repo is session-scoped so getUser() always - // returns a row while a session is active. + cached: cached, + getFetchedAt: (_) => user.calendarFetchedAt, fetchFromNetwork: () => - _fetchCalendarFromNetwork(user!.id, startDate, endDate), + _fetchCalendarFromNetwork(user.id, startDate, endDate), refresh: refresh, ); } + /// Selects calendar events that overlap with the given date range, + /// ordered by start date ascending. + SimpleSelectStatement<$CalendarEventsTable, CalendarEvent> _eventsOverlapping( + DateTime startDate, + DateTime endDate, + ) { + return _database.select(_database.calendarEvents) + ..where( + (e) => + e.start.isSmallerOrEqualValue(endDate) & + e.end.isBiggerOrEqualValue(startDate), + ) + ..orderBy([(e) => OrderingTerm.asc(e.start)]); + } + Future> _fetchCalendarFromNetwork( int userId, DateTime startDate, @@ -78,9 +88,9 @@ class CalendarRepository { final wideStartDate = startDate.month < 8 ? DateTime(startDate.year - 1, 8, 1) : DateTime(startDate.year, 8, 1); - // Use end-of-day so events ending on July 31 after 00:00 are included in - // the sync window. - final wideEndDate = DateTime(wideStartDate.year + 1, 7, 31, 23, 59, 59); + // Use the start of the next day (exclusive upper bound) so events ending + // at any time on July 31 are included in the sync window. + final wideEndDate = DateTime(wideStartDate.year + 1, 8, 1); // No SSO needed — getCalendar uses the portal session established at login. final dtos = await _authRepository.withAuth( @@ -90,17 +100,28 @@ class CalendarRepository { await _database.transaction(() async { final portalIds = dtos.map((e) => e.id).whereType().toSet(); - for (final dto in dtos) { - final id = dto.id; - // portalId is nullable in the DTO (NTUT servers occasionally omit it). - // Skip events without an ID — we can't sync or deduplicate them. - if (id == null) continue; - - await _database - .into(_database.calendarEvents) - .insert( - CalendarEventsCompanion.insert( - portalId: id, + await _database.batch((batch) { + for (final dto in dtos) { + final id = dto.id; + // portalId is nullable in the DTO (NTUT servers occasionally omit it). + // Skip events without an ID — we can't sync or deduplicate them. + if (id == null) continue; + + batch.insert( + _database.calendarEvents, + CalendarEventsCompanion.insert( + portalId: id, + start: Value(dto.start), + end: Value(dto.end), + allDay: Value(dto.allDay), + title: Value(dto.title), + place: Value(dto.place), + content: Value(dto.content), + ownerName: Value(dto.ownerName), + creatorName: Value(dto.creatorName), + ), + onConflict: DoUpdate( + (old) => CalendarEventsCompanion( start: Value(dto.start), end: Value(dto.end), allDay: Value(dto.allDay), @@ -110,32 +131,36 @@ class CalendarRepository { ownerName: Value(dto.ownerName), creatorName: Value(dto.creatorName), ), - onConflict: DoUpdate( - (old) => CalendarEventsCompanion( - start: Value(dto.start), - end: Value(dto.end), - allDay: Value(dto.allDay), - title: Value(dto.title), - place: Value(dto.place), - content: Value(dto.content), - ownerName: Value(dto.ownerName), - creatorName: Value(dto.creatorName), - ), - target: [_database.calendarEvents.portalId], - ), - ); - } + target: [_database.calendarEvents.portalId], + ), + ); + } + }); - // Sync: delete events in the fetched range that are no longer on the portal. - // Guard against an empty set — isNotIn([]) generates "NOT IN ()" which - // SQLite treats as always true, wiping all events in the range. + // Sync: delete events in the fetched range that are no longer on the + // portal. Use overlap semantics (consistent with read queries) so + // boundary-spanning events are also cleaned up. if (portalIds.isNotEmpty) { await (_database.delete(_database.calendarEvents)..where((e) { - return e.start.isBiggerOrEqualValue(wideStartDate) & - e.end.isSmallerOrEqualValue(wideEndDate) & + return e.start.isSmallerOrEqualValue(wideEndDate) & + e.end.isBiggerOrEqualValue(wideStartDate) & e.portalId.isNotIn(portalIds); })) .go(); + } else { + // Portal returned no events (or none with IDs). This could be a + // transient portal error, so log a warning before wiping the cache. + log( + 'Portal returned 0 syncable events for ' + '${wideStartDate.toIso8601String()}–${wideEndDate.toIso8601String()}, ' + 'clearing cached events in range', + name: 'CalendarRepository', + ); + await (_database.delete(_database.calendarEvents)..where((e) { + return e.start.isSmallerOrEqualValue(wideEndDate) & + e.end.isBiggerOrEqualValue(wideStartDate); + })) + .go(); } // Update the fetch timestamp for this user only. @@ -145,12 +170,6 @@ class CalendarRepository { }); // Re-fetch from DB to return only the originally requested range - return (_database.select(_database.calendarEvents) - ..where((e) { - return e.start.isSmallerOrEqualValue(endDate) & - e.end.isBiggerOrEqualValue(startDate); - }) - ..orderBy([(e) => OrderingTerm.asc(e.start)])) - .get(); + return _eventsOverlapping(startDate, endDate).get(); } }