Add Fastlane Play Store integration with automated screenshots

Fastlane supply config (android/fastlane) with de-DE metadata structure,
integration_test-driven screenshot automation (screenshot_test.dart,
test_driver, tool/screenshots.sh) using stable keys on the login form,
refreshed materials/screenshots (phone + 7-inch) and app banner/symbol.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 23:43:10 +02:00
parent 9b74c9fd81
commit 1114291313
44 changed files with 444 additions and 0 deletions
+12
View File
@@ -350,3 +350,15 @@ hs_err_pid*
*.idea* *.idea*
**/.DS_store **/.DS_store
# Fastlane (Play-Store-Upload)
android/fastlane/report.xml
android/fastlane/Preview.html
android/fastlane/play-service-account.json
android/fastlane/README.md.bak
# lokale Screenshots außerhalb der Fastlane-Metadaten
/screenshots/
# Fastlane-Bild-PNGs sind generierte Kopien; kanonisch/versioniert liegen die
# Screenshots unter materials/screenshots/. Nur die leeren Ordner (.gitkeep)
# bleiben eingecheckt, damit supply die Struktur vorfindet.
android/fastlane/metadata/android/**/images/**/*.png
+6
View File
@@ -0,0 +1,6 @@
package_name("eu.mhsl.marianum.mobile.client")
# Service-Account-JSON für die Google Play Developer API. Pfad über die
# Umgebungsvariable SUPPLY_JSON_KEY setzen (die Datei selbst wird per
# .gitignore nicht eingecheckt).
json_key_file(ENV["SUPPLY_JSON_KEY"] || "fastlane/play-service-account.json")
+29
View File
@@ -0,0 +1,29 @@
default_platform(:android)
# Play-Store-Auslieferung über fastlane supply. Die Screenshots erzeugt
# ../tool/screenshots.sh (Demo-Login + integration_test) direkt in
# metadata/android/<locale>/images/. Texte/Changelogs liegen daneben in
# metadata/android/<locale>/.
platform :android do
desc "Nur Screenshots hochladen (verändert keine Texte/Binaries)"
lane :upload_screenshots do
upload_to_play_store(
skip_upload_apk: true,
skip_upload_aab: true,
skip_upload_metadata: true,
skip_upload_changelogs: true,
skip_upload_images: false,
skip_upload_screenshots: false,
metadata_path: "./fastlane/metadata/android",
)
end
desc "Store-Texte, Changelogs und Screenshots hochladen (keine Binaries)"
lane :upload_metadata do
upload_to_play_store(
skip_upload_apk: true,
skip_upload_aab: true,
metadata_path: "./fastlane/metadata/android",
)
end
end
+2
View File
@@ -0,0 +1,2 @@
# fastlane-Plugins (aktuell keine). Datei vorhanden, damit `fastlane` das
# Plugin-Handling initialisiert.
+35
View File
@@ -0,0 +1,35 @@
# Fastlane Play-Store-Screenshots & Upload
Schritt 2 der Play-Store-Automatisierung. Die App muss dafür **nicht** neu
gebaut werden der Demo-Modus (`demo@`-Login) liefert die kompletten Inhalte
aus Fixtures.
## 1. Screenshots aufnehmen
Ein Gerät/Emulator (≥ Android 8) per `adb devices` sichtbar, dann aus dem
**Client**-Wurzelverzeichnis:
```bash
tool/screenshots.sh
```
Fährt Phone (1080×1920), 7″ (1200×1920) und 10″ (1600×2560) nacheinander durch
(setzt die Displaygröße per `adb shell wm size/density`) und legt die PNGs in
`metadata/android/de-DE/images/{phone,sevenInch,tenInch}Screenshots/` ab.
- Nur eine Größe: `PROFILES="phone" tool/screenshots.sh`
- Profile-Build (flüssigere Frames): `BUILD_MODE=profile tool/screenshots.sh`
## 2. Hochladen
Service-Account-JSON der Google Play Developer API besorgen und referenzieren:
```bash
export SUPPLY_JSON_KEY=/pfad/zu/play-service-account.json
cd android
fastlane upload_screenshots # nur Bilder
# oder
fastlane upload_metadata # Bilder + Texte/Changelogs, keine Binaries
```
Der Service-Account-Key wird **nicht** eingecheckt (siehe `.gitignore`).
+215
View File
@@ -0,0 +1,215 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:marianum_mobile/app.dart';
import 'package:marianum_mobile/main.dart' as app;
import 'package:marianum_mobile/routing/app_routes.dart';
import 'package:marianum_mobile/state/app/modules/app_modules.dart';
import 'package:marianum_mobile/state/app/modules/settings/bloc/settings_cubit.dart';
import 'package:marianum_mobile/view/pages/talk/widgets/chat_tile.dart';
/// Marketing-Screenshot-Lauf für die Stores. Loggt sich über den Demo-Login
/// (`demo@…`, siehe DemoMode) ein und nimmt einen kuratierten Satz Screens auf:
/// 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″).
///
/// Kein Netzwerk: der Demo-Modus beantwortet allen Backend-Verkehr aus
/// Fixtures, daher ist der Lauf deterministisch.
Future<void> main() async {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('Store-Screenshots über den Demo-Login', (tester) async {
// Der integration_test-Harness installiert eigene Error-Handler. app()
// überschreibt sie in main() durch die produktiven (ClientErrorReporter),
// was die End-of-Test-Buchführung des Harness sprengt (_pendingExceptionDetails).
// Daher vorher sichern und nach main() zurücksetzen.
final harnessFlutterOnError = FlutterError.onError;
final harnessDispatcherOnError = PlatformDispatcher.instance.onError;
_log('main() starten');
await app.main();
FlutterError.onError = harnessFlutterOnError;
PlatformDispatcher.instance.onError = harnessDispatcherOnError;
await tester.pump(const Duration(seconds: 1));
_log('Login');
await _login(tester);
_log('warte auf App-Shell');
await _pumpUntil(
tester,
find.byType(App),
timeout: const Duration(seconds: 20),
);
_log('warte auf Post-Login-Splash-Ende');
await _pumpUntilGone(
tester,
find.byKey(const ValueKey('post-login-splash')),
timeout: const Duration(seconds: 12),
);
await tester.pump(const Duration(seconds: 1));
// Screenshots auf Android brauchen eine einmalige Umwandlung der
// Flutter-Surface in eine lesbare Image-Texture. Erst hier nach Login und
// Splash , damit der sichtbare Screen erst kurz vor der Aufnahme „einfriert".
if (Platform.isAndroid) {
_log('convertFlutterSurfaceToImage');
await binding.convertFlutterSurfaceToImage();
}
// 1 + 2: Stundenplan in beiden Themes.
await _goTo(tester, Modules.timetable);
_setTheme(tester, ThemeMode.light);
await _capture(tester, binding, '1_stundenplan_hell');
_setTheme(tester, ThemeMode.dark);
await _capture(tester, binding, '2_stundenplan_dunkel');
// Restlicher Satz einheitlich im hellen Theme.
_setTheme(tester, ThemeMode.light);
// 3: Talk-Liste.
await _goTo(tester, Modules.talk);
await _capture(tester, binding, '3_talk_liste');
// 4: Talk-Chat (ersten Chat der Liste öffnen).
await tester.tap(find.byType(ChatTile).first);
await _capture(tester, binding, '4_talk_chat');
_popPushedPage(tester);
await tester.pump(const Duration(milliseconds: 500));
// 5: Dateien.
await _goTo(tester, Modules.files);
await _capture(tester, binding, '5_dateien');
// 6: „Mehr"-Bereich (letzter Tab, hinter den Modul-Tabs).
_goToMore(tester);
await _capture(tester, binding, '6_mehr');
// 7: Einstellungen (Vollbild-Push).
AppRoutes.openSettings(tester.element(find.byType(App)));
await _capture(tester, binding, '7_einstellungen');
_popPushedPage(tester);
_log('fertig');
});
}
void _log(String message) => debugPrint('SHOTS: $message');
Future<void> _login(WidgetTester tester) async {
final loginVisible = await _pumpUntil(
tester,
find.byKey(const Key('login-username-field')),
);
if (!loginVisible) {
_log('kein Login-Screen sichtbar bereits angemeldet, überspringe Login');
return;
}
await tester.enterText(
find.byKey(const Key('login-username-field')),
'demo@screenshots',
);
await tester.enterText(find.byKey(const Key('login-password-field')), 'demo');
await tester.pump();
await tester.tap(find.byKey(const Key('login-submit-button')));
await tester.pump(const Duration(milliseconds: 500));
}
/// Setzt das App-Theme über den [SettingsCubit] (MaterialApp folgt
/// `settings.appTheme`). `val(write: true)` plant den Emit als Microtask.
void _setTheme(WidgetTester tester, ThemeMode mode) {
final context = tester.element(find.byType(App));
context.read<SettingsCubit>().val(write: true).appTheme = mode;
}
/// 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 {
final appFinder = find.byType(App);
if (appFinder.evaluate().isEmpty) {
_log('WARN: App-Shell fehlt, kann $module nicht ansteuern');
return;
}
final context = tester.element(appFinder);
if (!AppRoutes.goToTab(context, module)) {
final resolved = AppModule.modules(context)[module];
if (resolved != null) AppRoutes.openModule(context, resolved);
}
}
/// Springt auf den „Mehr"-Tab. Der liegt hinter den Modul-Tabs, sein Index ist
/// also die Anzahl der Bottom-Bar-Module.
void _goToMore(WidgetTester tester) {
final context = tester.element(find.byType(App));
final moreIndex = AppModule.getBottomBarModules(context).length;
app.Main.bottomNavigator.jumpToTab(moreIndex);
}
/// Schließt einen per [AppRoutes] gepushten Vollbild-Screen wieder.
void _popPushedPage(WidgetTester tester) {
final navigator = AppRoutes.rootNavigatorKey.currentState;
if (navigator != null && navigator.canPop()) navigator.pop();
}
/// Lässt die Oberfläche zur Ruhe kommen und legt den Screenshot ab.
Future<void> _capture(
WidgetTester tester,
IntegrationTestWidgetsFlutterBinding binding,
String name,
) async {
// Feste Setzzeit statt „warte auf Spinner-Ende": die Demo-Fixtures kommen
// synchron (keine künstlichen Delays), aber Module wie Dateien/Chat laden erst
// beim Navigieren nach. Eine globale CircularProgressIndicator-Prüfung taugt
// nicht, weil die PersistentTabView alle Tabs am Leben hält und
// Avatar-Platzhalter dauerhaft einen Spinner zeigen. In 500ms-Schritten
// pumpen, damit Timer/Microtasks (u.a. der Theme-Emit) durchlaufen.
await _settle(tester, const Duration(seconds: 6));
_log('-> $name: takeScreenshot');
await binding.takeScreenshot(name);
}
/// Pumpt [duration] in 500ms-Schritten ab, damit die App async Fixtures laden
/// und ihre Frames rendern kann (Ersatz für pumpAndSettle, das an den
/// Dauer-Animationen hängen bleibt).
Future<void> _settle(WidgetTester tester, Duration duration) async {
final steps = duration.inMilliseconds ~/ 500;
for (var i = 0; i < steps; i++) {
await tester.pump(const Duration(milliseconds: 500));
}
}
/// Pumpt in kurzen Schritten, bis [finder] erscheint oder [timeout] abläuft.
/// Gibt zurück, ob [finder] gefunden wurde.
Future<bool> _pumpUntil(
WidgetTester tester,
Finder finder, {
Duration timeout = const Duration(seconds: 10),
}) async {
final end = DateTime.now().add(timeout);
while (DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 200));
if (finder.evaluate().isNotEmpty) return true;
}
return false;
}
/// Gegenstück zu [_pumpUntil]: pumpt, bis [finder] nichts mehr trifft.
Future<bool> _pumpUntilGone(
WidgetTester tester,
Finder finder, {
Duration timeout = const Duration(seconds: 10),
}) async {
final end = DateTime.now().add(timeout);
while (DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 200));
if (finder.evaluate().isEmpty) return true;
}
return false;
}
+3
View File
@@ -111,6 +111,7 @@ class _LoginCardState extends State<LoginCard> {
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
TextFormField( TextFormField(
key: const Key('login-username-field'),
controller: _usernameController, controller: _usernameController,
enabled: !loading, enabled: !loading,
validator: _required, validator: _required,
@@ -125,6 +126,7 @@ class _LoginCardState extends State<LoginCard> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
TextFormField( TextFormField(
key: const Key('login-password-field'),
controller: _passwordController, controller: _passwordController,
focusNode: _passwordFocus, focusNode: _passwordFocus,
enabled: !loading, enabled: !loading,
@@ -146,6 +148,7 @@ class _LoginCardState extends State<LoginCard> {
SizedBox( SizedBox(
height: 50, height: 50,
child: FilledButton( child: FilledButton(
key: const Key('login-submit-button'),
onPressed: loading ? null : _submit, onPressed: loading ? null : _submit,
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 671 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

+5
View File
@@ -98,6 +98,11 @@ dependencies:
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
# Screenshot-Automatisierung für den Play Store: treibt den Demo-Login über
# flutter drive und ruft binding.takeScreenshot pro Hauptscreen auf
# (integration_test/ + test_driver/, orchestriert von tool/screenshots.sh).
integration_test:
sdk: flutter
fake_async: ^1.3.1 fake_async: ^1.3.1
flutter_launcher_icons: ^0.14.3 flutter_launcher_icons: ^0.14.3
+21
View File
@@ -0,0 +1,21 @@
import 'dart:io';
import 'package:integration_test/integration_test_driver_extended.dart';
/// Host-seitiger Treiber für den Screenshot-Lauf. Schreibt jeden von
/// `binding.takeScreenshot(name)` gelieferten PNG-Frame nach
/// `$SCREENSHOT_OUT_DIR/<name>.png` (Default: ./screenshots). Das Zielverzeichnis
/// setzt tool/screenshots.sh pro Gerätegröße auf das passende Fastlane-Bildverzeichnis.
Future<void> main() async {
final outDir = Platform.environment['SCREENSHOT_OUT_DIR'] ?? 'screenshots';
await integrationDriver(
onScreenshot: (name, bytes, [args]) async {
final file = File('$outDir/$name.png');
await file.create(recursive: true);
await file.writeAsBytes(bytes);
stdout.writeln('Screenshot gespeichert: ${file.path}');
return true;
},
);
}
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
#
# Store-Screenshots über den Demo-Login aufnehmen.
#
# Fährt den integration_test-Screenshot-Lauf (integration_test/screenshot_test.dart)
# über flutter drive für die drei von Google Play verlangten Formfaktoren durch
# (Phone + 7″ + 10″ Tablet sind inzwischen Pflicht). Statt drei AVDs zu verwalten,
# wird EIN laufendes Gerät/Emulator pro Durchlauf per `adb shell wm size/density`
# auf die Zielauflösung gebracht; takeScreenshot liefert dann Frames in genau
# dieser Pixelgröße.
#
# Ablage:
# - kanonisch (versioniert): materials/screenshots/android/<formfaktor>/
# - Kopie für den Upload: android/fastlane/metadata/android/de-DE/images/<...>Screenshots/
# Danach: `cd android && fastlane upload_screenshots`.
#
# Voraussetzungen:
# - genau ein per `adb devices` sichtbares Gerät/Emulator (>= Android 8)
# - flutter, adb im PATH
#
# Aufruf (vom Client-Projektwurzelverzeichnis):
# tool/screenshots.sh # debug-Build (schnell)
# BUILD_MODE=profile tool/screenshots.sh
# PROFILES="phone" tool/screenshots.sh # nur eine Größe
set -euo pipefail
cd "$(dirname "$0")/.."
BUILD_MODE="${BUILD_MODE:-debug}"
MATERIALS_BASE="materials/screenshots/android"
FASTLANE_BASE="android/fastlane/metadata/android/de-DE/images"
APP_ID="eu.mhsl.marianum.mobile.client"
# name | breite x höhe (px) | dichte (dpi) | materials-Ordner | fastlane-Ordner
# Phone nutzt die NATIVE Displayauflösung des Geräts (voller Hochformat, nicht
# auf 16:9 gestaucht). Tablets im QUERFORMAT (Breite > Höhe); bei den
# Landscape-Breiten (>1000dp) zeigt Talk zudem die Master-Detail-Splitview.
# Dichten so gewählt, dass die adaptive Bottom-Bar die passende Tab-Anzahl zeigt.
declare -A PROFILE_SIZE=(
[phone]="native" [seven]="1920x1200" [ten]="2560x1600"
)
declare -A PROFILE_DENSITY=(
[phone]="native" [seven]="240" [ten]="280"
)
declare -A PROFILE_MATERIALS=(
[phone]="phone" [seven]="7-inch" [ten]="10-inch"
)
declare -A PROFILE_FASTLANE=(
[phone]="phoneScreenshots"
[seven]="sevenInchScreenshots"
[ten]="tenInchScreenshots"
)
PROFILES="${PROFILES:-phone seven ten}"
# App-APK einmal vorab bauen, damit sie vor jedem `flutter drive` installiert
# und die Notification-Permission gegrantet werden kann (drive deinstalliert die
# App nach jedem Lauf, sonst wäre der Grant weg und ein Systemdialog könnte die
# Screenshots verdecken). Der eigentliche drive-Lauf reinstalliert sie per -r.
APK="build/app/outputs/flutter-apk/app-${BUILD_MODE}.apk"
echo "Baue App-APK ($BUILD_MODE) vorab…"
flutter build apk "--$BUILD_MODE"
reset_display() {
echo "Setze Displaygröße/-dichte zurück…"
adb shell wm size reset || true
adb shell wm density reset || true
}
trap reset_display EXIT
for profile in $PROFILES; do
size="${PROFILE_SIZE[$profile]}"
density="${PROFILE_DENSITY[$profile]}"
materials_dir="$MATERIALS_BASE/${PROFILE_MATERIALS[$profile]}"
fastlane_dir="$FASTLANE_BASE/${PROFILE_FASTLANE[$profile]}"
echo ""
echo "=== Profil '$profile' ($size @ ${density}dpi) -> $materials_dir ==="
mkdir -p "$materials_dir" "$fastlane_dir"
rm -f "$materials_dir"/*.png "$fastlane_dir"/*.png
# "native" -> Geräteauflösung/-dichte verwenden (für Phone), sonst erzwingen.
if [ "$size" = "native" ]; then
adb shell wm size reset
adb shell wm density reset
else
adb shell wm size "$size"
adb shell wm density "$density"
fi
# App sicherstellen, frische (ausgeloggte) Session erzwingen und
# Notification-Permission erteilen, bevor drive läuft. pm clear setzt auch die
# Runtime-Permissions zurück, daher der Grant DANACH.
[ -f "$APK" ] && adb install -r "$APK" >/dev/null 2>&1 || true
adb shell pm clear "$APP_ID" >/dev/null 2>&1 || true
adb shell pm grant "$APP_ID" android.permission.POST_NOTIFICATIONS 2>/dev/null || true
sleep 2
SCREENSHOT_OUT_DIR="$materials_dir" flutter drive \
--driver=test_driver/integration_test.dart \
--target=integration_test/screenshot_test.dart \
--"$BUILD_MODE" \
--no-dds
# Kanonische Bilder in den Fastlane-Upload-Ordner spiegeln.
cp "$materials_dir"/*.png "$fastlane_dir"/
echo "Profil '$profile' fertig: $(ls -1 "$materials_dir"/*.png 2>/dev/null | wc -l) Screenshots"
done
reset_display
trap - EXIT
echo ""
echo "Kanonische Screenshots: $MATERIALS_BASE/{phone,7-inch,10-inch}/"
echo "Upload: cd android && fastlane upload_screenshots"