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:
co-authored by
Claude Opus 5.5
parent
842dd61031
commit
87b472a373
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
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,
|
||||
),
|
||||
child: const Icon(Icons.person_rounded, size: 40, color: Colors.white),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
call.room.getLocalizedDisplayname().characters.firstOrNull?.toUpperCase() ?? '?',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 36, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
),
|
||||
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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user