academicPerformanceProvider top-level property
Provides score screen state using a cache-first, revalidate-on-demand flow.
This provider is the single integration point between UI rendering, local
persistence, and remote NTUT systems. On normal screen entry, it reads from
Drift and emits quickly so users can see cached scores without waiting for
network round-trips. It then attempts authenticated SSO fetches, resolves
missing course metadata, persists normalized results back to the database,
and emits refreshed data. During explicit pull-to-refresh, the provider
intentionally does not emit cached data first, so the loading indicator only
finishes after the full refresh pipeline completes. If remote fetch fails but
cached data exists, it emits that fallback instead of throwing, allowing the
screen to recover gracefully while clearly distinguishing offline fallback
from a true network refresh through refreshedFromNetwork.
Implementation
final academicPerformanceProvider = StreamProvider.autoDispose<ScorePageState>(
(ref) async* {
final authRepo = ref.watch(authRepositoryProvider);
final portalService = ref.watch(portalServiceProvider);
final queryService = ref.watch(studentQueryServiceProvider);
final courseService = ref.watch(courseServiceProvider);
final db = ref.watch(databaseProvider);
final user = await db.select(db.users).getSingleOrNull();
if (user == null) {
throw StateError('User not found. Please login again.');
}
final cached = await _loadAcademicPerformanceFromDb(
db: db,
userId: user.id,
);
final shouldEmitCachedFirst = !ref.isRefresh;
if (shouldEmitCachedFirst && cached.semesters.isNotEmpty) {
yield cached;
}
try {
final refreshed = await authRepo.withAuth(() async {
await portalService.sso(PortalServiceCode.studentQueryService);
final semesters = _sortScoresWithinSemesters(
await queryService.getAcademicPerformance(),
);
final gpaRows = await queryService.getGPA();
final gpaBySemester = <String, GpaDto>{
for (final row in gpaRows)
if (row.semester.year != null && row.semester.term != null)
_semesterMapKey(row.semester): row,
};
final allCodes = semesters
.expand((s) => s.scores)
.map((s) => s.courseCode)
.whereType<String>()
.map((code) => code.trim())
.where((code) => code.isNotEmpty)
.toSet();
final existingCourses = await (db.select(
db.courses,
)..where((t) => t.code.isIn(allCodes.toList()))).get();
final Map<String, String> courseNames = {
for (final c in existingCourses) c.code: c.nameZh ?? c.code,
};
final missingCodes = allCodes
.where((code) => !courseNames.containsKey(code))
.toList();
if (missingCodes.isNotEmpty) {
await portalService.sso(PortalServiceCode.courseService);
await Future.wait(
missingCodes.map((code) async {
try {
final dto = await courseService.getCourse(code);
if (dto.nameZh != null) {
await db
.into(db.courses)
.insertOnConflictUpdate(
CoursesCompanion.insert(
code: code,
credits: dto.credits ?? 0,
hours: dto.hours ?? 0,
nameZh: Value(dto.nameZh),
nameEn: Value(dto.nameEn),
fetchedAt: Value(DateTime.now()),
),
);
courseNames[code] = dto.nameZh!;
}
} catch (e) {
debugPrint(
'Failed to fetch course metadata for code ($code): $e',
);
}
}),
);
}
await _persistAcademicPerformance(
db: db,
userId: user.id,
semesters: semesters,
gpaBySemester: gpaBySemester,
);
return (
semesters: semesters,
names: courseNames,
gpaBySemester: gpaBySemester,
refreshedFromNetwork: true,
);
});
yield refreshed;
} catch (error) {
if (cached.semesters.isEmpty) {
rethrow;
}
debugPrint('Score refresh failed, showing cached data: $error');
yield cached;
}
},
);