- Einstellung 'Navigation: Seitenmenue | Untere Leiste' (Handy, Standard bleibt Seitenmenue): Tabs Chats/Server/Du, Server-Uebersicht als Karten, Du-Tab mit Profil + Einstellungen, Zurueck-Taste ueber die Tabs - CallDock als eigenes Widget (Anrufleiste auch ueber der Tab-Leiste) - Sprachkanaele am Handy als Kachel mit 'wer ist drin' - Suchfeld mit Lupe, 'Kanaele durchsuchen' im Server, Filter-Chips am PC umbrechend, '#' nur bei Server-Kanaelen, englische Reste entfernt - Anrufansicht merkt sich Bubbles/Kacheln, Bubbles mit leichtem Glanz - UI-Tour: Gruppenanruf (echter zweiter LiveKit-Teilnehmer), Bubbles mit 5/7 Personen, untere Leiste; Version 0.1.42 Co-Authored-By: Claude Opus 5.5 <[email protected]>
414 lines
19 KiB
Dart
414 lines
19 KiB
Dart
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<void> smooth(Duration d) async {
|
||
final end = DateTime.now().add(d);
|
||
while (DateTime.now().isBefore(end)) {
|
||
await tester.pump(const Duration(milliseconds: 16));
|
||
}
|
||
}
|
||
|
||
Future<void> 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<String, dynamic>.from(
|
||
await c.getRoomStateWithKey(voiceId, EventTypes.RoomPowerLevels, ''),
|
||
);
|
||
pl['events'] = {
|
||
...Map<String, dynamic>.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<ScaffoldState>(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<Room>()
|
||
.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<void> 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<void> _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');
|
||
}
|