fix(ui): UX-Audit – Sackgassen, tote Knöpfe, englische Reste; Screenshot-Tour

Bernds Wunsch 2026-10-08: UI grundsätzlich prüfen (Navigation, Sackgassen,
Anruf-Logik). Audit + Konzept in docs/UI_AUDIT.md, belegt mit neuer
Screenshot-Tour integration_test/ui_audit_tour_test.dart (35 Zustände,
Handy + PC, zwei Testkonten, Test-Space mit Sprachkanal).

Sofort behoben:
- S1/S2: Gegenseite legt auf / Anruf scheitert / Sprachkanal bricht ab →
  automatisch zurück zum Chat mit Hinweis (Fehler sichtbar statt still);
  leere Anrufansicht deutsch mit Zurück-Knopf (vorher englische Sackgasse
  ohne Zurück am Handy).
- S4: toter „Threads“-Knopf entfernt. S5: Space betreten (PC) öffnet
  letzten bzw. ersten Textkanal. S6: keine doppelte Anrufsteuerung am PC.
- Annehmen-Fenster mit Anrufer-Avatar und Anrufart; eigene Kachel mit
  eigenem Initial statt „D“; Mitgliederliste rund mit Profilbild und „(Du)“;
  Tastaturkürzel nur am PC; englische Texte in Anruf/Leerzuständen deutsch.
- Tests: Fehlerempfänger des Testrahmens nach app.main() zurück (vorher
  verschwanden Fehlermeldungen), Lesebestätigungs-Test im Wegwerf-Raum statt
  in der DM, robustere Schritte; test.ps1 räumt liegengebliebene
  Test-Prozesse weg; gemeinsame Hilfen integration_test/support/.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Bernd Steckmeister
2026-10-09 11:35:40 +02:00
co-authored by Claude Opus 5.5
parent 842dd61031
commit 87b472a373
22 changed files with 934 additions and 378 deletions
+114
View File
@@ -0,0 +1,114 @@
# UI/UX-Audit Pyramid (2026-10-08)
Grundlage: Code-Analyse der Navigation + automatische Screenshot-Tour
`integration_test/ui_audit_tour_test.dart` (35 Zustände, Handy 1080×2340 und
PC 1600×1000, zwei Testkonten, Bilder in `build/ui_audit/`, `p_*` = Handy,
`d_*` = PC). Neu erzeugen:
```
$env:PYRAMID_PROFILE_DIR="$env:USERPROFILE\.pyramid-autopilot\profile1"
flutter test integration_test/ui_audit_tour_test.dart -d windows
```
## 1. Wie die Navigation heute funktioniert
Vier Zustände steuern, was man sieht (`lib/core/app_state.dart`):
| Zustand | Werte | Wirkung |
|---|---|---|
| `activeSpaceIdProvider` | `dms`, `rooms`, Space-ID | welche Liste links/unten steht |
| `activeRoomIdProvider` | Raum-ID oder leer | welcher Chat offen ist |
| `viewModeProvider` | `chat`, `voice` | Hauptbereich zeigt Chat **oder** Anrufansicht |
| `activeModalProvider` | `none`, `settings`, `search` | Overlay |
**Handy:** Hamburger öffnet ein schmales Seitenmenü (unbeschriftet: Logo =
Chats, orangenes „R“ = Räume, Space-Kürzel, „+“, Zahnrad). Darunter die
Liste; ein Chat gleitet darüber; „Anruf“ ist ein weiterer Vordergrund.
**PC:** Leiste (Spaces) + Liste + Hauptbereich (+ Mitglieder rechts).
Was wohin führt:
- Liste → Chat (alle Listen), Sprachkanal antippen → beitreten (Mini-Leiste
unten), nochmal antippen → verlassen.
- Chat-Kopf (nur DM) → Anruf/Video → Anrufansicht. **Gruppen: kein Anruf.**
- ⋮ (Handy) → Suche, Mitglieder. PC-Kopf: Threads (ohne Funktion), Anheften,
Suche, Mitglieder.
- Mini-Leiste → Anrufansicht; Anrufansicht „Minimieren“ → zurück zum Chat.
- Zahnrad (Profilbalken unten **und** Seitenmenü) → Einstellungen.
## 2. Sackgassen und tote Elemente (alle behoben, siehe 5.)
| # | Wo | Was passiert | Schwere |
|---|---|---|---|
| S1 | Gegenseite legt auf / Anruf scheitert / Sprachkanal bricht ab | Ansicht bleibt auf „Anruf“: englische Leerseite „No active call – Join a voice room“, Handy ohne Zurück (p_18) | hoch |
| S2 | dieselbe Leerseite | LiveKit-Fehlermeldung wird dort nicht angezeigt (stiller Fehler) | hoch |
| S3 | Gruppenchat (Handy + PC) | kein Anruf-Knopf – Gruppenanruf-Code nicht erreichbar (p_06, d_06) | hoch |
| S4 | PC-Chatkopf „Threads“ | Knopf ohne Funktion | mittel |
| S5 | Space betreten (PC) | rechts nur „Wähle einen Chat aus“, letzter/erster Kanal öffnet nicht (d_04) | mittel |
| S6 | Anruf läuft (PC) | Steuerung doppelt: Anrufansicht + Mini-Leiste links (d_15) | niedrig |
## 3. Befunde nach Bereich
**Listen**
- Haupt-Navigation am Handy versteckt im Seitenmenü, Symbole unbeschriftet
und teils kryptisch (orangenes „R“-Dreieck für „Räume“) (p_02).
- „Räume“ listet auch alle Kanäle aus Spaces → doppelt; Sprachkanäle hängen
ohne Überschrift unten, sehen anders aus als Chats (p_03, p_17).
- Suchfeld ohne Lupe, eingerückter Text.
- Profilbalken unten + Anruf-Leiste (zweizeilig) belegen am Handy ~25 % der
Höhe (p_12, p_17).
**Chats**
- Gruppen sehen aus wie „#Kanal“ (Kopf, Liste) statt wie Gruppen.
- Leerer Chat englisch („No messages yet“), PC-Leerzustand halb englisch.
- Mitgliederliste: eckige Avatare (sonst rund), man selbst nicht als „Du“.
**Anrufe**
- Anrufansicht für reine Sprachanrufe: riesige schwarze Video-Kachel mit
Initial (p_15). Eigene Kachel zeigt „D“ (von „Du“) statt eigenem Avatar.
Keine Gesprächsdauer.
- Kopf-Symbole „Hero/Grid“ und „Minimieren“ unklar (englische Tooltips).
- Mini-Leiste „VOICE CONNECTED“ (englisch) auch bei 1:1-Anrufen; Mikro/Kopfhörer
lila gefüllt = „an“ – liest sich wie Warnung; in der Anrufansicht ist die
Logik umgekehrt (rot = stumm).
- Annehmen-Fenster: Platzhalter-Symbol statt Avatar, kein „Sprach-/Videoanruf“.
**Allgemein**
- Englische Reste (Bildschirm-Auswähler erledigt; Anrufansicht, Leerzustände).
- Statusleisten-Streifen in anderer Farbe über Overlays (p_07, p_09).
- „Tastaturkürzel“ in den Handy-Einstellungen.
## 4. Konzept: modern, aufgeräumt, übersichtlich
Leitlinie: **WhatsApp für Chats, Discord für Server/Sprachkanäle** – beides
in einer klaren Struktur, nichts doppelt, kein Zustand ohne Weg zurück.
1. **Navigation Handy:** untere Leiste mit beschrifteten Tabs statt
Seitenmenü – *Chats* (DMs + Gruppen zusammen, nach Aktivität), *Server*
(Spaces als Karten → Kanäle, Sprachkanäle mit Live-Teilnehmern), *Du*
(Profil, Status, Einstellungen). Profilbalken unten entfällt.
2. **Listen:** einheitliche Kacheln; Space-Kanäle gruppiert in „Textkanäle“ /
„Sprachkanäle“; Sprachkanal-Kachel zeigt Teilnehmer-Avatare; Kanäle eines
Spaces erscheinen nicht zusätzlich unter „Räume“.
3. **Anruf-Logik (ein Modell):** 1:1-Anruf, Gruppenanruf (Gruppenchat) und
Sprachkanal (Server) teilen eine Anrufansicht und eine kompakte
„Anruf-Pille“. Anruf endet → automatisch zurück zum Chat, mit kurzem
Hinweis („Anruf beendet · 3:12“).
4. **Anrufansicht:** Sprachanruf = große runde Avatare mit Sprech-Ring,
Name, Dauer (wie WhatsApp/Discord); Video/Bildschirm = Kacheln bzw.
großes Bild + Filmstreifen. Steuerung einheitlich: neutral = an,
rot durchgestrichen = aus.
5. **Visuell:** runde Avatare überall (echte Bilder), Kopfzeilen mit Avatar +
Untertitel (Mitgliederzahl/online), Leerzustände mit Handlung („Schreib die
erste Nachricht“), ruhigere Flächen (Karten statt Linien), einheitliche
Abstände/Radien, vollständig deutsch.
## 5. Sofort behoben (diese Runde)
S1–S6, englische Reste in Anruf/Leerzuständen, Annehmen-Fenster mit Avatar
und Anrufart, eigene Kachel mit eigenem Avatar, „Du“ in der Mitgliederliste,
„Tastaturkürzel“ nur am PC – Details in PROGRESS.md.
## 6. Offene Richtungsentscheidungen (Bernd)
Navigation Handy (Tabs vs. Seitenmenü), Chats + Gruppen zusammen, Stil der
Anrufansicht, Anruf-Pille – siehe Fragen im Chat.
+54 -285
View File
@@ -1,4 +1,3 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
@@ -10,9 +9,10 @@ import 'package:matrix/matrix.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
import 'package:pyramid/main.dart' as app;
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:webrtc_interface/webrtc_interface.dart';
import 'support/test_partner.dart';
/// Echter 1:1-Anruf zwischen der App (pyramidtest1, Testprofil) und einem
/// zweiten Matrix-Client im selben Prozess (pyramidtest2, kurzlebiges Gerät,
/// wird am Ende abgemeldet). Bernds Fehlerbericht 2026-10-08: Ton kam nicht
@@ -29,14 +29,18 @@ import 'package:webrtc_interface/webrtc_interface.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final profile = Platform.environment['PYRAMID_PROFILE_DIR'];
final accounts = _loadAccounts();
final accounts = loadTestAccounts();
testWidgets(
'1:1-Anruf: Ton beide Richtungen, Bildschirm teilen beide Richtungen',
(tester) async {
Logs().level = Level.verbose;
// 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);
await pumpUntil(tester, () => find.byType(MaterialApp).evaluate().isNotEmpty);
FlutterError.onError = testOnError;
final container = ProviderScope.containerOf(
tester.element(find.byType(MaterialApp).first),
);
@@ -46,7 +50,7 @@ void main() {
expect(me.userID, '@pyramidtest1:steggi-matrix.work');
final partner = (await tester.runAsync(
() => _Partner.start(accounts!['user2']!, accounts['pass2']!),
() => TestPartner.start(accounts!['user2']!, accounts['pass2']!),
))!;
String? roomId;
addTearDown(() => tester.runAsync(() async {
@@ -62,24 +66,24 @@ void main() {
preset: CreateRoomPreset.privateChat,
name: 'Anruftest (automatisch)',
));
_log('Raum angelegt: $roomId');
await _pumpUntil(
logStep('Raum angelegt: $roomId');
await pumpUntil(
tester,
() => me.getRoomById(roomId!)?.membership == Membership.invite,
what: 'Einladung kommt bei der App an',
);
await tester.runAsync(() => me.getRoomById(roomId!)!.join());
_log('App ist beigetreten');
logStep('App ist beigetreten');
// Die Gegenseite ist Ersteller und damit im Raum; ihr Mitglieder-Cache
// wird vom SDK nur bei Bedarf geladen – kurz warten genügt.
await _pumpFor(tester, const Duration(seconds: 3));
await pumpFor(tester, const Duration(seconds: 3));
final voip = container.read(callSignalingProvider);
// ═══ Teil 1: Gegenseite ruft an, App nimmt über den echten Knopf an ═══
// (prüft die Kandidaten-Pufferung der App mit einem ungepatchten
// Anrufer – genau Bernds Fall „lange/kein Verbindungsaufbau“)
_log('Teil 1: Gegenseite ruft an');
logStep('Teil 1: Gegenseite ruft an');
await tester.runAsync(() => partner.run(() => partner.voip.inviteToCall(
partner.client.getRoomById(roomId!)!,
CallType.kVoice,
@@ -87,9 +91,9 @@ void main() {
final answerBtn = find.byWidgetPredicate(
(w) => w is FloatingActionButton && w.heroTag == 'answer_call',
);
await _pumpUntil(tester, () => answerBtn.evaluate().isNotEmpty,
await pumpUntil(tester, () => answerBtn.evaluate().isNotEmpty,
what: 'Annehmen-Knopf erscheint');
await _pumpFor(tester, const Duration(seconds: 2)); // klingeln lassen
await pumpFor(tester, const Duration(seconds: 2)); // klingeln lassen
await tester.tap(answerBtn.first);
await _waitConnected(tester, voip, partner);
@@ -98,8 +102,8 @@ void main() {
// Stumm über den echten Knopf → Gegenseite sieht „stumm“, Spur ist aus.
bool partnerSeesMuted() =>
partner.delegate.call?.remoteUserMediaStream?.audioMuted == true;
await tester.tap(find.byIcon(Icons.mic_rounded).first);
await _pumpUntil(tester, partnerSeesMuted,
await tester.tap(_ctrlBtn(Icons.mic_rounded));
await pumpUntil(tester, partnerSeesMuted,
timeout: const Duration(seconds: 15),
what: 'Gegenseite sieht: App ist stumm');
expect(voip.isMicMuted, isTrue);
@@ -108,15 +112,15 @@ void main() {
isFalse,
reason: 'Mikrofonspur ist trotz Stumm noch an',
);
await tester.tap(find.byIcon(Icons.mic_off_rounded).first);
await _pumpUntil(tester, () => !partnerSeesMuted() && !voip.isMicMuted,
await tester.tap(_ctrlBtn(Icons.mic_off_rounded));
await pumpUntil(tester, () => !partnerSeesMuted() && !voip.isMicMuted,
timeout: const Duration(seconds: 15),
what: 'Stumm wieder aufgehoben');
_log('Stumm/Entstummen über den Knopf: ok');
logStep('Stumm/Entstummen über den Knopf: ok');
// Taub → Gegenseite nicht mehr hören UND selbst stumm; danach zurück.
await tester.tap(find.byIcon(Icons.headphones_rounded).first);
await _pumpUntil(
await tester.tap(_ctrlBtn(Icons.headphones_rounded));
await pumpUntil(
tester,
() => voip.isDeafened && partnerSeesMuted(),
timeout: const Duration(seconds: 15),
@@ -127,8 +131,8 @@ void main() {
expect(remoteAudio, isNotEmpty);
expect(remoteAudio.every((t) => !t.enabled), isTrue,
reason: 'Ton der Gegenseite ist trotz Taub noch an');
await tester.tap(find.byIcon(Icons.headset_off_rounded).first);
await _pumpUntil(
await tester.tap(_ctrlBtn(Icons.headset_off_rounded));
await pumpUntil(
tester,
() => !voip.isDeafened && !partnerSeesMuted() && !voip.isMicMuted,
timeout: const Duration(seconds: 15),
@@ -140,13 +144,13 @@ void main() {
.every((t) => t.enabled),
isTrue,
);
_log('Taub/Hören über den Knopf: ok');
logStep('Taub/Hören über den Knopf: ok');
// Gegenseite teilt Bildschirm → App zeigt ihn
await tester.runAsync(
() => partner.run(() => partner.delegate.call!.setScreensharingEnabled(true)),
);
await _pumpUntil(
await pumpUntil(
tester,
() =>
voip.isRemoteScreensharing &&
@@ -154,21 +158,21 @@ void main() {
timeout: const Duration(seconds: 30),
what: 'Bildschirm der Gegenseite erscheint in der App',
);
await _pumpFor(tester, const Duration(seconds: 3));
await pumpFor(tester, const Duration(seconds: 3));
final appVideoIn = (await tester.runAsync(
() => _rtp(voip.currentCall!.pc!, 'video'),
))!;
_log('BILD bei der App (von der Gegenseite): $appVideoIn');
logStep('BILD bei der App (von der Gegenseite): $appVideoIn');
expect(appVideoIn.received, greaterThan(0),
reason: 'Bildschirm der Gegenseite kommt nicht an');
await tester.runAsync(
() => partner.run(() => partner.delegate.call!.setScreensharingEnabled(false)),
);
await _pumpUntil(tester, () => !voip.isRemoteScreensharing,
await pumpUntil(tester, () => !voip.isRemoteScreensharing,
what: 'Bildschirm der Gegenseite verschwindet wieder');
// App teilt ihren Bildschirm über den echten Auswähler
await _pumpUntil(
await pumpUntil(
tester,
() => find.byIcon(Icons.screen_share_rounded).evaluate().isNotEmpty,
what: 'Teilen-Knopf in der Anrufleiste',
@@ -177,10 +181,10 @@ void main() {
final tile = find.byWidgetPredicate(
(w) => w.runtimeType.toString() == '_SourceTile',
);
await _pumpUntil(tester, () => tile.evaluate().isNotEmpty,
await pumpUntil(tester, () => tile.evaluate().isNotEmpty,
timeout: const Duration(seconds: 20), what: 'Quellen-Auswähler');
await tester.tap(tile.first);
await _pumpUntil(
await pumpUntil(
tester,
() {
final s = partner.delegate.call?.remoteScreenSharingStream?.stream;
@@ -191,27 +195,27 @@ void main() {
timeout: const Duration(seconds: 30),
what: 'Bildschirm der App kommt bei der Gegenseite an',
);
await _pumpFor(tester, const Duration(seconds: 3));
await pumpFor(tester, const Duration(seconds: 3));
final partnerVideoIn = (await tester.runAsync(
() => _rtp(partner.delegate.call!.pc!, 'video'),
))!;
_log('BILD bei der Gegenseite (von der App): $partnerVideoIn');
logStep('BILD bei der Gegenseite (von der App): $partnerVideoIn');
expect(partnerVideoIn.received, greaterThan(0),
reason: 'Bildschirm der App kommt nicht an');
await tester.runAsync(() => voip.hangup());
await _pumpUntil(tester, () => voip.currentCall == null,
await pumpUntil(tester, () => voip.currentCall == null,
what: 'aufgelegt (Teil 1)');
await _pumpFor(tester, const Duration(seconds: 3));
await pumpFor(tester, const Duration(seconds: 3));
// ═══ Teil 2: App ruft an, Gegenseite nimmt an ═══
_log('Teil 2: App ruft an');
logStep('Teil 2: App ruft an');
partner.fixCandidates = true; // Gegenseite wie eine gepatchte Pyramid
await tester.runAsync(() => voip.startCall(roomId!, video: false));
await _waitConnected(tester, voip, partner);
await _checkAudio(tester, voip, partner);
await tester.runAsync(() => voip.hangup());
await _pumpUntil(tester, () => voip.currentCall == null,
await pumpUntil(tester, () => voip.currentCall == null,
what: 'aufgelegt (Teil 2)');
},
skip: profile == null || accounts == null,
@@ -222,10 +226,10 @@ void main() {
Future<void> _waitConnected(
WidgetTester tester,
CallSignalingService voip,
_Partner partner,
TestPartner partner,
) async {
final t0 = DateTime.now();
await _pumpUntil(
await pumpUntil(
tester,
() =>
voip.currentCall?.state == CallState.kConnected &&
@@ -235,26 +239,26 @@ Future<void> _waitConnected(
every5s: () {
final a = voip.currentCall;
final b = partner.delegate.call;
_log('App: ${a?.state} ice=${a?.pc?.iceConnectionState} | '
logStep('App: ${a?.state} ice=${a?.pc?.iceConnectionState} | '
'Gegenseite: ${b?.state} ice=${b?.pc?.iceConnectionState}');
},
);
_log('verbunden nach ${DateTime.now().difference(t0).inMilliseconds} ms');
logStep('verbunden nach ${DateTime.now().difference(t0).inMilliseconds} ms');
}
Future<void> _checkAudio(
WidgetTester tester,
CallSignalingService voip,
_Partner partner,
TestPartner partner,
) async {
await _pumpFor(tester, const Duration(seconds: 4));
await pumpFor(tester, const Duration(seconds: 4));
final appAudio = (await tester.runAsync(
() => _rtp(voip.currentCall!.pc!, 'audio'),
))!;
final partnerAudio = (await tester.runAsync(
() => _rtp(partner.delegate.call!.pc!, 'audio'),
))!;
_log('TON App: $appAudio | Gegenseite: $partnerAudio');
logStep('TON App: $appAudio | Gegenseite: $partnerAudio');
expect(appAudio.sent, greaterThan(0), reason: 'App sendet keinen Ton');
expect(appAudio.received, greaterThan(0), reason: 'App empfängt keinen Ton');
expect(partnerAudio.received, greaterThan(0),
@@ -280,246 +284,11 @@ Future<_Rtp> _rtp(rtc.RTCPeerConnection pc, String kind) async {
return _Rtp(sent, received);
}
/// Zweiter Teilnehmer: eigener Matrix-Client + VoIP im selben Prozess.
class _Partner {
_Partner(this.client, this.delegate, this.voip, this.zone) {
_sub = client.onCallEvents.stream.listen((events) {
for (final e in events) {
if (e.type != EventTypes.CallCandidates || e.senderId == client.userID) {
continue;
}
final list = e.content.tryGetList<Map<String, dynamic>>('candidates');
if (list != null) _buffer.addAll(list);
}
});
_timer = Timer.periodic(const Duration(milliseconds: 500), (_) async {
final pc = delegate.call?.pc;
if (!fixCandidates || pc == null) return;
try {
if (await pc.getRemoteDescription() == null) return;
for (final c in List.of(_buffer)) {
final cand = c['candidate'] as String?;
if (cand == null || !_applied.add(cand)) continue;
await pc.addCandidate(RTCIceCandidate(
cand,
c['sdpMid']?.toString() ?? '',
(c['sdpMLineIndex'] as num?)?.round() ?? 0,
));
}
} catch (_) {}
});
}
final Client client;
final _PartnerDelegate delegate;
final VoIP voip;
final Zone zone;
/// Aktion der Gegenseite in ihrer Zone ausführen (Fehler über Completer).
Future<T> run<T>(Future<T> Function() f) {
final done = Completer<T>();
zone.run(() async {
try {
done.complete(await f());
} catch (e, s) {
done.completeError(e, s);
}
});
return done.future;
}
/// Gegenseite reicht Kandidaten nach wie die gepatchte App.
bool fixCandidates = false;
final _buffer = <Map<String, dynamic>>[];
final _applied = <String>{};
StreamSubscription? _sub;
Timer? _timer;
static Future<_Partner> start(String user, String pass) async {
final db = await databaseFactoryFfi.openDatabase(inMemoryDatabasePath);
final client = Client(
'pyramid-call-partner',
database: await MatrixSdkDatabase.init('pyramid-call-partner', database: db),
);
await client.init(waitForFirstSync: false, waitUntilLoadCompletedLoaded: false);
await client.checkHomeserver(Uri.parse('https://steggi-matrix.work'));
await client.login(
LoginType.mLoginPassword,
identifier: AuthenticationUserIdentifier(user: user),
password: pass,
initialDeviceDisplayName: 'Pyramid-Anruftest (kurzlebig)',
);
final delegate = _PartnerDelegate();
// Eigene Zone: Fehler der Gegenseite sichtbar protokollieren statt sie
// im Testrahmen zu verlieren.
final zone = Zone.current.fork(
specification: ZoneSpecification(
handleUncaughtError: (self, parent, z, e, s) =>
_log('Gegenseite (abgefangen): ${e.toString().split('\n').first}'),
),
);
final voip = zone.run(() => VoIP(client, delegate));
return _Partner(client, delegate, voip, zone);
}
Future<void> dispose(String? roomId) async {
_timer?.cancel();
await _sub?.cancel();
try {
await delegate.call?.hangup(reason: CallErrorCode.userHangup);
} catch (_) {}
if (roomId != null) {
try {
await client.leaveRoom(roomId);
await client.forgetRoom(roomId);
} catch (_) {}
}
try {
client.backgroundSync = false;
await client.logout();
} catch (_) {}
}
}
class _PartnerDelegate implements WebRTCDelegate {
CallSession? call;
@override
MediaDevices get mediaDevices => _PartnerMedia(rtc.navigator.mediaDevices);
@override
Future<RTCPeerConnection> createPeerConnection(
Map<String, dynamic> configuration, [
Map<String, dynamic> constraints = const {},
]) =>
rtc.createPeerConnection(configuration, constraints);
@override
Future<void> handleNewCall(CallSession session) async {
call = session;
if (session.direction == CallDirection.kIncoming) {
// Wie ein Mensch nach kurzem Klingeln annehmen. Sofortiges Annehmen
// trifft eine Lücke im SDK: Kandidaten, die WÄHREND answer()
// eintreffen, landen in einer Warteschlange, die nie mehr abgearbeitet
// wird (CallSession.onCandidatesReceived vs. answer()).
unawaited(Future.delayed(const Duration(seconds: 2), () async {
try {
await session.answer();
} catch (e) {
_log('Gegenseite: Annehmen fehlgeschlagen: $e');
}
}));
}
}
@override
Future<void> handleCallEnded(CallSession session) async {}
@override
Future<void> handleMissedCall(CallSession session) async {}
@override
Future<void> handleNewGroupCall(GroupCallSession groupCall) async {}
@override
Future<void> handleGroupCallEnded(GroupCallSession groupCall) async {}
@override
Future<void> playRingtone() async {}
@override
Future<void> stopRingtone() async {}
@override
Future<void> registerListeners(CallSession session) async {}
@override
bool get isWeb => false;
@override
bool get canHandleNewCall => true;
@override
EncryptionKeyProvider? get keyProvider => null;
}
/// Wie die App am PC: Bildschirm über eine Desktop-Quelle (erster Monitor).
class _PartnerMedia extends MediaDevices {
_PartnerMedia(this._d);
final MediaDevices _d;
@override
Future<MediaStream> getUserMedia(Map<String, dynamic> c) => _d.getUserMedia(c);
@override
Future<MediaStream> getDisplayMedia(Map<String, dynamic> c) async {
final sources = await rtc.desktopCapturer
.getSources(types: [rtc.SourceType.Screen]);
return _d.getDisplayMedia({
'video': {
'mandatory': {
'chromeMediaSource': 'desktop',
'chromeMediaSourceId': sources.first.id,
},
'optional': [],
},
'audio': false,
});
}
@override
Future<List<MediaDeviceInfo>> enumerateDevices() => _d.enumerateDevices();
@override
MediaTrackSupportedConstraints getSupportedConstraints() =>
_d.getSupportedConstraints();
@override
set ondevicechange(Function(dynamic event)? callback) =>
_d.ondevicechange = callback;
@override
Function(dynamic event)? get ondevicechange => _d.ondevicechange;
@override
// ignore: deprecated_member_use
Future<List<dynamic>> getSources() => _d.getSources();
@override
Future<MediaDeviceInfo> selectAudioOutput([AudioOutputOptions? options]) =>
_d.selectAudioOutput(options);
}
Map<String, String>? _loadAccounts() {
final home = Platform.environment['USERPROFILE'] ?? '';
final file = File('$home\\.pyramid-autopilot\\test-accounts.txt');
if (!file.existsSync()) return null;
final map = <String, String>{};
for (final line in file.readAsLinesSync()) {
final t = line.trim();
if (t.isEmpty || t.startsWith('#')) continue;
final i = t.indexOf('=');
if (i > 0) map[t.substring(0, i)] = t.substring(i + 1);
}
return map;
}
Future<void> _pumpFor(WidgetTester tester, Duration d) async {
final end = DateTime.now().add(d);
while (DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 100));
}
}
void _log(String m) {
// ignore: avoid_print
print('SCHRITT ${DateTime.now().toIso8601String().substring(11, 19)} $m');
}
Future<void> _pumpUntil(
WidgetTester tester,
bool Function() condition, {
Duration timeout = const Duration(seconds: 60),
String what = '',
void Function()? onTimeout,
void Function()? every5s,
}) async {
final end = DateTime.now().add(timeout);
var next = DateTime.now().add(const Duration(seconds: 5));
while (DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 200));
if (condition()) return;
if (every5s != null && DateTime.now().isAfter(next)) {
every5s();
next = DateTime.now().add(const Duration(seconds: 5));
}
}
_log('ZEITÜBERSCHREITUNG: $what');
onTimeout?.call();
fail('Zeitüberschreitung: $what');
}
/// Knopf der Anrufleiste mit diesem Symbol (nicht die Stumm-ANZEIGE in der
/// Teilnehmer-Kachel, die dasselbe Symbol trägt).
Finder _ctrlBtn(IconData icon) => find.descendant(
of: find.byWidgetPredicate((w) =>
w.runtimeType.toString() == '_VoiceCtrlBtn' &&
(w as dynamic).icon == icon),
matching: find.byIcon(icon),
).first;
+40 -33
View File
@@ -44,8 +44,16 @@ void main() {
);
addTearDown(() => tester.runAsync(() => partner!.logout()));
// 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));
FlutterError.onError = testOnError;
final container = ProviderScope.containerOf(
tester.element(find.byType(MaterialApp).first),
);
@@ -55,14 +63,40 @@ void main() {
expect(client.userID, '@pyramidtest1:steggi-matrix.work');
await _pumpFor(tester, const Duration(seconds: 4));
// ── 1. Lesebestätigung live ──
final dm = client.rooms.firstWhere(
(r) =>
r.membership == Membership.join &&
r.directChatMatrixID == '@pyramidtest2:steggi-matrix.work',
// ── 2. Einladung im Chat annehmen ──
final welcome = 'Willkommen im Einladungstest ${DateTime.now().millisecondsSinceEpoch}';
final inviteRoomId = await tester.runAsync(() async {
final id = await partner!.createRoom('Einladungstest');
await partner.sendText(id, welcome);
await partner.invite(id, client.userID!);
return id;
});
addTearDown(() => tester.runAsync(() => partner!.leave(inviteRoomId!)));
// Einladung kommt per Sync an → Chat öffnen (wie aus der Benachrichtigung).
final end = DateTime.now().add(const Duration(seconds: 30));
while (client.getRoomById(inviteRoomId!)?.membership != Membership.invite &&
DateTime.now().isBefore(end)) {
await _pumpFor(tester, const Duration(milliseconds: 500));
}
container.read(activeRoomIdProvider.notifier).state = inviteRoomId;
await _pumpUntil(tester, find.text('Annehmen'));
await _pumpFor(tester, const Duration(seconds: 1)); // Hereingleiten abwarten
await tester.tap(find.text('Annehmen'));
// Ohne App-Neustart: Verlauf mit der Begrüßung erscheint.
await _pumpUntil(
tester,
find.textContaining(welcome),
timeout: const Duration(seconds: 40),
);
expect(find.text('Annehmen'), findsNothing);
// ── 1. Lesebestätigung live (nach dem Beitritt) ──
// (im Wegwerf-Raum aus Teil 2 – die DM soll nicht mit jedem Lauf
// wachsen)
final dm = client.getRoomById(inviteRoomId)!;
container.read(activeRoomIdProvider.notifier).state = dm.id;
await _pumpFor(tester, const Duration(seconds: 4));
await _pumpFor(tester, const Duration(seconds: 2));
final text = 'Lesetest ${DateTime.now().millisecondsSinceEpoch}';
final eventId = await tester.runAsync(() => dm.sendTextEvent(text));
@@ -94,33 +128,6 @@ void main() {
reason: '✓✓ erscheint nicht live unter der gelesenen Nachricht',
);
// ── 2. Einladung im Chat annehmen ──
final welcome = 'Willkommen im Einladungstest ${DateTime.now().millisecondsSinceEpoch}';
final inviteRoomId = await tester.runAsync(() async {
final id = await partner!.createRoom('Einladungstest');
await partner.sendText(id, welcome);
await partner.invite(id, client.userID!);
return id;
});
addTearDown(() => tester.runAsync(() => partner!.leave(inviteRoomId!)));
// Einladung kommt per Sync an → Chat öffnen (wie aus der Benachrichtigung).
final end = DateTime.now().add(const Duration(seconds: 30));
while (client.getRoomById(inviteRoomId!)?.membership != Membership.invite &&
DateTime.now().isBefore(end)) {
await _pumpFor(tester, const Duration(milliseconds: 500));
}
container.read(activeRoomIdProvider.notifier).state = inviteRoomId;
await _pumpUntil(tester, find.text('Annehmen'));
await tester.tap(find.text('Annehmen'));
// Ohne App-Neustart: Verlauf mit der Begrüßung erscheint.
await _pumpUntil(
tester,
find.textContaining(welcome),
timeout: const Duration(seconds: 40),
);
expect(find.text('Annehmen'), findsNothing);
// ── 3. „Neue Nachrichten“-Trenner MITTEN in einer Gruppe verschwindet ──
// (Bernd 2026-10-08: blieb manchmal stehen – bei mehreren Nachrichten
// desselben Absenders hintereinander, gelesen bis zur ersten.)
@@ -27,12 +27,16 @@ void main() {
testWidgets(
'LiveKit-Beitritt mit Server-Token: Identität = eigene Matrix-ID',
(tester) async {
// app.main() ersetzt FlutterError.onError – danach den Empfänger des
// Testrahmens zurücksetzen, sonst verschwinden Fehlermeldungen.
final testOnError = FlutterError.onError;
app.main();
final end = DateTime.now().add(const Duration(seconds: 90));
while (find.byType(MaterialApp).evaluate().isEmpty &&
DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 200));
}
FlutterError.onError = testOnError;
final container = ProviderScope.containerOf(
tester.element(find.byType(MaterialApp).first),
);
+27 -7
View File
@@ -47,6 +47,12 @@ void main() {
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,
@@ -56,6 +62,8 @@ void main() {
final container = ProviderScope.containerOf(
tester.element(find.byType(MaterialApp).first),
);
FlutterError.onError = testOnError;
await _shot(tester, '01_chatliste');
// ── Direktnachricht: Sprechblasen (Handy-Standard) und Liste ──
@@ -78,12 +86,15 @@ void main() {
final quote = find.byWidgetPredicate(
(w) => w is SpringPress && w.pressedScale == 0.97,
);
await tester.scrollUntilVisible(
quote.first,
300,
scrollable: find.byType(Scrollable).first,
maxScrolls: 40,
);
// 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);
@@ -103,7 +114,16 @@ void main() {
// 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 ownMsg = tester.getCenter(find.text('Danke, sieht gut aus!').first);
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);
+256
View File
@@ -0,0 +1,256 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'package:matrix/matrix.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:webrtc_interface/webrtc_interface.dart';
// Gemeinsame Hilfen für Live-Tests mit zwei Testkonten (Anruftest,
// UI-Tour): zweiter Matrix-Client im selben Prozess, Zugangsdaten,
// Warte-Helfer. Kein eigenständiger Test.
/// Zweiter Teilnehmer: eigener Matrix-Client + VoIP im selben Prozess.
class TestPartner {
TestPartner(this.client, this.delegate, this.voip, this.zone) {
_sub = client.onCallEvents.stream.listen((events) {
for (final e in events) {
if (e.type != EventTypes.CallCandidates || e.senderId == client.userID) {
continue;
}
final list = e.content.tryGetList<Map<String, dynamic>>('candidates');
if (list != null) _buffer.addAll(list);
}
});
_timer = Timer.periodic(const Duration(milliseconds: 500), (_) async {
final pc = delegate.call?.pc;
if (!fixCandidates || pc == null) return;
try {
if (await pc.getRemoteDescription() == null) return;
for (final c in List.of(_buffer)) {
final cand = c['candidate'] as String?;
if (cand == null || !_applied.add(cand)) continue;
await pc.addCandidate(RTCIceCandidate(
cand,
c['sdpMid']?.toString() ?? '',
(c['sdpMLineIndex'] as num?)?.round() ?? 0,
));
}
} catch (_) {}
});
}
final Client client;
final TestPartnerDelegate delegate;
final VoIP voip;
final Zone zone;
/// Aktion der Gegenseite in ihrer Zone ausführen (Fehler über Completer).
Future<T> run<T>(Future<T> Function() f) {
final done = Completer<T>();
zone.run(() async {
try {
done.complete(await f());
} catch (e, s) {
done.completeError(e, s);
}
});
return done.future;
}
/// Gegenseite reicht Kandidaten nach wie die gepatchte App.
bool fixCandidates = false;
final _buffer = <Map<String, dynamic>>[];
final _applied = <String>{};
StreamSubscription? _sub;
Timer? _timer;
static Future<TestPartner> start(String user, String pass) async {
final db = await databaseFactoryFfi.openDatabase(inMemoryDatabasePath);
final client = Client(
'pyramid-call-partner',
database: await MatrixSdkDatabase.init('pyramid-call-partner', database: db),
);
await client.init(waitForFirstSync: false, waitUntilLoadCompletedLoaded: false);
await client.checkHomeserver(Uri.parse('https://steggi-matrix.work'));
await client.login(
LoginType.mLoginPassword,
identifier: AuthenticationUserIdentifier(user: user),
password: pass,
initialDeviceDisplayName: 'Pyramid-Anruftest (kurzlebig)',
);
final delegate = TestPartnerDelegate();
// Eigene Zone: Fehler der Gegenseite sichtbar protokollieren statt sie
// im Testrahmen zu verlieren.
final zone = Zone.current.fork(
specification: ZoneSpecification(
handleUncaughtError: (self, parent, z, e, s) =>
logStep('Gegenseite (abgefangen): ${e.toString().split('\n').first}'),
),
);
final voip = zone.run(() => VoIP(client, delegate));
return TestPartner(client, delegate, voip, zone);
}
Future<void> dispose(String? roomId) async {
_timer?.cancel();
await _sub?.cancel();
try {
await delegate.call?.hangup(reason: CallErrorCode.userHangup);
} catch (_) {}
if (roomId != null) {
try {
await client.leaveRoom(roomId);
await client.forgetRoom(roomId);
} catch (_) {}
}
try {
client.backgroundSync = false;
await client.logout();
} catch (_) {}
}
}
class TestPartnerDelegate implements WebRTCDelegate {
CallSession? call;
@override
MediaDevices get mediaDevices => TestPartnerMedia(rtc.navigator.mediaDevices);
@override
Future<RTCPeerConnection> createPeerConnection(
Map<String, dynamic> configuration, [
Map<String, dynamic> constraints = const {},
]) =>
rtc.createPeerConnection(configuration, constraints);
@override
Future<void> handleNewCall(CallSession session) async {
call = session;
if (session.direction == CallDirection.kIncoming) {
// Wie ein Mensch nach kurzem Klingeln annehmen. Sofortiges Annehmen
// trifft eine Lücke im SDK: Kandidaten, die WÄHREND answer()
// eintreffen, landen in einer Warteschlange, die nie mehr abgearbeitet
// wird (CallSession.onCandidatesReceived vs. answer()).
unawaited(Future.delayed(const Duration(seconds: 2), () async {
try {
await session.answer();
} catch (e) {
logStep('Gegenseite: Annehmen fehlgeschlagen: $e');
}
}));
}
}
@override
Future<void> handleCallEnded(CallSession session) async {}
@override
Future<void> handleMissedCall(CallSession session) async {}
@override
Future<void> handleNewGroupCall(GroupCallSession groupCall) async {}
@override
Future<void> handleGroupCallEnded(GroupCallSession groupCall) async {}
@override
Future<void> playRingtone() async {}
@override
Future<void> stopRingtone() async {}
@override
Future<void> registerListeners(CallSession session) async {}
@override
bool get isWeb => false;
@override
bool get canHandleNewCall => true;
@override
EncryptionKeyProvider? get keyProvider => null;
}
/// Wie die App am PC: Bildschirm über eine Desktop-Quelle (erster Monitor).
class TestPartnerMedia extends MediaDevices {
TestPartnerMedia(this._d);
final MediaDevices _d;
@override
Future<MediaStream> getUserMedia(Map<String, dynamic> c) => _d.getUserMedia(c);
@override
Future<MediaStream> getDisplayMedia(Map<String, dynamic> c) async {
final sources = await rtc.desktopCapturer
.getSources(types: [rtc.SourceType.Screen]);
return _d.getDisplayMedia({
'video': {
'mandatory': {
'chromeMediaSource': 'desktop',
'chromeMediaSourceId': sources.first.id,
},
'optional': [],
},
'audio': false,
});
}
@override
Future<List<MediaDeviceInfo>> enumerateDevices() => _d.enumerateDevices();
@override
MediaTrackSupportedConstraints getSupportedConstraints() =>
_d.getSupportedConstraints();
@override
set ondevicechange(Function(dynamic event)? callback) =>
_d.ondevicechange = callback;
@override
Function(dynamic event)? get ondevicechange => _d.ondevicechange;
@override
// ignore: deprecated_member_use
Future<List<dynamic>> getSources() => _d.getSources();
@override
Future<MediaDeviceInfo> selectAudioOutput([AudioOutputOptions? options]) =>
_d.selectAudioOutput(options);
}
Map<String, String>? loadTestAccounts() {
final home = Platform.environment['USERPROFILE'] ?? '';
final file = File('$home\\.pyramid-autopilot\\test-accounts.txt');
if (!file.existsSync()) return null;
final map = <String, String>{};
for (final line in file.readAsLinesSync()) {
final t = line.trim();
if (t.isEmpty || t.startsWith('#')) continue;
final i = t.indexOf('=');
if (i > 0) map[t.substring(0, i)] = t.substring(i + 1);
}
return map;
}
Future<void> pumpFor(WidgetTester tester, Duration d) async {
final end = DateTime.now().add(d);
while (DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 100));
}
}
void logStep(String m) {
// ignore: avoid_print
print('SCHRITT ${DateTime.now().toIso8601String().substring(11, 19)} $m');
}
Future<void> pumpUntil(
WidgetTester tester,
bool Function() condition, {
Duration timeout = const Duration(seconds: 60),
String what = '',
void Function()? onTimeout,
void Function()? every5s,
}) async {
final end = DateTime.now().add(timeout);
var next = DateTime.now().add(const Duration(seconds: 5));
while (DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 200));
if (condition()) return;
if (every5s != null && DateTime.now().isAfter(next)) {
every5s();
next = DateTime.now().add(const Duration(seconds: 5));
}
}
logStep('ZEITÜBERSCHREITUNG: $what');
onTimeout?.call();
fail('Zeitüberschreitung: $what');
}
+278
View File
@@ -0,0 +1,278 @@
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:pyramid/core/matrix_client.dart';
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
import 'package:pyramid/features/voice_channel/voice_channel_service.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();
Future<void> both(String name, {Duration settle = const Duration(milliseconds: 1500)}) async {
phone();
await pumpFor(tester, settle);
await _shot(tester, 'p_$name');
desktop();
await pumpFor(tester, 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));
container.read(activeSpaceIdProvider.notifier).state = 'rooms';
await both('03_raeume');
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 = 'rooms';
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 = 'rooms';
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;
},
skip: profile == null || accounts == null,
timeout: const Timeout(Duration(minutes: 8)),
);
}
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');
}
+8
View File
@@ -0,0 +1,8 @@
import 'package:matrix/matrix.dart';
/// Raumtyp der Pyramid-Sprachkanäle (creation_content.type).
const voiceChannelRoomType = 'io.pyramid.voice_channel';
/// Ist [room] ein Sprachkanal (statt eines Text-Chats)?
bool isVoiceChannelRoom(Room room) =>
room.getState(EventTypes.RoomCreate)?.content['type'] == voiceChannelRoomType;
+2 -2
View File
@@ -49,7 +49,7 @@ class _MiniCallWidgetState extends ConsumerState<MiniCallWidget> {
final call = widget.call;
final String displayName = isVoip
? (call.currentCall?.room.getLocalizedDisplayname() ?? 'Unknown')
? (call.currentCall?.room.getLocalizedDisplayname() ?? 'Unbekannt')
: call.displayName;
final bool isConnecting = isVoip ? false : call.isConnecting;
@@ -160,7 +160,7 @@ class _MiniCallWidgetState extends ConsumerState<MiniCallWidget> {
const _LiveDot(),
const SizedBox(width: 6),
Text(
isConnecting ? 'CONNECTING...' : 'VOICE CONNECTED',
isConnecting ? 'CONNECTING...' : (isVoip ? 'ANRUF LÄUFT' : (call.isVoiceChannel ? 'SPRACHKANAL' : 'GRUPPENANRUF')),
style: TextStyle(
color: pt.online,
fontSize: 10,
+14 -10
View File
@@ -14,7 +14,7 @@ class _BigAvatar extends StatelessWidget {
gradient: LinearGradient(colors: [data.avatarColor, data.avatarColor.withAlpha(150)], begin: Alignment.topLeft, end: Alignment.bottomRight),
shape: BoxShape.circle,
),
child: Center(child: Text(data.name.isNotEmpty ? data.name[0].toUpperCase() : '?', style: const TextStyle(color: Colors.white, fontSize: 40, fontWeight: FontWeight.bold))),
child: Center(child: Text(data.initial, style: const TextStyle(color: Colors.white, fontSize: 40, fontWeight: FontWeight.bold))),
),
);
}
@@ -34,7 +34,7 @@ class _SmallAvatar extends StatelessWidget {
gradient: LinearGradient(colors: [data.avatarColor, data.avatarColor.withAlpha(150)], begin: Alignment.topLeft, end: Alignment.bottomRight),
shape: BoxShape.circle,
),
child: Center(child: Text(data.name.isNotEmpty ? data.name[0].toUpperCase() : '?', style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold))),
child: Center(child: Text(data.initial, style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold))),
),
);
}
@@ -127,14 +127,12 @@ class _NoCall extends StatelessWidget {
child: Row(
children: [
PyrIconBtn(
icon: const Icon(Icons.menu_rounded),
tooltip: 'Toggle sidebar',
onPressed: () => ref.read(railCollapsedProvider.notifier).update((s) => !s),
icon: const Icon(Icons.arrow_back_rounded),
tooltip: 'Zurück',
onPressed: () => ref.read(viewModeProvider.notifier).state = ViewMode.chat,
),
const SizedBox(width: 8),
Icon(Icons.headphones_rounded, size: 16, color: pt.fgMuted),
const SizedBox(width: 8),
Text('No active call', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)),
Text('Kein Anruf', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)),
],
),
),
@@ -143,9 +141,15 @@ class _NoCall extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('𐃏', style: TextStyle(fontSize: 48, color: pt.fgDim.withAlpha(100))),
Icon(Icons.call_end_rounded, size: 48, color: pt.fgDim),
const SizedBox(height: 12),
Text('Join a voice room to start a call', style: TextStyle(color: pt.fgMuted, fontSize: 14)),
Text('Gerade läuft kein Anruf.', style: TextStyle(color: pt.fgMuted, fontSize: 14)),
const SizedBox(height: 16),
FilledButton(
onPressed: () => ref.read(viewModeProvider.notifier).state = ViewMode.chat,
style: FilledButton.styleFrom(backgroundColor: pt.accent, foregroundColor: pt.accentFg),
child: const Text('Zurück zum Chat'),
),
],
),
),
+1 -1
View File
@@ -193,7 +193,7 @@ class _CallHeader extends StatelessWidget {
const Spacer(),
PyrIconBtn(
icon: Icon(isGridView ? Icons.grid_view_rounded : Icons.featured_video_rounded, color: isGridView ? pt.accent : pt.fg),
tooltip: isGridView ? 'Hero mode' : 'Grid mode',
tooltip: isGridView ? 'Großansicht' : 'Kachelansicht',
onPressed: onToggleView,
),
const SizedBox(width: 4),
@@ -12,6 +12,13 @@ class _ParticipantData {
final Color avatarColor;
final dynamic originalParticipant;
final double aspectRatio;
/// Woraus der Anfangsbuchstabe im Avatar gebildet wird (Standard: [name]).
final String? initialFrom;
String get initial {
final src = (initialFrom ?? name).replaceFirst('@', '').trim();
return src.isEmpty ? '?' : src.characters.first.toUpperCase();
}
const _ParticipantData({
required this.id,
@@ -24,6 +31,7 @@ class _ParticipantData {
this.avatarColor = Colors.grey,
this.originalParticipant,
this.aspectRatio = 16.0 / 9.0,
this.initialFrom,
});
}
@@ -148,7 +156,7 @@ class VoiceChannelView extends ConsumerWidget {
final hasRemoteVideo = voip.remoteRenderer.srcObject != null && voip.remoteRenderer.srcObject!.getVideoTracks().isNotEmpty;
participants.add(_ParticipantData(
id: 'remote',
name: voip.currentCall?.room.getLocalizedDisplayname() ?? 'Remote',
name: voip.currentCall?.room.getLocalizedDisplayname() ?? 'Gegenseite',
videoWidget: hasRemoteVideo ? rtc.RTCVideoView(voip.remoteRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null,
isMuted: false,
isSpeaking: voip.isRemoteSpeaking,
@@ -164,6 +172,7 @@ class VoiceChannelView extends ConsumerWidget {
participants.add(_ParticipantData(
id: 'local',
name: 'Du',
initialFrom: voip.currentCall?.room.client.userID?.split(':').first,
videoWidget: hasLocalVideo ? rtc.RTCVideoView(voip.localRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null,
isMuted: voip.isMicMuted,
isSpeaking: voip.isLocalSpeaking,
+4 -10
View File
@@ -111,13 +111,13 @@ class _ChatHeader extends ConsumerWidget {
PyrIconBtn(
size: 28,
icon: const Icon(Icons.phone_rounded),
tooltip: 'Voice call',
tooltip: 'Anrufen',
onPressed: () => _startCall(ref, room, cam: false),
),
PyrIconBtn(
size: 28,
icon: const Icon(Icons.videocam_rounded),
tooltip: 'Video call',
tooltip: 'Videoanruf',
onPressed: () => _startCall(ref, room, cam: true),
),
if (size.width > 700)
@@ -129,12 +129,6 @@ class _ChatHeader extends ConsumerWidget {
),
],
if (size.width > 800) ...[
PyrIconBtn(
size: 28,
icon: const Icon(Icons.inbox_rounded),
tooltip: 'Threads',
onPressed: () {},
),
PyrIconBtn(
size: 28,
icon: const Icon(Icons.push_pin_rounded),
@@ -557,13 +551,13 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
PyrIconBtn(
size: 28,
icon: const Icon(Icons.phone_rounded),
tooltip: 'Voice call',
tooltip: 'Anrufen',
onPressed: () => _startCall(cam: false),
),
PyrIconBtn(
size: 28,
icon: const Icon(Icons.videocam_rounded),
tooltip: 'Video call',
tooltip: 'Videoanruf',
onPressed: () => _startCall(cam: true),
),
if (size.width > 700)
@@ -65,7 +65,7 @@ class _MessageList extends StatelessWidget {
Icon(Icons.forum_outlined, size: 48, color: pt.fgDim),
const SizedBox(height: 16),
Text(
'No messages yet',
'Noch keine Nachrichten – schreib die erste!',
style: TextStyle(color: pt.fgMuted, fontSize: 14),
),
],
+1 -1
View File
@@ -120,7 +120,7 @@ class NoChatSelected extends StatelessWidget {
),
const SizedBox(height: 8),
Text(
'Choose from the sidebar on the left',
'Links findest du alle Chats und Räume.',
style: TextStyle(color: pt.fgDim, fontSize: 13),
),
],
+1 -1
View File
@@ -535,7 +535,7 @@ class _ChatViewState extends ConsumerState<ChatView> {
),
error: (e, _) => Center(
child: Text(
'Could not load messages',
'Nachrichten konnten nicht geladen werden.',
style: TextStyle(color: pt.fgDim, fontSize: 14),
),
),
+15 -11
View File
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/widgets/mxc_image.dart';
import 'package:pyramid/widgets/pyramid_logo.dart';
class MembersPanel extends ConsumerWidget {
@@ -94,7 +95,7 @@ class MembersPanel extends ConsumerWidget {
child: Padding(
padding: const EdgeInsets.only(top: 24),
child: Text(
'No members loaded',
'Keine Mitglieder geladen',
style: TextStyle(color: pt.fgDim, fontSize: 12),
),
),
@@ -177,19 +178,22 @@ class _MemberItemState extends State<_MemberItem> {
opacity: widget.isOffline ? 0.5 : 1,
child: Stack(
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(pt.rSm),
),
child: Center(
// Rund wie überall sonst, mit echtem Profilbild.
MxcAvatar(
mxcUri: member.avatarUrl,
client: member.room.client,
size: 32,
borderRadius: BorderRadius.circular(16),
placeholder: (_) => Container(
width: 32,
height: 32,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
alignment: Alignment.center,
child: Text(
initial,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
@@ -214,7 +218,7 @@ class _MemberItemState extends State<_MemberItem> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
member.id == member.room.client.userID ? '$name (Du)' : name,
style: TextStyle(
color: widget.isOffline ? pt.fgMuted : pt.fg,
fontSize: 13,
@@ -112,7 +112,7 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
),
),
error: (e, _) => Center(
child: Text('Error',
child: Text('Fehler',
style: TextStyle(color: pt.fgDim, fontSize: 12)),
),
data: (rooms) => _RoomsList(
@@ -136,7 +136,11 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
final hasVoipCall = voip.currentCall != null &&
voip.currentCall!.state != CallState.kEnded &&
voip.currentCall!.state != CallState.kRinging;
final hasCallDock = call.isActive || hasVoipCall;
// Am PC bei offener Anrufansicht nicht doppelt steuern
// (UI-Audit S6); am Handy liegt die Anrufansicht ohnehin darüber.
final callViewOpen = ref.watch(viewModeProvider) == ViewMode.voice &&
!isMobileLayout(MediaQuery.sizeOf(context));
final hasCallDock = !callViewOpen && (call.isActive || hasVoipCall);
final isLandscapeCompact =
MediaQuery.sizeOf(context).height < 500;
@@ -147,8 +151,8 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (call.isActive) MiniCallWidget(call: call),
if (hasVoipCall) MiniCallWidget(call: voip),
if (call.isActive && !callViewOpen) MiniCallWidget(call: call),
if (hasVoipCall && !callViewOpen) MiniCallWidget(call: voip),
],
),
);
+21 -2
View File
@@ -2,7 +2,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/appearance.dart' show isMobileLayout;
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/room_kinds.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/rooms/rooms_provider.dart';
import 'package:pyramid/widgets/create_join_dialog.dart';
@@ -32,8 +34,25 @@ class SpacesRail extends ConsumerWidget {
}
// Switch space and restore last visited room for the new space
ref.read(activeSpaceIdProvider.notifier).state = id;
ref.read(activeRoomIdProvider.notifier).state =
ref.read(lastRoomPerSpaceProvider)[id];
var target = ref.read(lastRoomPerSpaceProvider)[id];
// Am PC nicht auf „Wähle einen Chat aus“ landen: ersten Textkanal des
// Spaces öffnen (UI-Audit S5). Am Handy bleibt die Kanalliste sichtbar.
if (target == null &&
id != null &&
id != 'dms' &&
id != 'rooms' &&
!isMobileLayout(MediaQuery.sizeOf(context))) {
final client = ref.read(matrixClientProvider).valueOrNull;
final space = client?.getRoomById(id);
for (final child in space?.spaceChildren ?? []) {
final r = child.roomId == null ? null : client!.getRoomById(child.roomId!);
if (r != null && r.membership == Membership.join && !isVoiceChannelRoom(r)) {
target = r.id;
break;
}
}
}
ref.read(activeRoomIdProvider.notifier).state = target;
final scaffold = Scaffold.maybeOf(context);
if (scaffold != null && scaffold.isDrawerOpen) {
scaffold.closeDrawer();
+68 -8
View File
@@ -19,6 +19,7 @@ import 'package:pyramid/features/rooms/rooms_panel.dart';
import 'package:pyramid/features/spaces/spaces_rail.dart';
import 'package:pyramid/layout/mobile_page_stack.dart';
import 'package:pyramid/widgets/spring_press.dart' show motionDuration;
import 'package:pyramid/widgets/mxc_image.dart';
import 'package:pyramid/widgets/search_modal.dart';
import 'package:pyramid/widgets/settings_modal.dart';
import 'package:pyramid/widgets/share_target_dialog.dart';
@@ -151,6 +152,44 @@ class _AppShellState extends ConsumerState<AppShell>
ref.read(activeModalProvider.notifier).state = ModalKind.none;
}
bool _hadVoipCall = false;
bool _hadLiveKit = false;
/// Anruf ist vorbei: Anrufansicht verlassen (sofern kein anderer Anruf
/// mehr läuft) und kurz Bescheid geben.
void _leaveCallView(String message) {
WidgetsBinding.instance.addPostFrameCallback((_) {
try {
_leaveCallViewNow(message);
} catch (e, s) {
debugPrint('[AppShell] Anrufansicht verlassen: $e $s');
}
});
}
void _leaveCallViewNow(String message) {
{
if (!mounted) return;
final voip = ref.read(callSignalingProvider);
final lk = ref.read(voiceChannelProvider);
final stillInCall = (voip.currentCall != null &&
voip.currentCall!.state != CallState.kEnded) ||
lk.isActive ||
lk.isConnecting;
if (stillInCall) return;
if (ref.read(viewModeProvider) == ViewMode.voice) {
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
}
ScaffoldMessenger.maybeOf(context)
?..hideCurrentSnackBar()
..showSnackBar(SnackBar(
content: Text(message),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 3),
));
}
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
@@ -164,6 +203,12 @@ class _AppShellState extends ConsumerState<AppShell>
// würde der Nutzer permanent zurück in die Call-UI gezwungen und könnte
// sie nie minimieren.
ref.listen(callSignalingProvider, (prev, next) {
// Anruf vorbei (aufgelegt, abgelehnt, gescheitert) → nicht auf der
// leeren Anrufansicht stehen lassen (UI-Audit S1, 2026-10-08).
final hasCall = next.currentCall != null &&
next.currentCall!.state != CallState.kEnded;
if (_hadVoipCall && !hasCall) _leaveCallView('Anruf beendet');
_hadVoipCall = hasCall;
final wasConnected = _wasVoipConnected;
final isConnected = next.currentCall != null &&
next.currentCall!.state == CallState.kConnected;
@@ -176,6 +221,12 @@ class _AppShellState extends ConsumerState<AppShell>
});
ref.listen(voiceChannelProvider, (prev, next) {
final lkActive = next.isActive || next.isConnecting;
if (_hadLiveKit && !lkActive) {
// Fehler (z. B. Verbindung verloren) sichtbar machen statt still.
_leaveCallView(next.error ?? (next.isVoiceChannel ? 'Sprachkanal verlassen' : 'Anruf beendet'));
}
_hadLiveKit = lkActive;
// Only auto-switch to voice view for video/audio calls, NOT voice channels
// (voice channels should stay in background with a mini bar)
final wasActive = _wasLiveKitActive;
@@ -519,18 +570,27 @@ class _IncomingCallOverlay extends ConsumerWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: pt.accent,
shape: BoxShape.circle,
MxcAvatar(
mxcUri: call.room.avatar,
client: call.room.client,
size: 88,
placeholder: (_) => Container(
width: 88,
height: 88,
decoration: BoxDecoration(
color: pt.accent,
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Text(
call.room.getLocalizedDisplayname().characters.firstOrNull?.toUpperCase() ?? '?',
style: const TextStyle(color: Colors.white, fontSize: 36, fontWeight: FontWeight.w700),
),
),
child: const Icon(Icons.person_rounded, size: 40, color: Colors.white),
),
const SizedBox(height: 24),
Text(
'Eingehender Anruf',
call.type == CallType.kVideo ? 'Eingehender Videoanruf' : 'Eingehender Anruf',
style: TextStyle(color: pt.fgDim, fontSize: 13),
),
const SizedBox(height: 8),
+1 -1
View File
@@ -214,7 +214,7 @@ class _SettingsModalState extends ConsumerState<SettingsModal> {
child: ListView(
padding: const EdgeInsets.symmetric(vertical: 8),
children: [
for (final c in _categories)
for (final c in _categories.where((c) => c.$1 != 'keys'))
_MobileCategoryTile(
label: c.$2,
icon: c.$3,
+6
View File
@@ -135,6 +135,12 @@ if ($Ui) {
}
} finally {
Remove-Item Env:PYRAMID_PROFILE_DIR -ErrorAction SilentlyContinue
# Integrationstests lassen die Test-App manchmal weiterlaufen – die
# hielte die Testprofil-DB offen (zwei Clients auf einer DB beim
# nächsten Lauf). Nur Prozesse aus DIESEM Build-Ordner beenden.
Get-Process pyramid -ErrorAction SilentlyContinue |
Where-Object { $_.Path -like "$PWDuild\windows\*" } |
Stop-Process -Force -Confirm:$false -ErrorAction SilentlyContinue
}
Write-Host "Screenshots: build/ui_screens/ (Testinhalte ggf. mit test/live_ui_fixture_test.dart anlegen)"
}