feat(ui): untere Leiste (Chats/Server/Du) als Option, Server-Uebersicht, Du-Tab, Feinschliff

- 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]>
This commit is contained in:
Bernd Steckmeister
2026-10-10 14:22:02 +02:00
co-authored by Claude Opus 5.5
parent de24053167
commit 391ebf0ffa
19 changed files with 1139 additions and 87 deletions
+1 -1
View File
@@ -225,7 +225,7 @@ void main() {
// ── Gruppenraum (Namen + Avatare in den Sprechblasen) ──
container.read(activeRoomIdProvider.notifier).state = null;
container.read(activeSpaceIdProvider.notifier).state = 'rooms';
container.read(activeSpaceIdProvider.notifier).state = 'dms';
await _pumpUntil(
tester,
find.text(_groupName),
+142 -7
View File
@@ -8,9 +8,15 @@ 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';
@@ -49,12 +55,21 @@ void main() {
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 pumpFor(tester, settle);
await smooth(settle);
await _shot(tester, 'p_$name');
desktop();
await pumpFor(tester, settle);
await smooth(settle);
await _shot(tester, 'd_$name');
phone();
await pumpFor(tester, const Duration(milliseconds: 400));
@@ -147,8 +162,13 @@ void main() {
await _shot(tester, 'p_02_drawer');
await tester.tapAt(const Offset(390, 400));
await pumpFor(tester, const Duration(seconds: 1));
container.read(activeSpaceIdProvider.notifier).state = 'rooms';
await both('03_raeume');
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');
@@ -160,7 +180,7 @@ void main() {
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 = 'rooms';
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;
@@ -216,7 +236,7 @@ void main() {
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 = 'rooms';
container.read(activeSpaceIdProvider.notifier).state = 'dms';
container.read(activeRoomIdProvider.notifier).state = callRoomId;
await pumpFor(tester, const Duration(seconds: 2));
final voip = container.read(callSignalingProvider);
@@ -254,9 +274,124 @@ void main() {
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: 8)),
timeout: const Timeout(Duration(minutes: 12)),
);
}