Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/ui/account/profile_picture.dart
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ class _ProfilePictureButtonState extends State<ProfilePictureButton> 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),
),
),
],
Expand Down
28 changes: 22 additions & 6 deletions lib/ui/account/student_id_page.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -197,7 +199,12 @@ class _StudentIdPageState extends State<StudentIdPage> {
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<Widget> body;
if (card != null) {
Expand Down Expand Up @@ -227,10 +234,13 @@ class _StudentIdPageState extends State<StudentIdPage> {
),
),
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),
),
),
),
],
),
Expand Down Expand Up @@ -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<OneSequenceGestureRecognizer>(EagerGestureRecognizer.new),
},
);
}

/// The card Schuly draws itself from the school profile; tap or "Turn over" flips it.
Expand Down
82 changes: 74 additions & 8 deletions lib/ui/dashboard/schools_sheet.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<Path> {
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;
}
43 changes: 42 additions & 1 deletion tools/store-screenshots/fake-api/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 += `<rect x="${x}" y="${y}" width="1" height="1"/>`;
}
return "data:image/svg+xml;base64," + Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25">${cells}</svg>`).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 `<div style="position:absolute;left:0;top:0;width:340px;height:44px;background:#0b5394"></div>`
+ `<div style="${t}left:16px;top:12px;font-size:16px;font-weight:bold;color:#fff">Lernendenausweis 2026/27</div>`
+ `<div style="${t}left:16px;top:60px;font-size:13px">Berufsfachschule Beispielstadt</div>`
+ `<img src="${photo}" style="position:absolute;left:16px;top:90px;width:150px;height:200px;object-fit:cover">`
+ `<div style="${t}left:16px;top:306px;font-size:11px;color:#555">Name</div><div style="${t}left:16px;top:322px;font-size:18px;font-weight:bold">Muster</div>`
+ `<div style="${t}left:16px;top:352px;font-size:11px;color:#555">Vorname</div><div style="${t}left:16px;top:368px;font-size:18px;font-weight:bold">Mia</div>`
+ `<div style="${t}left:16px;top:398px;font-size:11px;color:#555">Geburtsdatum</div><div style="${t}left:16px;top:414px;font-size:15px">14.03.2009</div>`
+ `<div style="${t}left:16px;top:442px;font-size:11px;color:#555">Ausbildung</div><div style="${t}left:16px;top:458px;font-size:15px">Informatikerin EFZ</div>`
+ `<img src="${qr}" style="position:absolute;left:70px;top:496px;width:200px;height:200px">`
+ `<div style="${t}left:16px;top:712px;font-size:12px;color:#555">Gültig bis 31.07.2027</div>`
+ `<div style="position:absolute;left:0;top:740px;width:340px;height:16px;background:#0b5394"></div>`;
};

// SLOW=<ms> 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<string, (req: Request, url: URL) => any> = {
Expand All @@ -214,7 +243,14 @@ const routes: Record<string, (req: Request, url: URL) => 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,
Expand All @@ -234,6 +270,7 @@ const routes: Record<string, (req: Request, url: URL) => 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 });
Expand All @@ -251,6 +288,10 @@ const routes: Record<string, (req: Request, url: URL) => 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"],
};

Expand Down
Loading