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 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -10,9 +9,10 @@ import 'package:matrix/matrix.dart';
|
|||||||
import 'package:pyramid/core/matrix_client.dart';
|
import 'package:pyramid/core/matrix_client.dart';
|
||||||
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
||||||
import 'package:pyramid/main.dart' as app;
|
import 'package:pyramid/main.dart' as app;
|
||||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
|
||||||
import 'package:webrtc_interface/webrtc_interface.dart';
|
import 'package:webrtc_interface/webrtc_interface.dart';
|
||||||
|
|
||||||
|
import 'support/test_partner.dart';
|
||||||
|
|
||||||
/// Echter 1:1-Anruf zwischen der App (pyramidtest1, Testprofil) und einem
|
/// Echter 1:1-Anruf zwischen der App (pyramidtest1, Testprofil) und einem
|
||||||
/// zweiten Matrix-Client im selben Prozess (pyramidtest2, kurzlebiges Gerät,
|
/// zweiten Matrix-Client im selben Prozess (pyramidtest2, kurzlebiges Gerät,
|
||||||
/// wird am Ende abgemeldet). Bernds Fehlerbericht 2026-10-08: Ton kam nicht
|
/// 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() {
|
void main() {
|
||||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
final profile = Platform.environment['PYRAMID_PROFILE_DIR'];
|
final profile = Platform.environment['PYRAMID_PROFILE_DIR'];
|
||||||
final accounts = _loadAccounts();
|
final accounts = loadTestAccounts();
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
'1:1-Anruf: Ton beide Richtungen, Bildschirm teilen beide Richtungen',
|
'1:1-Anruf: Ton beide Richtungen, Bildschirm teilen beide Richtungen',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
Logs().level = Level.verbose;
|
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();
|
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(
|
final container = ProviderScope.containerOf(
|
||||||
tester.element(find.byType(MaterialApp).first),
|
tester.element(find.byType(MaterialApp).first),
|
||||||
);
|
);
|
||||||
@@ -46,7 +50,7 @@ void main() {
|
|||||||
expect(me.userID, '@pyramidtest1:steggi-matrix.work');
|
expect(me.userID, '@pyramidtest1:steggi-matrix.work');
|
||||||
|
|
||||||
final partner = (await tester.runAsync(
|
final partner = (await tester.runAsync(
|
||||||
() => _Partner.start(accounts!['user2']!, accounts['pass2']!),
|
() => TestPartner.start(accounts!['user2']!, accounts['pass2']!),
|
||||||
))!;
|
))!;
|
||||||
String? roomId;
|
String? roomId;
|
||||||
addTearDown(() => tester.runAsync(() async {
|
addTearDown(() => tester.runAsync(() async {
|
||||||
@@ -62,24 +66,24 @@ void main() {
|
|||||||
preset: CreateRoomPreset.privateChat,
|
preset: CreateRoomPreset.privateChat,
|
||||||
name: 'Anruftest (automatisch)',
|
name: 'Anruftest (automatisch)',
|
||||||
));
|
));
|
||||||
_log('Raum angelegt: $roomId');
|
logStep('Raum angelegt: $roomId');
|
||||||
await _pumpUntil(
|
await pumpUntil(
|
||||||
tester,
|
tester,
|
||||||
() => me.getRoomById(roomId!)?.membership == Membership.invite,
|
() => me.getRoomById(roomId!)?.membership == Membership.invite,
|
||||||
what: 'Einladung kommt bei der App an',
|
what: 'Einladung kommt bei der App an',
|
||||||
);
|
);
|
||||||
await tester.runAsync(() => me.getRoomById(roomId!)!.join());
|
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
|
// Die Gegenseite ist Ersteller und damit im Raum; ihr Mitglieder-Cache
|
||||||
// wird vom SDK nur bei Bedarf geladen – kurz warten genügt.
|
// 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);
|
final voip = container.read(callSignalingProvider);
|
||||||
|
|
||||||
// ═══ Teil 1: Gegenseite ruft an, App nimmt über den echten Knopf an ═══
|
// ═══ Teil 1: Gegenseite ruft an, App nimmt über den echten Knopf an ═══
|
||||||
// (prüft die Kandidaten-Pufferung der App mit einem ungepatchten
|
// (prüft die Kandidaten-Pufferung der App mit einem ungepatchten
|
||||||
// Anrufer – genau Bernds Fall „lange/kein Verbindungsaufbau“)
|
// 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(
|
await tester.runAsync(() => partner.run(() => partner.voip.inviteToCall(
|
||||||
partner.client.getRoomById(roomId!)!,
|
partner.client.getRoomById(roomId!)!,
|
||||||
CallType.kVoice,
|
CallType.kVoice,
|
||||||
@@ -87,9 +91,9 @@ void main() {
|
|||||||
final answerBtn = find.byWidgetPredicate(
|
final answerBtn = find.byWidgetPredicate(
|
||||||
(w) => w is FloatingActionButton && w.heroTag == 'answer_call',
|
(w) => w is FloatingActionButton && w.heroTag == 'answer_call',
|
||||||
);
|
);
|
||||||
await _pumpUntil(tester, () => answerBtn.evaluate().isNotEmpty,
|
await pumpUntil(tester, () => answerBtn.evaluate().isNotEmpty,
|
||||||
what: 'Annehmen-Knopf erscheint');
|
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 tester.tap(answerBtn.first);
|
||||||
await _waitConnected(tester, voip, partner);
|
await _waitConnected(tester, voip, partner);
|
||||||
|
|
||||||
@@ -98,8 +102,8 @@ void main() {
|
|||||||
// Stumm über den echten Knopf → Gegenseite sieht „stumm“, Spur ist aus.
|
// Stumm über den echten Knopf → Gegenseite sieht „stumm“, Spur ist aus.
|
||||||
bool partnerSeesMuted() =>
|
bool partnerSeesMuted() =>
|
||||||
partner.delegate.call?.remoteUserMediaStream?.audioMuted == true;
|
partner.delegate.call?.remoteUserMediaStream?.audioMuted == true;
|
||||||
await tester.tap(find.byIcon(Icons.mic_rounded).first);
|
await tester.tap(_ctrlBtn(Icons.mic_rounded));
|
||||||
await _pumpUntil(tester, partnerSeesMuted,
|
await pumpUntil(tester, partnerSeesMuted,
|
||||||
timeout: const Duration(seconds: 15),
|
timeout: const Duration(seconds: 15),
|
||||||
what: 'Gegenseite sieht: App ist stumm');
|
what: 'Gegenseite sieht: App ist stumm');
|
||||||
expect(voip.isMicMuted, isTrue);
|
expect(voip.isMicMuted, isTrue);
|
||||||
@@ -108,15 +112,15 @@ void main() {
|
|||||||
isFalse,
|
isFalse,
|
||||||
reason: 'Mikrofonspur ist trotz Stumm noch an',
|
reason: 'Mikrofonspur ist trotz Stumm noch an',
|
||||||
);
|
);
|
||||||
await tester.tap(find.byIcon(Icons.mic_off_rounded).first);
|
await tester.tap(_ctrlBtn(Icons.mic_off_rounded));
|
||||||
await _pumpUntil(tester, () => !partnerSeesMuted() && !voip.isMicMuted,
|
await pumpUntil(tester, () => !partnerSeesMuted() && !voip.isMicMuted,
|
||||||
timeout: const Duration(seconds: 15),
|
timeout: const Duration(seconds: 15),
|
||||||
what: 'Stumm wieder aufgehoben');
|
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.
|
// Taub → Gegenseite nicht mehr hören UND selbst stumm; danach zurück.
|
||||||
await tester.tap(find.byIcon(Icons.headphones_rounded).first);
|
await tester.tap(_ctrlBtn(Icons.headphones_rounded));
|
||||||
await _pumpUntil(
|
await pumpUntil(
|
||||||
tester,
|
tester,
|
||||||
() => voip.isDeafened && partnerSeesMuted(),
|
() => voip.isDeafened && partnerSeesMuted(),
|
||||||
timeout: const Duration(seconds: 15),
|
timeout: const Duration(seconds: 15),
|
||||||
@@ -127,8 +131,8 @@ void main() {
|
|||||||
expect(remoteAudio, isNotEmpty);
|
expect(remoteAudio, isNotEmpty);
|
||||||
expect(remoteAudio.every((t) => !t.enabled), isTrue,
|
expect(remoteAudio.every((t) => !t.enabled), isTrue,
|
||||||
reason: 'Ton der Gegenseite ist trotz Taub noch an');
|
reason: 'Ton der Gegenseite ist trotz Taub noch an');
|
||||||
await tester.tap(find.byIcon(Icons.headset_off_rounded).first);
|
await tester.tap(_ctrlBtn(Icons.headset_off_rounded));
|
||||||
await _pumpUntil(
|
await pumpUntil(
|
||||||
tester,
|
tester,
|
||||||
() => !voip.isDeafened && !partnerSeesMuted() && !voip.isMicMuted,
|
() => !voip.isDeafened && !partnerSeesMuted() && !voip.isMicMuted,
|
||||||
timeout: const Duration(seconds: 15),
|
timeout: const Duration(seconds: 15),
|
||||||
@@ -140,13 +144,13 @@ void main() {
|
|||||||
.every((t) => t.enabled),
|
.every((t) => t.enabled),
|
||||||
isTrue,
|
isTrue,
|
||||||
);
|
);
|
||||||
_log('Taub/Hören über den Knopf: ok');
|
logStep('Taub/Hören über den Knopf: ok');
|
||||||
|
|
||||||
// Gegenseite teilt Bildschirm → App zeigt ihn
|
// Gegenseite teilt Bildschirm → App zeigt ihn
|
||||||
await tester.runAsync(
|
await tester.runAsync(
|
||||||
() => partner.run(() => partner.delegate.call!.setScreensharingEnabled(true)),
|
() => partner.run(() => partner.delegate.call!.setScreensharingEnabled(true)),
|
||||||
);
|
);
|
||||||
await _pumpUntil(
|
await pumpUntil(
|
||||||
tester,
|
tester,
|
||||||
() =>
|
() =>
|
||||||
voip.isRemoteScreensharing &&
|
voip.isRemoteScreensharing &&
|
||||||
@@ -154,21 +158,21 @@ void main() {
|
|||||||
timeout: const Duration(seconds: 30),
|
timeout: const Duration(seconds: 30),
|
||||||
what: 'Bildschirm der Gegenseite erscheint in der App',
|
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(
|
final appVideoIn = (await tester.runAsync(
|
||||||
() => _rtp(voip.currentCall!.pc!, 'video'),
|
() => _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),
|
expect(appVideoIn.received, greaterThan(0),
|
||||||
reason: 'Bildschirm der Gegenseite kommt nicht an');
|
reason: 'Bildschirm der Gegenseite kommt nicht an');
|
||||||
await tester.runAsync(
|
await tester.runAsync(
|
||||||
() => partner.run(() => partner.delegate.call!.setScreensharingEnabled(false)),
|
() => partner.run(() => partner.delegate.call!.setScreensharingEnabled(false)),
|
||||||
);
|
);
|
||||||
await _pumpUntil(tester, () => !voip.isRemoteScreensharing,
|
await pumpUntil(tester, () => !voip.isRemoteScreensharing,
|
||||||
what: 'Bildschirm der Gegenseite verschwindet wieder');
|
what: 'Bildschirm der Gegenseite verschwindet wieder');
|
||||||
|
|
||||||
// App teilt ihren Bildschirm über den echten Auswähler
|
// App teilt ihren Bildschirm über den echten Auswähler
|
||||||
await _pumpUntil(
|
await pumpUntil(
|
||||||
tester,
|
tester,
|
||||||
() => find.byIcon(Icons.screen_share_rounded).evaluate().isNotEmpty,
|
() => find.byIcon(Icons.screen_share_rounded).evaluate().isNotEmpty,
|
||||||
what: 'Teilen-Knopf in der Anrufleiste',
|
what: 'Teilen-Knopf in der Anrufleiste',
|
||||||
@@ -177,10 +181,10 @@ void main() {
|
|||||||
final tile = find.byWidgetPredicate(
|
final tile = find.byWidgetPredicate(
|
||||||
(w) => w.runtimeType.toString() == '_SourceTile',
|
(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');
|
timeout: const Duration(seconds: 20), what: 'Quellen-Auswähler');
|
||||||
await tester.tap(tile.first);
|
await tester.tap(tile.first);
|
||||||
await _pumpUntil(
|
await pumpUntil(
|
||||||
tester,
|
tester,
|
||||||
() {
|
() {
|
||||||
final s = partner.delegate.call?.remoteScreenSharingStream?.stream;
|
final s = partner.delegate.call?.remoteScreenSharingStream?.stream;
|
||||||
@@ -191,27 +195,27 @@ void main() {
|
|||||||
timeout: const Duration(seconds: 30),
|
timeout: const Duration(seconds: 30),
|
||||||
what: 'Bildschirm der App kommt bei der Gegenseite an',
|
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(
|
final partnerVideoIn = (await tester.runAsync(
|
||||||
() => _rtp(partner.delegate.call!.pc!, 'video'),
|
() => _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),
|
expect(partnerVideoIn.received, greaterThan(0),
|
||||||
reason: 'Bildschirm der App kommt nicht an');
|
reason: 'Bildschirm der App kommt nicht an');
|
||||||
|
|
||||||
await tester.runAsync(() => voip.hangup());
|
await tester.runAsync(() => voip.hangup());
|
||||||
await _pumpUntil(tester, () => voip.currentCall == null,
|
await pumpUntil(tester, () => voip.currentCall == null,
|
||||||
what: 'aufgelegt (Teil 1)');
|
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 ═══
|
// ═══ 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
|
partner.fixCandidates = true; // Gegenseite wie eine gepatchte Pyramid
|
||||||
await tester.runAsync(() => voip.startCall(roomId!, video: false));
|
await tester.runAsync(() => voip.startCall(roomId!, video: false));
|
||||||
await _waitConnected(tester, voip, partner);
|
await _waitConnected(tester, voip, partner);
|
||||||
await _checkAudio(tester, voip, partner);
|
await _checkAudio(tester, voip, partner);
|
||||||
await tester.runAsync(() => voip.hangup());
|
await tester.runAsync(() => voip.hangup());
|
||||||
await _pumpUntil(tester, () => voip.currentCall == null,
|
await pumpUntil(tester, () => voip.currentCall == null,
|
||||||
what: 'aufgelegt (Teil 2)');
|
what: 'aufgelegt (Teil 2)');
|
||||||
},
|
},
|
||||||
skip: profile == null || accounts == null,
|
skip: profile == null || accounts == null,
|
||||||
@@ -222,10 +226,10 @@ void main() {
|
|||||||
Future<void> _waitConnected(
|
Future<void> _waitConnected(
|
||||||
WidgetTester tester,
|
WidgetTester tester,
|
||||||
CallSignalingService voip,
|
CallSignalingService voip,
|
||||||
_Partner partner,
|
TestPartner partner,
|
||||||
) async {
|
) async {
|
||||||
final t0 = DateTime.now();
|
final t0 = DateTime.now();
|
||||||
await _pumpUntil(
|
await pumpUntil(
|
||||||
tester,
|
tester,
|
||||||
() =>
|
() =>
|
||||||
voip.currentCall?.state == CallState.kConnected &&
|
voip.currentCall?.state == CallState.kConnected &&
|
||||||
@@ -235,26 +239,26 @@ Future<void> _waitConnected(
|
|||||||
every5s: () {
|
every5s: () {
|
||||||
final a = voip.currentCall;
|
final a = voip.currentCall;
|
||||||
final b = partner.delegate.call;
|
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}');
|
'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(
|
Future<void> _checkAudio(
|
||||||
WidgetTester tester,
|
WidgetTester tester,
|
||||||
CallSignalingService voip,
|
CallSignalingService voip,
|
||||||
_Partner partner,
|
TestPartner partner,
|
||||||
) async {
|
) async {
|
||||||
await _pumpFor(tester, const Duration(seconds: 4));
|
await pumpFor(tester, const Duration(seconds: 4));
|
||||||
final appAudio = (await tester.runAsync(
|
final appAudio = (await tester.runAsync(
|
||||||
() => _rtp(voip.currentCall!.pc!, 'audio'),
|
() => _rtp(voip.currentCall!.pc!, 'audio'),
|
||||||
))!;
|
))!;
|
||||||
final partnerAudio = (await tester.runAsync(
|
final partnerAudio = (await tester.runAsync(
|
||||||
() => _rtp(partner.delegate.call!.pc!, 'audio'),
|
() => _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.sent, greaterThan(0), reason: 'App sendet keinen Ton');
|
||||||
expect(appAudio.received, greaterThan(0), reason: 'App empfängt keinen Ton');
|
expect(appAudio.received, greaterThan(0), reason: 'App empfängt keinen Ton');
|
||||||
expect(partnerAudio.received, greaterThan(0),
|
expect(partnerAudio.received, greaterThan(0),
|
||||||
@@ -280,246 +284,11 @@ Future<_Rtp> _rtp(rtc.RTCPeerConnection pc, String kind) async {
|
|||||||
return _Rtp(sent, received);
|
return _Rtp(sent, received);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Zweiter Teilnehmer: eigener Matrix-Client + VoIP im selben Prozess.
|
/// Knopf der Anrufleiste mit diesem Symbol (nicht die Stumm-ANZEIGE in der
|
||||||
class _Partner {
|
/// Teilnehmer-Kachel, die dasselbe Symbol trägt).
|
||||||
_Partner(this.client, this.delegate, this.voip, this.zone) {
|
Finder _ctrlBtn(IconData icon) => find.descendant(
|
||||||
_sub = client.onCallEvents.stream.listen((events) {
|
of: find.byWidgetPredicate((w) =>
|
||||||
for (final e in events) {
|
w.runtimeType.toString() == '_VoiceCtrlBtn' &&
|
||||||
if (e.type != EventTypes.CallCandidates || e.senderId == client.userID) {
|
(w as dynamic).icon == icon),
|
||||||
continue;
|
matching: find.byIcon(icon),
|
||||||
}
|
).first;
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -44,8 +44,16 @@ void main() {
|
|||||||
);
|
);
|
||||||
addTearDown(() => tester.runAsync(() => partner!.logout()));
|
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();
|
app.main();
|
||||||
await _pumpUntil(tester, find.byType(MaterialApp));
|
await _pumpUntil(tester, find.byType(MaterialApp));
|
||||||
|
|
||||||
|
FlutterError.onError = testOnError;
|
||||||
final container = ProviderScope.containerOf(
|
final container = ProviderScope.containerOf(
|
||||||
tester.element(find.byType(MaterialApp).first),
|
tester.element(find.byType(MaterialApp).first),
|
||||||
);
|
);
|
||||||
@@ -55,14 +63,40 @@ void main() {
|
|||||||
expect(client.userID, '@pyramidtest1:steggi-matrix.work');
|
expect(client.userID, '@pyramidtest1:steggi-matrix.work');
|
||||||
await _pumpFor(tester, const Duration(seconds: 4));
|
await _pumpFor(tester, const Duration(seconds: 4));
|
||||||
|
|
||||||
// ── 1. Lesebestätigung live ──
|
// ── 2. Einladung im Chat annehmen ──
|
||||||
final dm = client.rooms.firstWhere(
|
final welcome = 'Willkommen im Einladungstest ${DateTime.now().millisecondsSinceEpoch}';
|
||||||
(r) =>
|
final inviteRoomId = await tester.runAsync(() async {
|
||||||
r.membership == Membership.join &&
|
final id = await partner!.createRoom('Einladungstest');
|
||||||
r.directChatMatrixID == '@pyramidtest2:steggi-matrix.work',
|
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;
|
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 text = 'Lesetest ${DateTime.now().millisecondsSinceEpoch}';
|
||||||
final eventId = await tester.runAsync(() => dm.sendTextEvent(text));
|
final eventId = await tester.runAsync(() => dm.sendTextEvent(text));
|
||||||
@@ -94,33 +128,6 @@ void main() {
|
|||||||
reason: '✓✓ erscheint nicht live unter der gelesenen Nachricht',
|
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 ──
|
// ── 3. „Neue Nachrichten“-Trenner MITTEN in einer Gruppe verschwindet ──
|
||||||
// (Bernd 2026-10-08: blieb manchmal stehen – bei mehreren Nachrichten
|
// (Bernd 2026-10-08: blieb manchmal stehen – bei mehreren Nachrichten
|
||||||
// desselben Absenders hintereinander, gelesen bis zur ersten.)
|
// desselben Absenders hintereinander, gelesen bis zur ersten.)
|
||||||
|
|||||||
@@ -27,12 +27,16 @@ void main() {
|
|||||||
testWidgets(
|
testWidgets(
|
||||||
'LiveKit-Beitritt mit Server-Token: Identität = eigene Matrix-ID',
|
'LiveKit-Beitritt mit Server-Token: Identität = eigene Matrix-ID',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
|
// app.main() ersetzt FlutterError.onError – danach den Empfänger des
|
||||||
|
// Testrahmens zurücksetzen, sonst verschwinden Fehlermeldungen.
|
||||||
|
final testOnError = FlutterError.onError;
|
||||||
app.main();
|
app.main();
|
||||||
final end = DateTime.now().add(const Duration(seconds: 90));
|
final end = DateTime.now().add(const Duration(seconds: 90));
|
||||||
while (find.byType(MaterialApp).evaluate().isEmpty &&
|
while (find.byType(MaterialApp).evaluate().isEmpty &&
|
||||||
DateTime.now().isBefore(end)) {
|
DateTime.now().isBefore(end)) {
|
||||||
await tester.pump(const Duration(milliseconds: 200));
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
}
|
}
|
||||||
|
FlutterError.onError = testOnError;
|
||||||
final container = ProviderScope.containerOf(
|
final container = ProviderScope.containerOf(
|
||||||
tester.element(find.byType(MaterialApp).first),
|
tester.element(find.byType(MaterialApp).first),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ void main() {
|
|||||||
addTearDown(tester.view.reset);
|
addTearDown(tester.view.reset);
|
||||||
final size = tester.view.physicalSize / tester.view.devicePixelRatio;
|
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();
|
app.main();
|
||||||
await _pumpUntil(
|
await _pumpUntil(
|
||||||
tester,
|
tester,
|
||||||
@@ -56,6 +62,8 @@ void main() {
|
|||||||
final container = ProviderScope.containerOf(
|
final container = ProviderScope.containerOf(
|
||||||
tester.element(find.byType(MaterialApp).first),
|
tester.element(find.byType(MaterialApp).first),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
FlutterError.onError = testOnError;
|
||||||
await _shot(tester, '01_chatliste');
|
await _shot(tester, '01_chatliste');
|
||||||
|
|
||||||
// ── Direktnachricht: Sprechblasen (Handy-Standard) und Liste ──
|
// ── Direktnachricht: Sprechblasen (Handy-Standard) und Liste ──
|
||||||
@@ -78,12 +86,15 @@ void main() {
|
|||||||
final quote = find.byWidgetPredicate(
|
final quote = find.byWidgetPredicate(
|
||||||
(w) => w is SpringPress && w.pressedScale == 0.97,
|
(w) => w is SpringPress && w.pressedScale == 0.97,
|
||||||
);
|
);
|
||||||
await tester.scrollUntilVisible(
|
// Nach oben blättern, bis ein Zitat gebaut ist (die Liste baut nur
|
||||||
quote.first,
|
// sichtbare Einträge; .first auf eine leere Trefferliste wirft).
|
||||||
300,
|
for (var i = 0; i < 60 && quote.evaluate().isEmpty; i++) {
|
||||||
scrollable: find.byType(Scrollable).first,
|
await tester.drag(find.byType(Scrollable).first, const Offset(0, 300),
|
||||||
maxScrolls: 40,
|
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 _pumpFor(tester, const Duration(milliseconds: 600));
|
||||||
await _shot(tester, '02e_zitat_vor_dem_tippen');
|
await _shot(tester, '02e_zitat_vor_dem_tippen');
|
||||||
await tester.tap(quote.first, warnIfMissed: false);
|
await tester.tap(quote.first, warnIfMissed: false);
|
||||||
@@ -103,7 +114,16 @@ void main() {
|
|||||||
// es nur auf echten Handys; hier über Lange-Drücken → Antworten.)
|
// es nur auf echten Handys; hier über Lange-Drücken → Antworten.)
|
||||||
// Neben die eigene Sprechblase drücken (nicht in den Text – dort
|
// Neben die eigene Sprechblase drücken (nicht in den Text – dort
|
||||||
// greift am PC die Textauswahl).
|
// 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 tester.longPressAt(Offset(size.width * 0.15, ownMsg.dy));
|
||||||
await _pumpFor(tester, const Duration(milliseconds: 500));
|
await _pumpFor(tester, const Duration(milliseconds: 500));
|
||||||
await tester.tap(find.byIcon(Icons.reply_rounded).first);
|
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 call = widget.call;
|
||||||
|
|
||||||
final String displayName = isVoip
|
final String displayName = isVoip
|
||||||
? (call.currentCall?.room.getLocalizedDisplayname() ?? 'Unknown')
|
? (call.currentCall?.room.getLocalizedDisplayname() ?? 'Unbekannt')
|
||||||
: call.displayName;
|
: call.displayName;
|
||||||
|
|
||||||
final bool isConnecting = isVoip ? false : call.isConnecting;
|
final bool isConnecting = isVoip ? false : call.isConnecting;
|
||||||
@@ -160,7 +160,7 @@ class _MiniCallWidgetState extends ConsumerState<MiniCallWidget> {
|
|||||||
const _LiveDot(),
|
const _LiveDot(),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
isConnecting ? 'CONNECTING...' : 'VOICE CONNECTED',
|
isConnecting ? 'CONNECTING...' : (isVoip ? 'ANRUF LÄUFT' : (call.isVoiceChannel ? 'SPRACHKANAL' : 'GRUPPENANRUF')),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: pt.online,
|
color: pt.online,
|
||||||
fontSize: 10,
|
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),
|
gradient: LinearGradient(colors: [data.avatarColor, data.avatarColor.withAlpha(150)], begin: Alignment.topLeft, end: Alignment.bottomRight),
|
||||||
shape: BoxShape.circle,
|
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),
|
gradient: LinearGradient(colors: [data.avatarColor, data.avatarColor.withAlpha(150)], begin: Alignment.topLeft, end: Alignment.bottomRight),
|
||||||
shape: BoxShape.circle,
|
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(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
PyrIconBtn(
|
PyrIconBtn(
|
||||||
icon: const Icon(Icons.menu_rounded),
|
icon: const Icon(Icons.arrow_back_rounded),
|
||||||
tooltip: 'Toggle sidebar',
|
tooltip: 'Zurück',
|
||||||
onPressed: () => ref.read(railCollapsedProvider.notifier).update((s) => !s),
|
onPressed: () => ref.read(viewModeProvider.notifier).state = ViewMode.chat,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Icon(Icons.headphones_rounded, size: 16, color: pt.fgMuted),
|
Text('Kein Anruf', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)),
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text('No active call', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -143,9 +141,15 @@ class _NoCall extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
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),
|
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(),
|
const Spacer(),
|
||||||
PyrIconBtn(
|
PyrIconBtn(
|
||||||
icon: Icon(isGridView ? Icons.grid_view_rounded : Icons.featured_video_rounded, color: isGridView ? pt.accent : pt.fg),
|
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,
|
onPressed: onToggleView,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ class _ParticipantData {
|
|||||||
final Color avatarColor;
|
final Color avatarColor;
|
||||||
final dynamic originalParticipant;
|
final dynamic originalParticipant;
|
||||||
final double aspectRatio;
|
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({
|
const _ParticipantData({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -24,6 +31,7 @@ class _ParticipantData {
|
|||||||
this.avatarColor = Colors.grey,
|
this.avatarColor = Colors.grey,
|
||||||
this.originalParticipant,
|
this.originalParticipant,
|
||||||
this.aspectRatio = 16.0 / 9.0,
|
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;
|
final hasRemoteVideo = voip.remoteRenderer.srcObject != null && voip.remoteRenderer.srcObject!.getVideoTracks().isNotEmpty;
|
||||||
participants.add(_ParticipantData(
|
participants.add(_ParticipantData(
|
||||||
id: 'remote',
|
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,
|
videoWidget: hasRemoteVideo ? rtc.RTCVideoView(voip.remoteRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null,
|
||||||
isMuted: false,
|
isMuted: false,
|
||||||
isSpeaking: voip.isRemoteSpeaking,
|
isSpeaking: voip.isRemoteSpeaking,
|
||||||
@@ -164,6 +172,7 @@ class VoiceChannelView extends ConsumerWidget {
|
|||||||
participants.add(_ParticipantData(
|
participants.add(_ParticipantData(
|
||||||
id: 'local',
|
id: 'local',
|
||||||
name: 'Du',
|
name: 'Du',
|
||||||
|
initialFrom: voip.currentCall?.room.client.userID?.split(':').first,
|
||||||
videoWidget: hasLocalVideo ? rtc.RTCVideoView(voip.localRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null,
|
videoWidget: hasLocalVideo ? rtc.RTCVideoView(voip.localRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null,
|
||||||
isMuted: voip.isMicMuted,
|
isMuted: voip.isMicMuted,
|
||||||
isSpeaking: voip.isLocalSpeaking,
|
isSpeaking: voip.isLocalSpeaking,
|
||||||
|
|||||||
@@ -111,13 +111,13 @@ class _ChatHeader extends ConsumerWidget {
|
|||||||
PyrIconBtn(
|
PyrIconBtn(
|
||||||
size: 28,
|
size: 28,
|
||||||
icon: const Icon(Icons.phone_rounded),
|
icon: const Icon(Icons.phone_rounded),
|
||||||
tooltip: 'Voice call',
|
tooltip: 'Anrufen',
|
||||||
onPressed: () => _startCall(ref, room, cam: false),
|
onPressed: () => _startCall(ref, room, cam: false),
|
||||||
),
|
),
|
||||||
PyrIconBtn(
|
PyrIconBtn(
|
||||||
size: 28,
|
size: 28,
|
||||||
icon: const Icon(Icons.videocam_rounded),
|
icon: const Icon(Icons.videocam_rounded),
|
||||||
tooltip: 'Video call',
|
tooltip: 'Videoanruf',
|
||||||
onPressed: () => _startCall(ref, room, cam: true),
|
onPressed: () => _startCall(ref, room, cam: true),
|
||||||
),
|
),
|
||||||
if (size.width > 700)
|
if (size.width > 700)
|
||||||
@@ -129,12 +129,6 @@ class _ChatHeader extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (size.width > 800) ...[
|
if (size.width > 800) ...[
|
||||||
PyrIconBtn(
|
|
||||||
size: 28,
|
|
||||||
icon: const Icon(Icons.inbox_rounded),
|
|
||||||
tooltip: 'Threads',
|
|
||||||
onPressed: () {},
|
|
||||||
),
|
|
||||||
PyrIconBtn(
|
PyrIconBtn(
|
||||||
size: 28,
|
size: 28,
|
||||||
icon: const Icon(Icons.push_pin_rounded),
|
icon: const Icon(Icons.push_pin_rounded),
|
||||||
@@ -557,13 +551,13 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
|
|||||||
PyrIconBtn(
|
PyrIconBtn(
|
||||||
size: 28,
|
size: 28,
|
||||||
icon: const Icon(Icons.phone_rounded),
|
icon: const Icon(Icons.phone_rounded),
|
||||||
tooltip: 'Voice call',
|
tooltip: 'Anrufen',
|
||||||
onPressed: () => _startCall(cam: false),
|
onPressed: () => _startCall(cam: false),
|
||||||
),
|
),
|
||||||
PyrIconBtn(
|
PyrIconBtn(
|
||||||
size: 28,
|
size: 28,
|
||||||
icon: const Icon(Icons.videocam_rounded),
|
icon: const Icon(Icons.videocam_rounded),
|
||||||
tooltip: 'Video call',
|
tooltip: 'Videoanruf',
|
||||||
onPressed: () => _startCall(cam: true),
|
onPressed: () => _startCall(cam: true),
|
||||||
),
|
),
|
||||||
if (size.width > 700)
|
if (size.width > 700)
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ class _MessageList extends StatelessWidget {
|
|||||||
Icon(Icons.forum_outlined, size: 48, color: pt.fgDim),
|
Icon(Icons.forum_outlined, size: 48, color: pt.fgDim),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'No messages yet',
|
'Noch keine Nachrichten – schreib die erste!',
|
||||||
style: TextStyle(color: pt.fgMuted, fontSize: 14),
|
style: TextStyle(color: pt.fgMuted, fontSize: 14),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ class NoChatSelected extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Choose from the sidebar on the left',
|
'Links findest du alle Chats und Räume.',
|
||||||
style: TextStyle(color: pt.fgDim, fontSize: 13),
|
style: TextStyle(color: pt.fgDim, fontSize: 13),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -535,7 +535,7 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
),
|
),
|
||||||
error: (e, _) => Center(
|
error: (e, _) => Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Could not load messages',
|
'Nachrichten konnten nicht geladen werden.',
|
||||||
style: TextStyle(color: pt.fgDim, fontSize: 14),
|
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:matrix/matrix.dart';
|
||||||
import 'package:pyramid/core/matrix_client.dart';
|
import 'package:pyramid/core/matrix_client.dart';
|
||||||
import 'package:pyramid/core/theme.dart';
|
import 'package:pyramid/core/theme.dart';
|
||||||
|
import 'package:pyramid/widgets/mxc_image.dart';
|
||||||
import 'package:pyramid/widgets/pyramid_logo.dart';
|
import 'package:pyramid/widgets/pyramid_logo.dart';
|
||||||
|
|
||||||
class MembersPanel extends ConsumerWidget {
|
class MembersPanel extends ConsumerWidget {
|
||||||
@@ -94,7 +95,7 @@ class MembersPanel extends ConsumerWidget {
|
|||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(top: 24),
|
padding: const EdgeInsets.only(top: 24),
|
||||||
child: Text(
|
child: Text(
|
||||||
'No members loaded',
|
'Keine Mitglieder geladen',
|
||||||
style: TextStyle(color: pt.fgDim, fontSize: 12),
|
style: TextStyle(color: pt.fgDim, fontSize: 12),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -177,19 +178,22 @@ class _MemberItemState extends State<_MemberItem> {
|
|||||||
opacity: widget.isOffline ? 0.5 : 1,
|
opacity: widget.isOffline ? 0.5 : 1,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
// Rund wie überall sonst, mit echtem Profilbild.
|
||||||
width: 28,
|
MxcAvatar(
|
||||||
height: 28,
|
mxcUri: member.avatarUrl,
|
||||||
decoration: BoxDecoration(
|
client: member.room.client,
|
||||||
color: color,
|
size: 32,
|
||||||
borderRadius: BorderRadius.circular(pt.rSm),
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
placeholder: (_) => Container(
|
||||||
child: Center(
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||||
|
alignment: Alignment.center,
|
||||||
child: Text(
|
child: Text(
|
||||||
initial,
|
initial,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 11,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -214,7 +218,7 @@ class _MemberItemState extends State<_MemberItem> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
name,
|
member.id == member.room.client.userID ? '$name (Du)' : name,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: widget.isOffline ? pt.fgMuted : pt.fg,
|
color: widget.isOffline ? pt.fgMuted : pt.fg,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
error: (e, _) => Center(
|
error: (e, _) => Center(
|
||||||
child: Text('Error',
|
child: Text('Fehler',
|
||||||
style: TextStyle(color: pt.fgDim, fontSize: 12)),
|
style: TextStyle(color: pt.fgDim, fontSize: 12)),
|
||||||
),
|
),
|
||||||
data: (rooms) => _RoomsList(
|
data: (rooms) => _RoomsList(
|
||||||
@@ -136,7 +136,11 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
|
|||||||
final hasVoipCall = voip.currentCall != null &&
|
final hasVoipCall = voip.currentCall != null &&
|
||||||
voip.currentCall!.state != CallState.kEnded &&
|
voip.currentCall!.state != CallState.kEnded &&
|
||||||
voip.currentCall!.state != CallState.kRinging;
|
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 =
|
final isLandscapeCompact =
|
||||||
MediaQuery.sizeOf(context).height < 500;
|
MediaQuery.sizeOf(context).height < 500;
|
||||||
|
|
||||||
@@ -147,8 +151,8 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (call.isActive) MiniCallWidget(call: call),
|
if (call.isActive && !callViewOpen) MiniCallWidget(call: call),
|
||||||
if (hasVoipCall) MiniCallWidget(call: voip),
|
if (hasVoipCall && !callViewOpen) MiniCallWidget(call: voip),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:matrix/matrix.dart';
|
import 'package:matrix/matrix.dart';
|
||||||
import 'package:pyramid/core/app_state.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/matrix_client.dart';
|
||||||
|
import 'package:pyramid/core/room_kinds.dart';
|
||||||
import 'package:pyramid/core/theme.dart';
|
import 'package:pyramid/core/theme.dart';
|
||||||
import 'package:pyramid/features/rooms/rooms_provider.dart';
|
import 'package:pyramid/features/rooms/rooms_provider.dart';
|
||||||
import 'package:pyramid/widgets/create_join_dialog.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
|
// Switch space and restore last visited room for the new space
|
||||||
ref.read(activeSpaceIdProvider.notifier).state = id;
|
ref.read(activeSpaceIdProvider.notifier).state = id;
|
||||||
ref.read(activeRoomIdProvider.notifier).state =
|
var target = ref.read(lastRoomPerSpaceProvider)[id];
|
||||||
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);
|
final scaffold = Scaffold.maybeOf(context);
|
||||||
if (scaffold != null && scaffold.isDrawerOpen) {
|
if (scaffold != null && scaffold.isDrawerOpen) {
|
||||||
scaffold.closeDrawer();
|
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/features/spaces/spaces_rail.dart';
|
||||||
import 'package:pyramid/layout/mobile_page_stack.dart';
|
import 'package:pyramid/layout/mobile_page_stack.dart';
|
||||||
import 'package:pyramid/widgets/spring_press.dart' show motionDuration;
|
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/search_modal.dart';
|
||||||
import 'package:pyramid/widgets/settings_modal.dart';
|
import 'package:pyramid/widgets/settings_modal.dart';
|
||||||
import 'package:pyramid/widgets/share_target_dialog.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;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final pt = PyramidTheme.of(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
|
// würde der Nutzer permanent zurück in die Call-UI gezwungen und könnte
|
||||||
// sie nie minimieren.
|
// sie nie minimieren.
|
||||||
ref.listen(callSignalingProvider, (prev, next) {
|
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 wasConnected = _wasVoipConnected;
|
||||||
final isConnected = next.currentCall != null &&
|
final isConnected = next.currentCall != null &&
|
||||||
next.currentCall!.state == CallState.kConnected;
|
next.currentCall!.state == CallState.kConnected;
|
||||||
@@ -176,6 +221,12 @@ class _AppShellState extends ConsumerState<AppShell>
|
|||||||
});
|
});
|
||||||
|
|
||||||
ref.listen(voiceChannelProvider, (prev, next) {
|
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
|
// Only auto-switch to voice view for video/audio calls, NOT voice channels
|
||||||
// (voice channels should stay in background with a mini bar)
|
// (voice channels should stay in background with a mini bar)
|
||||||
final wasActive = _wasLiveKitActive;
|
final wasActive = _wasLiveKitActive;
|
||||||
@@ -519,18 +570,27 @@ class _IncomingCallOverlay extends ConsumerWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
MxcAvatar(
|
||||||
width: 80,
|
mxcUri: call.room.avatar,
|
||||||
height: 80,
|
client: call.room.client,
|
||||||
decoration: BoxDecoration(
|
size: 88,
|
||||||
color: pt.accent,
|
placeholder: (_) => Container(
|
||||||
shape: BoxShape.circle,
|
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),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
'Eingehender Anruf',
|
call.type == CallType.kVideo ? 'Eingehender Videoanruf' : 'Eingehender Anruf',
|
||||||
style: TextStyle(color: pt.fgDim, fontSize: 13),
|
style: TextStyle(color: pt.fgDim, fontSize: 13),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ class _SettingsModalState extends ConsumerState<SettingsModal> {
|
|||||||
child: ListView(
|
child: ListView(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
children: [
|
children: [
|
||||||
for (final c in _categories)
|
for (final c in _categories.where((c) => c.$1 != 'keys'))
|
||||||
_MobileCategoryTile(
|
_MobileCategoryTile(
|
||||||
label: c.$2,
|
label: c.$2,
|
||||||
icon: c.$3,
|
icon: c.$3,
|
||||||
|
|||||||
@@ -135,6 +135,12 @@ if ($Ui) {
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
Remove-Item Env:PYRAMID_PROFILE_DIR -ErrorAction SilentlyContinue
|
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)"
|
Write-Host "Screenshots: build/ui_screens/ (Testinhalte ggf. mit test/live_ui_fixture_test.dart anlegen)"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user