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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>()`, 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<T>()`, 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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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<T>()` (lazy auth with re-auth coalescing, never-completing future on auth failure), session persistence via flutter_secure_storage
- PreferencesRepository - Typed `PrefKey<T>` 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

Expand Down Expand Up @@ -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<bool>`) 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<T>().future` — callers never see auth errors, only `DioException` for network failures.
**Session Lifecycle:** `sessionProvider` (`Notifier<bool>`) 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<T>().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.

Expand Down
5 changes: 3 additions & 2 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -105,8 +106,8 @@ Future<void> 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(
Expand Down
76 changes: 38 additions & 38 deletions lib/repositories/auth_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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].
Expand Down Expand Up @@ -100,11 +99,11 @@ final authRepositoryProvider = Provider<AuthRepository>((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;
Expand Down Expand Up @@ -300,42 +299,44 @@ 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<User?> 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<User>(
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<User?> 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
/// calls.
///
/// On missing or rejected credentials, destroys the session and returns a
/// never-completing future (router guard handles redirect).
Future<User> _fetchUserFromNetwork() async {
Future<void> refreshUser() async {
final user = await _database.select(_database.users).getSingleOrNull();
if (user == null) {
throw StateError('Cannot fetch user profile when not logged in');
Expand All @@ -348,7 +349,7 @@ class AuthRepository {
try {
userDto = await _reauthenticate();
} on _AuthFailedException {
return Completer<User>().future;
return Completer<void>().future;
}

final (profile, records) = await withAuth(
Expand All @@ -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(
Expand Down Expand Up @@ -411,8 +412,6 @@ class AuthRepository {
),
);
}

return _database.select(_database.users).getSingle();
});
}

Expand Down Expand Up @@ -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<UserRegistration?> 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<UserRegistration?> watchActiveRegistration() {
return (_database.select(_database.userRegistrations)
..where(
(r) => r.enrollmentStatus.equalsValue(EnrollmentStatus.learning),
Expand All @@ -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.
Expand Down
Loading
Loading