diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 326b32e4..d33ba99f 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -34,8 +34,6 @@ android {
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
- // OIDC redirect scheme for flutter_appauth's RedirectUriReceiverActivity.
- // Must match OidcConfig.redirectScheme (lib/config/oidc_config.dart).
}
flavorDimensions += "environment"
diff --git a/android/app/src/dev/res/xml/network_security_config.xml b/android/app/src/dev/res/xml/network_security_config.xml
new file mode 100644
index 00000000..9c924a26
--- /dev/null
+++ b/android/app/src/dev/res/xml/network_security_config.xml
@@ -0,0 +1,11 @@
+
+
+
+
+ localhost
+ 127.0.0.1
+ 10.0.2.2
+ 192.168.188.162
+ 192.168.188.93
+
+
diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml
index 23606f23..ecba09e0 100644
--- a/android/app/src/main/res/xml/network_security_config.xml
+++ b/android/app/src/main/res/xml/network_security_config.xml
@@ -1,10 +1,5 @@
+
-
- localhost
- 127.0.0.1
- 10.0.2.2
- 192.168.188.162
- 192.168.188.93
-
+
diff --git a/lib/services/linked_accounts_controller.dart b/lib/services/linked_accounts_controller.dart
index ed57a7ef..e5319aed 100644
--- a/lib/services/linked_accounts_controller.dart
+++ b/lib/services/linked_accounts_controller.dart
@@ -76,9 +76,27 @@ class LinkedAccountsController extends ChangeNotifier with WidgetsBindingObserve
return out;
}
+ Future? _loadInFlight;
+
/// Providers and accounts, fresh from the server. A failure is kept in [error] and the last
/// good lists stay on screen.
- Future load() async {
+ ///
+ /// Listeners may call this while they are being notified, so a call during a load joins it.
+ /// The load counts as running until its last notification is out; otherwise a listener that
+ /// loads when nothing is [loaded] would start over after every failure.
+ Future load() {
+ final running = _loadInFlight;
+ if (running != null) return running;
+ final done = Completer();
+ _loadInFlight = done.future;
+ _load().whenComplete(() {
+ _loadInFlight = null;
+ done.complete();
+ });
+ return done.future;
+ }
+
+ Future _load() async {
_loading = true;
_error = null;
_notify();
diff --git a/lib/services/private_data_adapter.dart b/lib/services/private_data_adapter.dart
index 99a9eaf2..dd0c6903 100644
--- a/lib/services/private_data_adapter.dart
+++ b/lib/services/private_data_adapter.dart
@@ -16,34 +16,68 @@ class PrivateDataAdapter {
return d == null ? null : Date(d.year, d.month, d.day);
}
+ static String _classId(String subject) => 'private-class-$subject';
+
+ static String? _classIdOrNull(String? subject) => subject == null || subject.isEmpty ? null : _classId(subject);
+
+ /// The exam a grade belongs to. A grade without an exam id still needs one: the pages list grades through their exam.
+ static String _examIdOf(PrivateGrade g) => g.examId?.isNotEmpty == true ? g.examId! : 'private-grade-${g.id ?? '${g.subject}-${g.date}-${g.score}'}';
+
static GradeDto grade(PrivateGrade g) => GradeDto((b) => b
..id = g.id
..score = g.score
- ..examId = g.examId
+ ..examId = _examIdOf(g)
..schoolUserId = privateSchoolUserId);
static ExamDto exam(PrivateExam e) => ExamDto((b) => b
..id = e.id
..name = (e.name?.isNotEmpty == true ? e.name! : e.subject) ?? 'Exam'
..classAverage = 0
+ ..date = _dateOnly(e.startDate)
+ ..classId = _classIdOrNull(e.subject)
..description = e.comment);
+ /// The stateless exam list holds upcoming exams, while grades mostly belong to past ones. An exam
+ /// is made up from the grade for those, as the account-mode sync does on the server.
+ static ExamDto _examFromGrade(PrivateGrade g) {
+ final date = _dateOnly(g.date);
+ return ExamDto((b) => b
+ ..id = _examIdOf(g)
+ ..name = g.comment?.isNotEmpty == true ? g.comment! : (date == null ? (g.subject ?? 'Grade') : 'Grade ${date.day}.${date.month}.${date.year}')
+ ..classAverage = 0
+ ..date = date
+ ..classId = _classIdOrNull(g.subject));
+ }
+
static AbsenceDto absence(PrivateAbsence a) {
final from = _dateOr(a.from, DateTime.now());
return AbsenceDto((b) => b
..id = a.id
- ..reason = (a.reason?.isNotEmpty == true ? a.reason! : 'Absence')
+ ..reason = a.reason?.isNotEmpty == true ? a.reason! : (a.subject?.isNotEmpty == true ? a.subject! : 'Absence')
..type = AbsenceType.absence
+ ..isExcused = a.excused
..from = from
..until = _dateOr(a.to, from)
..schoolUserId = privateSchoolUserId);
}
+ /// Mirrors the words the server's holiday sync treats as a day off.
+ static const _dayOffWords = ['ferien', 'feiertag', 'schulfrei', 'unterrichtsfrei', 'brückentag', 'brueckentag', 'vacances', 'vacanze', 'congé', 'conge', 'neujahr', 'berchtold', 'karfreitag', 'ostern', 'ostermontag', 'tag der arbeit', 'auffahrt', 'pfingst', 'fronleichnam', 'bundesfeier', 'nationalfeiertag', 'maria himmelfahrt', 'allerheiligen', 'maria empfängnis', 'weihnacht', 'stephan'];
+
+ /// The stateless routes pass the school's own event type on: "Timetable", "Event", "Exam" and the like.
+ static AgendaEntryType _entryType(PrivateAgendaEvent e) {
+ final title = (e.title ?? '').toLowerCase();
+ return switch (e.type?.toLowerCase()) {
+ 'holiday' => AgendaEntryType.holiday,
+ 'exam' || 'test' => AgendaEntryType.test,
+ 'event' => _dayOffWords.any(title.contains) ? AgendaEntryType.holiday : AgendaEntryType.event,
+ _ => AgendaEntryType.lesson,
+ };
+ }
+
static AgendaEntryDto agendaEntry(PrivateAgendaEvent e) => AgendaEntryDto((b) => b
..id = e.id
- ..entryType = e.type?.toLowerCase() == 'holiday'
- ? AgendaEntryType.holiday
- : AgendaEntryType.lesson
+ ..entryType = _entryType(e)
..title = (e.title?.isNotEmpty == true ? e.title! : 'Entry')
..place = e.room
..description = e.comment
@@ -51,11 +85,17 @@ class PrivateDataAdapter {
..endDate = _date(e.endDate)
..schoolUserId = privateSchoolUserId);
- static SchoolUserDto schoolUser(
- PrivateUserInfo? info,
- List grades,
- List absences,
- ) =>
+ static List _subjects(List grades, List exams) {
+ final subjects = {
+ for (final g in grades)
+ if (g.subject?.isNotEmpty == true) g.subject!,
+ for (final e in exams)
+ if (e.subject?.isNotEmpty == true) e.subject!,
+ };
+ return subjects.toList()..sort();
+ }
+
+ static SchoolUserDto schoolUser(PrivateUserInfo? info, List grades, List absences, [List exams = const []]) =>
SchoolUserDto((b) => b
..id = privateSchoolUserId
..firstName = info?.firstName ?? ''
@@ -64,35 +104,55 @@ class PrivateDataAdapter {
..entryDate = _dateOnly(info?.entryDate)
..role = Roles.student
..grades = ListBuilder(grades.map(grade))
- ..absences = ListBuilder(absences.map(absence)));
+ ..absences = ListBuilder(absences.map(absence))
+ ..classes = ListBuilder(_subjects(grades, exams).map((s) => UserClassDto((c) => c
+ ..classId = _classId(s)
+ ..className = s))));
- static List exams(List e) =>
- e.map(exam).toList(growable: false);
+ /// The school's exams, plus one for every grade that has none in that list.
+ static List exams(List e, [List grades = const []]) {
+ final known = {for (final x in e) x.id};
+ return [
+ ...e.map(exam),
+ for (final g in grades)
+ if (known.add(_examIdOf(g))) _examFromGrade(g),
+ ];
+ }
static List absencesList(List a) =>
a.map(absence).toList(growable: false);
- static List agenda(List e) =>
- e.map(agendaEntry).toList(growable: false);
+ /// The school's agenda, plus the exams still to come, which account mode also shows in the timetable.
+ static List agenda(List e, {List exams = const [], List grades = const []}) {
+ final known = {for (final x in e) x.id};
+ final graded = {for (final g in grades) _examIdOf(g)};
+ return [
+ ...e.map(agendaEntry),
+ for (final x in exams)
+ if (_date(x.startDate) != null && !graded.contains(x.id) && known.add(x.id))
+ AgendaEntryDto((b) => b
+ ..id = x.id
+ ..entryType = AgendaEntryType.test
+ ..title = [if (x.subject?.isNotEmpty == true) x.subject!, if (x.name?.isNotEmpty == true && x.name != x.subject) x.name!].join(': ')
+ ..place = x.room
+ ..description = x.comment
+ ..date = _date(x.startDate)!
+ ..endDate = _date(x.endDate)
+ ..classId = _classIdOrNull(x.subject)
+ ..schoolUserId = privateSchoolUserId),
+ ];
+ }
static List classes(
List grades,
List exams,
) {
- final subjects = {};
- for (final g in grades) {
- if (g.subject != null && g.subject!.isNotEmpty) subjects.add(g.subject!);
- }
- for (final e in exams) {
- if (e.subject != null && e.subject!.isNotEmpty) subjects.add(e.subject!);
- }
- final sorted = subjects.toList()..sort();
- return sorted
+ final all = PrivateDataAdapter.exams(exams, grades);
+ return _subjects(grades, exams)
.map((subject) => ClassDto((b) => b
- ..id = 'private-class-$subject'
+ ..id = _classId(subject)
..name = subject
- ..exams = ListBuilder(
- exams.where((e) => e.subject == subject).map(exam))))
+ ..exams = ListBuilder(all.where((e) => e.classId == _classId(subject)))))
.toList(growable: false);
}
}
diff --git a/lib/services/school_data_service.dart b/lib/services/school_data_service.dart
index 34fb6176..7946d4fa 100644
--- a/lib/services/school_data_service.dart
+++ b/lib/services/school_data_service.dart
@@ -46,6 +46,18 @@ class SchoolDataService extends ChangeNotifier {
Object? get error => _error;
bool get hasLoaded => _hasLoaded;
+ /// Test-only: sets the data directly and notifies, as a finished refresh does.
+ @visibleForTesting
+ void setForTest({SchoolUserDto? me, List exams = const [], List agenda = const [], List absences = const [], List classes = const []}) {
+ _me = me;
+ _exams = exams;
+ _agenda = agenda;
+ _absences = absences;
+ _classes = classes;
+ _hasLoaded = true;
+ notifyListeners();
+ }
+
Map get classNameById {
final out = {};
for (final c in _classes) {
@@ -229,19 +241,21 @@ class SchoolDataService extends ChangeNotifier {
List absences;
List agenda;
List classes;
+ var complete = true;
if (account.accessToken != null) {
final d = await TokenProxyClient.instance.fetchAll(account);
- mine = PrivateDataAdapter.schoolUser(d.userInfo, d.grades, d.absences);
- exams = PrivateDataAdapter.exams(d.exams);
+ complete = d.failedSections == 0;
+ mine = PrivateDataAdapter.schoolUser(d.userInfo, d.grades, d.absences, d.exams);
+ exams = PrivateDataAdapter.exams(d.exams, d.grades);
absences = PrivateDataAdapter.absencesList(d.absences);
- agenda = PrivateDataAdapter.agenda(d.agenda);
+ agenda = PrivateDataAdapter.agenda(d.agenda, exams: d.exams, grades: d.grades);
classes = PrivateDataAdapter.classes(d.grades, d.exams);
} else {
final d = await ScrapeProxyClient.instance.data(account);
- mine = PrivateDataAdapter.schoolUser(d.userInfo, d.grades, const []);
- exams = PrivateDataAdapter.exams(d.exams);
+ mine = PrivateDataAdapter.schoolUser(d.userInfo, d.grades, const [], d.exams);
+ exams = PrivateDataAdapter.exams(d.exams, d.grades);
absences = const [];
- agenda = PrivateDataAdapter.agenda(d.agenda);
+ agenda = PrivateDataAdapter.agenda(d.agenda, exams: d.exams, grades: d.grades);
classes = PrivateDataAdapter.classes(d.grades, d.exams);
}
@@ -254,9 +268,14 @@ class SchoolDataService extends ChangeNotifier {
_reports = const [];
_teachers = const [];
_documents = const [];
- await SchoolDataSnapshotStore.instance.save(
- 'private',
- SchoolDataSnapshot(me: _me, exams: _exams, agenda: _agenda, absences: _absences, classes: _classes, reports: _reports, teachers: _teachers, documents: _documents));
+ // A partial answer is shown, but the last complete snapshot stays on disk for the next launch.
+ if (complete) {
+ await SchoolDataSnapshotStore.instance.save(
+ 'private',
+ SchoolDataSnapshot(me: _me, exams: _exams, agenda: _agenda, absences: _absences, classes: _classes, reports: _reports, teachers: _teachers, documents: _documents));
+ } else {
+ ToastService.error('Some data could not be refreshed', 'Your school did not answer every request. Pull down to try again.');
+ }
_hasLoaded = true;
} catch (e) {
if (gen != _generation) return;
diff --git a/lib/services/token_proxy_client.dart b/lib/services/token_proxy_client.dart
index 5979009f..4c5f224d 100644
--- a/lib/services/token_proxy_client.dart
+++ b/lib/services/token_proxy_client.dart
@@ -2,6 +2,7 @@ import 'dart:convert';
import '../ui/widgets/browser_sign_in.dart';
import 'package:dio/dio.dart';
+import 'package:flutter/foundation.dart';
import '../domain/private_data.dart';
import 'backend_dio.dart';
@@ -15,13 +16,17 @@ import 'totp_service.dart';
/// supplies its own credentials (token + context_state) per request; nothing is
/// stored server-side. The system's stateless route comes from the catalog.
class TokenProxyClient {
- TokenProxyClient._();
+ TokenProxyClient._()
+ : _dio = backendDio(
+ connectTimeout: const Duration(seconds: 10),
+ receiveTimeout: const Duration(seconds: 60),
+ );
static final TokenProxyClient instance = TokenProxyClient._();
- final Dio _dio = backendDio(
- connectTimeout: const Duration(seconds: 10),
- receiveTimeout: const Duration(seconds: 60),
- );
+ @visibleForTesting
+ TokenProxyClient.withDio(this._dio);
+
+ final Dio _dio;
/// Headless credential login (private mode): POST email + password (+ TOTP) to
/// the stateless `/login` and get back tokens + the rotated context_state.
@@ -117,18 +122,36 @@ class TokenProxyClient {
}
}
+ /// Fetches every section at once. One section the school fails to answer does not cost the
+ /// others; only a refused token (the caller refreshes it) or a total failure throws.
Future _fetchAll(PrivateAccount a) async {
- final info = await userInfo(a);
- final g = await grades(a);
- final e = await exams(a);
- final ab = await absences(a);
- final ag = await agenda(a);
+ var failed = 0;
+ DioException? failure;
+ Future section(Future fetch, T fallback) async {
+ try {
+ return await fetch;
+ } on DioException catch (e) {
+ if (e.response?.statusCode == 401) rethrow;
+ failed++;
+ failure ??= e;
+ return fallback;
+ }
+ }
+
+ final info = section(userInfo(a), null);
+ final g = section(grades(a), const []);
+ final e = section(exams(a), const []);
+ final ab = section(absences(a), const []);
+ final ag = section(agenda(a), const []);
+ await Future.wait