From 81ed4e0f510d77bbf94cbf03f5a05b66ed80c338 Mon Sep 17 00:00:00 2001 From: PianoNic <79938743+Pianonic@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:37:12 +0100 Subject: [PATCH 1/7] Serve a made-up student ID card from the fake API --- tools/store-screenshots/fake-api/server.ts | 34 ++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tools/store-screenshots/fake-api/server.ts b/tools/store-screenshots/fake-api/server.ts index bea5ebd..9b40359 100644 --- a/tools/store-screenshots/fake-api/server.ts +++ b/tools/store-screenshots/fake-api/server.ts @@ -203,6 +203,35 @@ const outlookEvents = [ { id: "ev4", title: "Training", start: at("2026-11-13", "18:00"), end: at("2026-11-13", "19:30"), isAllDay: false, location: "Sporthalle", organizer: null, preview: null, webLink: "https://outlook.office.com/calendar/item/ev4", joinUrl: null }, ].map((e) => ({ ...e, accountId: MS_ACC, showAs: "busy", isCancelled: false })); +// A made-up Lernendenausweis shaped like the Schulnetz one: elements placed absolutely with inline CSS, `data:` images, +// and taller than a phone's card area, so the page has to show all of it. +const qr = (() => { + let cells = ""; + for (let y = 0; y < 25; y++) for (let x = 0; x < 25; x++) { + const finder = (a: number, b: number) => a >= 0 && a < 7 && b >= 0 && b < 7 && (a === 0 || a === 6 || b === 0 || b === 6 || (a > 1 && a < 5 && b > 1 && b < 5)); + if (finder(x, y) || finder(x - 18, y) || finder(x, y - 18) || (!(x < 8 && y < 8) && !(x > 16 && y < 8) && !(x < 8 && y > 16) && (x * 7 + y * 13 + x * y) % 3 === 0)) cells += ``; + } + return "data:image/svg+xml;base64," + Buffer.from(`${cells}`).toString("base64"); +})(); +const studentIdCardHtml = async () => { + const photo = "data:image/jpeg;base64," + Buffer.from(await Bun.file(STATIC + "/mia.jpg").arrayBuffer()).toString("base64"); + const t = "position:absolute;font-family:Arial,sans-serif;color:#111;"; + return `
` + + `
Lernendenausweis 2026/27
` + + `
Berufsfachschule Beispielstadt
` + + `` + + `
Name
Muster
` + + `
Vorname
Mia
` + + `
Geburtsdatum
14.03.2009
` + + `
Ausbildung
Informatikerin EFZ
` + + `` + + `
Gültig bis 31.07.2027
` + + `
`; +}; + +// SLOW= holds back the student ID card and the picture upload, to see their spinners. +const slow = () => Bun.sleep(Number(process.env.SLOW ?? 0)); + const tokens = () => ({ access_token: "fake-access-" + Date.now(), refresh_token: "fake-refresh", expires_in: 86400, token_type: "Bearer" }); const routes: Record any> = { @@ -234,6 +263,7 @@ const routes: Record any> = { return { accounts: [{ id: MS_ACC, email: "mia.muster@bfs-beispielstadt.ch", displayName: "Mia Muster", status: "ok", message: null }], events }; }, "PUT /api/me/avatar": async (req) => { + await slow(); const form = await req.formData(); const file = form.get("file") as File | null; if (!file) return new Response(JSON.stringify({ message: "no file" }), { status: 400 }); @@ -251,6 +281,10 @@ const routes: Record any> = { [`GET ${BASE}/accounts/${ACC}/sync`]: () => ({ lastSync: minutesAgo(12), syncStatus: "Up to date", syncError: null }), [`POST ${BASE}/accounts/${ACC}/sync`]: () => null, [`GET ${BASE}/accounts/${ACC}/absences/reported`]: () => reported, + [`GET ${BASE}/accounts/${ACC}/student-id-card`]: async () => { + await slow(); + return { html: await studentIdCardHtml(), fetchedAt: nowIso(), source: "mobile" }; + }, [`GET ${BASE}/accounts/${ACC}/absences/reasons`]: () => ["Krankheit", "Arzttermin", "Unfall", "Familiäre Gründe", "Militär / Zivilschutz"], }; From b21ff7164d177f98de49d2b7dfffe9cba9b188a1 Mon Sep 17 00:00:00 2001 From: PianoNic <79938743+Pianonic@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:37:13 +0100 Subject: [PATCH 2/7] Show the whole student ID card --- lib/ui/account/student_id_page.dart | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/lib/ui/account/student_id_page.dart b/lib/ui/account/student_id_page.dart index 7aa5ed7..eaa66ab 100644 --- a/lib/ui/account/student_id_page.dart +++ b/lib/ui/account/student_id_page.dart @@ -1,6 +1,8 @@ import '../core/ui/auth_image.dart'; 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/widgets.dart'; import 'package:forui/forui.dart'; @@ -197,7 +199,12 @@ class _StudentIdPageState extends State { final typography = context.theme.typography; final dates = Dates(context); final card = _card; - final height = math.max(420.0, MediaQuery.sizeOf(context).height * 0.62); + // 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 List body; if (card != null) { @@ -227,10 +234,13 @@ class _StudentIdPageState extends State { ), ), if (_loading) - const SizedBox( - width: 14, - height: 14, - child: FCircularProgress(), + SizedBox.square( + dimension: 14, + child: FCircularProgress( + style: (s) => s.copyWith( + iconStyle: s.iconStyle.copyWith(size: 14), + ), + ), ), ], ), @@ -316,7 +326,13 @@ class _OfficialCardState extends State<_OfficialCard> { } @override - Widget build(BuildContext context) => WebViewWidget(controller: _controller); + Widget build(BuildContext context) => WebViewWidget( + controller: _controller, + // Drags on the card scroll the card, not the page, so a card taller than its box can be scrolled to its end. + gestureRecognizers: { + Factory(EagerGestureRecognizer.new), + }, + ); } /// The card Schuly draws itself from the school profile; tap or "Turn over" flips it. From 67369512243f29341a150b4b742934d207dca59d Mon Sep 17 00:00:00 2001 From: PianoNic <79938743+Pianonic@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:37:13 +0100 Subject: [PATCH 3/7] Size the small loading spinners to their box --- lib/ui/account/profile_picture.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ui/account/profile_picture.dart b/lib/ui/account/profile_picture.dart index 830549d..4aa4dd8 100644 --- a/lib/ui/account/profile_picture.dart +++ b/lib/ui/account/profile_picture.dart @@ -119,7 +119,7 @@ class _ProfilePictureButtonState extends State with Single width: 24, height: 24, decoration: BoxDecoration(shape: BoxShape.circle, color: colors.background, border: Border.all(color: colors.border)), - child: _busy ? const Padding(padding: EdgeInsets.all(5), child: FCircularProgress()) : Icon(FIcons.pencil, size: 12, color: colors.foreground), + child: _busy ? Padding(padding: const EdgeInsets.all(5), child: FCircularProgress(style: (s) => s.copyWith(iconStyle: s.iconStyle.copyWith(size: 12)))) : Icon(FIcons.pencil, size: 12, color: colors.foreground), ), ), ], From e46f4504c472e7841536647b6bf7a64f791ee2d1 Mon Sep 17 00:00:00 2001 From: PianoNic <79938743+Pianonic@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:37:13 +0100 Subject: [PATCH 4/7] Split account pictures diagonally with the provider logo --- lib/ui/linking/schools_accounts_screen.dart | 76 ++++++++++++++++++++- 1 file changed, 74 insertions(+), 2 deletions(-) diff --git a/lib/ui/linking/schools_accounts_screen.dart b/lib/ui/linking/schools_accounts_screen.dart index 44a1fa8..f7c227c 100644 --- a/lib/ui/linking/schools_accounts_screen.dart +++ b/lib/ui/linking/schools_accounts_screen.dart @@ -9,6 +9,7 @@ import '../../services/linking_service.dart'; import '../../services/school_data_service.dart'; import '../../services/toast_service.dart'; import '../core/theme/schuly_theme.dart'; +import '../core/ui/auth_image.dart'; import '../core/ui/kit.dart'; import 'add_account_flow.dart'; import 'provider_icon.dart'; @@ -172,8 +173,8 @@ class _SchoolsAccountsScreenState extends State { children: [ for (final a in entry.value) FTile( - // The account's own picture, as on the web's account cards; initials when it has none. - prefix: a.isProviderError ? null : Avatar(url: a.pictureUrl, name: a.title, size: 36), + // The provider's logo and the account's own picture, split diagonally; only the logo when it has no picture. + prefix: a.isProviderError ? null : _AccountAvatar(account: a, provider: _c.provider(a.providerId), size: 36), title: Text(a.isProviderError ? t.accountsProviderFailed(a.providerName) : a.title), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -198,6 +199,77 @@ class _SchoolsAccountsScreenState extends State { } /// A provider's logo, name and what it gives Schuly. +/// An account's round picture, cut diagonally from top right to bottom left: the provider's logo in the top-left half, +/// the account's own picture in the bottom-right half. Only the logo when the account has no picture, and the initial +/// when the provider is unknown. +class _AccountAvatar extends StatelessWidget { + const _AccountAvatar({required this.account, required this.provider, required this.size}); + + final LinkedAccount account; + final LinkProvider? provider; + final double size; + + @override + Widget build(BuildContext context) { + final provider = this.provider; + if (provider == null) return Avatar(url: account.pictureUrl, name: account.title, size: size); + Widget logo(double s) => ProviderIcon(id: provider.id, lucide: provider.lucide, svgUrl: provider.svgUrl, size: s); + final url = account.pictureUrl; + if (url == null || url.isEmpty) return FAvatar.raw(size: size, child: logo(size * 0.5)); + final colors = context.theme.colors; + return ClipOval( + child: SizedBox.square( + dimension: size, + child: Stack( + children: [ + Positioned.fill( + child: ClipPath( + clipper: const _DiagonalHalf(topLeft: true), + child: ColoredBox(color: colors.muted), + ), + ), + // Centred in the top-left half disc, clear of the cut. + Positioned(left: size * 0.16, top: size * 0.16, child: logo(size * 0.32)), + Positioned.fill( + child: ClipPath( + clipper: const _DiagonalHalf(topLeft: false), + child: Image(image: AuthImage(url), fit: BoxFit.cover, errorBuilder: (_, _, _) => ColoredBox(color: colors.muted)), + ), + ), + ], + ), + ), + ); + } +} + +/// One side of the cut from the top-right to the bottom-left corner, leaving a thin gap along it. +class _DiagonalHalf extends CustomClipper { + const _DiagonalHalf({required this.topLeft}); + + final bool topLeft; + + @override + Path getClip(Size size) { + const gap = 1.0; + final w = size.width, h = size.height; + return topLeft + ? (Path() + ..moveTo(0, 0) + ..lineTo(w - gap, 0) + ..lineTo(0, h - gap) + ..close()) + : (Path() + ..moveTo(w, gap) + ..lineTo(w, h) + ..lineTo(gap, h) + ..close()); + } + + @override + bool shouldReclip(_DiagonalHalf old) => old.topLeft != topLeft; +} + class _ProviderHead extends StatelessWidget { const _ProviderHead({required this.name, required this.provider}); From 739d370d5ed17eca1fa517e121cdcec786ae72a6 Mon Sep 17 00:00:00 2001 From: PianoNic <79938743+Pianonic@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:49:14 +0100 Subject: [PATCH 5/7] Show account pictures on Schools & accounts as before --- lib/ui/linking/schools_accounts_screen.dart | 76 +-------------------- 1 file changed, 2 insertions(+), 74 deletions(-) diff --git a/lib/ui/linking/schools_accounts_screen.dart b/lib/ui/linking/schools_accounts_screen.dart index f7c227c..44a1fa8 100644 --- a/lib/ui/linking/schools_accounts_screen.dart +++ b/lib/ui/linking/schools_accounts_screen.dart @@ -9,7 +9,6 @@ import '../../services/linking_service.dart'; import '../../services/school_data_service.dart'; import '../../services/toast_service.dart'; import '../core/theme/schuly_theme.dart'; -import '../core/ui/auth_image.dart'; import '../core/ui/kit.dart'; import 'add_account_flow.dart'; import 'provider_icon.dart'; @@ -173,8 +172,8 @@ class _SchoolsAccountsScreenState extends State { children: [ for (final a in entry.value) FTile( - // The provider's logo and the account's own picture, split diagonally; only the logo when it has no picture. - prefix: a.isProviderError ? null : _AccountAvatar(account: a, provider: _c.provider(a.providerId), size: 36), + // The account's own picture, as on the web's account cards; initials when it has none. + prefix: a.isProviderError ? null : Avatar(url: a.pictureUrl, name: a.title, size: 36), title: Text(a.isProviderError ? t.accountsProviderFailed(a.providerName) : a.title), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -199,77 +198,6 @@ class _SchoolsAccountsScreenState extends State { } /// A provider's logo, name and what it gives Schuly. -/// An account's round picture, cut diagonally from top right to bottom left: the provider's logo in the top-left half, -/// the account's own picture in the bottom-right half. Only the logo when the account has no picture, and the initial -/// when the provider is unknown. -class _AccountAvatar extends StatelessWidget { - const _AccountAvatar({required this.account, required this.provider, required this.size}); - - final LinkedAccount account; - final LinkProvider? provider; - final double size; - - @override - Widget build(BuildContext context) { - final provider = this.provider; - if (provider == null) return Avatar(url: account.pictureUrl, name: account.title, size: size); - Widget logo(double s) => ProviderIcon(id: provider.id, lucide: provider.lucide, svgUrl: provider.svgUrl, size: s); - final url = account.pictureUrl; - if (url == null || url.isEmpty) return FAvatar.raw(size: size, child: logo(size * 0.5)); - final colors = context.theme.colors; - return ClipOval( - child: SizedBox.square( - dimension: size, - child: Stack( - children: [ - Positioned.fill( - child: ClipPath( - clipper: const _DiagonalHalf(topLeft: true), - child: ColoredBox(color: colors.muted), - ), - ), - // Centred in the top-left half disc, clear of the cut. - Positioned(left: size * 0.16, top: size * 0.16, child: logo(size * 0.32)), - Positioned.fill( - child: ClipPath( - clipper: const _DiagonalHalf(topLeft: false), - child: Image(image: AuthImage(url), fit: BoxFit.cover, errorBuilder: (_, _, _) => ColoredBox(color: colors.muted)), - ), - ), - ], - ), - ), - ); - } -} - -/// One side of the cut from the top-right to the bottom-left corner, leaving a thin gap along it. -class _DiagonalHalf extends CustomClipper { - const _DiagonalHalf({required this.topLeft}); - - final bool topLeft; - - @override - Path getClip(Size size) { - const gap = 1.0; - final w = size.width, h = size.height; - return topLeft - ? (Path() - ..moveTo(0, 0) - ..lineTo(w - gap, 0) - ..lineTo(0, h - gap) - ..close()) - : (Path() - ..moveTo(w, gap) - ..lineTo(w, h) - ..lineTo(gap, h) - ..close()); - } - - @override - bool shouldReclip(_DiagonalHalf old) => old.topLeft != topLeft; -} - class _ProviderHead extends StatelessWidget { const _ProviderHead({required this.name, required this.provider}); From 7f2b8b5db442ede32691c274594edb2fe80e9c95 Mon Sep 17 00:00:00 2001 From: PianoNic <79938743+Pianonic@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:49:14 +0100 Subject: [PATCH 6/7] Split school pictures in the Schools sheet diagonally with the logo --- lib/ui/dashboard/schools_sheet.dart | 82 ++++++++++++++++++++++++++--- 1 file changed, 74 insertions(+), 8 deletions(-) diff --git a/lib/ui/dashboard/schools_sheet.dart b/lib/ui/dashboard/schools_sheet.dart index c788dd6..9b3ed64 100644 --- a/lib/ui/dashboard/schools_sheet.dart +++ b/lib/ui/dashboard/schools_sheet.dart @@ -123,27 +123,93 @@ class _SchoolsSheet extends StatelessWidget { } } -/// The school's logo, or the profile picture the school holds, in a rounded square. +/// The school's logo in a rounded square. With a profile picture the circle is cut diagonally from top right to bottom +/// left: the logo in the top-left half, the picture in the bottom-right half. class _SchoolLogo extends StatelessWidget { const _SchoolLogo({required this.school}); final MySchool school; + static const _size = 40.0; + @override Widget build(BuildContext context) { final colors = context.theme.colors; final initials = school.name.trim().isEmpty ? '?' : school.name.trim().characters.take(2).toString().toUpperCase(); final fallback = Center(child: Text(initials, style: context.theme.typography.base.copyWith(fontWeight: FontWeight.w600))); - Widget network(String? url) => url == null || url.isEmpty ? fallback : Image(image: AuthImage(url), fit: BoxFit.cover, errorBuilder: (_, _, _) => fallback); - if (school.profilePictureUrl?.isNotEmpty == true) return Avatar(url: school.profilePictureUrl, name: school.name, size: 40); + Widget logo(Widget fallback) { + Widget network(String? url) => url == null || url.isEmpty ? fallback : Image(image: AuthImage(url), fit: BoxFit.cover, errorBuilder: (_, _, _) => fallback); + return school.schoolSystemKey != null + ? Image.asset('assets/schoolsystems/${school.schoolSystemKey}.webp', fit: BoxFit.contain, errorBuilder: (_, _, _) => network(school.logoUrl)) + : network(school.logoUrl); + } + + final picture = school.profilePictureUrl; + if (picture != null && picture.isNotEmpty) { + const logoSize = _size * 0.32; + return ClipOval( + child: SizedBox.square( + dimension: _size, + child: Stack( + children: [ + Positioned.fill( + child: ClipPath( + clipper: const _DiagonalHalf(topLeft: true), + child: ColoredBox(color: colors.muted), + ), + ), + // Centred in the top-left half disc, clear of the cut. + Positioned( + left: _size * 0.16, + top: _size * 0.16, + width: logoSize, + height: logoSize, + child: logo(FittedBox(child: fallback)), + ), + Positioned.fill( + child: ClipPath( + clipper: const _DiagonalHalf(topLeft: false), + child: Image(image: AuthImage(picture), fit: BoxFit.cover, errorBuilder: (_, _, _) => ColoredBox(color: colors.muted)), + ), + ), + ], + ), + ), + ); + } return Container( - width: 40, - height: 40, + width: _size, + height: _size, clipBehavior: Clip.antiAlias, decoration: BoxDecoration(color: colors.muted, borderRadius: BorderRadius.circular(SchulyRadii.base)), - child: school.schoolSystemKey != null - ? Padding(padding: const EdgeInsets.all(7), child: Image.asset('assets/schoolsystems/${school.schoolSystemKey}.webp', fit: BoxFit.contain, errorBuilder: (_, _, _) => network(school.logoUrl))) - : network(school.logoUrl), + child: school.schoolSystemKey != null ? Padding(padding: const EdgeInsets.all(7), child: logo(fallback)) : logo(fallback), ); } } + +/// One side of the cut from the top-right to the bottom-left corner, leaving a thin gap along it. +class _DiagonalHalf extends CustomClipper { + const _DiagonalHalf({required this.topLeft}); + + final bool topLeft; + + @override + Path getClip(Size size) { + const gap = 1.0; + final w = size.width, h = size.height; + return topLeft + ? (Path() + ..moveTo(0, 0) + ..lineTo(w - gap, 0) + ..lineTo(0, h - gap) + ..close()) + : (Path() + ..moveTo(w, gap) + ..lineTo(w, h) + ..lineTo(gap, h) + ..close()); + } + + @override + bool shouldReclip(_DiagonalHalf old) => old.topLeft != topLeft; +} From c27e8269f708f31914c42f27ff9fd95312d03a32 Mon Sep 17 00:00:00 2001 From: PianoNic <79938743+Pianonic@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:49:15 +0100 Subject: [PATCH 7/7] Add two more schools to the fake API for the Schools sheet --- tools/store-screenshots/fake-api/server.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tools/store-screenshots/fake-api/server.ts b/tools/store-screenshots/fake-api/server.ts index 9b40359..1ba26c7 100644 --- a/tools/store-screenshots/fake-api/server.ts +++ b/tools/store-screenshots/fake-api/server.ts @@ -243,7 +243,14 @@ const routes: Record any> = { "POST /auth/logout": () => null, "GET /api/Auth/me": () => ({ id: APP_USER, externalId: "mia.muster", email: "mia.muster@bfs-beispielstadt.ch", displayName: "Mia Muster", profilePictureUrl: picture, createdAt: "2025-08-20T08:00:00Z", schoolUsers: [{ id: ME, firstName: "Mia", lastName: "Muster", email: "mia.muster@bfs-beispielstadt.ch", role: "Student" }] }), "GET /api/Auth/me/privacy-notice": () => ({ policyVersion: "2026-05", policyUrl: "https://schuly.dev/en/privacy", acceptanceRequired: false, swissSchoolConfirmationRequired: false, acceptedVersion: "2026-05", acceptedAt: "2026-05-12T07:00:00Z", swissSchoolConfirmedAt: "2026-05-12T07:00:00Z" }), - "GET /api/Schools/my-schools": () => [{ id: SCHOOL, name: "BFS Beispielstadt", fullName: "Berufsfachschule Beispielstadt", email: "mia.muster@bfs-beispielstadt.ch", logoUrl: null, schoolSystemKey: "schulportal", profilePictureUrl: "/static/mia.jpg" }], + "GET /api/Schools/my-schools": () => [ + { id: SCHOOL, name: "BFS Beispielstadt", fullName: "Berufsfachschule Beispielstadt", email: "mia.muster@bfs-beispielstadt.ch", logoUrl: null, schoolSystemKey: "schulportal", profilePictureUrl: "/static/mia.jpg" }, + // Two more schools for the Schools sheet: one with a bundled logo and a picture, one with the logo only. + ...(EXTRAS ? [ + { id: "sch-bms", name: "BMS Beispielstadt", fullName: "Berufsmaturitätsschule Beispielstadt", email: "mia.muster@bms-beispielstadt.ch", logoUrl: null, schoolSystemKey: "schulnetz", profilePictureUrl: "/static/mia.jpg" }, + { id: "sch-uek", name: "üK Beispielstadt", fullName: "Überbetriebliche Kurse Beispielstadt", email: "mia.muster@uek-beispielstadt.ch", logoUrl: null, schoolSystemKey: "odaorg", profilePictureUrl: null }, + ] : []), + ], "GET /api/SchoolUsers": () => [schoolUser], "GET /api/Exams": () => exams, "GET /api/Agendas": () => agenda,