import 'dart:io'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:pyramid/core/app_state.dart'; import 'package:pyramid/core/appearance.dart'; import 'package:pyramid/core/theme.dart'; import 'package:pyramid/main.dart' as app; import 'package:pyramid/widgets/spring_press.dart'; /// UI-Durchlauf im HANDY-Format mit der echten App (Windows-Build) und einem /// TEST-Profil – erzeugt Screenshots unter build/ui_screens/. /// /// Läuft nur mit gesetztem PYRAMID_PROFILE_DIR (eingeloggtes Testprofil, z. B. /// pyramidtest1 unter %USERPROFILE%\.pyramid-autopilot\profile1) – nie gegen /// Bernds echtes Profil. Testinhalte vorher mit /// `test/live_ui_fixture_test.dart` anlegen (DM + Gruppe mit pyramidtest2). /// /// $env:PYRAMID_PROFILE_DIR="$env:USERPROFILE\.pyramid-autopilot\profile1" /// flutter test integration_test/mobile_ui_screens_test.dart -d windows /// /// Achtung: Keine zweite Pyramid-Instanz auf demselben Profil laufen lassen /// (zwei Clients auf einer olm-DB). Ansichten (Darstellung, Farbmodus, Raum) /// werden nur über den App-Zustand umgeschaltet – nichts wird gespeichert. /// /// Simuliert wird ein typisches Handy: 1080×2340 px, Dichte 2,625 /// (≈ 411×891 dp), Statusleiste oben 24 dp, Gestenleiste unten 16 dp. const _otherUser = 'pyramidtest2'; const _groupName = 'UI-Test Gruppe'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); final profile = Platform.environment['PYRAMID_PROFILE_DIR']; testWidgets( 'Handy-UI: Chatliste, Chat, Bild-Vollansicht, Gruppe, Einstellungen', (tester) async { tester.view ..physicalSize = const Size(1080, 2340) ..devicePixelRatio = 2.625 ..padding = const FakeViewPadding(top: 63, bottom: 42) ..viewPadding = const FakeViewPadding(top: 63, bottom: 42); addTearDown(tester.view.reset); final size = tester.view.physicalSize / tester.view.devicePixelRatio; // app.main() ersetzt FlutterError.onError – danach den Empfänger des // Testrahmens zurücksetzen, sonst verschwinden Fehlermeldungen. final testOnError = FlutterError.onError; app.main(); await _pumpUntil( tester, find.text(_otherUser), timeout: const Duration(seconds: 90), ); final container = ProviderScope.containerOf( tester.element(find.byType(MaterialApp).first), ); FlutterError.onError = testOnError; await _shot(tester, '01_chatliste'); // ── Direktnachricht: Sprechblasen (Handy-Standard) und Liste ── await tester.tap(find.text(_otherUser).first); // Chat gleitet federnd über die Liste. await tester.pump(const Duration(milliseconds: 90)); await _shot(tester, '01b_chat_gleitet_herein'); await _pumpFor(tester, const Duration(seconds: 6)); await _scrollToNewest(tester); await _shot(tester, '02_chat'); // Kontakt-Banner aufklappen (Tipp auf den Namen im Kopf) und wieder zu. await tester.tap(find.text(_otherUser).first); await _pumpFor(tester, const Duration(seconds: 1)); await _shot(tester, '02c_dm_kopf_aufgeklappt'); await tester.tap(find.text(_otherUser).first); await _pumpFor(tester, const Duration(seconds: 1)); // Antwort-Zitat antippen → springt zur zitierten Nachricht. final quote = find.byWidgetPredicate( (w) => w is SpringPress && w.pressedScale == 0.97, ); // Nach oben blättern, bis ein Zitat gebaut ist (die Liste baut nur // sichtbare Einträge; .first auf eine leere Trefferliste wirft). for (var i = 0; i < 60 && quote.evaluate().isEmpty; i++) { await tester.drag(find.byType(Scrollable).first, const Offset(0, 300), warnIfMissed: false); await tester.pump(const Duration(milliseconds: 120)); } expect(quote, findsWidgets, reason: 'kein Antwort-Zitat im Test-Chat'); await tester.ensureVisible(quote.first); await _pumpFor(tester, const Duration(milliseconds: 600)); await _shot(tester, '02e_zitat_vor_dem_tippen'); await tester.tap(quote.first, warnIfMissed: false); await _pumpFor(tester, const Duration(milliseconds: 900)); await _shot(tester, '02f_sprung_zum_zitat'); await _scrollToNewest(tester); // ⋮-Menü mit Suchen/Mitglieder. await tester.tap(find.byIcon(Icons.more_vert_rounded).first); await _pumpFor(tester, const Duration(seconds: 1)); expect(find.text('Mitglieder'), findsWidgets); await _shot(tester, '02d_menue'); await tester.tapAt(Offset(size.width / 2, size.height / 2)); await _pumpFor(tester, const Duration(seconds: 1)); // Antworten → Antwort-Leiste federt auf. (Wischen zum Antworten gibt // es nur auf echten Handys; hier über Lange-Drücken → Antworten.) // Neben die eigene Sprechblase drücken (nicht in den Text – dort // greift am PC die Textauswahl). final ownText = find.text('Danke, sieht gut aus!'); for (var i = 0; i < 60 && ownText.evaluate().isEmpty; i++) { await tester.drag(find.byType(Scrollable).first, const Offset(0, 300), warnIfMissed: false); await tester.pump(const Duration(milliseconds: 120)); } // Mittig holen – unten läge sie unter der schwebenden Eingabeleiste. await Scrollable.ensureVisible(tester.element(ownText.first), alignment: 0.5); await _pumpFor(tester, const Duration(milliseconds: 600)); final ownMsg = tester.getCenter(ownText.first); await tester.longPressAt(Offset(size.width * 0.15, ownMsg.dy)); await _pumpFor(tester, const Duration(milliseconds: 500)); await tester.tap(find.byIcon(Icons.reply_rounded).first); await tester.pump(const Duration(milliseconds: 60)); await _shot(tester, '02g_antwort_leiste_federt'); await _pumpFor(tester, const Duration(milliseconds: 800)); await _shot(tester, '02h_antwort_leiste'); final closeReply = find.byIcon(Icons.close_rounded); if (closeReply.evaluate().isNotEmpty) { await tester.tap(closeReply.first); await _pumpFor(tester, const Duration(milliseconds: 600)); } container.read(messageStyleProvider.notifier).state = MessageStyle.list; await _pumpFor(tester, const Duration(seconds: 2)); await _shot(tester, '02b_chat_liste'); container.read(messageStyleProvider.notifier).state = MessageStyle.auto; await _pumpFor(tester, const Duration(seconds: 2)); // ── Bild-Vollansicht ── // Größtes Bild im Chat = eines der Testbilder; erst sichtbar scrollen. Future openViewer() async { final image = _largestImage(tester); expect(image, isNotNull, reason: 'kein Bild im Test-Chat gefunden'); await tester.ensureVisible(image!); await _pumpFor(tester, const Duration(milliseconds: 600)); await tester.tap(image); await _pumpFor(tester, const Duration(seconds: 2)); expect( find.byType(InteractiveViewer), findsOneWidget, reason: 'Antippen des Bildes hat die Vollansicht nicht geöffnet', ); } await openViewer(); await _shot(tester, '03_vollansicht'); // Tippen NEBEN das Bild (ganz unten, unter dem Bild) schließt. await tester.tapAt(Offset(size.width / 2, size.height - 30)); await _pumpFor(tester, const Duration(seconds: 2)); await _shot(tester, '04_nach_tippen_daneben'); expect( find.byType(InteractiveViewer), findsNothing, reason: 'Tippen neben das Bild hat die Vollansicht nicht geschlossen', ); // Tippen AUFS Bild schließt NICHT (blendet nur die Leiste aus). await openViewer(); await tester.tapAt(Offset(size.width / 2, size.height / 2)); await _pumpFor(tester, const Duration(seconds: 1)); expect( find.byType(InteractiveViewer), findsOneWidget, reason: 'Tippen aufs Bild darf die Vollansicht nicht schließen', ); // Doppeltipp zoomt hinein, nochmal doppelt wieder heraus. Future doubleTapCenter() async { await tester.tapAt(Offset(size.width / 2, size.height / 2)); await tester.pump(const Duration(milliseconds: 80)); await tester.tapAt(Offset(size.width / 2, size.height / 2)); await _pumpFor(tester, const Duration(seconds: 1)); } await doubleTapCenter(); final viewer = tester.widget( find.byType(InteractiveViewer), ); expect( viewer.transformationController!.value.getMaxScaleOnAxis(), greaterThan(1.5), reason: 'Doppeltipp hat nicht gezoomt', ); await _shot(tester, '05_doppeltipp_zoom'); await doubleTapCenter(); // Wischen nach unten schließt. await tester.dragFrom( Offset(size.width / 2, size.height / 2), const Offset(0, 320), ); await _pumpFor(tester, const Duration(seconds: 2)); expect( find.byType(InteractiveViewer), findsNothing, reason: 'Wischen nach unten hat die Vollansicht nicht geschlossen', ); await _shot(tester, '06_nach_wischen'); // ── Farbmodus Hell (nur Zustand) ── final savedVariant = container.read(themeVariantProvider); container.read(themeVariantProvider.notifier).state = ThemeVariant.light; await _pumpFor(tester, const Duration(seconds: 2)); await _shot(tester, '07_chat_hell'); container.read(themeVariantProvider.notifier).state = savedVariant; await _pumpFor(tester, const Duration(seconds: 1)); // ── Gruppenraum (Namen + Avatare in den Sprechblasen) ── container.read(activeRoomIdProvider.notifier).state = null; container.read(activeSpaceIdProvider.notifier).state = 'dms'; await _pumpUntil( tester, find.text(_groupName), timeout: const Duration(seconds: 30), ); await tester.tap(find.text(_groupName).first); await _pumpFor(tester, const Duration(seconds: 5)); await _scrollToNewest(tester); await _shot(tester, '08_gruppe'); // ── Einstellungen → Erscheinungsbild ── container.read(activeModalProvider.notifier).state = ModalKind.settings; await _pumpFor(tester, const Duration(seconds: 2)); await tester.tap(find.text('Erscheinungsbild').first); await _pumpFor(tester, const Duration(seconds: 2)); await _shot(tester, '09_einstellungen_erscheinungsbild'); await tester.drag( find.text('Schriftgröße').first, const Offset(0, -500), warnIfMissed: false, ); await _pumpFor(tester, const Duration(seconds: 1)); await _shot(tester, '10_einstellungen_text_chat'); container.read(activeModalProvider.notifier).state = ModalKind.none; await _pumpFor(tester, const Duration(seconds: 1)); // ── Zurück zur Chatliste: Chat gleitet hinaus, Liste bleibt erhalten ── container.read(activeRoomIdProvider.notifier).state = null; await tester.pump(const Duration(milliseconds: 90)); await _shot(tester, '11_zurueck_gleitet_hinaus'); await _pumpFor(tester, const Duration(seconds: 1)); expect(find.text(_groupName), findsWidgets); await _shot(tester, '12_liste_nach_zurueck'); }, skip: profile == null, timeout: const Timeout(Duration(minutes: 6)), ); } Finder? _largestImage(WidgetTester tester) { Element? best; var bestArea = 150.0 * 150.0; for (final element in find.byType(Image).evaluate()) { final box = element.renderObject; if (box is! RenderBox || !box.hasSize) continue; final area = box.size.width * box.size.height; if (area > bestArea) { bestArea = area; best = element; } } return best == null ? null : find.byElementPredicate((e) => e == best); } /// Ganz nach unten zu den neuesten Nachrichten scrollen. Future _scrollToNewest(WidgetTester tester) async { for (var i = 0; i < 6; i++) { final lists = find.byType(Scrollable); if (lists.evaluate().isEmpty) return; await tester.fling(lists.first, const Offset(0, -1500), 3000, warnIfMissed: false); await _pumpFor(tester, const Duration(milliseconds: 500)); } await _pumpFor(tester, const Duration(seconds: 2)); } Future _pumpFor(WidgetTester tester, Duration duration) async { final end = DateTime.now().add(duration); while (DateTime.now().isBefore(end)) { await tester.pump(const Duration(milliseconds: 100)); } } Future _pumpUntil( WidgetTester tester, Finder finder, { required Duration timeout, }) async { final end = DateTime.now().add(timeout); while (DateTime.now().isBefore(end)) { await tester.pump(const Duration(milliseconds: 200)); if (finder.evaluate().isNotEmpty) return; } fail('Zeitüberschreitung: $finder nicht gefunden'); } /// Speichert, was gerade auf dem (simulierten) Bildschirm ist. Future _shot(WidgetTester tester, String name) async { await tester.pump(); final renderView = tester.binding.renderViews.first; final layer = renderView.debugLayer! as OffsetLayer; final physical = tester.view.physicalSize; final image = await tester.binding.runAsync( () => layer.toImage(Offset.zero & physical), ); final bytes = await tester.binding.runAsync( () => image!.toByteData(format: ui.ImageByteFormat.png), ); final file = File('build/ui_screens/$name.png'); file.parent.createSync(recursive: true); file.writeAsBytesSync(bytes!.buffer.asUint8List()); // ignore: avoid_print print('Screenshot: ${file.path}'); }