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:matrix/matrix.dart'; import 'package:pyramid/core/app_state.dart'; import 'package:livekit_client/livekit_client.dart' as lk; import 'package:pyramid/core/livekit_token.dart'; import 'package:pyramid/core/matrix_client.dart'; import 'package:pyramid/core/settings_prefs.dart' show bottomNavProvider; import 'package:pyramid/features/call_signaling/call_signaling_service.dart'; import 'package:pyramid/features/call_ui/voice_channel.dart' show CallBubblesDemo; import 'package:pyramid/features/group_call/group_call.dart'; import 'package:pyramid/features/voice_channel/voice_channel_service.dart'; import 'package:pyramid/layout/mobile_tabs.dart'; import 'package:pyramid/main.dart' as app; import 'support/test_partner.dart'; /// UI-Tour für das UX-Audit (Bernd 2026-10-08): fährt alle wichtigen /// Zustände ab und speichert jeden als Screenshot im Handy- UND im /// PC-Format nach build/ui_audit/ (p_* = Handy, d_* = PC). /// /// Testkonten: App = pyramidtest1 (Testprofil), Gegenseite = pyramidtest2 /// (kurzlebiges Gerät). Legt beim ersten Lauf einen Test-Space mit Text- und /// Sprachkanal an (bleibt für spätere Touren bestehen) und für den Anruf einen /// Wegwerf-Raum. Nutzt kurz Mikrofon (Anruf, Sprachkanal). /// /// $env:PYRAMID_PROFILE_DIR="$env:USERPROFILE\.pyramid-autopilot\profile1" /// flutter test integration_test/ui_audit_tour_test.dart -d windows const _spaceName = 'UI-Test Space'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); final profile = Platform.environment['PYRAMID_PROFILE_DIR']; final accounts = loadTestAccounts(); testWidgets( 'UI-Tour: alle Zustände, Handy + PC', (tester) async { void phone() => tester.view ..physicalSize = const Size(1080, 2340) ..devicePixelRatio = 2.625 ..padding = const FakeViewPadding(top: 63, bottom: 42) ..viewPadding = const FakeViewPadding(top: 63, bottom: 42); void desktop() => tester.view ..physicalSize = const Size(1600, 1000) ..devicePixelRatio = 1.0 ..padding = FakeViewPadding.zero ..viewPadding = FakeViewPadding.zero; addTearDown(tester.view.reset); phone(); // Flüssig pumpen (~60 Bilder/s), damit Animationen wie auf dem Gerät // zur Ruhe kommen (pumpFor zeichnet nur alle 100 ms). Future smooth(Duration d) async { final end = DateTime.now().add(d); while (DateTime.now().isBefore(end)) { await tester.pump(const Duration(milliseconds: 16)); } } Future both(String name, {Duration settle = const Duration(milliseconds: 1500)}) async { phone(); await smooth(settle); await _shot(tester, 'p_$name'); desktop(); await smooth(settle); await _shot(tester, 'd_$name'); phone(); await pumpFor(tester, const Duration(milliseconds: 400)); } // 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.byType(MaterialApp).evaluate().isNotEmpty); FlutterError.onError = testOnError; final container = ProviderScope.containerOf( tester.element(find.byType(MaterialApp).first), ); final me = (await tester.runAsync( () => container.read(matrixClientProvider.future), ))!; await pumpFor(tester, const Duration(seconds: 5)); final partner = (await tester.runAsync( () => TestPartner.start(accounts!['user2']!, accounts['pass2']!), ))!; String? callRoomId; addTearDown(() => tester.runAsync(() async { await partner.dispose(callRoomId); final r = callRoomId == null ? null : me.getRoomById(callRoomId); await r?.leave().catchError((_) {}); await r?.forget().catchError((_) {}); })); // ── Fixture: Test-Space mit Text- und Sprachkanal (einmalig) ── var space = me.rooms.where((r) => r.isSpace && r.name == _spaceName).firstOrNull; if (space == null) { logStep('lege Test-Space an'); final ids = (await tester.runAsync(() async { final c = partner.client; final spaceId = await c.createRoom( name: _spaceName, creationContent: {'type': 'm.space'}, preset: CreateRoomPreset.privateChat, invite: [me.userID!], ); final textId = await c.createRoom( name: 'allgemein', preset: CreateRoomPreset.privateChat, invite: [me.userID!], ); final voiceId = await c.createRoom( name: 'Sprachkanal', preset: CreateRoomPreset.privateChat, creationContent: {'type': 'io.pyramid.voice_channel'}, invite: [me.userID!], ); final pl = Map.from( await c.getRoomStateWithKey(voiceId, EventTypes.RoomPowerLevels, ''), ); pl['events'] = { ...Map.from(pl['events'] as Map? ?? {}), 'io.pyramid.voice.presence': 0, }; await c.setRoomStateWithKey(voiceId, EventTypes.RoomPowerLevels, '', pl); for (final child in [textId, voiceId]) { await c.setRoomStateWithKey(spaceId, 'm.space.child', child, { 'via': ['steggi-matrix.work'], }); } await c.sendMessage(textId, EventTypes.Message, 'm${DateTime.now().microsecondsSinceEpoch}', {'msgtype': 'm.text', 'body': 'Willkommen im Test-Space 👋'}); return [spaceId, textId, voiceId]; }))!; for (final id in ids) { await pumpUntil(tester, () => me.getRoomById(id)?.membership == Membership.invite, what: 'Einladung $id'); await tester.runAsync(() => me.getRoomById(id)!.join()); } await pumpUntil(tester, () => me.rooms.any((r) => r.isSpace && r.name == _spaceName && r.membership == Membership.join), what: 'Space beigetreten'); space = me.rooms.firstWhere((r) => r.isSpace && r.name == _spaceName); await pumpFor(tester, const Duration(seconds: 3)); } // ── 1. Listen ── container.read(activeSpaceIdProvider.notifier).state = 'dms'; container.read(activeRoomIdProvider.notifier).state = null; await both('01_dms'); tester.state(find.byType(Scaffold).first).openDrawer(); await pumpFor(tester, const Duration(seconds: 1)); await _shot(tester, 'p_02_drawer'); await tester.tapAt(const Offset(390, 400)); await pumpFor(tester, const Duration(seconds: 1)); final groupsChip = find.text('Gruppen'); if (groupsChip.evaluate().isNotEmpty) { await tester.tap(groupsChip.first); await both('03_chats_gruppen'); await tester.tap(find.text('Alle').first); await pumpFor(tester, const Duration(milliseconds: 500)); } container.read(activeSpaceIdProvider.notifier).state = space.id; await both('04_space'); // ── 2. Chats ── final dm = me.rooms.firstWhere((r) => r.membership == Membership.join && r.directChatMatrixID == '@pyramidtest2:steggi-matrix.work'); container.read(activeSpaceIdProvider.notifier).state = 'dms'; container.read(activeRoomIdProvider.notifier).state = dm.id; await both('05_dm_chat', settle: const Duration(seconds: 3)); final group = me.rooms.firstWhere((r) => r.getLocalizedDisplayname() == 'UI-Test Gruppe'); container.read(activeSpaceIdProvider.notifier).state = 'dms'; container.read(activeRoomIdProvider.notifier).state = group.id; await both('06_gruppe', settle: const Duration(seconds: 3)); container.read(membersPanelOpenProvider.notifier).state = true; await both('07_mitglieder'); container.read(membersPanelOpenProvider.notifier).state = false; final textRoom = space.spaceChildren .map((c) => c.roomId == null ? null : me.getRoomById(c.roomId!)) .whereType() .firstWhere((r) => r.getLocalizedDisplayname() == 'allgemein'); container.read(activeSpaceIdProvider.notifier).state = space.id; container.read(activeRoomIdProvider.notifier).state = textRoom.id; await both('08_space_textkanal', settle: const Duration(seconds: 3)); // ── 3. Modale ── container.read(activeModalProvider.notifier).state = ModalKind.settings; await both('09_einstellungen'); container.read(activeModalProvider.notifier).state = ModalKind.search; await both('10_suche'); container.read(activeModalProvider.notifier).state = ModalKind.none; // ── 4. Sackgasse: Anruf-Ansicht ohne Anruf ── container.read(viewModeProvider.notifier).state = ViewMode.voice; await both('11_anrufansicht_ohne_anruf'); container.read(viewModeProvider.notifier).state = ViewMode.chat; // ── 5. Sprachkanal beitreten (echter Tipp in der Space-Liste) ── container.read(activeRoomIdProvider.notifier).state = null; container.read(activeSpaceIdProvider.notifier).state = space.id; await pumpFor(tester, const Duration(seconds: 2)); final voiceTile = find.text('Sprachkanal'); if (voiceTile.evaluate().isNotEmpty) { await tester.tap(voiceTile.first); final lk = container.read(voiceChannelProvider); await pumpUntil(tester, () => lk.isActive && !lk.isConnecting, timeout: const Duration(seconds: 30), what: 'Sprachkanal verbunden'); await both('12_sprachkanal_verbunden_liste', settle: const Duration(seconds: 2)); container.read(viewModeProvider.notifier).state = ViewMode.voice; await both('13_sprachkanal_ansicht', settle: const Duration(seconds: 2)); container.read(viewModeProvider.notifier).state = ViewMode.chat; await tester.runAsync(() => lk.hangUp()); await pumpFor(tester, const Duration(seconds: 2)); } else { logStep('Sprachkanal nicht in der Liste gefunden'); await _shot(tester, 'p_12_sprachkanal_fehlt'); } // ── 6. 1:1-Anruf: klingelt, läuft, minimiert ── callRoomId = await tester.runAsync(() => partner.client.createRoom( invite: [me.userID!], preset: CreateRoomPreset.privateChat, name: 'pyramidtest2 (Anruftest)', )); await pumpUntil(tester, () => me.getRoomById(callRoomId!)?.membership == Membership.invite); await tester.runAsync(() => me.getRoomById(callRoomId!)!.join()); await pumpFor(tester, const Duration(seconds: 3)); container.read(activeSpaceIdProvider.notifier).state = 'dms'; container.read(activeRoomIdProvider.notifier).state = callRoomId; await pumpFor(tester, const Duration(seconds: 2)); final voip = container.read(callSignalingProvider); await tester.runAsync(() => partner.run(() => partner.voip.inviteToCall( partner.client.getRoomById(callRoomId!)!, CallType.kVoice, ))); final answerBtn = find.byWidgetPredicate( (w) => w is FloatingActionButton && w.heroTag == 'answer_call', ); await pumpUntil(tester, () => answerBtn.evaluate().isNotEmpty, what: 'klingelt'); await both('14_anruf_klingelt'); await tester.tap(answerBtn.first); await pumpUntil(tester, () => voip.currentCall?.state == CallState.kConnected, what: 'verbunden'); await both('15_anruf_laeuft', settle: const Duration(seconds: 2)); container.read(viewModeProvider.notifier).state = ViewMode.chat; await both('16_anruf_minimiert_chat'); container.read(activeRoomIdProvider.notifier).state = null; await both('17_anruf_minimiert_liste'); // Gegenseite legt auf → wo landet man? container.read(viewModeProvider.notifier).state = ViewMode.voice; await pumpFor(tester, const Duration(seconds: 1)); await tester.runAsync(() => partner.run( () => partner.delegate.call!.hangup(reason: CallErrorCode.userHangup))); await pumpUntil(tester, () => voip.currentCall == null, what: 'aufgelegt'); // Keine Sackgasse mehr: automatisch zurück zum Chat (UI-Audit S1). await pumpUntil( tester, () => container.read(viewModeProvider) == ViewMode.chat, timeout: const Duration(seconds: 10), what: 'nach Auflegen zurück im Chat', ); await pumpFor(tester, const Duration(seconds: 1)); // Ausgleiten abwarten expect(find.text('Kein Anruf'), findsNothing); await both('18_nach_auflegen_durch_gegenseite'); container.read(viewModeProvider.notifier).state = ViewMode.chat; // ── 7. Gruppenanruf: Beitreten-Karte, Anruf mit zwei Personen ── // Anwesenheit für alle erlauben (wer darf, ändert die Rechte). await tester.runAsync(() async { for (final c in [me, partner.client]) { final r = c.getRoomById(group.id); if (r != null && r.canChangePowerLevel) { await allowVoicePresenceForAll(c, group.id).catchError((_) {}); break; } } }); await pumpFor(tester, const Duration(seconds: 2)); Future partnerPresence(bool active) => tester.runAsync(() => partner.client.setRoomStateWithKey( group.id, 'io.pyramid.voice.presence', partner.client.userID!, active ? { 'active': true, 'joined_at': DateTime.now().millisecondsSinceEpoch, 'display_name': 'pyramidtest2', } : {'active': false}, ).then((_) {})); var presenceWritten = false; try { await partnerPresence(true); presenceWritten = true; } catch (e) { logStep('Gegenseite darf keine Anwesenheit schreiben: $e'); } container.read(activeSpaceIdProvider.notifier).state = 'dms'; container.read(activeRoomIdProvider.notifier).state = group.id; if (presenceWritten) { await pumpUntil(tester, () => find.text('Gruppenanruf läuft').evaluate().isNotEmpty, timeout: const Duration(seconds: 20), what: 'Beitreten-Karte'); } await both('19_gruppenanruf_karte', settle: const Duration(seconds: 2)); // Gegenseite tritt dem LiveKit-Raum wirklich bei (zweite Bubble). lk.Room? partnerLk; try { partnerLk = await tester.runAsync(() async { final t = await LiveKitTokenService.fetch( room: group.id, matrixToken: partner.client.accessToken!, ); final r = lk.Room(); await r.connect(t.url, t.token); return r; }); } catch (e) { logStep('Gegenseite konnte LiveKit nicht beitreten: $e'); } final lkCall = container.read(voiceChannelProvider); await tester.tap(find.byTooltip('Gruppenanruf beitreten').evaluate().isNotEmpty ? find.byTooltip('Gruppenanruf beitreten').first : find.byTooltip('Gruppenanruf').first); await pumpUntil(tester, () => lkCall.isActive && !lkCall.isConnecting, timeout: const Duration(seconds: 30), what: 'Gruppenanruf verbunden'); await both('20_gruppenanruf_bubbles', settle: const Duration(seconds: 3)); await tester.tap(find.byTooltip('Kachelansicht').first); await both('21_gruppenanruf_kacheln', settle: const Duration(seconds: 2)); await tester.tap(find.byTooltip('Bubble-Ansicht').first); await pumpFor(tester, const Duration(seconds: 1)); container.read(viewModeProvider.notifier).state = ViewMode.chat; await both('22_gruppenanruf_minimiert_chat', settle: const Duration(seconds: 2)); await tester.runAsync(() => lkCall.hangUp()); await tester.runAsync(() async => partnerLk?.disconnect()); if (presenceWritten) await partnerPresence(false); await pumpFor(tester, const Duration(seconds: 2)); // ── 8. Bubbles mit mehreren Personen (Vorführ-Daten) ── final overlay = Overlay.of( tester.element(find.byType(Scaffold).first), rootOverlay: true, ); final demo = OverlayEntry(builder: (_) => const CallBubblesDemo()); overlay.insert(demo); await both('23_bubbles_5_personen', settle: const Duration(seconds: 3)); demo.remove(); final demo7 = OverlayEntry(builder: (_) => const CallBubblesDemo(count: 7)); overlay.insert(demo7); await both('24_bubbles_7_personen', settle: const Duration(seconds: 3)); demo7.remove(); await pumpFor(tester, const Duration(milliseconds: 500)); // ── 9. Handy: untere Leiste statt Seitenmenü (Vergleich) ── phone(); container.read(activeRoomIdProvider.notifier).state = null; await tester.runAsync(() => container.read(bottomNavProvider.notifier).set(true)); container.read(mobileTabProvider.notifier).state = MobileTab.chats; container.read(activeSpaceIdProvider.notifier).state = 'dms'; await pumpFor(tester, const Duration(seconds: 2)); await _shot(tester, 'p_30_unten_chats'); await tester.tap(find.text('Server').last); await pumpFor(tester, const Duration(seconds: 2)); await _shot(tester, 'p_31_unten_server'); await tester.tap(find.text(_spaceName).first); await pumpFor(tester, const Duration(seconds: 2)); await _shot(tester, 'p_32_unten_server_kanaele'); await tester.tap(find.text('Du').last); await pumpFor(tester, const Duration(seconds: 2)); await _shot(tester, 'p_33_unten_du'); container.read(activeRoomIdProvider.notifier).state = group.id; await pumpFor(tester, const Duration(seconds: 2)); await _shot(tester, 'p_34_unten_chat_offen'); container.read(activeRoomIdProvider.notifier).state = null; await pumpFor(tester, const Duration(seconds: 1)); await tester.tap(find.text('Chats').last); await pumpFor(tester, const Duration(seconds: 1)); await tester.runAsync(() => container.read(bottomNavProvider.notifier).set(false)); await pumpFor(tester, const Duration(seconds: 1)); }, skip: profile == null || accounts == null, timeout: const Timeout(Duration(minutes: 12)), ); } 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_audit/$name.png'); file.parent.createSync(recursive: true); file.writeAsBytesSync(bytes!.buffer.asUint8List()); logStep('Screenshot $name'); }