From 24be69174cc8dbc7239dec5c0694b6e3228a5ff4 Mon Sep 17 00:00:00 2001 From: PianoNic <79938743+Pianonic@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:56:54 +0100 Subject: [PATCH 1/9] Stop the accounts load from re-entering itself at startup The absences page reloads its target when the linked accounts change, and resolving the target loads them when none are loaded. load() notified before its first await, so the listener called load() again in the same stack: a synchronous loop on the UI thread that froze every signed-in launch on the spinner and grew the heap until it was exhausted. A load now joins one in flight, including during its final notification, so a failed load is not retried by its own listener. --- lib/services/linked_accounts_controller.dart | 20 ++++++- test/linked_accounts_load_test.dart | 56 ++++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 test/linked_accounts_load_test.dart diff --git a/lib/services/linked_accounts_controller.dart b/lib/services/linked_accounts_controller.dart index ed57a7e..e5319ae 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/test/linked_accounts_load_test.dart b/test/linked_accounts_load_test.dart new file mode 100644 index 0000000..99f94dd --- /dev/null +++ b/test/linked_accounts_load_test.dart @@ -0,0 +1,56 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:schuly/services/absence_reporting_service.dart'; +import 'package:schuly/services/linked_accounts_controller.dart'; + +import 'fakes/fake_linking_api.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + /// The startup hang: the absences page reloads its target whenever the accounts change, and + /// resolving the target loads the accounts when none are loaded yet. Each load notified the + /// listener before its first await, so this recursed forever on the UI thread. + ({LinkedAccountsController controller, int Function() depth}) resolvingListener(FakeLinkingApi api) { + final controller = LinkedAccountsController(api: api, refreshDependents: () async {}, clearCaches: () async {}); + var depth = 0; + controller.addListener(() { + // A stop far above what a working load needs, so a regression fails instead of eating the heap. + if (depth++ > 20) return; + AbsenceReportingService.instance.resolveTarget(isPrivate: false, accounts: controller); + }); + return (controller: controller, depth: () => depth); + } + + test('a listener that resolves the target does not re-enter load', () async { + final probe = resolvingListener(FakeLinkingApi()); + await probe.controller.load(); + expect(probe.controller.loaded, isTrue); + expect(probe.depth(), lessThanOrEqualTo(2)); + }); + + test('a failing load is not retried by its own listener', () async { + final api = FakeLinkingApi()..accountsFailure = Exception('offline'); + final probe = resolvingListener(api); + await probe.controller.load(); + await pumpEventQueue(); + expect(probe.controller.loaded, isFalse); + expect(probe.controller.error, isNotNull); + expect(api.accountsCalls, 1); + expect(probe.depth(), lessThanOrEqualTo(2)); + }); + + test('concurrent loads share one request', () async { + final api = FakeLinkingApi(); + final controller = LinkedAccountsController(api: api, refreshDependents: () async {}, clearCaches: () async {}); + await Future.wait([controller.load(), controller.load()]); + expect(api.accountsCalls, 1); + }); + + test('a later load fetches again', () async { + final api = FakeLinkingApi(); + final controller = LinkedAccountsController(api: api, refreshDependents: () async {}, clearCaches: () async {}); + await controller.load(); + await controller.load(); + expect(api.accountsCalls, 2); + }); +} From 08b0c143811ff62f202a2c3b0cd9e8972f286b49 Mon Sep 17 00:00:00 2001 From: PianoNic <79938743+Pianonic@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:59:12 +0100 Subject: [PATCH 2/9] Show school data on the dashboard pages as soon as it arrives Closes #513. The dashboard builds its pages as const, so rebuilding the dashboard never reached them: home, grades and absences kept what they saw on first build until the app restarted, and the timetable stopped updating once it had picked a day. Each page now follows SchoolDataService itself. --- lib/services/school_data_service.dart | 12 +++ lib/ui/absences/absences_page.dart | 5 ++ lib/ui/grades/grades_page.dart | 15 ++++ lib/ui/home/home_page.dart | 5 +- lib/ui/timetable/timetable_page.dart | 9 +- test/dashboard_pages_refresh_test.dart | 113 +++++++++++++++++++++++++ 6 files changed, 155 insertions(+), 4 deletions(-) create mode 100644 test/dashboard_pages_refresh_test.dart diff --git a/lib/services/school_data_service.dart b/lib/services/school_data_service.dart index 34fb617..9757c34 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) { diff --git a/lib/ui/absences/absences_page.dart b/lib/ui/absences/absences_page.dart index 4275420..c443146 100644 --- a/lib/ui/absences/absences_page.dart +++ b/lib/ui/absences/absences_page.dart @@ -33,6 +33,8 @@ class _AbsencesPageState extends State { ActiveAccountService.instance.addListener(_loadReported); LinkedAccountsController.instance.addListener(_loadReported); AppModeService.instance.addListener(_loadReported); + // This page is const in the dashboard, so its parent rebuilding does not reach it. + SchoolDataService.instance.addListener(_onDataChanged); _loadReported(); } @@ -41,9 +43,12 @@ class _AbsencesPageState extends State { ActiveAccountService.instance.removeListener(_loadReported); LinkedAccountsController.instance.removeListener(_loadReported); AppModeService.instance.removeListener(_loadReported); + SchoolDataService.instance.removeListener(_onDataChanged); super.dispose(); } + void _onDataChanged() => setState(() {}); + Future _loadReported() async { final gen = ++_generation; AbsenceReportTarget? target; diff --git a/lib/ui/grades/grades_page.dart b/lib/ui/grades/grades_page.dart index 70bf150..cf18dcc 100644 --- a/lib/ui/grades/grades_page.dart +++ b/lib/ui/grades/grades_page.dart @@ -22,6 +22,21 @@ class _GradesView extends StatefulWidget { class _GradesViewState extends State<_GradesView> { int? _selectedKey; + // This page is const in the dashboard, so its parent rebuilding does not reach it. + @override + void initState() { + super.initState(); + SchoolDataService.instance.addListener(_onDataChanged); + } + + @override + void dispose() { + SchoolDataService.instance.removeListener(_onDataChanged); + super.dispose(); + } + + void _onDataChanged() => setState(() {}); + static int _semesterKey(Date? d) { if (d == null) return 0; if (d.month >= 8) return d.year * 10 + 1; diff --git a/lib/ui/home/home_page.dart b/lib/ui/home/home_page.dart index 43bddc3..184229e 100644 --- a/lib/ui/home/home_page.dart +++ b/lib/ui/home/home_page.dart @@ -12,8 +12,11 @@ import '../timetable/lesson_tile.dart'; class HomePage extends StatelessWidget { const HomePage({super.key}); + // This page is const in the dashboard, so its parent rebuilding does not reach it. @override - Widget build(BuildContext context) { + Widget build(BuildContext context) => ListenableBuilder(listenable: SchoolDataService.instance, builder: (context, _) => _content(context)); + + Widget _content(BuildContext context) { final svc = SchoolDataService.instance; final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); diff --git a/lib/ui/timetable/timetable_page.dart b/lib/ui/timetable/timetable_page.dart index c64c983..ef46009 100644 --- a/lib/ui/timetable/timetable_page.dart +++ b/lib/ui/timetable/timetable_page.dart @@ -21,22 +21,25 @@ class _TimetablePageState extends State { @override void initState() { super.initState(); - SchoolDataService.instance.addListener(_autoAnchor); + SchoolDataService.instance.addListener(_onDataChanged); _autoAnchor(); } @override void dispose() { - SchoolDataService.instance.removeListener(_autoAnchor); + SchoolDataService.instance.removeListener(_onDataChanged); super.dispose(); } + // This page is const in the dashboard, so its parent rebuilding does not reach it. + void _onDataChanged() => setState(_autoAnchor); + void _autoAnchor() { if (_userPicked || _selected != null) return; final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); final day = _nearestEntryDay(SchoolDataService.instance.agenda, today); - if (day != null && mounted) setState(() => _selected = day); + if (day != null) _selected = day; } static DateTime? _nearestEntryDay(List agenda, DateTime today) { diff --git a/test/dashboard_pages_refresh_test.dart b/test/dashboard_pages_refresh_test.dart new file mode 100644 index 0000000..6986e14 --- /dev/null +++ b/test/dashboard_pages_refresh_test.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:forui/forui.dart'; +import 'package:schuly/l10n/app_localizations.dart'; +import 'package:schuly/services/school_data_service.dart'; +import 'package:schuly/services/secure_store.dart'; +import 'package:schuly/ui/absences/absences_page.dart'; +import 'package:schuly/ui/grades/grades_page.dart'; +import 'package:schuly/ui/home/home_page.dart'; +import 'package:schuly/ui/timetable/timetable_page.dart'; +import 'package:schuly_api/schuly_api.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _EmptyStore implements SecureKeyValue { + @override + Future read(String key) async => null; + + @override + Future write(String key, String value) async {} + + @override + Future delete(String key) async {} + + @override + Future deleteAll() async {} +} + +Widget _host(Widget page) => MaterialApp( + localizationsDelegates: const [ + ...AppLocalizations.localizationsDelegates, + ...FLocalizations.localizationsDelegates, + ], + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => FTheme(data: FThemes.zinc.light, child: FToaster(child: child!)), + home: Material(child: page), + ); + +/// The dashboard builds its pages as `const`, so each one has to follow the data itself (#513). +void main() { + final today = DateTime.now(); + final exam = ExamDto((b) => b + ..id = 'e1' + ..name = 'Algebra test' + ..classAverage = 4.5 + ..date = Date(today.year, today.month, today.day)); + final me = SchoolUserDto((b) => b + ..id = 'me' + ..firstName = 'A' + ..lastName = 'B' + ..email = 'a@example.com' + ..role = Roles.student + ..grades.add(GradeDto((g) => g + ..id = 'g1' + ..score = 5.5 + ..examId = 'e1'))); + AgendaEntryDto lesson(String title) => AgendaEntryDto((b) => b + ..id = title + ..entryType = AgendaEntryType.lesson + ..title = title + ..date = DateTime(today.year, today.month, today.day, 10) + ..endDate = DateTime(today.year, today.month, today.day, 11)); + final absence = AbsenceDto((b) => b + ..id = 'a1' + ..reason = 'Dentist' + ..type = AbsenceType.absence + ..from = today + ..until = today); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + SecureStore.useBackend(_EmptyStore()); + SchoolDataService.instance.clear(); + }); + tearDown(SchoolDataService.instance.clear); + + testWidgets('home shows data that arrives after its first build', (tester) async { + await tester.pumpWidget(_host(const HomePage())); + expect(find.textContaining('Algebra test'), findsNothing); + SchoolDataService.instance.setForTest(me: me, exams: [exam]); + await tester.pump(); + expect(find.textContaining('Algebra test'), findsWidgets); + }); + + testWidgets('grades shows data that arrives after its first build', (tester) async { + await tester.pumpWidget(_host(const GradesPage())); + expect(find.text('No grades yet'), findsOneWidget); + SchoolDataService.instance.setForTest(me: me, exams: [exam]); + await tester.pump(); + expect(find.text('No grades yet'), findsNothing); + }); + + testWidgets('absences shows data that arrives after its first build', (tester) async { + await tester.pumpWidget(_host(const AbsencesPage())); + await tester.pump(); + expect(find.text('No absences'), findsOneWidget); + SchoolDataService.instance.setForTest(me: me, absences: [absence]); + await tester.pump(); + expect(find.text('No absences'), findsNothing); + // Let the page's own account lookup finish. + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 50))); + await tester.pump(const Duration(seconds: 1)); + }); + + testWidgets('the timetable follows a refresh after it picked its day', (tester) async { + SchoolDataService.instance.setForTest(me: me, agenda: [lesson('Maths')]); + await tester.pumpWidget(_host(const TimetablePage())); + await tester.pump(); + expect(find.textContaining('Maths'), findsWidgets); + SchoolDataService.instance.setForTest(me: me, agenda: [lesson('Physics')]); + await tester.pump(); + expect(find.textContaining('Physics'), findsWidgets); + }); +} From 28ef931840100b2a631bcc267b989a763aac2650 Mon Sep 17 00:00:00 2001 From: PianoNic <79938743+Pianonic@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:03:42 +0100 Subject: [PATCH 3/9] Show grades, classes and holidays from the private-mode routes Grades whose exam was not in the upcoming-exams list vanished, exams had no date or class, the grade sections had no names, holidays arrived as lessons, and upcoming exams were missing from the timetable. The adapter now maps what the stateless routes already send. --- lib/services/private_data_adapter.dart | 114 +++++++++++++++++++------ lib/services/school_data_service.dart | 12 +-- lib/ui/grades/grades_page.dart | 2 +- test/private_data_adapter_test.dart | 82 ++++++++++++++++++ 4 files changed, 176 insertions(+), 34 deletions(-) create mode 100644 test/private_data_adapter_test.dart diff --git a/lib/services/private_data_adapter.dart b/lib/services/private_data_adapter.dart index 99a9eaf..dd0c690 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 9757c34..ece2930 100644 --- a/lib/services/school_data_service.dart +++ b/lib/services/school_data_service.dart @@ -243,17 +243,17 @@ class SchoolDataService extends ChangeNotifier { List classes; 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); + 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); } diff --git a/lib/ui/grades/grades_page.dart b/lib/ui/grades/grades_page.dart index cf18dcc..8b2e29e 100644 --- a/lib/ui/grades/grades_page.dart +++ b/lib/ui/grades/grades_page.dart @@ -187,7 +187,7 @@ class _ClassSection extends StatelessWidget { title: Text(e.name), subtitle: Text([ if ((myGrades[e.id]?.weighting ?? 1) != 1) 'weight ${formatGrade(myGrades[e.id]!.weighting ?? 1)}', - 'class ⌀ ${formatGrade(e.classAverage)}', + if (isGraded(e.classAverage)) 'class ⌀ ${formatGrade(e.classAverage)}', ].join(' · ')), suffix: GradePill(myGrades[e.id]?.score), onPress: () => _showExamDetail(context, e, myGrades[e.id]), diff --git a/test/private_data_adapter_test.dart b/test/private_data_adapter_test.dart new file mode 100644 index 0000000..5720bcc --- /dev/null +++ b/test/private_data_adapter_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:schuly/domain/private_data.dart'; +import 'package:schuly/services/private_data_adapter.dart'; +import 'package:schuly_api/schuly_api.dart'; + +void main() { + const pastGrade = PrivateGrade(id: 'g1', examId: 'past', subject: 'Maths', score: 5.5, date: '2026-03-12'); + const noExamId = PrivateGrade(id: 'g2', subject: 'Physics', score: 4.5, date: '2026-09-02', comment: 'Optics'); + const upcoming = PrivateExam(id: 'next', name: 'Algebra', subject: 'Maths', startDate: '2030-05-02T08:00:00Z', endDate: '2030-05-02T09:00:00Z', room: 'A1'); + + group('exams', () { + final exams = PrivateDataAdapter.exams(const [upcoming], const [pastGrade, noExamId]); + + test('every grade has an exam, so the grades page can list it', () { + final user = PrivateDataAdapter.schoolUser(null, const [pastGrade, noExamId], const [], const [upcoming]); + final ids = exams.map((e) => e.id).toSet(); + expect(user.grades!.map((g) => g.examId), everyElement(isIn(ids))); + }); + + test('an exam made from a grade carries its date, class and note', () { + final optics = exams.singleWhere((e) => e.name == 'Optics'); + expect(optics.date, Date(2026, 9, 2)); + expect(optics.classId, 'private-class-Physics'); + }); + + test('a school exam carries its date and class', () { + final exam = exams.singleWhere((e) => e.id == 'next'); + expect(exam.date, isNotNull); + expect(exam.classId, 'private-class-Maths'); + }); + + test('a grade whose exam is in the list adds no second exam', () { + const graded = PrivateExam(id: 'past', name: 'Fractions', subject: 'Maths'); + expect(PrivateDataAdapter.exams(const [graded], const [pastGrade]).map((e) => e.name), ['Fractions']); + }); + }); + + test('the user is in a class for each subject, which names the grade sections', () { + final user = PrivateDataAdapter.schoolUser(null, const [pastGrade, noExamId], const [], const [upcoming]); + expect(user.classes!.map((c) => c.className), ['Maths', 'Physics']); + expect(user.classes!.first.classId, 'private-class-Maths'); + }); + + test('classes list the exams made from grades', () { + final classes = PrivateDataAdapter.classes(const [pastGrade, noExamId], const [upcoming]); + expect(classes.singleWhere((c) => c.name == 'Physics').exams!.map((e) => e.name), ['Optics']); + expect(classes.singleWhere((c) => c.name == 'Maths').exams, hasLength(2)); + }); + + group('agenda', () { + AgendaEntryType typeOf(String? type, String title) => PrivateDataAdapter.agendaEntry(PrivateAgendaEvent(id: 'x', title: title, type: type, startDate: '2030-01-01')).entryType; + + test('the school event types map to the matching entry type', () { + expect(typeOf('Timetable', 'Maths'), AgendaEntryType.lesson); + expect(typeOf('Exam', 'Maths'), AgendaEntryType.test); + expect(typeOf('Event', 'Sports day'), AgendaEntryType.event); + expect(typeOf('Event', 'Herbstferien'), AgendaEntryType.holiday); + expect(typeOf(null, 'Course day'), AgendaEntryType.lesson); + }); + + test('an exam still to come shows in the timetable', () { + final agenda = PrivateDataAdapter.agenda(const [], exams: const [upcoming]); + final entry = agenda.single; + expect(entry.entryType, AgendaEntryType.test); + expect(entry.title, 'Maths: Algebra'); + expect(entry.place, 'A1'); + }); + + test('an exam already graded or already on the agenda is not added', () { + const graded = PrivateGrade(id: 'g', examId: 'next', subject: 'Maths', score: 5); + expect(PrivateDataAdapter.agenda(const [], exams: const [upcoming], grades: const [graded]), isEmpty); + const listed = PrivateAgendaEvent(id: 'next', title: 'Algebra', type: 'Exam', startDate: '2030-05-02'); + expect(PrivateDataAdapter.agenda(const [listed], exams: const [upcoming]), hasLength(1)); + }); + }); + + test('an absence keeps whether it is excused, and falls back to its subject as the reason', () { + final absence = PrivateDataAdapter.absence(const PrivateAbsence(id: 'a', from: '2026-01-05', subject: 'Maths', excused: true)); + expect(absence.isExcused, isTrue); + expect(absence.reason, 'Maths'); + }); +} From 7bb62b774ebfb283d4a8ed1d943fbe646285d193 Mon Sep 17 00:00:00 2001 From: PianoNic <79938743+Pianonic@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:04:00 +0100 Subject: [PATCH 4/9] Keep the private-mode sections that answered when one fails One failing endpoint threw away the whole refresh, and the five requests ran one after the other. They now run together, a failed section stays empty with a notice, and the last complete snapshot is kept on disk. --- lib/services/school_data_service.dart | 13 ++++-- lib/services/token_proxy_client.dart | 57 ++++++++++++++++++++------- test/token_proxy_client_test.dart | 51 ++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 18 deletions(-) create mode 100644 test/token_proxy_client_test.dart diff --git a/lib/services/school_data_service.dart b/lib/services/school_data_service.dart index ece2930..7946d4f 100644 --- a/lib/services/school_data_service.dart +++ b/lib/services/school_data_service.dart @@ -241,8 +241,10 @@ class SchoolDataService extends ChangeNotifier { List absences; List agenda; List classes; + var complete = true; if (account.accessToken != null) { final d = await TokenProxyClient.instance.fetchAll(account); + 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); @@ -266,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 5979009..4c5f224 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([info, g, e, ab, ag]); + if (failed == 5) throw failure!; return TokenPrivateData( - userInfo: info, - grades: g, - exams: e, - absences: ab, - agenda: ag, + userInfo: await info, + grades: await g, + exams: await e, + absences: await ab, + agenda: await ag, + failedSections: failed, ); } @@ -239,11 +262,15 @@ class TokenPrivateData { final List exams; final List absences; final List agenda; + + /// How many sections could not be fetched and are empty here. + final int failedSections; const TokenPrivateData({ required this.userInfo, required this.grades, required this.exams, required this.absences, required this.agenda, + this.failedSections = 0, }); } diff --git a/test/token_proxy_client_test.dart b/test/token_proxy_client_test.dart new file mode 100644 index 0000000..f685990 --- /dev/null +++ b/test/token_proxy_client_test.dart @@ -0,0 +1,51 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:schuly/services/private_account_store.dart'; +import 'package:schuly/services/token_proxy_client.dart'; + +class _FakeServer implements HttpClientAdapter { + _FakeServer(this.statusFor); + + final int Function(String path) statusFor; + final List requested = []; + + @override + Future fetch(RequestOptions options, Stream? requestStream, Future? cancelFuture) async { + requested.add(options.path); + final status = statusFor(options.path); + final body = options.path.endsWith('/userinfo') ? '{"firstName":"A"}' : (options.path.endsWith('/grades') ? '[{"id":"g1","score":5}]' : '[]'); + return ResponseBody.fromString(status == 200 ? body : jsonEncode({'code': 'failed'}), status, headers: { + Headers.contentTypeHeader: [Headers.jsonContentType], + }); + } + + @override + void close({bool force = false}) {} +} + +void main() { + const account = PrivateAccount(systemKey: 's', loginMethod: 'm', baseUrl: 'https://school.example', displayName: 'School', statelessBasePath: '/stateless', accessToken: 'token'); + + TokenProxyClient client(_FakeServer server) => TokenProxyClient.withDio(Dio(BaseOptions(baseUrl: 'https://backend.example'))..httpClientAdapter = server); + + test('a section the school fails to answer does not cost the others', () async { + final server = _FakeServer((path) => path.endsWith('/absences') ? 502 : 200); + final data = await client(server).fetchAll(account); + expect(data.grades, hasLength(1)); + expect(data.userInfo?.firstName, 'A'); + expect(data.absences, isEmpty); + expect(data.failedSections, 1); + }); + + test('a full answer reports no failed section', () async { + final data = await client(_FakeServer((_) => 200)).fetchAll(account); + expect(data.failedSections, 0); + }); + + test('nothing answering at all is an error', () async { + expect(client(_FakeServer((_) => 502)).fetchAll(account), throwsA(isA())); + }); +} From 704d1c7c3e0d5b208b43e7000509f03992d342d3 Mon Sep 17 00:00:00 2001 From: PianoNic <79938743+Pianonic@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:14:21 +0100 Subject: [PATCH 5/9] Draw SVG provider icons on the sign-in buttons Image.network cannot decode SVG, so a provider whose IconUrl is an SVG showed no icon. Adds flutter_svg and a RemoteIcon widget that handles both raster and SVG, with either URL shape. --- lib/ui/auth/sign_in_screen.dart | 3 ++- lib/ui/widgets/remote_icon.dart | 22 ++++++++++++++++++ pubspec.lock | 40 +++++++++++++++++++++++++++++++++ pubspec.yaml | 1 + test/remote_icon_test.dart | 20 +++++++++++++++++ 5 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 lib/ui/widgets/remote_icon.dart create mode 100644 test/remote_icon_test.dart diff --git a/lib/ui/auth/sign_in_screen.dart b/lib/ui/auth/sign_in_screen.dart index 4f3d82d..acfd19e 100644 --- a/lib/ui/auth/sign_in_screen.dart +++ b/lib/ui/auth/sign_in_screen.dart @@ -7,6 +7,7 @@ import 'package:forui/forui.dart'; import '../../l10n/app_localizations.dart'; import '../../services/auth_service.dart'; import '../update/check_for_updates.dart'; +import '../widgets/remote_icon.dart'; import '../privacy/privacy_notice_screen.dart'; enum _Step { signIn, register, code, verifyEmail, forgot } @@ -379,7 +380,7 @@ class _SignInScreenState extends State { FButton( style: FButtonStyle.outline(), onPress: _busy ? null : () => _signInWith(provider), - prefix: provider.iconUrl == null ? null : Image.network(provider.iconUrl!, width: 18, height: 18, errorBuilder: (_, _, _) => const SizedBox.shrink()), + prefix: provider.iconUrl == null ? null : RemoteIcon(provider.iconUrl!, size: 18), child: Text('Continue with ${provider.displayName}'), ), FButton(style: FButtonStyle.ghost(), onPress: () => _go(_Step.forgot), child: const Text('Forgot password?')), diff --git a/lib/ui/widgets/remote_icon.dart b/lib/ui/widgets/remote_icon.dart new file mode 100644 index 0000000..4f61128 --- /dev/null +++ b/lib/ui/widgets/remote_icon.dart @@ -0,0 +1,22 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +bool isSvgUrl(String url) => Uri.tryParse(url)?.path.toLowerCase().endsWith('.svg') ?? false; + +/// An icon the server names by URL, which may be a raster image or an SVG. `Image.network` cannot +/// draw SVG, so an `.svg` path goes straight to the SVG renderer, and an image that fails to decode +/// gets a second try there because a URL need not end in its format. Draws nothing when both fail. +class RemoteIcon extends StatelessWidget { + const RemoteIcon(this.url, {super.key, required this.size}); + + final String url; + final double size; + + @override + Widget build(BuildContext context) { + if (isSvgUrl(url)) return _svg(); + return Image.network(url, width: size, height: size, errorBuilder: (_, _, _) => _svg()); + } + + Widget _svg() => SvgPicture.network(url, width: size, height: size, errorBuilder: (_, _, _) => SizedBox(width: size, height: size)); +} diff --git a/pubspec.lock b/pubspec.lock index f732985..462844a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -339,6 +339,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" + url: "https://pub.dev" + source: hosted + version: "2.3.0" flutter_test: dependency: "direct dev" description: flutter @@ -621,6 +629,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" path_provider: dependency: "direct main" description: @@ -945,6 +961,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.6.0" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "9d0e3b9cb16542ad660daee871e726a10d13a93b7b5391677c3160e8f5e83935" + url: "https://pub.dev" + source: hosted + version: "1.2.3" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "4dca4feb77dc3ec7f6e27e49c53241eb8217f55e4f9b12599a27f8903bca5682" + url: "https://pub.dev" + source: hosted + version: "1.3.0" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 881fe47..f7498da 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -68,6 +68,7 @@ dependencies: flutter_web_auth_2: ^5.1.0 package_info_plus: ^9.0.1 markdown: ^7.3.1 + flutter_svg: ^2.2.0 dev_dependencies: flutter_test: diff --git a/test/remote_icon_test.dart b/test/remote_icon_test.dart new file mode 100644 index 0000000..7dc6b43 --- /dev/null +++ b/test/remote_icon_test.dart @@ -0,0 +1,20 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:schuly/ui/widgets/remote_icon.dart'; + +void main() { + test('svg urls are recognised by their path, whatever the query', () { + expect(isSvgUrl('https://server.example/sso-icons/gaggao-id.svg'), isTrue); + expect(isSvgUrl('https://server.example/ICON.SVG?v=2'), isTrue); + expect(isSvgUrl('https://server.example/sso-icons/google.png'), isFalse); + expect(isSvgUrl('https://server.example/icon?file=a.svg'), isFalse); + }); + + testWidgets('a raster url is drawn as an image, not as svg', (tester) async { + await tester.pumpWidget(const Directionality(textDirection: TextDirection.ltr, child: RemoteIcon('https://server.example/sso-icons/google.png', size: 18))); + expect(find.byType(Image), findsOneWidget); + expect(find.byType(SvgPicture), findsNothing); + await tester.pumpWidget(const SizedBox()); + }); +} From 5669e6434ff81581a2788720e4a1f9c9893620f4 Mon Sep 17 00:00:00 2001 From: PianoNic <79938743+Pianonic@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:15:37 +0100 Subject: [PATCH 6/9] Limit cleartext traffic to the dev flavor Closes #470. Release builds no longer carry the developer LAN addresses or allow plain HTTP. The dev flavor overrides the config and keeps its local backends. --- .../dev/res/xml/network_security_config.xml | 11 ++++++++++ .../main/res/xml/network_security_config.xml | 9 ++------ test/network_security_config_test.dart | 21 +++++++++++++++++++ 3 files changed, 34 insertions(+), 7 deletions(-) create mode 100644 android/app/src/dev/res/xml/network_security_config.xml create mode 100644 test/network_security_config_test.dart 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 0000000..9c924a2 --- /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 23606f2..ecba09e 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/test/network_security_config_test.dart b/test/network_security_config_test.dart new file mode 100644 index 0000000..62519e5 --- /dev/null +++ b/test/network_security_config_test.dart @@ -0,0 +1,21 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const path = 'res/xml/network_security_config.xml'; + + test('release flavors allow no cleartext traffic', () { + final main = File('android/app/src/main/$path').readAsStringSync(); + expect(main, contains('cleartextTrafficPermitted="false"')); + expect(main, isNot(contains('cleartextTrafficPermitted="true"'))); + expect(main, isNot(contains('192.168.'))); + expect(File('android/app/src/prod/$path').existsSync(), isFalse); + }); + + test('the dev flavor keeps its local backends', () { + final dev = File('android/app/src/dev/$path').readAsStringSync(); + expect(dev, contains('cleartextTrafficPermitted="true"')); + expect(dev, contains('10.0.2.2')); + }); +} From b7e9a811fd7cad49f073e2d6d5f32fc70bb83ed5 Mon Sep 17 00:00:00 2001 From: PianoNic <79938743+Pianonic@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:17:51 +0100 Subject: [PATCH 7/9] Offer a retry when the dashboard or a class fails to load The dashboard spinner had no end while requests could take minutes, and failures showed a raw exception with no way out. A slow load now offers a retry after 30 seconds, and failures read as a sentence with a retry. --- lib/ui/classes/class_detail_screen.dart | 9 +-- lib/ui/core/ui/load_state.dart | 91 +++++++++++++++++++++++++ lib/ui/dashboard/dashboard_screen.dart | 17 ++--- test/load_state_test.dart | 40 +++++++++++ 4 files changed, 142 insertions(+), 15 deletions(-) create mode 100644 lib/ui/core/ui/load_state.dart create mode 100644 test/load_state_test.dart diff --git a/lib/ui/classes/class_detail_screen.dart b/lib/ui/classes/class_detail_screen.dart index 9133f1b..7b0af0b 100644 --- a/lib/ui/classes/class_detail_screen.dart +++ b/lib/ui/classes/class_detail_screen.dart @@ -5,6 +5,7 @@ import 'package:schuly_api/schuly_api.dart'; import '../../config/backend_config.dart'; import '../../services/api_client.dart'; import '../../services/api_time.dart'; +import '../core/ui/load_state.dart'; import '../core/grade_color.dart'; class ClassDetailScreen extends StatefulWidget { @@ -54,13 +55,7 @@ class _ClassDetailScreenState extends State { if (_loading) { body = const Center(child: FCircularProgress()); } else if (_error != null) { - body = Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: SelectableText('Failed to load: $_error', - style: TextStyle(color: colors.destructive)), - ), - ); + body = LoadFailure(error: _error!, onRetry: _load); } else if (c == null) { body = Center(child: Text('Not found', style: TextStyle(color: colors.mutedForeground))); } else { diff --git a/lib/ui/core/ui/load_state.dart b/lib/ui/core/ui/load_state.dart new file mode 100644 index 0000000..9713c00 --- /dev/null +++ b/lib/ui/core/ui/load_state.dart @@ -0,0 +1,91 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:forui/forui.dart'; + +import '../../../services/api_error.dart'; + +/// A failed load: what went wrong in words a person can act on, and a way to try again. +class LoadFailure extends StatelessWidget { + const LoadFailure({super.key, required this.error, required this.onRetry}); + + final Object error; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) => _Message(text: ApiError.describe(error), color: context.theme.colors.destructive, onRetry: onRetry); +} + +/// A spinner that does not spin forever: after [timeout] it says so and offers a retry, while the +/// load it stands for keeps running and can still finish. +class LoadingWithRetry extends StatefulWidget { + const LoadingWithRetry({super.key, required this.onRetry}); + + /// How long a load may take before the retry appears. + static Duration timeout = const Duration(seconds: 30); + + final VoidCallback onRetry; + + @override + State createState() => _LoadingWithRetryState(); +} + +class _LoadingWithRetryState extends State { + Timer? _timer; + bool _slow = false; + + @override + void initState() { + super.initState(); + _startTimer(); + } + + void _startTimer() { + _timer = Timer(LoadingWithRetry.timeout, () { + if (mounted) setState(() => _slow = true); + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + void _retry() { + setState(() => _slow = false); + _startTimer(); + widget.onRetry(); + } + + @override + Widget build(BuildContext context) { + if (!_slow) return const Center(child: FCircularProgress()); + return _Message(text: 'This is taking longer than usual. Check your connection and try again.', color: context.theme.colors.mutedForeground, onRetry: _retry); + } +} + +class _Message extends StatelessWidget { + const _Message({required this.text, required this.color, required this.onRetry}); + + final String text; + final Color color; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 16, + children: [ + Text(text, textAlign: TextAlign.center, style: TextStyle(color: color)), + FButton(mainAxisSize: MainAxisSize.min, prefix: const Icon(FIcons.refreshCw), onPress: onRetry, child: const Text('Try again')), + ], + ), + ), + ); + } +} diff --git a/lib/ui/dashboard/dashboard_screen.dart b/lib/ui/dashboard/dashboard_screen.dart index 28b07df..2c191e4 100644 --- a/lib/ui/dashboard/dashboard_screen.dart +++ b/lib/ui/dashboard/dashboard_screen.dart @@ -12,6 +12,7 @@ import '../../services/private_account_store.dart'; import '../../services/profile_refresh_requests.dart'; import '../../services/school_data_service.dart'; import '../absences/absences_page.dart'; +import '../core/ui/load_state.dart'; import '../account/account_page.dart'; import '../grades/grades_page.dart'; import '../home/home_page.dart'; @@ -135,6 +136,12 @@ class _DashboardScreenState extends State with WidgetsBindingOb } } + /// Starts the load over. The school data has to be asked for as well: an unchanged active school does not refresh it. + Future _retryLoad() async { + await _bootstrap(); + if (mounted && !AppModeService.instance.isPrivate && ActiveAccountService.instance.schools.isNotEmpty) await SchoolDataService.instance.refresh(); + } + Future _addSchool() async { await runAddAccountFlow(context); } @@ -190,13 +197,7 @@ class _DashboardScreenState extends State with WidgetsBindingOb Positioned.fill( child: ColoredBox( color: colors.background, - child: Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: SelectableText('Failed to load: ${data.error}', - style: TextStyle(color: colors.destructive)), - ), - ), + child: LoadFailure(error: data.error!, onRetry: _retryLoad), ), ), ]); @@ -206,7 +207,7 @@ class _DashboardScreenState extends State with WidgetsBindingOb Positioned.fill( child: ColoredBox( color: colors.background, - child: const Center(child: FCircularProgress()), + child: LoadingWithRetry(onRetry: _retryLoad), ), ), ]); diff --git a/test/load_state_test.dart b/test/load_state_test.dart new file mode 100644 index 0000000..c6efe45 --- /dev/null +++ b/test/load_state_test.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:forui/forui.dart'; +import 'package:schuly/ui/core/ui/load_state.dart'; + +Widget _host(Widget child) => MaterialApp( + builder: (context, child) => FTheme(data: FThemes.zinc.light, child: child!), + home: Material(child: child), + ); + +void main() { + setUp(() => LoadingWithRetry.timeout = const Duration(seconds: 30)); + + testWidgets('a spinner offers a retry after the timeout, and spins again when used', (tester) async { + var retries = 0; + await tester.pumpWidget(_host(LoadingWithRetry(onRetry: () => retries++))); + expect(find.byType(FCircularProgress), findsOneWidget); + expect(find.text('Try again'), findsNothing); + + await tester.pump(const Duration(seconds: 31)); + expect(find.byType(FCircularProgress), findsNothing); + await tester.tap(find.text('Try again')); + await tester.pump(); + expect(retries, 1); + expect(find.byType(FCircularProgress), findsOneWidget); + + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 1)); + }); + + testWidgets('a failure reads as a sentence and can be retried', (tester) async { + var retries = 0; + await tester.pumpWidget(_host(LoadFailure(error: Exception('Server down'), onRetry: () => retries++))); + expect(find.text('Server down.'), findsOneWidget); + await tester.tap(find.text('Try again')); + expect(retries, 1); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 1)); + }); +} From 1c663bb3a5e07387c14618f4030f7f1520a43ee0 Mon Sep 17 00:00:00 2001 From: PianoNic <79938743+Pianonic@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:18:35 +0100 Subject: [PATCH 8/9] Remove dead code from the root, dashboard and link flow screens --- android/app/build.gradle.kts | 2 -- lib/ui/core/ui/root_screen.dart | 9 +-------- lib/ui/dashboard/dashboard_screen.dart | 16 ++++------------ lib/ui/linking/link_flow_screen.dart | 3 --- 4 files changed, 5 insertions(+), 25 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 326b32e..d33ba99 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/lib/ui/core/ui/root_screen.dart b/lib/ui/core/ui/root_screen.dart index 51e3a9f..ab2054c 100644 --- a/lib/ui/core/ui/root_screen.dart +++ b/lib/ui/core/ui/root_screen.dart @@ -58,7 +58,6 @@ class _RootScreenState extends State { bool? _ready; // signed in (account) or connected (private) bool _onboarded = true; // assume seen until loaded, to avoid a flash bool _busy = false; - String? _error; /// The privacy notice still to confirm. Checked in the background, so no network call decides whether the app starts. PrivacyNoticeInfo? _openNotice; @@ -146,10 +145,7 @@ class _RootScreenState extends State { } Future _signIn({bool register = false}) async { - setState(() { - _busy = true; - _error = null; - }); + setState(() => _busy = true); try { final signedIn = await Navigator.of(context).push(MaterialPageRoute(builder: (_) => SignInScreen(register: register))); if (signedIn == true) await _refresh(); @@ -253,9 +249,6 @@ class _RootScreenState extends State { child: const Text('Use without an account'), ), ], - if (_error != null) - SelectableText(_error!, - style: TextStyle(color: colors.destructive)), const CheckForUpdatesLink(), ], ), diff --git a/lib/ui/dashboard/dashboard_screen.dart b/lib/ui/dashboard/dashboard_screen.dart index 2c191e4..be417af 100644 --- a/lib/ui/dashboard/dashboard_screen.dart +++ b/lib/ui/dashboard/dashboard_screen.dart @@ -65,16 +65,12 @@ class _DashboardScreenState extends State with WidgetsBindingOb final stale = _lastClaimsRead == null || DateTime.now().difference(_lastClaimsRead!) > const Duration(seconds: 60); if (!requested && !stale) return; - unawaited(_refreshProfile(bustCache: requested)); - } - - /// Re-reads the signed-in account's name, email and picture. Called on resume - /// so a picture changed in the web app shows up without an app restart. - Future _refreshProfile({required bool bustCache}) async { ProfileRefreshRequests.clear(); - await _loadProfile(); + unawaited(_loadProfile()); } + /// Reads the signed-in account's name, email and picture; again on resume, so + /// a picture changed in the web app shows up without an app restart. Future _loadProfile() async { _lastClaimsRead = DateTime.now(); try { @@ -132,7 +128,7 @@ class _DashboardScreenState extends State with WidgetsBindingOb return; } await SchoolDataService.instance.refresh(); - await _addSchool(); + if (mounted) await runAddAccountFlow(context); } } @@ -142,10 +138,6 @@ class _DashboardScreenState extends State with WidgetsBindingOb if (mounted && !AppModeService.instance.isPrivate && ActiveAccountService.instance.schools.isNotEmpty) await SchoolDataService.instance.refresh(); } - Future _addSchool() async { - await runAddAccountFlow(context); - } - void _openSidebar() { openAccountsSidebar( context, diff --git a/lib/ui/linking/link_flow_screen.dart b/lib/ui/linking/link_flow_screen.dart index 8846470..b357d8d 100644 --- a/lib/ui/linking/link_flow_screen.dart +++ b/lib/ui/linking/link_flow_screen.dart @@ -102,9 +102,6 @@ class _LinkFlowScreenState extends State { _launchedRedirect = true; WidgetsBinding.instance.addPostFrameCallback((_) => _openRedirect()); } - if (s.status == LinkSessionStatus.failed || s.status == LinkSessionStatus.expired) { - // The step carries the reason; nothing more can be submitted. - } } bool _polling = false; From bb30ba6cd838307ba46ec0c9d19ab404ef3ee7d6 Mon Sep 17 00:00:00 2001 From: PianoNic <79938743+Pianonic@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:18:53 +0100 Subject: [PATCH 9/9] Link the authenticator screen to aka.ms/mfasetup Closes #515. --- .../authenticator_vault_screen.dart | 13 +++++++ test/authenticator_mfa_link_test.dart | 38 +++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 test/authenticator_mfa_link_test.dart diff --git a/lib/ui/authenticator/authenticator_vault_screen.dart b/lib/ui/authenticator/authenticator_vault_screen.dart index 7c2068b..1f9eb21 100644 --- a/lib/ui/authenticator/authenticator_vault_screen.dart +++ b/lib/ui/authenticator/authenticator_vault_screen.dart @@ -25,6 +25,9 @@ class AuthenticatorVaultScreen extends StatefulWidget { State createState() => _AuthenticatorVaultScreenState(); } +/// Where a Microsoft account adds an authenticator app. +const kMicrosoftMfaSetupUrl = 'https://aka.ms/mfasetup'; + class _AuthenticatorVaultScreenState extends State { Timer? _timer; bool _loading = true; @@ -82,6 +85,10 @@ class _AuthenticatorVaultScreenState extends State { ); } + Future _openMfaSetup() async { + await launchUrl(Uri.parse(kMicrosoftMfaSetupUrl), mode: LaunchMode.externalApplication); + } + Future _copy(String? code) async { if (code == null) return; await Clipboard.setData(ClipboardData(text: code)); @@ -165,6 +172,12 @@ class _AuthenticatorVaultScreenState extends State { onPress: _openGuide, child: const Text('How do I set this up?'), ), + FButton( + style: FButtonStyle.ghost(), + prefix: const Icon(FIcons.externalLink), + onPress: _openMfaSetup, + child: const Text('Open Microsoft security settings'), + ), ], ), ), diff --git a/test/authenticator_mfa_link_test.dart b/test/authenticator_mfa_link_test.dart new file mode 100644 index 0000000..38218b9 --- /dev/null +++ b/test/authenticator_mfa_link_test.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:forui/forui.dart'; +import 'package:schuly/services/secure_store.dart'; +import 'package:schuly/ui/authenticator/authenticator_vault_screen.dart'; + +class _EmptyStore implements SecureKeyValue { + @override + Future read(String key) async => null; + + @override + Future write(String key, String value) async {} + + @override + Future delete(String key) async {} + + @override + Future deleteAll() async {} +} + +void main() { + test('the Microsoft setup link is the short one Microsoft keeps working', () { + expect(kMicrosoftMfaSetupUrl, 'https://aka.ms/mfasetup'); + }); + + testWidgets('the empty authenticator screen links to it', (tester) async { + SecureStore.useBackend(_EmptyStore()); + await tester.pumpWidget(MaterialApp( + builder: (context, child) => FTheme(data: FThemes.zinc.light, child: FToaster(child: child!)), + home: const AuthenticatorVaultScreen(), + )); + await tester.pump(); + await tester.pump(); + expect(find.text('Open Microsoft security settings'), findsOneWidget); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 1)); + }); +}