diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 8f421c0..c6af93b 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -269,6 +269,7 @@ "studentIdUnavailable": "Der Lernendenausweis konnte nicht geladen werden", "studentIdSavedCopy": "Angezeigt wird die auf diesem Gerät gespeicherte Kopie.", "studentIdPullToRetry": "Zum erneuten Versuchen nach unten ziehen.", + "studentIdRefresh": "Aktualisieren", "settingsAppearance": "Darstellung", "settingsAppearanceDetail": "Deine Akzentfarbe zeigt, was aktiv ist.", "settingsAccent": "Akzent", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 464e43c..7d971a1 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -832,6 +832,7 @@ "studentIdUnavailable": "Couldn't load the Lernendenausweis", "studentIdSavedCopy": "Showing the copy saved on this device.", "studentIdPullToRetry": "Pull down to try again.", + "studentIdRefresh": "Refresh", "settingsAppearance": "Appearance", "settingsAppearanceDetail": "Your accent marks what's active.", "settingsAccent": "Accent", diff --git a/lib/ui/account/student_id_page.dart b/lib/ui/account/student_id_page.dart index eaa66ab..f972341 100644 --- a/lib/ui/account/student_id_page.dart +++ b/lib/ui/account/student_id_page.dart @@ -4,6 +4,7 @@ import 'dart:math' as math; import 'package:flutter/foundation.dart' show Factory; import 'package:flutter/gestures.dart' show EagerGestureRecognizer, OneSequenceGestureRecognizer; import 'package:flutter/material.dart' show MaterialPageRoute; +import 'package:flutter/services.dart' show SystemChrome, SystemUiMode; import 'package:flutter/widgets.dart'; import 'package:forui/forui.dart'; import 'package:schuly_api/schuly_api.dart'; @@ -70,11 +71,15 @@ bool studentIdCardAllowsNavigation(String url) { return scheme == 'data' || scheme == 'about'; } -/// Full brightness and no screen lock while the card is shown, so a QR reader at the door can scan it; both are put back on exit. +/// Full brightness, no screen lock and no system bars while the card is shown, so a QR reader at the door can scan it; +/// all are put back on exit. class StudentIdScreen { const StudentIdScreen(); Future enter() async { + try { + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + } catch (_) {} var wasAwake = false; try { wasAwake = await WakelockPlus.enabled; @@ -87,6 +92,9 @@ class StudentIdScreen { } Future leave({required bool wasAwake}) async { + try { + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + } catch (_) {} try { await ScreenBrightness.instance.resetApplicationScreenBrightness(); } catch (_) {} @@ -97,8 +105,8 @@ class StudentIdScreen { } /// The official Lernendenausweis as Schulnetz renders it, unchanged, in a WebView without JavaScript. The saved copy shows -/// at once and is refreshed in the background; pulling down asks Schulnetz again. Only a school that does not offer the -/// card gets the self-built [fallback], marked as not the official one. +/// at once, on the whole screen, and is refreshed in the background; the refresh button asks Schulnetz again. Only a +/// school that does not offer the card gets the self-built [fallback], marked as not the official one. class StudentIdPage extends StatefulWidget { const StudentIdPage({ super.key, @@ -195,66 +203,13 @@ class _StudentIdPageState extends State { @override Widget build(BuildContext context) { final t = AppLocalizations.of(context)!; - final colors = context.theme.colors; - final typography = context.theme.typography; - final dates = Dates(context); final card = _card; - // The card gets all the height the page has left below the title and above the "As of" row, so the page itself - // does not scroll; a card taller than that scrolls inside the WebView. - final media = MediaQuery.of(context); - double line(TextStyle style) => media.textScaler.scale(style.fontSize ?? 14) * (style.height ?? 1.2); - final chrome = 48 + 6 + 16 + line(typography.xl2) + 10 + 12 + line(typography.sm) + 24; - final height = math.max(320.0, media.size.height - media.viewPadding.top - media.viewPadding.bottom - chrome); + final height = math.max(420.0, MediaQuery.sizeOf(context).height * 0.62); + + if (card != null) return _fullscreen(context, card); final List body; - if (card != null) { - body = [ - Container( - key: const ValueKey('student-id-official'), - height: height, - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - color: const Color(0xFFFFFFFF), - borderRadius: BorderRadius.circular(SchulyRadii.card), - ), - child: (widget.cardView ?? (html) => _OfficialCard(html: html))( - card.html, - ), - ), - Padding( - padding: const EdgeInsets.only(top: 10, bottom: 12), - child: Row( - children: [ - Expanded( - child: Text( - t.studentIdAsOf( - '${dates.full(card.fetchedAt)} ${dates.hm(card.fetchedAt)}', - ), - style: typography.sm.copyWith(color: colors.mutedForeground), - ), - ), - if (_loading) - SizedBox.square( - dimension: 14, - child: FCircularProgress( - style: (s) => s.copyWith( - iconStyle: s.iconStyle.copyWith(size: 14), - ), - ), - ), - ], - ), - ), - if (_failure == StudentIdCardFailure.signInExpired) - Callout( - title: t.studentIdSignInExpired, - text: t.studentIdSavedCopy, - bad: true, - ), - if (_failure == StudentIdCardFailure.unavailable) - Callout(title: t.studentIdUnavailable, text: t.studentIdSavedCopy), - ]; - } else if (_failure == StudentIdCardFailure.notOffered) { + if (_failure == StudentIdCardFailure.notOffered) { final fallback = widget.fallback; body = [ Callout( @@ -293,6 +248,115 @@ class _StudentIdPageState extends State { children: body, ); } + + /// The official card on the whole screen: no title, only a small close button in the top corner and one line at the + /// bottom with the date, the refresh and what went wrong. A card taller than the screen scrolls inside the WebView. + Widget _fullscreen(BuildContext context, StudentIdCard card) { + final t = AppLocalizations.of(context)!; + final colors = context.theme.colors; + final typography = context.theme.typography; + final dates = Dates(context); + final padding = MediaQuery.paddingOf(context); + final failure = switch (_failure) { + StudentIdCardFailure.signInExpired => t.studentIdSignInExpired, + StudentIdCardFailure.unavailable => t.studentIdUnavailable, + _ => null, + }; + return FScaffold( + childPad: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: Stack( + children: [ + Positioned.fill( + child: ColoredBox( + key: const ValueKey('student-id-official'), + color: const Color(0xFFFFFFFF), + child: (widget.cardView ?? (html) => _OfficialCard(html: html))(card.html), + ), + ), + Positioned( + top: padding.top + 8, + right: padding.right + 8, + child: _RoundButton( + label: t.close, + icon: FIcons.x, + onPress: () => Navigator.of(context).maybePop(), + ), + ), + ], + ), + ), + Padding( + padding: EdgeInsets.fromLTRB(padding.left + 16, 6, padding.right + 8, padding.bottom + 6), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (failure != null) + Text( + '$failure. ${t.studentIdSavedCopy}', + style: typography.xs.copyWith(color: context.tokens.bad), + ), + Text( + t.studentIdAsOf('${dates.full(card.fetchedAt)} ${dates.hm(card.fetchedAt)}'), + style: typography.xs.copyWith(color: colors.mutedForeground), + ), + ], + ), + ), + SizedBox.square( + dimension: 36, + child: _loading + ? Center( + child: SizedBox.square( + dimension: 14, + child: FCircularProgress(style: (s) => s.copyWith(iconStyle: s.iconStyle.copyWith(size: 14))), + ), + ) + : _RoundButton(label: t.studentIdRefresh, icon: FIcons.refreshCw, onPress: _refresh, plain: true), + ), + ], + ), + ), + ], + ), + ); + } +} + +/// A small round icon button for the fullscreen card: dark and see-through over the card, or [plain] in the footer. +class _RoundButton extends StatelessWidget { + const _RoundButton({required this.label, required this.icon, required this.onPress, this.plain = false}); + + final String label; + final IconData icon; + final VoidCallback onPress; + final bool plain; + + @override + Widget build(BuildContext context) { + final colors = context.theme.colors; + return Semantics( + button: true, + label: label, + child: FTappable( + onPress: onPress, + child: Container( + width: 36, + height: 36, + alignment: Alignment.center, + decoration: plain ? null : const BoxDecoration(shape: BoxShape.circle, color: Color(0x73000000)), + child: Icon(icon, size: 18, color: plain ? colors.mutedForeground : const Color(0xFFFFFFFF)), + ), + ), + ); + } } /// The Schulnetz HTML, untouched: JavaScript off, white background, and no way to navigate anywhere. diff --git a/test/student_id_page_test.dart b/test/student_id_page_test.dart index daba675..260bdfa 100644 --- a/test/student_id_page_test.dart +++ b/test/student_id_page_test.dart @@ -78,7 +78,7 @@ void main() { expect((await cache.read('account:a1'))!.html, 'new'); }); - testWidgets('pulling down really asks the server again', (tester) async { + testWidgets('the refresh button really asks the server again', (tester) async { var calls = 0; final service = StudentIdCardService.withDeps(get: (_) async { calls++; @@ -89,11 +89,13 @@ void main() { await tester.pumpAndSettle(); expect(find.text('HTML:card 1'), findsOneWidget); - await tester.fling(find.text('Lernendenausweis').first, const Offset(0, 400), 1000); + await tester.tap(find.bySemanticsLabel('Aktualisieren')); await tester.pumpAndSettle(); expect(calls, 2); expect(find.text('HTML:card 2'), findsOneWidget); + // Lets the button's press feedback timer run out. + await tester.pump(const Duration(seconds: 1)); }); testWidgets('only a school without the card gets the self-built one, labelled as not official', (tester) async { @@ -115,10 +117,27 @@ void main() { await tester.pumpAndSettle(); expect(find.text('HTML:saved'), findsOneWidget); - expect(find.text('Deine Schulanmeldung ist abgelaufen'), findsOneWidget); + expect(find.textContaining('Deine Schulanmeldung ist abgelaufen'), findsOneWidget); expect(find.byKey(const ValueKey('student-id-self-built')), findsNothing); }); + testWidgets('the card fills the screen with no title, and the close button leaves the page', (tester) async { + final service = StudentIdCardService.withDeps(get: (_) async => _body('card', '2026-11-10T07:30:00Z'), cache: MemoryCardCache()); + await _pump(tester, Builder(builder: (context) => Center(child: GestureDetector(onTap: () => Navigator.of(context).push(PageRouteBuilder(pageBuilder: (_, _, _) => StudentIdPage(service: service, target: _target, screen: _FakeScreen(), cardView: _view))), child: const Text('open'))))); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + expect(find.text('HTML:card'), findsOneWidget); + expect(find.text('Lernendenausweis'), findsNothing); + expect(tester.getSize(find.byKey(const ValueKey('student-id-official'))).width, 800); + expect(find.textContaining('Stand: 10.11.2026'), findsOneWidget); + + await tester.tap(find.bySemanticsLabel('Schliessen')); + await tester.pumpAndSettle(); + expect(find.text('HTML:card'), findsNothing); + expect(find.text('open'), findsOneWidget); + }); + testWidgets('brightness and wake lock are raised while open and restored on exit', (tester) async { final screen = _FakeScreen(); final service = StudentIdCardService.withDeps(get: (_) async => _body('card', '2026-11-10T07:30:00Z'), cache: MemoryCardCache());