diff --git a/README.md b/README.md index 83819a1..0807d98 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,8 @@ bun run dev You need Flutter and [bun](https://bun.sh). The dev build talks to a server on `http://localhost:5033`. The rest is in [the app docs](https://docs.schuly.dev/Schuly/). +The app screenshots for the website and store listings come from [`tools/store-screenshots`](tools/store-screenshots). + ## License [PolyForm Noncommercial 1.0.0](LICENSE) diff --git a/tools/store-screenshots/.gitattributes b/tools/store-screenshots/.gitattributes new file mode 100644 index 0000000..dfdb8b7 --- /dev/null +++ b/tools/store-screenshots/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/tools/store-screenshots/.gitignore b/tools/store-screenshots/.gitignore new file mode 100644 index 0000000..cc0899e --- /dev/null +++ b/tools/store-screenshots/.gitignore @@ -0,0 +1,2 @@ +.venv/ +out/ diff --git a/tools/store-screenshots/README.md b/tools/store-screenshots/README.md new file mode 100644 index 0000000..a832a96 --- /dev/null +++ b/tools/store-screenshots/README.md @@ -0,0 +1,65 @@ +# Store screenshots + +Takes the app screenshots on an Android emulator and frames them in a Pixel phone. The website uses the five framed images: `public/assets/schuly-{start,agenda,grades,absences,account}-page-v2.{png,webp}` in SchulyWebsite. The same raw shots can go into store listings. + +The app runs against a fake API with made-up data (Mia Muster, Berufsfachschule Beispielstadt), so no real account or school is involved. + +| File | What it does | +|---|---| +| `fake-api/server.ts` | Bun server that answers the app's `/auth` and `/api` calls with fixed data on port 5591 (`PORT=` to change). It accepts any sign-in. | +| `fake-api/avatar.py` | Draws the made-up profile picture `fake-api/static/mia.jpg` (already committed). | +| `capture.sh` | Emulator, build, sign-in, capture and framing steps. | +| `ui.sh` | Lists the on-screen elements and their bounds, for when the tap coordinates in `capture.sh` need updating. | +| `frame.py` | Puts a 1080×2400 screenshot into the Pixel 9 Pro XL device art and scales it to 660×1394. | +| `deviceart/` | The device art (`back.webp`, 100 kB) and its licence. | + +## Prerequisites + +- Bun. +- Flutter, as for building the app. +- The Android SDK with `sdkmanager`, `avdmanager`, `emulator` and `adb` on `PATH`, and hardware acceleration for the emulator. +- Python 3 with a virtual environment for the framing: + + ```sh + cd tools/store-screenshots + python -m venv .venv + .venv/Scripts/pip install -r requirements.txt # on macOS/Linux: .venv/bin/pip + ``` + +- A bash shell. On Windows, use Git Bash. + +## Regenerate + +Run these from the repo root. Start the fake API in its own terminal and leave it running: + +```sh +bun tools/store-screenshots/fake-api/server.ts +``` + +Then run the steps, all at once or one by one: + +```sh +tools/store-screenshots/capture.sh all # avd boot build prepare login shoot frame +tools/store-screenshots/capture.sh cleanup # stop the emulator and delete the AVD +``` + +| Step | What it does | +|---|---| +| `avd` | Creates `SchulyShots_Pixel8_API35`: a Pixel 8 (1080×2400) with `system-images;android-35;google_apis;x86_64`, 2 GB RAM. The Google APIs image is needed because `adb root` must work to set the clock. | +| `boot` | Starts it on port 5580 (`emulator-5580`) with `-no-snapshot -gpu host -memory 2048` and waits for the boot to finish. | +| `build` | `flutter build apk --flavor dev --release --target-platform android-x64 --dart-define=BACKEND_BASE_URL=http://10.0.2.2:5591`. `10.0.2.2` is how the emulator reaches the host. | +| `prepare` | Installs the APK, turns off automatic time, sets the timezone to Europe/Zurich, the clock to Fri 6.11.2026 09:41 and 24-hour format, turns dark mode on and enters the status bar demo mode. | +| `login` | Taps through onboarding and signs in as `mia.muster` in account mode. | +| `shoot` | Restarts the app, then saves home, grades, absences, account and the timetable on Tue 10.11. to `out/raw/*.png` with `adb exec-out screencap`. Before each shot it resets the clock to 09:41, full Wi-Fi, no mobile signal and no notifications. | +| `frame` | Writes `out/framed/schuly--page-v2.png` and `.webp`. | + +Copy `out/framed/*` to SchulyWebsite's `public/assets/`. If the images change, give them a new name suffix (`-v3`) and update the references, so that caches load the new ones. The hero phone image on the website is `out/raw/home-dark.png`; see SchulyWebsite's `tools/hero-images`. + +Settings you can override with environment variables: `AVD`, `IMAGE`, `EMU_PORT`, `API_PORT`, `CLOCK` (`MMDDhhmmYYYY.ss`), `OUT` and `PY`. `DRY_RUN=1` prints every command without running it. + +The taps in `login` and `shoot` are screen coordinates for the Pixel 8. If the app's layout changes, run `tools/store-screenshots/ui.sh` on the screen you're stuck at, and update the coordinates. To frame a single screenshot by hand, run `python frame.py `. + +## Licences + +- `deviceart/back.webp` is the Pixel 9 Pro XL device art from the Android Open Source Project / Android Studio, under the Apache License 2.0 (`deviceart/LICENSE.txt`). `frame.py` shortens the frame to the screenshot's aspect ratio. SchulyWebsite credits it in `CREDITS.txt`. +- The fake data and `static/mia.jpg` are made up for these screenshots. diff --git a/tools/store-screenshots/capture.sh b/tools/store-screenshots/capture.sh new file mode 100644 index 0000000..aa3a974 --- /dev/null +++ b/tools/store-screenshots/capture.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# Captures the app screenshots on an Android emulator against the fake API, then frames them. +# Usage: tools/store-screenshots/capture.sh ... (run from the repo root; steps: avd boot build prepare login shoot frame cleanup, or all) +# DRY_RUN=1 prints the commands instead of running them. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$HERE/../.." && pwd)" +OUT="${OUT:-$HERE/out}" +AVD="${AVD:-SchulyShots_Pixel8_API35}" +IMAGE="${IMAGE:-system-images;android-35;google_apis;x86_64}" +PORT="${EMU_PORT:-5580}" +SERIAL="emulator-$PORT" +API_PORT="${API_PORT:-5591}" +PKG=com.schuly.app.dev +CLOCK="${CLOCK:-110609412026.00}" # MMDDhhmmYYYY.ss: Fri 6.11.2026, 09:41 +PY="${PY:-$HERE/.venv/Scripts/python}"; [ -x "$PY" ] || PY="$HERE/.venv/bin/python" + +run() { if [ -n "${DRY_RUN:-}" ]; then echo "+ $*"; else "$@"; fi; } +# MSYS_NO_PATHCONV stops Git Bash on Windows from rewriting device paths such as /sdcard/... for adb. +adbs() { if [ -n "${DRY_RUN:-}" ]; then echo "+ adb -s $SERIAL $*"; else MSYS_NO_PATHCONV=1 adb -s "$SERIAL" "$@"; fi; } +quiet() { if [ -n "${DRY_RUN:-}" ]; then adbs "$@"; else adbs "$@" >/dev/null; fi; } +demo() { quiet shell am broadcast -a com.android.systemui.demo -e command "$@"; } +tap() { adbs shell input tap "$1" "$2"; sleep "${3:-1.5}"; } +tab() { tap "$1" 2256; } # bottom navigation: 118 home, 330 timetable, 540 grades, 750 absences, 961 account + +# Resets the clock and status bar, then saves a 1080x2400 PNG. +shot() { + quiet shell date "$CLOCK" + demo network -e wifi show -e level 4 -e fully true + demo network -e mobile hide + demo clock -e hhmm 0941 + demo notifications -e visible false + sleep "${WAIT:-1}" + mkdir -p "$OUT/raw" + if [ -n "${DRY_RUN:-}" ]; then echo "+ adb -s $SERIAL exec-out screencap -p > $OUT/raw/$1.png"; else MSYS_NO_PATHCONV=1 adb -s "$SERIAL" exec-out screencap -p > "$OUT/raw/$1.png"; fi + echo "saved $1" +} + +step_avd() { + # Pixel 8 (1080x2400, 420 dpi) on API 35 with Google APIs, so adb root works for setting the clock. + run sdkmanager "$IMAGE" + if [ -n "${DRY_RUN:-}" ]; then echo "+ echo no | avdmanager create avd -n $AVD -k $IMAGE -d pixel_8"; else echo no | avdmanager create avd -n "$AVD" -k "$IMAGE" -d pixel_8; fi + local cfg="$HOME/.android/avd/$AVD.avd/config.ini" + run sed -i 's/^hw.ramSize=.*/hw.ramSize=2048/; s/^hw.keyboard=.*/hw.keyboard=yes/; s/^hw.gpu.enabled=.*/hw.gpu.enabled=yes/' "$cfg" +} + +step_boot() { + if [ -n "${DRY_RUN:-}" ]; then echo "+ emulator -avd $AVD -port $PORT -no-snapshot -no-boot-anim -gpu host -memory 2048 &"; else emulator -avd "$AVD" -port "$PORT" -no-snapshot -no-boot-anim -gpu host -memory 2048 >"$OUT/emulator.log" 2>&1 & fi + adbs wait-for-device + [ -n "${DRY_RUN:-}" ] && return + until [ "$(adb -s "$SERIAL" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = "1" ]; do sleep 3; done + echo booted +} + +step_build() { + # Dev flavor, release mode, pointed at the fake API through the emulator's host alias. + (cd "$ROOT" && run flutter build apk --flavor dev --release --target-platform android-x64 --dart-define=BACKEND_BASE_URL="http://10.0.2.2:$API_PORT") +} + +step_prepare() { + quiet root; sleep 3 + adbs install -r "$ROOT/build/app/outputs/flutter-apk/app-dev-release.apk" + adbs shell settings put global auto_time 0 + adbs shell settings put global auto_time_zone 0 + adbs shell cmd alarm set-timezone Europe/Zurich + adbs shell setprop persist.sys.timezone Europe/Zurich + adbs shell date "$CLOCK" + adbs shell settings put system time_12_24 24 + adbs shell cmd uimode night yes + adbs shell settings put global sysui_demo_allowed 1 + demo enter + demo battery -e level 100 -e plugged false +} + +step_login() { + # Coordinates are for the Pixel 8 screen. If the app's onboarding changed, find the new ones with ui.sh. + quiet shell monkey -p "$PKG" -c android.intent.category.LAUNCHER 1; sleep 10 + for _ in 1 2 3 4; do tap 540 2238 1.2; done # through the onboarding pages + tap 540 850 0.5; tap 540 2240 # account mode, continue + tap 540 1764 # pick the school system + tap 540 790 0.5; adbs shell input text mia.muster + tap 540 990 0.5; adbs shell input text Beispiel2026 + adbs shell input keyevent 111; sleep 0.5 # hide the keyboard + tap 540 1147 5 # sign in (the fake API accepts any login) +} + +step_shoot() { + adbs shell am force-stop "$PKG" + quiet shell monkey -p "$PKG" -c android.intent.category.LAUNCHER 1; sleep 7 + shot home-dark + tab 540; shot grades-dark + tab 750; shot absences-dark + tab 961; WAIT=2 shot account-dark + # Timetable on Tue 10.11.: swipe the date strip one week on, then tap the day. + tab 330 + adbs shell input swipe 900 430 300 430 300; sleep 1.5 + tap 326 430 1.2; shot agenda-dark +} + +step_frame() { + mkdir -p "$OUT/framed" + for p in "home-dark start" "agenda-dark agenda" "grades-dark grades" "absences-dark absences" "account-dark account"; do + set -- $p + run "$PY" "$HERE/frame.py" "$OUT/raw/$1.png" "$OUT/framed/schuly-$2-page-v2.png" "$OUT/framed/schuly-$2-page-v2.webp" + done +} + +step_cleanup() { + adbs emu kill || true + run avdmanager delete avd -n "$AVD" +} + +[ $# -gt 0 ] || { sed -n '2,4p' "$0"; exit 1; } +[ "$1" = all ] && set -- avd boot build prepare login shoot frame +mkdir -p "$OUT" +for s in "$@"; do "step_$s"; done diff --git a/tools/store-screenshots/deviceart/LICENSE.txt b/tools/store-screenshots/deviceart/LICENSE.txt new file mode 100644 index 0000000..f433b1a --- /dev/null +++ b/tools/store-screenshots/deviceart/LICENSE.txt @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/tools/store-screenshots/deviceart/back.webp b/tools/store-screenshots/deviceart/back.webp new file mode 100644 index 0000000..54e8973 Binary files /dev/null and b/tools/store-screenshots/deviceart/back.webp differ diff --git a/tools/store-screenshots/fake-api/avatar.py b/tools/store-screenshots/fake-api/avatar.py new file mode 100644 index 0000000..2831066 --- /dev/null +++ b/tools/store-screenshots/fake-api/avatar.py @@ -0,0 +1,35 @@ +"""Draws the made-up profile picture static/mia.jpg (a sunset over hills), so no real photo is used.""" +import sys +from PIL import Image, ImageDraw, ImageFilter +import random, math +W=512; random.seed(7) +img=Image.new('RGB',(W,W)) +px=img.load() +top=(48,58,120); mid=(232,120,110); low=(250,196,130) +for y in range(W): + t=y/(W*0.62) + if t<0.6: a,b,u=top,mid,t/0.6 + else: a,b,u=mid,low,min(1,(t-0.6)/0.4) + c=tuple(int(a[i]+(b[i]-a[i])*u) for i in range(3)) + for x in range(W): px[x,y]=c +d=ImageDraw.Draw(img) +# sun +sun=Image.new('L',(W,W),0); ImageDraw.Draw(sun).ellipse((300,230,380,310),fill=255) +img.paste((255,226,170),(0,0),sun.filter(ImageFilter.GaussianBlur(2))) +def ridge(base,amp,seed,col,peaks): + random.seed(seed); pts=[(0,W)] + for x in range(0,W+8,8): + h=base-sum(a*math.exp(-((x-p)/w)**2) for p,a,w in peaks)+random.uniform(-amp,amp) + pts.append((x,h)) + pts.append((W,W)); d.polygon(pts,fill=col) +ridge(330,3,1,(120,82,120),[(90,120,70),(250,150,60),(420,100,80)]) +ridge(360,4,2,(78,56,96),[(40,70,60),(180,110,55),(360,80,70),(500,60,50)]) +ridge(395,3,3,(44,36,68),[(120,40,90),(330,55,80)]) +# lake +for y in range(400,W): + for x in range(W): + r,g,b=px[x,800-y] if 800-y>0 else (40,40,60) + f=0.55 + px[x,y]=(int(r*f+20*(1-f)),int(g*f+30*(1-f)),int(b*f+60*(1-f))) +img=img.filter(ImageFilter.GaussianBlur(0.8)) +img.save(sys.path[0] + '/static/mia.jpg', quality=90) diff --git a/tools/store-screenshots/fake-api/server.ts b/tools/store-screenshots/fake-api/server.ts new file mode 100644 index 0000000..c25778e --- /dev/null +++ b/tools/store-screenshots/fake-api/server.ts @@ -0,0 +1,219 @@ +// Fake Schuly API with made-up data for the app screenshots. Nothing here is real. +// Usage: bun tools/store-screenshots/fake-api/server.ts (PORT=5591 by default; the emulator reaches it at http://10.0.2.2:5591) +const PORT = Number(process.env.PORT ?? 5591); +const STATIC = import.meta.dir + "/static"; + +// Europe/Zurich offset: CEST between last Sunday of March and last Sunday of October. +function lastSunday(y: number, m: number) { const d = new Date(Date.UTC(y, m + 1, 0)); d.setUTCDate(d.getUTCDate() - d.getUTCDay()); return d; } +function off(y: number, mo: number, d: number) { const t = Date.UTC(y, mo - 1, d); return t >= lastSunday(y, 2).getTime() && t < lastSunday(y, 9).getTime() ? "+02:00" : "+01:00"; } +const p2 = (n: number) => String(n).padStart(2, "0"); +const ymd = (y: number, m: number, d: number) => `${y}-${p2(m)}-${p2(d)}`; +const at = (date: string, hm: string) => { const [y, m, d] = date.split("-").map(Number); return `${date}T${hm}:00${off(y, m, d)}`; }; + +const SCHOOL = "sch-bfs"; +const ME = "su-mia"; +const APP_USER = "au-mia"; +const ACC = "acc-portal"; +const BASE = "/api/plugins/schulportal"; + +const classes = [ + { id: "cls-mat", name: "Mathematik" }, + { id: "cls-eng", name: "Englisch" }, + { id: "cls-abu", name: "Allgemeinbildung" }, + { id: "cls-m164", name: "Modul 164 Datenbanken erstellen" }, + { id: "cls-m223", name: "Modul 223 Multi-User-Applikationen" }, + { id: "cls-spo", name: "Sport" }, + { id: "cls-m162", name: "Modul 162 Daten analysieren" }, +]; +const currentClassIds = ["cls-mat", "cls-eng", "cls-abu", "cls-m164", "cls-m223", "cls-spo"]; + +const teachers = [ + { code: "KeTh", firstName: "Thomas", lastName: "Keller" }, + { code: "BrCl", firstName: "Claire", lastName: "Brunner" }, + { code: "MeLu", firstName: "Lukas", lastName: "Meier" }, + { code: "HuSa", firstName: "Sandra", lastName: "Huber" }, + { code: "StRe", firstName: "Reto", lastName: "Steiner" }, + { code: "FrMa", firstName: "Marco", lastName: "Frei" }, +].map((t, i) => ({ id: `t-${i}`, schoolId: SCHOOL, schoolName: "Berufsfachschule Beispielstadt", ...t })); +const tName = (code: string) => { const t = teachers.find((x) => x.code === code)!; return `${t.lastName} ${t.firstName} (${t.code})`; }; + +// Weekly timetable: Tuesday and Friday are school days. +type Slot = { from: string; to: string; cls: string; title: string; room: string; teacher: string }; +const week: Record = { + 2: [ + { from: "08:15", to: "09:50", cls: "cls-m164", title: "M164 Datenbanken", room: "C311", teacher: "HuSa" }, + { from: "10:10", to: "11:45", cls: "cls-eng", title: "Englisch", room: "B112", teacher: "BrCl" }, + { from: "12:45", to: "14:20", cls: "cls-spo", title: "Sport", room: "Turnhalle 2", teacher: "FrMa" }, + ], + 5: [ + { from: "08:15", to: "09:50", cls: "cls-mat", title: "Mathematik", room: "B204", teacher: "KeTh" }, + { from: "10:10", to: "11:45", cls: "cls-m223", title: "M223 Multi-User-Apps", room: "C312", teacher: "StRe" }, + { from: "12:45", to: "14:20", cls: "cls-abu", title: "Allgemeinbildung", room: "A105", teacher: "MeLu" }, + { from: "14:30", to: "15:15", cls: "cls-abu", title: "Klassenstunde", room: "A105", teacher: "MeLu" }, + ], +}; +const holidays = [ + { id: "h-herbst", title: "Herbstferien", from: "2026-09-28", to: "2026-10-09" }, + { id: "h-weihnacht", title: "Weihnachtsferien", from: "2026-12-21", to: "2027-01-01" }, + { id: "h-sport", title: "Sportferien", from: "2027-02-08", to: "2027-02-19" }, +]; +// Tests replace the lesson in their slot; key = date|from. +const tests: Record = { + "2026-11-06|12:45": { title: "ABU – Test Mietrecht" }, + "2026-11-10|10:10": { title: "Englisch – Vocabulary Unit 5" }, + "2026-11-13|08:15": { title: "Mathematik – Prüfung Funktionen" }, + "2026-11-17|08:15": { title: "M164 – LB3 Datenbankprojekt" }, + "2026-11-20|10:10": { title: "M223 – LB2 Transaktionen" }, +}; +const events = [ + { id: "ev-messe", title: "Exkursion Berufsmesse", date: "2026-11-24", from: "08:15", to: "15:00", place: "Messezentrum Beispielstadt", cls: null }, + { id: "ev-eltern", title: "Elternabend 2. Lehrjahr", date: "2026-11-19", from: "19:00", to: "20:30", place: "Aula", cls: null }, +]; + +function inHoliday(date: string) { return holidays.some((h) => date >= h.from && date <= h.to); } + +const agenda: any[] = []; +for (let t = Date.UTC(2026, 7, 17); t <= Date.UTC(2026, 11, 18); t += 86400000) { + const d = new Date(t); + const date = ymd(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate()); + const slots = week[d.getUTCDay()]; + if (!slots || inHoliday(date)) continue; + for (const s of slots) { + const test = tests[`${date}|${s.from}`]; + agenda.push({ + id: `ag-${date}-${s.from}`, + entryType: test ? "Test" : "Lesson", + title: test ? test.title : s.title, + description: tName(s.teacher), + place: s.room, + date: at(date, s.from), + endDate: at(date, s.to), + classId: s.cls, + schoolId: SCHOOL, + schoolUserId: ME, + }); + } +} +for (const e of events) agenda.push({ id: e.id, entryType: "Event", title: e.title, place: e.place, date: at(e.date, e.from), endDate: at(e.date, e.to), schoolId: SCHOOL, schoolUserId: ME }); +for (const h of holidays) agenda.push({ id: h.id, entryType: "Holiday", title: h.title, date: at(h.from, "00:00"), endDate: at(h.to, "23:59"), schoolId: SCHOOL }); + +// Exams and Mia's grades. +const examDefs: [string, string, string, number, number, number, string?][] = [ + // id, class, name, date, score, classAverage, weighting, description + ["ex-mat-1", "cls-mat", "Lineare Gleichungssysteme", "2026-09-04", 4.5, 4.4, 1], + ["ex-mat-2", "cls-mat", "Potenzen und Wurzeln", "2026-10-16", 5.0, 4.6, 1], + ["ex-mat-3", "cls-mat", "Kurztest Funktionen", "2026-10-30", 3.75, 4.1, 0.5], + ["ex-eng-1", "cls-eng", "Vocabulary Test Unit 3", "2026-09-08", 5.5, 4.9, 0.5], + ["ex-eng-2", "cls-eng", "Reading Comprehension", "2026-10-20", 5.0, 4.7, 1], + ["ex-eng-3", "cls-eng", "Vocabulary Test Unit 4", "2026-11-03", 6, 5.0, 0.5], + ["ex-abu-1", "cls-abu", "Vertragsrecht", "2026-09-11", 4.75, 4.5, 1], + ["ex-abu-2", "cls-abu", "Präsentation Steuern", "2026-10-23", 5.25, 5.0, 1], + ["ex-m164-1", "cls-m164", "LB1 ERD und Normalisierung", "2026-09-15", 5.5, 4.8, 1], + ["ex-m164-2", "cls-m164", "LB2 SQL-Abfragen", "2026-11-03", 5.0, 4.6, 1], + ["ex-m223-1", "cls-m223", "LB1 Mehrbenutzer-Konzept", "2026-10-16", 5.25, 4.9, 1], + ["ex-spo-1", "cls-spo", "Ausdauerlauf 12 Minuten", "2026-09-22", 5.5, 5.0, 1], + // 2. semester 25/26 + ["ex-mat-0", "cls-mat", "Bruchrechnen und Terme", "2026-03-13", 5.0, 4.5, 1], + ["ex-eng-0", "cls-eng", "Grammar Test Tenses", "2026-04-21", 4.5, 4.4, 1], + ["ex-abu-0", "cls-abu", "Arbeitsvertrag", "2026-05-29", 5.5, 4.8, 1], + ["ex-m162-0", "cls-m162", "LB1 Datenmodelle", "2026-06-16", 5.0, 4.7, 1], +]; +const descriptions: Record = { + "ex-mat-3": "Lineare und quadratische Funktionen, Scheitelpunktform", + "ex-m164-2": "SELECT, JOIN, GROUP BY und Unterabfragen", + "ex-eng-3": "Unit 4: Work and careers", + "ex-abu-2": "Gruppenpräsentation zur Steuererklärung", +}; +const grades = examDefs.map(([id, , , , score, , w]) => ({ id: `g-${id}`, score, weighting: w, examId: id, schoolUserId: ME })); +const exams = examDefs.map(([id, cls, name, date, , avg]) => ({ id, name, description: descriptions[id] ?? null, type: "Classic", date, classAverage: avg, classId: cls, schoolId: SCHOOL, grades: [] })); + +const absences = [ + { id: "ab-1", reason: "Krankheit", type: "Absence", from: at("2026-11-03", "08:15"), until: at("2026-11-03", "14:20"), isExcused: false, schoolUserId: ME, schoolId: SCHOOL }, + { id: "ab-2", reason: "Zug verspätet", type: "Delay", from: at("2026-10-20", "08:15"), until: at("2026-10-20", "08:35"), isExcused: true, schoolUserId: ME, schoolId: SCHOOL }, + { id: "ab-3", reason: "Arzttermin", type: "Absence", from: at("2026-10-13", "10:10"), until: at("2026-10-13", "11:45"), isExcused: true, schoolUserId: ME, schoolId: SCHOOL }, + { id: "ab-5", reason: "Zahnarzt", type: "Absence", from: at("2026-09-15", "10:10"), until: at("2026-09-15", "11:45"), isExcused: true, schoolUserId: ME, schoolId: SCHOOL }, + { id: "ab-6", reason: "Bus verpasst", type: "Delay", from: at("2026-08-25", "08:15"), until: at("2026-08-25", "08:25"), isExcused: true, schoolUserId: ME, schoolId: SCHOOL }, + { id: "ab-4", reason: "Grippe", type: "Absence", from: at("2026-09-01", "08:15"), until: at("2026-09-04", "15:15"), isExcused: true, schoolUserId: ME, schoolId: SCHOOL }, +]; +const reported = [ + { ids: ["rep-1", "rep-2"], dateFrom: "2026-11-10", dateTo: "2026-11-10", hourFrom: "08:15", hourTo: "11:45", reason: "Arzttermin", comment: "Zeugnis folgt", profiles: ["M164", "Englisch"], reportedAt: at("2026-11-05", "18:12") }, +]; + +const classmates = [ + ["Luca", "Baumann"], ["Noah", "Fischer"], ["Lea", "Gerber"], ["Elena", "Schmid"], ["Jan", "Weber"], ["Sara", "Rossi"], ["Timo", "Zürcher"], ["Nina", "Frei"], ["Mia", "Muster"], ["Jonas", "Ammann"], ["Aline", "Kälin"], ["David", "Hofmann"], +].map(([f, l], i) => ({ id: l === "Muster" ? ME : `cm-${i}`, schoolId: SCHOOL, firstName: f, lastName: l, profilePictureUrl: l === "Muster" ? "/static/mia.jpg" : null, role: "Student", absences: [], grades: [] })); + +const myClasses = currentClassIds.map((id) => ({ classId: id, className: classes.find((c) => c.id === id)!.name })); +const schoolUser = { + id: ME, applicationUserId: APP_USER, schoolId: SCHOOL, schoolName: "Berufsfachschule Beispielstadt", + firstName: "Mia", lastName: "Muster", email: "mia.muster@bfs-beispielstadt.ch", profilePictureUrl: "/static/mia.jpg", + entryDate: "2025-08-11", role: "Student", state: "Active", + absences, grades, classes: myClasses, +}; +const classDtos = classes.map((c) => ({ id: c.id, name: c.name, description: null, schoolId: SCHOOL, schoolName: "Berufsfachschule Beispielstadt", students: [], agenda: [], exams: exams.filter((e) => e.classId === c.id) })); + +const documents = [ + { id: "doc-1", schoolUserId: ME, title: "Semesterzeugnis 2. Semester 25/26", category: "Zeugnis", enteredBy: "Sekretariat", fileName: "Zeugnis_2_25-26.pdf", fileSizeBytes: 184320, createdAt: at("2026-07-03", "10:00") }, + { id: "doc-2", schoolUserId: ME, title: "Bestätigung Lehrverhältnis", category: "Bestätigung", enteredBy: "Sekretariat", fileName: "Bestaetigung.pdf", fileSizeBytes: 92160, createdAt: at("2026-08-17", "09:30") }, +]; + +const nowIso = () => new Date().toISOString(); +const minutesAgo = (m: number) => new Date(Date.now() - m * 60000).toISOString(); +const linkedAccount = () => ({ + providerId: "schulportal", providerName: "Schulportal", id: ACC, title: "Berufsfachschule Beispielstadt", detail: "mia.muster", status: "ok", + linkedAt: "2025-08-20T08:00:00Z", lastSyncedAt: minutesAgo(12), canReauth: true, + capabilities: [{ kind: "school_data", label: "School data" }, { kind: "absence_reporting", label: "Absence reporting", basePath: `${BASE}/accounts` }], +}); + +const tokens = () => ({ access_token: "fake-access-" + Date.now(), refresh_token: "fake-refresh", expires_in: 86400, token_type: "Bearer" }); + +const routes: Record any> = { + "GET /api/app": () => ({ registrationEnabled: true, version: "3.2.0", policyVersion: "2026-05", policyUrl: "https://schuly.dev/en/privacy", swissSchoolConfirmationRequired: false }), + "GET /api/app/school-systems": () => [{ key: "schulportal", displayName: "Schulportal", loginMethod: "credentials", enabled: true, sortOrder: 0, pluginBasePath: BASE, loginFields: [] }], + "GET /auth/providers": () => [], + "POST /auth/login": () => tokens(), + "POST /auth/refresh": () => tokens(), + "POST /auth/logout": () => null, + "GET /api/Auth/me": () => ({ id: APP_USER, externalId: "mia.muster", email: "mia.muster@bfs-beispielstadt.ch", displayName: "Mia Muster", profilePictureUrl: "/static/mia.jpg", 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/SchoolUsers": () => [schoolUser], + "GET /api/Exams": () => exams, + "GET /api/Agendas": () => agenda, + "GET /api/Absences": () => absences, + "GET /api/Class": () => classDtos, + "GET /api/Class/search": (_r, u) => { const c = classDtos.find((x) => x.id === u.searchParams.get("classId")); return c ? { ...c, students: classmates, agenda: agenda.filter((a) => a.classId === c.id) } : null; }, + "GET /api/SemesterReports": () => [], + "GET /api/Teachers": () => teachers, + "GET /api/documents": () => documents, + "GET /api/notifications/preferences": () => ({ grades: true, absences: true, agenda: true, includeGradeValue: true, includeDetails: false }), + "POST /api/notifications/devices": () => null, + "GET /api/linking/providers": () => [{ id: "schulportal", displayName: "Schulportal", description: "Grades, timetable and absences from your school", capabilities: [{ kind: "school_data", label: "School data" }, { kind: "absence_reporting", label: "Absence reporting" }], flows: [], sortOrder: 0 }], + "GET /api/linking/accounts": () => [linkedAccount()], + [`GET ${BASE}/accounts`]: () => [{ id: ACC, schoolUserId: ME }], + [`GET ${BASE}/status`]: () => ({ version: "1.8.2" }), + [`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}/absences/reasons`]: () => ["Krankheit", "Arzttermin", "Unfall", "Familiäre Gründe", "Militär / Zivilschutz"], +}; + +Bun.serve({ + port: PORT, + hostname: "0.0.0.0", + async fetch(req) { + const url = new URL(req.url); + if (url.pathname.startsWith("/static/")) { + const f = Bun.file(STATIC + url.pathname.slice(7)); + return (await f.exists()) ? new Response(f, { headers: { "Content-Type": "image/jpeg" } }) : new Response("", { status: 404 }); + } + const key = `${req.method} ${url.pathname}`; + const h = routes[key]; + console.log(`${new Date().toISOString().slice(11, 19)} ${key}${h ? "" : " <-- UNHANDLED"}`); + if (!h) return new Response(JSON.stringify({ message: "not found" }), { status: 404, headers: { "Content-Type": "application/json" } }); + const body = h(req, url); + if (body === null) return new Response(null, { status: 204 }); + return Response.json(body); + }, +}); +console.log(`fake api on :${PORT}`); diff --git a/tools/store-screenshots/fake-api/static/mia.jpg b/tools/store-screenshots/fake-api/static/mia.jpg new file mode 100644 index 0000000..97ba507 Binary files /dev/null and b/tools/store-screenshots/fake-api/static/mia.jpg differ diff --git a/tools/store-screenshots/frame.py b/tools/store-screenshots/frame.py new file mode 100644 index 0000000..219c6ec --- /dev/null +++ b/tools/store-screenshots/frame.py @@ -0,0 +1,47 @@ +"""Frame a 1080x2400 screenshot in the AOSP Pixel 9 Pro XL device art (Apache 2.0), scaled to 660 px wide. + +Usage: python frame.py +""" +import sys +from pathlib import Path + +import numpy as np +from PIL import Image, ImageDraw + +ART = Path(__file__).resolve().parent / "deviceart" / "back.webp" # 1466x3101, screen 1344x2992 at (57, 56) +OUT_W = 660 + + +def phone_frame(src_w, src_h): + """The device art, shortened in the middle of the body so its screen matches the screenshot's aspect ratio.""" + art = Image.open(ART).convert("RGBA") + sw = 1344 + sh = round(sw * src_h / src_w) + cut = 2992 - sh + a = np.asarray(art) + if cut > 0: + a = np.concatenate([a[:1900], a[1900 + cut:]]) + return Image.fromarray(a), (57, 56, sw, sh) + + +def framed(src, dst_png, dst_webp): + shot = Image.open(src).convert("RGB") + frame, (x, y, sw, sh) = phone_frame(*shot.size) + shot = shot.resize((sw, sh), Image.LANCZOS) + mask = Image.new("L", (sw * 2, sh * 2), 0) + ImageDraw.Draw(mask).rounded_rectangle((0, 0, sw * 2 - 1, sh * 2 - 1), 216, fill=255) + mask = mask.resize((sw, sh), Image.LANCZOS) + out = Image.new("RGBA", frame.size, (0, 0, 0, 0)) + out.paste(shot, (x, y), mask) + out.alpha_composite(frame) + fw, fh = out.size + out = out.resize((OUT_W, round(fh * OUT_W / fw)), Image.LANCZOS) + out.save(dst_png, optimize=True) + out.save(dst_webp, "WEBP", quality=85, method=6) + print(dst_png, out.size) + + +if __name__ == "__main__": + if len(sys.argv) != 4: + sys.exit(__doc__) + framed(*sys.argv[1:4]) diff --git a/tools/store-screenshots/requirements.txt b/tools/store-screenshots/requirements.txt new file mode 100644 index 0000000..70d52c0 --- /dev/null +++ b/tools/store-screenshots/requirements.txt @@ -0,0 +1,2 @@ +Pillow>=10 +numpy>=1.26 diff --git a/tools/store-screenshots/ui.sh b/tools/store-screenshots/ui.sh new file mode 100644 index 0000000..43eeb6b --- /dev/null +++ b/tools/store-screenshots/ui.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Lists the text and clickable nodes on the emulator screen with their bounds, to find tap coordinates for capture.sh. +# Usage: tools/store-screenshots/ui.sh (EMU_PORT=5580 by default) +export MSYS_NO_PATHCONV=1 +SERIAL="emulator-${EMU_PORT:-5580}" +adb -s "$SERIAL" shell uiautomator dump /sdcard/ui.xml >/dev/null 2>&1 +adb -s "$SERIAL" exec-out cat /sdcard/ui.xml | python -c " +import sys, re +x = sys.stdin.read() +for m in re.finditer(r']*>', x): + n = m.group(0) + g = lambda k: (re.search(k + '=\"([^\"]*)\"', n) or [None, ''])[1] + t = g('text') or g('content-desc') + if t or g('clickable') == 'true': + print(repr(t[:60]), g('clickable'), g('bounds')) +"