diff --git a/AGENTS.md b/AGENTS.md index 1262e742..af07cc18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,11 +14,11 @@ Follow @CONTRIBUTING.md for git operation guidelines. - Service integration tests (copy `test/test_config.json.example` to `test/test_config.json`, then run `flutter test --dart-define-from-file=test/test_config.json -r failures-only`) - Drift database schema with all tables, views (ScoreDetails, UserAcademicSummaries) - Service DTOs migrated to Dart 3 records -- AuthRepository implementation (login, logout, lazy auth via `withAuth()`, session persistence via flutter_secure_storage, SSO coalescing via Completer, re-auth coalescing via Completer), PreferencesRepository, CourseRepository: getSemesters, getCourseTable (with TTL caching), getCourse (single course lookup with TTL) +- AuthRepository implementation (login, logout, lazy auth via `withAuth()`, session persistence via flutter_secure_storage, SSO coalescing via Completer, re-auth coalescing via Completer), PreferencesRepository, CourseRepository: watchSemesters, watchCourseTable (stream-based with TTL), getCourse (single course lookup with TTL) - Session-scoped providers: `sessionProvider` (bool) drives router guard and repository lifecycle. Auth failure destroys session → router redirects to login → session-scoped repositories are recreated with fresh state -- StudentRepository: getSemesterRecords (scores, GPA, rankings with TTL caching, parallel course code resolution) +- StudentRepository: watchSemesterRecords (stream-based with TTL, parallel course code resolution) - Session expiry detection: per-service Dio interceptors detect NTUT fake-200 expired sessions, throw `SessionExpiredException` for `withAuth` retry -- Riverpod setup (manual providers, no codegen — riverpod_generator incompatible with Drift-generated types) +- Riverpod setup (manual providers, no codegen) - go_router navigation setup - UI: intro screen, login screen, home screen with bottom navigation bar and three tabs (table, score, profile), about, easter egg, ShowcaseShell. Home uses `StatefulShellRoute` with `AnimatedShellContainer` for tab state preservation and cross-fade transitions. Each tab owns its own `Scaffold`. - i18n (zh_TW, en_US) via slang @@ -53,13 +53,13 @@ Follow @CONTRIBUTING.md for git operation guidelines. ## Architecture -MVVM pattern with Riverpod for DI and reactive state (manual providers, no codegen — riverpod_generator incompatible with Drift-generated types): +MVVM pattern with Riverpod for DI and reactive state (manual providers, no codegen): - UI calls repository actions directly via constructor providers (`ref.read`) -- UI observes data through screen-level FutureProviders (`ref.watch`) +- UI observes data through screen-level StreamProviders backed by Drift `.watch()` queries (`ref.watch`) - Repositories encapsulate business logic, coordinate Services (HTTP) and Database (Drift) -**Code generation:** Run `dart run build_runner build` (Drift, Riverpod) and `dart run slang` (i18n) after modifying annotated source files or i18n YAMLs. Commit generated files (`.g.dart`) alongside source changes. +**Code generation:** Run `dart run build_runner build` (Drift) and `dart run slang` (i18n) after modifying annotated source files or i18n YAMLs. Commit generated files (`.g.dart`) alongside source changes. **Credentials:** `tool/credentials.dart` manages encrypted credentials from the `tattoo-credentials` Git repo. Run `dart run tool/credentials.dart fetch` to decrypt and place Firebase configs, Android keystore, and service account. Config from env vars or `.env` file. @@ -114,12 +114,12 @@ MVVM pattern with Riverpod for DI and reactive state (manual providers, no codeg - AuthRepository - User identity, session, profile. Implemented: login, logout, `withAuth()` (lazy auth with re-auth coalescing, never-completing future on auth failure), session persistence via flutter_secure_storage - PreferencesRepository - Typed `PrefKey` enum with SharedPreferencesAsync -- CourseRepository - Implemented: getSemesters, getCourseTable (with TTL caching, DB persistence, bilingual names), getCourse (single course lookup with TTL). Stubs: getCourseOffering, getCourseDetails, getMaterials, getStudents -- StudentRepository - Implemented: getSemesterRecords (scores, GPA, rankings with TTL caching, parallel course code resolution via CourseRepository.getCourse). Uses Drift views (ScoreDetails, UserAcademicSummaries) as domain models. +- CourseRepository - Implemented: watchSemesters/refreshSemesters, watchCourseTable/refreshCourseTable (stream-based with TTL, DB persistence, bilingual names), getCourse (single course lookup with inline TTL). Stubs: getCourseOffering, getCourseDetails, getMaterials, getStudents +- StudentRepository - Implemented: watchSemesterRecords/refreshSemesterRecords (stream-based with TTL, parallel course code resolution via CourseRepository.getCourse). Uses Drift views (ScoreDetails, UserAcademicSummaries) as domain models. - Transform DTOs into relational DB tables - Return DTOs or domain models to UI - Handle data persistence and caching strategies -- **Method pattern (AuthRepository):** `getX({refresh})` methods use `fetchWithTtl` helper for smart caching - returns cached data if fresh (within TTL), fetches from network if stale. Set `refresh: true` to bypass TTL (pull-to-refresh). Internal `_fetchXFromNetwork()` methods handle network fetch logic. Special cases that only need partial data (e.g., `getAvatar()` only needs `avatarFilename`) query DB directly. Follow this pattern when implementing other repositories. +- **Method pattern:** `watchX()` returns a `Stream` backed by Drift `.watch()` — emits cached data immediately, then background-fetches if empty or stale (each method has its own hard-coded TTL `const`). Network errors are absorbed (stale data preferred over errors). `refreshX()` is the imperative counterpart for pull-to-refresh — fetches from network, writes to DB, and lets the stream re-emit. Special cases that only need partial data (e.g., `getAvatar()` only needs `avatarFilename`) query DB directly. `getCourse()` is an exception — it's a Future-based per-code lookup with inline TTL, used internally for batch resolution. ## Database @@ -171,7 +171,7 @@ MVVM pattern with Riverpod for DI and reactive state (manual providers, no codeg **Re-auth Coalescing:** `AuthRepository._reauthenticate` uses the same `Completer` pattern — first caller triggers login, concurrent callers await the same future. Prevents redundant login attempts when multiple `withAuth` calls detect session expiry simultaneously. -**Session Lifecycle:** `sessionProvider` (`Notifier`) drives auth state. `true` = authenticated, `false` = unauthenticated. Router guard watches it for redirect. Repository providers `ref.watch(sessionProvider)` to be recreated with fresh state when the session ends. On auth failure, `withAuth` destroys the session and returns a never-completing `Completer().future` — callers never see auth errors, only `DioException` for network failures. +**Session Lifecycle:** `sessionProvider` (`Notifier`) drives auth state. `true` = authenticated, `false` = unauthenticated. Router guard watches it for redirect. Repository providers `ref.watch(sessionProvider)` to be recreated with fresh state when the session ends. On auth failure, `withAuth` destroys the session and returns a never-completing `Completer().future` — session-scoped providers are already being disposed by the time callers would stall, so the hanging future is harmless. Callers only need to handle `DioException` for network failures. **InvalidCookieFilter:** iSchool+ returns malformed cookies; custom interceptor filters them. diff --git a/lib/main.dart b/lib/main.dart index 15a29d8c..4b36c8fb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,6 +9,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:tattoo/firebase_options.dart'; import 'package:tattoo/i18n/strings.g.dart'; +import 'package:tattoo/database/database.dart'; import 'package:tattoo/repositories/auth_repository.dart'; import 'package:tattoo/router/app_router.dart'; import 'package:tattoo/services/firebase_service.dart'; @@ -105,8 +106,8 @@ Future main() async { await LocaleSettings.useDeviceLocale(); - final authRepository = container.read(authRepositoryProvider); - final user = await authRepository.getUser(); + final database = container.read(databaseProvider); + final user = await database.select(database.users).getSingleOrNull(); if (user != null) container.read(sessionProvider.notifier).create(); final initialLocation = user != null ? AppRoutes.home : AppRoutes.intro; final router = createAppRouter( diff --git a/lib/repositories/auth_repository.dart b/lib/repositories/auth_repository.dart index 4f1e2b3a..aeef5116 100644 --- a/lib/repositories/auth_repository.dart +++ b/lib/repositories/auth_repository.dart @@ -12,7 +12,6 @@ import 'package:tattoo/models/login_exception.dart'; import 'package:tattoo/models/user.dart'; import 'package:tattoo/services/portal/portal_service.dart'; import 'package:tattoo/services/student_query/student_query_service.dart'; -import 'package:tattoo/utils/fetch_with_ttl.dart'; import 'package:tattoo/utils/http.dart'; /// Thrown when the avatar image exceeds [AuthRepository.maxAvatarSize]. @@ -100,11 +99,11 @@ final authRepositoryProvider = Provider((ref) { /// // Login /// final user = await auth.login('111360109', 'password'); /// -/// // Get user profile (with automatic cache refresh) -/// final user = await auth.getUser(); +/// // Observe user profile (auto-refreshes when stale) +/// final stream = auth.watchUser(); /// /// // Force refresh (for pull-to-refresh) -/// final user = await auth.getUser(refresh: true); +/// await auth.refreshUser(); /// ``` class AuthRepository { final PortalService _portalService; @@ -300,34 +299,36 @@ class AuthRepository { } /// Gets the current user with automatic cache refresh. + /// Watches the current user with automatic background refresh. /// - /// Returns `null` if not logged in. Returns cached data if fresh (within TTL), - /// fetches full profile from network if stale or missing. Falls back to stale - /// cached data when offline (network errors are absorbed). + /// Emits `null` when not logged in. Emits stale data immediately, then + /// triggers a background network fetch if stale. + /// The stream re-emits automatically when the DB is updated. /// - /// The returned user may have partial data ([User.fetchedAt] is null) if only - /// login has been performed. Full profile data is fetched automatically when - /// [User.fetchedAt] is null or stale. - /// - /// Set [refresh] to `true` to bypass TTL and always refetch (for pull-to-refresh). - Future getUser({bool refresh = false}) async { - final user = await _database.select(_database.users).getSingleOrNull(); - if (user == null) return null; // Not logged in, can't fetch - - try { - return await fetchWithTtl( - cached: user, - getFetchedAt: (u) => u.fetchedAt, - fetchFromNetwork: _fetchUserFromNetwork, - refresh: refresh, - ); - } on DioException { - return user; + /// Network errors during background refresh are absorbed — the stream + /// continues showing stale data rather than erroring. + Stream watchUser() async* { + const ttl = Duration(days: 3); + + await for (final user + in _database.select(_database.users).watchSingleOrNull()) { + yield user; + if (user == null) continue; + + final age = switch (user.fetchedAt) { + final t? => DateTime.now().difference(t), + null => ttl, + }; + if (age >= ttl) { + try { + await refreshUser(); + } catch (_) { + // Absorb: stale data is shown via stream + } + } } } - /// Fetches all user data from network. - /// /// Refreshes login-level fields (avatar, name, email) via Portal login, /// and academic data (profile, registrations) via the student query service. /// The login call also establishes a fresh session for the subsequent SSO @@ -335,7 +336,7 @@ class AuthRepository { /// /// On missing or rejected credentials, destroys the session and returns a /// never-completing future (router guard handles redirect). - Future _fetchUserFromNetwork() async { + Future refreshUser() async { final user = await _database.select(_database.users).getSingleOrNull(); if (user == null) { throw StateError('Cannot fetch user profile when not logged in'); @@ -348,7 +349,7 @@ class AuthRepository { try { userDto = await _reauthenticate(); } on _AuthFailedException { - return Completer().future; + return Completer().future; } final (profile, records) = await withAuth( @@ -360,7 +361,7 @@ class AuthRepository { sso: [.studentQueryService], ); - return _database.transaction(() async { + await _database.transaction(() async { await (_database.update( _database.users, )..where((t) => t.id.equals(user.id))).write( @@ -411,8 +412,6 @@ class AuthRepository { ), ); } - - return _database.select(_database.users).getSingle(); }); } @@ -525,12 +524,13 @@ class AuthRepository { } catch (_) {} } - /// Gets the user's active registration (where enrollment status is "在學"). + /// Watches the user's active registration (where enrollment status is "在學"). /// - /// Returns the most recent semester where the user is actively enrolled, - /// or `null` if no active registration exists. - /// Pure DB read — call [getUser] first to populate registration data. - Future getActiveRegistration() async { + /// Emits the most recent semester where the user is actively enrolled, + /// or `null` if no active registration exists. Automatically re-emits + /// when the underlying data changes (e.g., after [refreshUser] populates + /// registration data or after cache clear). + Stream watchActiveRegistration() { return (_database.select(_database.userRegistrations) ..where( (r) => r.enrollmentStatus.equalsValue(EnrollmentStatus.learning), @@ -540,7 +540,7 @@ class AuthRepository { (r) => OrderingTerm.desc(r.term), ]) ..limit(1)) - .getSingleOrNull(); + .watchSingleOrNull(); } /// Converts the image to JPEG, normalizes EXIF orientation, and compresses. diff --git a/lib/repositories/course_repository.dart b/lib/repositories/course_repository.dart index 134559d3..2dcef2a2 100644 --- a/lib/repositories/course_repository.dart +++ b/lib/repositories/course_repository.dart @@ -12,7 +12,6 @@ import 'package:tattoo/services/i_school_plus/i_school_plus_service.dart'; import 'package:tattoo/services/portal/portal_service.dart'; import 'package:tattoo/repositories/auth_repository.dart'; import 'package:tattoo/services/firebase_service.dart'; -import 'package:tattoo/utils/fetch_with_ttl.dart'; import 'package:tattoo/utils/localized.dart'; /// Data for a single cell in the course table grid. @@ -140,17 +139,14 @@ final courseRepositoryProvider = Provider((ref) { /// ```dart /// final repo = ref.watch(courseRepositoryProvider); /// -/// // Get available semesters -/// final semesters = await repo.getSemesters(); +/// // Observe available semesters (auto-refreshes when stale) +/// final stream = repo.watchSemesters(); /// -/// // Get course schedule for a semester -/// final courses = await repo.getCourseTable( -/// user: user, -/// semester: semesters.first, -/// ); +/// // Force refresh (for pull-to-refresh) +/// await repo.refreshSemesters(); /// -/// // Get materials for a course -/// final materials = await repo.getMaterials(courses.first); +/// // Observe course schedule for a semester +/// final courseStream = repo.watchCourseTable(semester: semesters.first); /// ``` class CourseRepository { final PortalService _portalService; @@ -174,84 +170,131 @@ class CourseRepository { _authRepository = authRepository, _firebaseService = firebaseService; - /// Gets available semesters for the authenticated student. + /// Watches available semesters for the authenticated student. /// - /// Returns cached data if fresh (within TTL). Set [refresh] to `true` to - /// bypass TTL (pull-to-refresh). - Future> getSemesters({bool refresh = false}) async { - final user = await _database.select(_database.users).getSingleOrNull(); - final cached = - await (_database.select(_database.semesters) - ..where((s) => s.inCourseSemesterList.equals(true)) - ..orderBy([ - (s) => OrderingTerm.desc(s.year), - (s) => OrderingTerm.desc(s.term), - ])) - .get(); - - return fetchWithTtl( - cached: cached.isEmpty ? null : cached, - getFetchedAt: (_) => user?.semestersFetchedAt, - fetchFromNetwork: _fetchSemestersFromNetwork, - refresh: refresh, - ); + /// Emits cached data immediately, then triggers a background network fetch + /// if data is empty or stale. The stream re-emits automatically when the + /// DB is updated. + /// + /// Network errors during background refresh are absorbed — the stream + /// continues showing stale (or empty) data rather than erroring. + Stream> watchSemesters() async* { + const ttl = Duration(days: 3); + + final query = _database.select(_database.semesters) + ..where((s) => s.inCourseSemesterList.equals(true)) + ..orderBy([ + (s) => OrderingTerm.desc(s.year), + (s) => OrderingTerm.desc(s.term), + ]); + + await for (final semesters in query.watch()) { + if (semesters.isEmpty) { + try { + await refreshSemesters(); + } catch (_) { + // Absorb: yield empty below so UI exits loading state + } + } + + yield semesters; + + final user = await _database.select(_database.users).getSingleOrNull(); + final age = switch (user?.semestersFetchedAt) { + final t? => DateTime.now().difference(t), + null => ttl, + }; + if (age >= ttl) { + try { + await refreshSemesters(); + } catch (_) { + // Absorb: stale data is shown via stream + } + } + } } - Future> _fetchSemestersFromNetwork() async { + /// Fetches fresh semester data from network and writes to DB. + /// + /// The [watchSemesters] stream automatically emits the updated value. + /// Network errors propagate to the caller. + Future refreshSemesters() async { final dtos = await _authRepository.withAuth( _courseService.getCourseSemesterList, sso: [.courseService], ); - final semesters = await _database.transaction(() async { - final results = await dtos.map((dto) async { + await _database.transaction(() async { + for (final dto in dtos) { if (dto case (year: final year?, term: final term?)) { - return _database.getOrCreateSemester( + await _database.getOrCreateSemester( year, term, inCourseSemesterList: true, ); } - }).wait; + } await (_database.update(_database.users)).write( UsersCompanion(semestersFetchedAt: Value(DateTime.now())), ); - - return results; }); - - return semesters.nonNulls.toList(); } - /// Gets the course schedule for a semester. + /// Watches the course schedule for a semester with automatic background refresh. /// - /// Returns cached data if fresh (within TTL). Set [refresh] to `true` to - /// bypass TTL (pull-to-refresh). + /// Emits cached data immediately, then triggers a background network fetch + /// if data is empty or stale. The stream re-emits automatically when the + /// DB is updated. /// /// Use [getCourseOffering] for related data (teachers, classrooms, schedules). - Future getCourseTable({ - required User user, - required Semester semester, - bool refresh = false, - }) async { - final cached = await _buildCourseTableData(semester.id); - final semesterRow = await (_database.select( - _database.semesters, - )..where((s) => s.id.equals(semester.id))).getSingle(); + Stream watchCourseTable({required int semesterId}) async* { + const ttl = Duration(days: 3); - return fetchWithTtl( - cached: cached.isEmpty ? null : cached, - getFetchedAt: (_) => semesterRow.courseTableFetchedAt, - fetchFromNetwork: () => _fetchCourseTableFromNetwork(user, semester), - refresh: refresh, - ); + final query = _database.select(_database.courseTableSlots) + ..where((s) => s.semester.equals(semesterId)); + + await for (final rows in query.watch()) { + final data = _buildCourseTableData(rows); + + if (data.isEmpty) { + try { + await refreshCourseTable(semesterId: semesterId); + } catch (_) { + // Absorb: yield empty below so UI exits loading state + } + } + + yield data; + + final semesterRow = await (_database.select( + _database.semesters, + )..where((s) => s.id.equals(semesterId))).getSingle(); + final age = switch (semesterRow.courseTableFetchedAt) { + final t? => DateTime.now().difference(t), + null => ttl, + }; + + if (age >= ttl) { + try { + await refreshCourseTable(semesterId: semesterId); + } catch (_) { + // Absorb: stale data is shown via stream + } + } + } } - Future _fetchCourseTableFromNetwork( - User user, - Semester semester, - ) async { + /// Fetches fresh course table data from network and writes to DB. + /// + /// The [watchCourseTable] stream automatically emits the updated value. + /// Network errors propagate to the caller. + Future refreshCourseTable({required int semesterId}) async { + final user = await _database.select(_database.users).getSingle(); + final semester = await (_database.select( + _database.semesters, + )..where((s) => s.id.equals(semesterId))).getSingle(); + final dtos = await _authRepository.withAuth( () => _courseService.getCourseTable( username: user.studentId, @@ -411,14 +454,10 @@ class CourseRepository { ), ); }); - - return _buildCourseTableData(semester.id); } - Future _buildCourseTableData(int semesterId) async { - final rows = await (_database.select( - _database.courseTableSlots, - )..where((s) => s.semester.equals(semesterId))).get(); + /// Builds [CourseTableData] from raw view rows, computing multi-period spans. + static CourseTableData _buildCourseTableData(List rows) { final data = CourseTableData(); for (final row in rows) { @@ -501,19 +540,22 @@ class CourseRepository { /// Returns cached data if fresh (within TTL). Set [refresh] to `true` to /// bypass TTL (pull-to-refresh). Future getCourse(String code, {bool refresh = false}) async { - final cached = await (_database.select( - _database.courses, - )..where((c) => c.code.equals(code))).getSingleOrNull(); - - return fetchWithTtl( - cached: cached, - getFetchedAt: (c) => c.fetchedAt, - fetchFromNetwork: () => _fetchCourseFromNetwork(code), - refresh: refresh, - ); - } + const ttl = Duration(days: 3); + + if (!refresh) { + final cached = await (_database.select( + _database.courses, + )..where((c) => c.code.equals(code))).getSingleOrNull(); + + if (cached != null) { + final age = switch (cached.fetchedAt) { + final t? => DateTime.now().difference(t), + null => ttl, + }; + if (age < ttl) return cached; + } + } - Future _fetchCourseFromNetwork(String code) async { final dto = await _authRepository.withAuth( () => _courseService.getCourse(code), sso: [.courseService], diff --git a/lib/repositories/student_repository.dart b/lib/repositories/student_repository.dart index fe587efe..1cd5e0e1 100644 --- a/lib/repositories/student_repository.dart +++ b/lib/repositories/student_repository.dart @@ -5,7 +5,6 @@ import 'package:tattoo/repositories/auth_repository.dart'; import 'package:tattoo/repositories/course_repository.dart'; import 'package:tattoo/services/firebase_service.dart'; import 'package:tattoo/services/student_query/student_query_service.dart'; -import 'package:tattoo/utils/fetch_with_ttl.dart'; /// Aggregated academic data for one semester. /// @@ -49,28 +48,63 @@ class StudentRepository { _firebaseService = firebaseService, _studentQueryService = studentQueryService; - /// Gets aggregated academic records grouped by semester. + /// Watches aggregated academic records grouped by semester. /// - /// Returns cached data if fresh (within TTL). Set [refresh] to `true` to - /// bypass TTL (pull-to-refresh). - Future> getSemesterRecords({ - bool refresh = false, - }) async { - final user = await _database.select(_database.users).getSingle(); + /// Emits cached data immediately, then triggers a background network fetch + /// if data is empty or stale. The stream re-emits + /// automatically when the DB is updated. + /// + /// Network errors during background refresh are absorbed — the stream + /// continues showing stale (or empty) data rather than erroring. + Stream> watchSemesterRecords() async* { + const ttl = Duration(days: 3); + + // Watch academic summaries as the trigger signal. Score data changes + // atomically in a transaction, so this catches all updates. + await for (final _ + in _database.select(_database.userAcademicSummaries).watch()) { + final user = await _database.select(_database.users).getSingleOrNull(); + if (user == null) { + yield []; + continue; + } - final cached = await _buildSemesterRecordData(user.id); + final records = await _buildSemesterRecordData(user.id); - return fetchWithTtl( - cached: cached.isEmpty ? null : cached, - getFetchedAt: (_) => user.scoreDataFetchedAt, - fetchFromNetwork: () => _fetchSemesterRecordsFromNetwork(user.id), - refresh: refresh, - ); + if (records.isEmpty) { + try { + await refreshSemesterRecords(); + } catch (_) { + // Absorb: yield empty below so UI exits loading state + } + } + + yield records; + + // Re-read user to get the latest scoreDataFetchedAt + final freshUser = await _database + .select(_database.users) + .getSingleOrNull(); + final age = switch (freshUser?.scoreDataFetchedAt) { + final t? => DateTime.now().difference(t), + null => ttl, + }; + if (age >= ttl) { + try { + await refreshSemesterRecords(); + } catch (_) { + // Absorb: stale data is shown via stream + } + } + } } - Future> _fetchSemesterRecordsFromNetwork( - int userId, - ) async { + /// Fetches fresh semester records from network and writes to DB. + /// + /// The [watchSemesterRecords] stream automatically emits the updated value. + /// Network errors propagate to the caller. + Future refreshSemesterRecords() async { + final userId = (await _database.select(_database.users).getSingle()).id; final (semesters, gpas, rankings) = await _authRepository.withAuth( () async { final semestersFuture = _studentQueryService.getAcademicPerformance(); @@ -225,8 +259,6 @@ class StudentRepository { ..where((u) => u.id.equals(userId))) .write(UsersCompanion(scoreDataFetchedAt: Value(DateTime.now()))); }); - - return _buildSemesterRecordData(userId); } Future> _buildSemesterRecordData( diff --git a/lib/screens/main/course_table/course_table_providers.dart b/lib/screens/main/course_table/course_table_providers.dart index 06512585..8685569f 100644 --- a/lib/screens/main/course_table/course_table_providers.dart +++ b/lib/screens/main/course_table/course_table_providers.dart @@ -1,32 +1,27 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:tattoo/database/database.dart'; import 'package:tattoo/repositories/course_repository.dart'; -import 'package:tattoo/screens/main/user_providers.dart'; /// Provides the available semesters for the current user. /// -/// Returns an empty list if the user is not logged in. -final courseTableSemestersProvider = FutureProvider.autoDispose>( - (ref) async { - final user = await ref.watch(userProfileProvider.future); - if (user == null) return []; - - return await ref.watch(courseRepositoryProvider).getSemesters(); +/// Watches the DB directly — automatically updates when semester data changes. +/// Background-refreshes stale data automatically. +final courseTableSemestersProvider = StreamProvider.autoDispose>( + (ref) { + return ref.watch(courseRepositoryProvider).watchSemesters(); }, ); /// Provides course table cells for a semester. /// -/// Returns an empty table if the user is not logged in. -final courseTableProvider = FutureProvider.autoDispose - .family(( - ref, - semester, - ) async { - final user = await ref.watch(userProfileProvider.future); - if (user == null) return CourseTableData(); - - return await ref +/// Watches the DB directly — automatically updates when course table data +/// changes. Background-refreshes stale data automatically. +/// +/// Keyed by [Semester.id] (not the full object) so that timestamp updates +/// on the semester row don't recreate the provider. +final courseTableProvider = StreamProvider.autoDispose + .family((ref, semesterId) { + return ref .watch(courseRepositoryProvider) - .getCourseTable(user: user, semester: semester); + .watchCourseTable(semesterId: semesterId); }); diff --git a/lib/screens/main/course_table/course_table_screen.dart b/lib/screens/main/course_table/course_table_screen.dart index 4021a9c3..5c215f93 100644 --- a/lib/screens/main/course_table/course_table_screen.dart +++ b/lib/screens/main/course_table/course_table_screen.dart @@ -20,33 +20,14 @@ class CourseTableScreen extends ConsumerWidget { const CourseTableScreen({super.key}); Future _refreshCourseTable( - BuildContext context, WidgetRef ref, Semester semester, ) async { - final userFuture = ref.read(userProfileProvider.future); final courseRepository = ref.read(courseRepositoryProvider); - - final user = await userFuture; - if (user == null) { - if (!context.mounted) return; - ref.invalidate(courseTableSemestersProvider); - ref.invalidate(courseTableProvider(semester)); - return; - } - - await Future.wait([ - courseRepository.getSemesters(refresh: true), - courseRepository.getCourseTable( - user: user, - semester: semester, - refresh: true, - ), - ]); - - if (!context.mounted) return; - ref.invalidate(courseTableSemestersProvider); - ref.invalidate(courseTableProvider(semester)); + await [ + courseRepository.refreshSemesters(), + courseRepository.refreshCourseTable(semesterId: semester.id), + ].wait; } void _showDemoTap(BuildContext context) { @@ -57,7 +38,6 @@ class CourseTableScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final screenContext = context; final profileAsync = ref.watch(userProfileProvider); final semestersAsync = ref.watch(courseTableSemestersProvider); final displayedSemesterTabLabels = switch (semestersAsync) { @@ -184,7 +164,7 @@ class CourseTableScreen extends ConsumerWidget { Consumer( builder: (context, tabRef, child) { final courseTableAsync = tabRef.watch( - courseTableProvider(semester), + courseTableProvider(semester.id), ); return switch (courseTableAsync) { @@ -200,7 +180,6 @@ class CourseTableScreen extends ConsumerWidget { courseTableAsync.isLoading && !courseTableAsync.hasValue, onRefresh: () => _refreshCourseTable( - screenContext, ref, semester, ), diff --git a/lib/screens/main/profile/profile_danger_zone.dart b/lib/screens/main/profile/profile_danger_zone.dart index f76b7956..631168c3 100644 --- a/lib/screens/main/profile/profile_danger_zone.dart +++ b/lib/screens/main/profile/profile_danger_zone.dart @@ -9,7 +9,6 @@ import 'package:tattoo/database/database.dart'; import 'package:tattoo/i18n/strings.g.dart'; import 'package:tattoo/repositories/auth_repository.dart'; import 'package:tattoo/repositories/preferences_repository.dart'; -import 'package:tattoo/screens/main/course_table/course_table_providers.dart'; import 'package:tattoo/screens/main/profile/profile_providers.dart'; import 'package:tattoo/screens/main/user_providers.dart'; import 'package:tattoo/utils/http.dart'; @@ -67,11 +66,8 @@ class ProfileDangerZone extends ConsumerWidget { } } await ref.read(databaseProvider).deleteCachedData(); - ref.invalidate(userProfileProvider); + // Stream-based providers auto-update via Drift .watch(). ref.invalidate(userAvatarProvider); - ref.invalidate(activeRegistrationProvider); - ref.invalidate(courseTableSemestersProvider); - ref.invalidate(courseTableProvider); }, ); diff --git a/lib/screens/main/profile/profile_providers.dart b/lib/screens/main/profile/profile_providers.dart index 794dcf79..dddd656b 100644 --- a/lib/screens/main/profile/profile_providers.dart +++ b/lib/screens/main/profile/profile_providers.dart @@ -4,15 +4,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:tattoo/database/database.dart'; import 'package:tattoo/i18n/strings.g.dart'; import 'package:tattoo/repositories/auth_repository.dart'; -import 'package:tattoo/screens/main/user_providers.dart'; /// Provides the user's active registration (current class and semester). /// -/// Depends on [userProfileProvider] to ensure registration data is populated. +/// Watches the DB view directly — automatically updates when registration +/// data changes (e.g., after login, profile fetch, or cache clear). final activeRegistrationProvider = - FutureProvider.autoDispose((ref) async { - await ref.watch(userProfileProvider.future); - return ref.watch(authRepositoryProvider).getActiveRegistration(); + StreamProvider.autoDispose((ref) { + return ref.watch(authRepositoryProvider).watchActiveRegistration(); }); /// Random action string from [t.profile.dangerZone.actions] for the easter egg button. diff --git a/lib/screens/main/profile/profile_screen.dart b/lib/screens/main/profile/profile_screen.dart index 5931c3e9..e983790b 100644 --- a/lib/screens/main/profile/profile_screen.dart +++ b/lib/screens/main/profile/profile_screen.dart @@ -12,8 +12,6 @@ import 'package:tattoo/repositories/auth_repository.dart'; import 'package:tattoo/router/app_router.dart'; import 'package:tattoo/screens/main/profile/profile_card.dart'; import 'package:tattoo/screens/main/profile/profile_danger_zone.dart'; -import 'package:tattoo/screens/main/profile/profile_providers.dart'; -import 'package:tattoo/screens/main/user_providers.dart'; import 'package:tattoo/utils/launch_url.dart'; class ProfileScreen extends ConsumerWidget { @@ -21,12 +19,7 @@ class ProfileScreen extends ConsumerWidget { static final _imagePicker = ImagePicker(); Future _refresh(WidgetRef ref) async { - await ref.read(authRepositoryProvider).getUser(refresh: true); - await Future.wait([ - ref.refresh(userProfileProvider.future), - ref.refresh(userAvatarProvider.future), - ref.refresh(activeRegistrationProvider.future), - ]); + await ref.read(authRepositoryProvider).refreshUser(); } Future _logout(WidgetRef ref) async { @@ -50,7 +43,6 @@ class ProfileScreen extends ConsumerWidget { final imageBytes = await imageFile.readAsBytes(); await ref.read(authRepositoryProvider).uploadAvatar(imageBytes); - ref.invalidate(userAvatarProvider); if (!context.mounted) return; _showMessage(context, t.profile.avatar.uploadSuccess); diff --git a/lib/screens/main/user_providers.dart b/lib/screens/main/user_providers.dart index f9c649bf..4e7fa8ae 100644 --- a/lib/screens/main/user_providers.dart +++ b/lib/screens/main/user_providers.dart @@ -6,14 +6,22 @@ import 'package:tattoo/repositories/auth_repository.dart'; /// Provides the current user's profile. /// -/// Returns `null` if not logged in. Automatically fetches full profile if stale. -final userProfileProvider = FutureProvider.autoDispose((ref) { - return ref.watch(authRepositoryProvider).getUser(); +/// Watches the DB directly — automatically updates when user data changes +/// (e.g., after login, profile fetch, or cache clear). Background-refreshes +/// stale data automatically. +final userProfileProvider = StreamProvider.autoDispose((ref) { + return ref.watch(authRepositoryProvider).watchUser(); }); /// Provides the current user's avatar file. /// +/// Rebuilds when [userProfileProvider] emits a new `avatarFilename`, +/// so background profile refreshes automatically update the avatar. /// Returns `null` if user has no avatar or not logged in. final userAvatarProvider = FutureProvider.autoDispose((ref) { + // Watch avatarFilename so this rebuilds when it changes. + ref.watch( + userProfileProvider.select((async) => async.asData?.value?.avatarFilename), + ); return ref.watch(authRepositoryProvider).getAvatar(); }); diff --git a/lib/utils/fetch_with_ttl.dart b/lib/utils/fetch_with_ttl.dart deleted file mode 100644 index 91ff21e6..00000000 --- a/lib/utils/fetch_with_ttl.dart +++ /dev/null @@ -1,80 +0,0 @@ -/// Global default TTL for cached data. -/// -/// Used by [fetchWithTtl] when no explicit TTL is provided. -/// Repositories can override this for entity-specific TTL requirements. -/// -/// **Making this configurable:** -/// To connect this to user preferences later: -/// 1. Create a provider that reads from SharedPreferences/secure storage -/// 2. Pass the value through repository constructors or as a parameter -/// 3. Example: `fetchWithTtl(cached: data, ttl: ref.watch(userTtlPrefProvider))` -const Duration defaultFetchTtl = Duration(days: 3); - -/// Helper for fetching data with TTL-based caching. -/// -/// Implements the fetch-if-stale pattern used across repositories: -/// - If [cached] is null, fetches from network -/// - If [refresh] is true, fetches from network (pull-to-refresh) -/// - If [cached] is older than [ttl], fetches from network -/// - Otherwise returns [cached] -/// -/// **TTL Configuration:** -/// - Defaults to [defaultFetchTtl] if not specified -/// - Repositories can override for entity-specific needs -/// - Can be made user-configurable via preferences provider -/// -/// **Example:** -/// ```dart -/// class AuthRepository { -/// Future getUser({bool refresh = false}) async { -/// final user = await _database.select(_database.users).getSingleOrNull(); -/// if (user == null) return null; // Not logged in -/// -/// return fetchWithTtl( -/// cached: user, -/// getFetchedAt: (u) => u.fetchedAt, -/// fetchFromNetwork: _fetchUserFromNetwork, -/// refresh: refresh, -/// ); -/// } -/// -/// // Override TTL for specific entities -/// Future getUserWithCustomTtl({bool refresh = false}) async { -/// final user = await _database.select(_database.users).getSingleOrNull(); -/// if (user == null) return null; -/// -/// return fetchWithTtl( -/// cached: user, -/// getFetchedAt: (u) => u.fetchedAt, -/// fetchFromNetwork: _fetchUserFromNetwork, -/// ttl: const Duration(days: 7), // Custom TTL -/// refresh: refresh, -/// ); -/// } -/// } -/// ``` -Future fetchWithTtl({ - required T? cached, - required DateTime? Function(T) getFetchedAt, - required Future Function() fetchFromNetwork, - Duration? ttl, - bool refresh = false, -}) async { - // No cached data, fetch fresh - if (cached == null) { - return fetchFromNetwork(); - } - - // Check if cached data is still fresh - final effectiveTtl = ttl ?? defaultFetchTtl; - final fetchedAt = getFetchedAt(cached); - if (!refresh && fetchedAt != null) { - final age = DateTime.now().difference(fetchedAt); - if (age < effectiveTtl) { - return cached; // Data is fresh, return cached - } - } - - // Data is stale or refresh requested, fetch fresh - return fetchFromNetwork(); -} diff --git a/test/utils/fetch_with_ttl_test.dart b/test/utils/fetch_with_ttl_test.dart deleted file mode 100644 index e29818e0..00000000 --- a/test/utils/fetch_with_ttl_test.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:tattoo/utils/fetch_with_ttl.dart'; - -void main() { - const cached = 'cached'; - const fresh = 'fresh'; - final now = DateTime.now(); - - Future fetchFromNetwork() async => fresh; - - group('fetchWithTtl', () { - test('fetches from network when no cached data', () async { - final result = await fetchWithTtl( - cached: null, - getFetchedAt: (_) => now, - fetchFromNetwork: fetchFromNetwork, - ); - expect(result, fresh); - }); - - test('returns cached data when fresh', () async { - final result = await fetchWithTtl( - cached: cached, - getFetchedAt: (_) => now.subtract(const Duration(hours: 1)), - fetchFromNetwork: fetchFromNetwork, - ); - expect(result, cached); - }); - - test('fetches from network when stale', () async { - final result = await fetchWithTtl( - cached: cached, - getFetchedAt: (_) => now.subtract(const Duration(days: 30)), - fetchFromNetwork: fetchFromNetwork, - ); - expect(result, fresh); - }); - - test('fetches from network when refresh is true', () async { - final result = await fetchWithTtl( - cached: cached, - getFetchedAt: (_) => now.subtract(const Duration(hours: 1)), - fetchFromNetwork: fetchFromNetwork, - refresh: true, - ); - expect(result, fresh); - }); - - test('fetches from network when fetchedAt is null', () async { - final result = await fetchWithTtl( - cached: cached, - getFetchedAt: (_) => null, - fetchFromNetwork: fetchFromNetwork, - ); - expect(result, fresh); - }); - - test('respects custom ttl', () async { - final result = await fetchWithTtl( - cached: cached, - getFetchedAt: (_) => now.subtract(const Duration(hours: 2)), - fetchFromNetwork: fetchFromNetwork, - ttl: const Duration(hours: 1), - ); - expect(result, fresh); - }); - }); -}