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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user