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), ), ), ], 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. 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; +} diff --git a/tools/store-screenshots/fake-api/server.ts b/tools/store-screenshots/fake-api/server.ts index bea5ebd..1ba26c7 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> = { @@ -214,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, @@ -234,6 +270,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 +288,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"], };