added automated App Store screenshots via iOS simulators and fastlane deliver

tool/screenshots_ios.sh runs the existing demo-login integration test on
iPhone 6.5" (iPhone 14 Plus) and iPad 13" simulators, stores the canonical PNGs
under materials/screenshots and mirrors alpha-free JPEGs to
ios/fastlane/screenshots for `fastlane upload_screenshots`.

Fixed the demo router answering timetable/custom-events with a week, which
crashed the inline iOS widget refresh after a demo login. The screenshot test
now skips the one-time Talk notification dialog and waits longer for the app
shell on cold simulators.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 16:58:29 +02:00
co-authored by Claude Opus 5.5
parent ada4d6a77c
commit c5592f117b
38 changed files with 241 additions and 2 deletions
+6
View File
@@ -362,3 +362,9 @@ android/fastlane/README.md.bak
# Screenshots unter materials/screenshots/. Nur die leeren Ordner (.gitkeep)
# bleiben eingecheckt, damit supply die Struktur vorfindet.
android/fastlane/metadata/android/**/images/**/*.png
# Fastlane (App-Store-Upload)
ios/fastlane/report.xml
ios/fastlane/Preview.html
ios/fastlane/app-store-connect-api-key.json
ios/fastlane/screenshots/**/*.jpg
+21 -2
View File
@@ -17,7 +17,8 @@ import 'package:marianum_mobile/view/pages/talk/widgets/chat_tile.dart';
/// Stundenplan (hell + dunkel), Talk-Liste, Talk-Chat, Dateien, „Mehr"-Bereich
/// und Einstellungen. Ausgeführt über `flutter drive` mit
/// test_driver/integration_test.dart, orchestriert von tool/screenshots.sh
/// (Phone / 7″ / 10″).
/// (Android: Phone / 7″ / 10″) bzw. tool/screenshots_ios.sh (iPhone 6,5″ /
/// iPad 13″).
///
/// Kein Netzwerk: der Demo-Modus beantwortet allen Backend-Verkehr aus
/// Fixtures, daher ist der Lauf deterministisch.
@@ -42,11 +43,13 @@ Future<void> main() async {
_log('Login');
await _login(tester);
// Großzügig: auf einem frisch angelegten iOS-Simulator dauert der erste
// Post-Login-Aufbau deutlich länger als 20s.
_log('warte auf App-Shell');
await _pumpUntil(
tester,
find.byType(App),
timeout: const Duration(seconds: 20),
timeout: const Duration(seconds: 60),
);
_log('warte auf Post-Login-Splash-Ende');
await _pumpUntilGone(
@@ -64,6 +67,12 @@ Future<void> main() async {
await binding.convertFlutterSurfaceToImage();
}
// Den einmaligen Benachrichtigungs-Dialog beim ersten Talk-Besuch
// unterdrücken. Auf Android erteilt tool/screenshots.sh die Permission
// vorab, auf iOS lässt sie sich im Simulator nicht vorab erteilen – der
// Dialog würde sonst über der Talk-Liste liegen.
_markTalkPermissionPromptShown(tester);
// 1 + 2: Stundenplan in beiden Themes.
await _goTo(tester, Modules.timetable);
_setTheme(tester, ThemeMode.light);
@@ -129,6 +138,16 @@ void _setTheme(WidgetTester tester, ThemeMode mode) {
context.read<SettingsCubit>().val(write: true).appTheme = mode;
}
void _markTalkPermissionPromptShown(WidgetTester tester) {
final context = tester.element(find.byType(App));
context
.read<SettingsCubit>()
.val(write: true)
.notificationSettings
.talkPermissionPromptShown =
true;
}
/// Navigiert zu [module] – über den Bottom-Tab, wenn er in der Leiste liegt,
/// sonst als Vollbild-Push. Größenunabhängig, da die Tab-Anzahl je Gerät variiert.
Future<void> _goTo(WidgetTester tester, Modules module) async {
+2
View File
@@ -0,0 +1,2 @@
app_identifier("eu.mhsl.marianum.mobile.client")
team_id("MY55VF3KPG")
+27
View File
@@ -0,0 +1,27 @@
default_platform(:ios)
# App-Store-Auslieferung über fastlane deliver. Die Screenshots erzeugt
# ../tool/screenshots_ios.sh (Demo-Login + integration_test) direkt in
# screenshots/de-DE/; deliver ordnet sie anhand der Pixelgröße iPhone 6,5″ bzw.
# iPad 13″ zu.
#
# Authentifizierung über einen App-Store-Connect-API-Key (JSON im fastlane-
# Format, siehe README.md). Pfad per APP_STORE_CONNECT_API_KEY setzen; die Datei
# selbst wird per .gitignore nicht eingecheckt.
platform :ios do
desc "Nur Screenshots hochladen (ersetzt die vorhandenen, verändert keine Texte/Binaries)"
lane :upload_screenshots do
upload_to_app_store(
api_key_path: ENV["APP_STORE_CONNECT_API_KEY"] || "fastlane/app-store-connect-api-key.json",
skip_binary_upload: true,
skip_metadata: true,
skip_app_version_update: true,
skip_screenshots: false,
overwrite_screenshots: true,
screenshots_path: "./fastlane/screenshots",
precheck_include_in_app_purchases: false,
run_precheck_before_submit: false,
force: true, # kein HTML-Vorschau-Bestätigungsschritt
)
end
end
+49
View File
@@ -0,0 +1,49 @@
# Fastlane – App-Store-Screenshots & Upload
iOS-Gegenstück zu `android/fastlane`. Die App muss dafür **nicht** neu gebaut
werden – der Demo-Modus (`demo@`-Login) liefert die kompletten Inhalte aus
Fixtures.
## 1. Screenshots aufnehmen
Aus dem **Client**-Wurzelverzeichnis:
```bash
tool/screenshots_ios.sh
```
Fährt „iPhone 14 Plus“ (6,5″, 1284×2778) und „iPad Pro 13-inch (M5)“
(13″, 2064×2752) nacheinander durch und legt die PNGs in
`materials/screenshots/{ios/6-5-inch,ipados/13-inch}/` sowie als JPEG-Upload-Kopie
(App Store Connect lehnt Alpha-Kanäle ab) in `screenshots/de-DE/` ab. Fehlende
Simulatoren legt das Skript selbst an.
- Nur eine Größe: `PROFILES="iphone" tool/screenshots_ios.sh`
- Andere Simulatoren: `IPHONE_SIM="iPhone 11 Pro Max" IPAD_SIM="…" tool/screenshots_ios.sh`
App und Schlüsselbund der verwendeten Simulatoren werden dabei zurückgesetzt.
## 2. Hochladen
fastlane installieren (`brew install fastlane`), in App Store Connect unter
*Benutzer und Zugriff → Integrationen → App Store Connect API* einen Key mit
Rolle *App-Manager* anlegen und als JSON ablegen:
```json
{
"key_id": "ABC123XYZ",
"issuer_id": "00000000-0000-0000-0000-000000000000",
"key": "-----BEGIN PRIVATE KEY-----\n…\n-----END PRIVATE KEY-----",
"in_house": false
}
```
```bash
export APP_STORE_CONNECT_API_KEY=/pfad/zu/app-store-connect-api-key.json
cd ios
fastlane upload_screenshots
```
Die Screenshots landen in der aktuell bearbeitbaren App-Store-Version
(„Vorbereitung für Einreichung“); vorhandene Screenshots werden ersetzt. Der
API-Key wird **nicht** eingecheckt (siehe `.gitignore`).
+2
View File
@@ -32,6 +32,8 @@ class DemoMarianumConnect {
return DemoTimetable.timegrid().result.map((e) => e.toJson()).toList();
case 'timetable/holidays':
return DemoTimetable.holidays().result.map((e) => e.toJson()).toList();
case 'timetable/custom-events':
return DemoTimetable.customEvents().toJson();
case 'holidays':
return DemoHolidays.upcoming().map((e) => e.toJson()).toList();
case 'users/search':
Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 555 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 564 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 757 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 604 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 490 KiB

+12
View File
@@ -8,6 +8,7 @@ import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_school
import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart';
import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart';
import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
import 'package:marianum_mobile/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
/// The demo interceptor answers MarianumConnect calls with these bodies; each
/// must round-trip through the same parser the real query uses. A shape
@@ -57,6 +58,17 @@ void main() {
);
});
// Eigener Case nötig: sonst greift der timetable/<type>/<id>-Fallback und
// liefert eine Woche statt `{events: [...]}` (iOS-Widget-Refresh crashte).
test('timetable/custom-events', () {
expect(
() => GetCustomTimetableEventResponse.fromJson(
asMap('timetable/custom-events'),
),
returnsNormally,
);
});
test('holidays → non-empty list of McHoliday', () {
final holidays = asList('holidays').map(McHoliday.fromJson).toList();
expect(holidays, isNotEmpty);
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env bash
#
# App-Store-Screenshots über den Demo-Login aufnehmen – iOS-Gegenstück zu
# tool/screenshots.sh.
#
# Fährt denselben integration_test-Lauf (integration_test/screenshot_test.dart)
# per flutter drive auf je einem iOS-Simulator pro Formfaktor: iPhone 6,5″
# (iPhone 14 Plus, 1284×2778) und iPad 13″ (iPad Pro 13-inch, 2064×2752). Die
# Simulatoren liefern die Frames direkt in der Zielauflösung, eine
# Größenumstellung wie per `adb shell wm size` ist nicht nötig. Fehlt ein
# Simulator, wird er mit gleichnamigem Gerätetyp angelegt.
#
# Ablage:
# - kanonisch (versioniert): materials/screenshots/{ios/6-5-inch,ipados/13-inch}/
# - Kopie für den Upload: ios/fastlane/screenshots/de-DE/<profil>_<name>.jpg
# (deliver ordnet die Bilder anhand der Pixelgröße dem Gerät zu; das Präfix
# verhindert nur Namenskollisionen zwischen iPhone und iPad.)
# Danach: `cd ios && fastlane upload_screenshots`.
#
# Voraussetzungen:
# - Xcode mit iOS-Simulator-Runtime; Simulatoren (Namen per IPHONE_SIM/IPAD_SIM
# überschreibbar; Name = Gerätetyp aus `xcrun simctl list devicetypes`)
# - flutter im PATH
#
# Achtung: Pro Simulator werden App und Schlüsselbund zurückgesetzt, damit der
# Lauf garantiert mit dem Demo-Login startet und keine echte Sitzung zeigt.
# Deshalb eigene Simulatoren verwenden, nicht den für die Entwicklung.
#
# Aufruf (vom Client-Projektwurzelverzeichnis):
# tool/screenshots_ios.sh # debug-Build
# BUILD_MODE=profile tool/screenshots_ios.sh
# PROFILES="iphone" tool/screenshots_ios.sh # nur eine Größe
set -euo pipefail
cd "$(dirname "$0")/.."
BUILD_MODE="${BUILD_MODE:-debug}"
FASTLANE_DIR="ios/fastlane/screenshots/de-DE"
BUNDLE_ID="eu.mhsl.marianum.mobile.client"
IPHONE_SIM="${IPHONE_SIM:-iPhone 14 Plus}"
IPAD_SIM="${IPAD_SIM:-iPad Pro 13-inch (M5)}"
PROFILES="${PROFILES:-iphone ipad}"
# UDID des ersten verfügbaren Simulators mit exakt diesem Namen.
sim_udid() {
xcrun simctl list devices available -j |
/usr/bin/python3 -c '
import json, sys
name = sys.argv[1]
for devices in json.load(sys.stdin)["devices"].values():
for d in devices:
if d["name"] == name:
print(d["udid"]); sys.exit(0)
sys.exit(1)' "$1"
}
BOOTED_BY_US=()
cleanup() {
for udid in ${BOOTED_BY_US[@]+"${BOOTED_BY_US[@]}"}; do
xcrun simctl status_bar "$udid" clear >/dev/null 2>&1 || true
xcrun simctl shutdown "$udid" >/dev/null 2>&1 || true
done
}
trap cleanup EXIT
mkdir -p "$FASTLANE_DIR"
for profile in $PROFILES; do
case "$profile" in
iphone) sim="$IPHONE_SIM"; materials_dir="materials/screenshots/ios/6-5-inch" ;;
ipad) sim="$IPAD_SIM"; materials_dir="materials/screenshots/ipados/13-inch" ;;
*) echo "Unbekanntes Profil '$profile' (erlaubt: iphone ipad)" >&2; exit 1 ;;
esac
udid="$(sim_udid "$sim")" || {
echo "Simulator '$sim' fehlt, lege ihn an…"
udid="$(xcrun simctl create "$sim" "$sim")"
}
echo ""
echo "=== Profil '$profile' ($sim, $udid) -> $materials_dir ==="
mkdir -p "$materials_dir"
rm -f "$materials_dir"/*.png "$FASTLANE_DIR/${profile}_"*.jpg
if ! xcrun simctl list devices | grep -q "$udid) (Booted)"; then
xcrun simctl boot "$udid"
BOOTED_BY_US+=("$udid")
fi
xcrun simctl bootstatus "$udid" -b >/dev/null
# Apple-typische Statusleiste: 9:41, volles WLAN/Netz, Akku voll.
xcrun simctl status_bar "$udid" override \
--time "9:41" --dataNetwork wifi --wifiMode active --wifiBars 3 \
--cellularMode active --cellularBars 4 --operatorName "" \
--batteryState charged --batteryLevel 100
# Frische, ausgeloggte Sitzung: App löschen und Schlüsselbund leeren
# (flutter_secure_storage überlebt auf iOS eine Deinstallation).
xcrun simctl uninstall "$udid" "$BUNDLE_ID" >/dev/null 2>&1 || true
xcrun simctl keychain "$udid" reset >/dev/null 2>&1 || true
SCREENSHOT_OUT_DIR="$materials_dir" flutter drive \
-d "$udid" \
--driver=test_driver/integration_test.dart \
--target=integration_test/screenshot_test.dart \
--"$BUILD_MODE" \
--no-dds
# Kanonische Bilder mit Profil-Präfix in den Fastlane-Upload-Ordner spiegeln.
# Als JPEG, weil App Store Connect Screenshots mit Alpha-Kanal ablehnt und
# takeScreenshot RGBA-PNGs liefert.
for f in "$materials_dir"/*.png; do
sips -s format jpeg -s formatOptions best "$f" \
--out "$FASTLANE_DIR/${profile}_$(basename "${f%.png}").jpg" >/dev/null
done
echo "Profil '$profile' fertig: $(ls -1 "$materials_dir"/*.png 2>/dev/null | wc -l | tr -d ' ') Screenshots"
done
echo ""
echo "Kanonische Screenshots: materials/screenshots/{ios/6-5-inch,ipados/13-inch}/"
echo "Upload: cd ios && fastlane upload_screenshots"