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([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/lib/ui/absences/absences_page.dart b/lib/ui/absences/absences_page.dart index 42754207..c443146a 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/auth/sign_in_screen.dart b/lib/ui/auth/sign_in_screen.dart index 4f3d82df..acfd19eb 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/authenticator/authenticator_vault_screen.dart b/lib/ui/authenticator/authenticator_vault_screen.dart index 7c2068bf..1f9eb21f 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/lib/ui/classes/class_detail_screen.dart b/lib/ui/classes/class_detail_screen.dart index 9133f1b4..7b0af0b0 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 00000000..9713c001 --- /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/core/ui/root_screen.dart b/lib/ui/core/ui/root_screen.dart index 51e3a9f6..ab2054c5 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 28b07df9..be417afb 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'; @@ -64,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 { @@ -131,12 +128,14 @@ class _DashboardScreenState extends State with WidgetsBindingOb return; } await SchoolDataService.instance.refresh(); - await _addSchool(); + if (mounted) await runAddAccountFlow(context); } } - Future _addSchool() async { - await runAddAccountFlow(context); + /// 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(); } void _openSidebar() { @@ -190,13 +189,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 +199,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/lib/ui/grades/grades_page.dart b/lib/ui/grades/grades_page.dart index 70bf1506..8b2e29e1 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; @@ -172,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/lib/ui/home/home_page.dart b/lib/ui/home/home_page.dart index 43bddc3a..184229e8 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/linking/link_flow_screen.dart b/lib/ui/linking/link_flow_screen.dart index 88464702..b357d8d6 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; diff --git a/lib/ui/timetable/timetable_page.dart b/lib/ui/timetable/timetable_page.dart index c64c983d..ef460092 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/lib/ui/widgets/remote_icon.dart b/lib/ui/widgets/remote_icon.dart new file mode 100644 index 00000000..4f611287 --- /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 f7329857..462844a3 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 881fe47d..f7498da8 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/authenticator_mfa_link_test.dart b/test/authenticator_mfa_link_test.dart new file mode 100644 index 00000000..38218b99 --- /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)); + }); +} diff --git a/test/dashboard_pages_refresh_test.dart b/test/dashboard_pages_refresh_test.dart new file mode 100644 index 00000000..6986e140 --- /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); + }); +} diff --git a/test/linked_accounts_load_test.dart b/test/linked_accounts_load_test.dart new file mode 100644 index 00000000..99f94ddf --- /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); + }); +} diff --git a/test/load_state_test.dart b/test/load_state_test.dart new file mode 100644 index 00000000..c6efe45b --- /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)); + }); +} diff --git a/test/network_security_config_test.dart b/test/network_security_config_test.dart new file mode 100644 index 00000000..62519e56 --- /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')); + }); +} diff --git a/test/private_data_adapter_test.dart b/test/private_data_adapter_test.dart new file mode 100644 index 00000000..5720bcca --- /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'); + }); +} diff --git a/test/remote_icon_test.dart b/test/remote_icon_test.dart new file mode 100644 index 00000000..7dc6b436 --- /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()); + }); +} diff --git a/test/token_proxy_client_test.dart b/test/token_proxy_client_test.dart new file mode 100644 index 00000000..f685990c --- /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())); + }); +}