Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
391ebf0ffa | ||
|
|
de24053167 | ||
|
|
87b472a373 | ||
|
|
842dd61031 |
+18
@@ -13,6 +13,24 @@ Schritt (und beim Abbruch mitten im Schritt den Zwischenstand). Format:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-10-08 (Teil 3) – Taub/Stumm, Mini-Leiste, HDR-Befund → 0.1.41
|
||||||
|
|
||||||
|
**Erledigt (f46cc22, Version 5204bb8, Gitea-Vorabversion v0.1.41):** Bernd hört
|
||||||
|
sich jetzt auf beiden Seiten. Behoben: Taub-Knopf war bei 1:1 wirkungslos
|
||||||
|
(nur Häkchen) → Empfangsspuren aus + selbst stumm wie Discord; Stumm-Knopf
|
||||||
|
schaltete bei Fehler im Status-Senden blind zurück (zeigte dann das
|
||||||
|
Gegenteil) → echter SDK-Zustand; Mini-Anrufleiste erschien schon beim
|
||||||
|
Klingeln. Live-Anruftest drückt Stumm/Taub über die echten Knöpfe.
|
||||||
|
|
||||||
|
**HDR-Befund:** Blasses/zu helles Bild beim Teilen unter Windows mit HDR ist
|
||||||
|
eine bekannte Grenze der WebRTC-DXGI-Aufnahme (Zoom/Discord gleich; Chrome
|
||||||
|
erst mit Windows Graphics Capture ab Win11 24H2). flutter_webrtc 1.4.1 /
|
||||||
|
libwebrtc-Wrapper bietet keine Capture-Optionen (`CreateDesktopCapturer(source,
|
||||||
|
showCursor)`). Jetzt Hinweis im Auswähler (Fenster teilen oder Win+Alt+B).
|
||||||
|
Echte Lösung = eigenes natives WGC-Aufnahmemodul mit Tonemapping → ROADMAP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-10-08 (Teil 2) – Anrufe im Live-Test nachgestellt: Teilen, Verbindung, Sprech-Ring → 0.1.40
|
## 2026-10-08 (Teil 2) – Anrufe im Live-Test nachgestellt: Teilen, Verbindung, Sprech-Ring → 0.1.40
|
||||||
|
|
||||||
**Erledigt (8074928, Version 064d730, Gitea-Vorabversion v0.1.40):**
|
**Erledigt (8074928, Version 064d730, Gitea-Vorabversion v0.1.40):**
|
||||||
|
|||||||
@@ -317,6 +317,12 @@ lassen, ohne dass andere Module angefasst werden müssen.
|
|||||||
|
|
||||||
## M3 – Calls: Logik, UI & Audio (Discord als Referenz)
|
## M3 – Calls: Logik, UI & Audio (Discord als Referenz)
|
||||||
|
|
||||||
|
- [ ] **Bildschirm teilen mit HDR (Windows):** WebRTC-DXGI-Aufnahme liefert bei
|
||||||
|
eingeschaltetem HDR ein blasses Bild (bekannte Grenze, Zoom/Discord gleich).
|
||||||
|
Lösung: eigenes Aufnahmemodul über Windows Graphics Capture mit
|
||||||
|
HDR→SDR-Tonemapping (wie Chrome ab Win11 24H2) oder neuere libwebrtc mit
|
||||||
|
WGC-Option. Bis dahin Hinweis im Auswähler (Fenster teilen / Win+Alt+B).
|
||||||
|
|
||||||
- [ ] Voice-Channel-Logik robust machen: Beitreten/Verlassen/Wechseln ohne Hänger,
|
- [ ] Voice-Channel-Logik robust machen: Beitreten/Verlassen/Wechseln ohne Hänger,
|
||||||
klarer Zustand wer drin ist, sauberes Verhalten bei Verbindungsabbruch
|
klarer Zustand wer drin ist, sauberes Verhalten bei Verbindungsabbruch
|
||||||
**Teilfortschritt 2026-07-06 (Nacht, mit 2 echten Test-Accounts durchgeklickt):**
|
**Teilfortschritt 2026-07-06 (Nacht, mit 2 echten Test-Accounts durchgeklickt):**
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -205,7 +225,7 @@ void main() {
|
|||||||
|
|
||||||
// ── Gruppenraum (Namen + Avatare in den Sprechblasen) ──
|
// ── Gruppenraum (Namen + Avatare in den Sprechblasen) ──
|
||||||
container.read(activeRoomIdProvider.notifier).state = null;
|
container.read(activeRoomIdProvider.notifier).state = null;
|
||||||
container.read(activeSpaceIdProvider.notifier).state = 'rooms';
|
container.read(activeSpaceIdProvider.notifier).state = 'dms';
|
||||||
await _pumpUntil(
|
await _pumpUntil(
|
||||||
tester,
|
tester,
|
||||||
find.text(_groupName),
|
find.text(_groupName),
|
||||||
|
|||||||
@@ -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,413 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:integration_test/integration_test.dart';
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
import 'package:pyramid/core/app_state.dart';
|
||||||
|
import 'package:livekit_client/livekit_client.dart' as lk;
|
||||||
|
import 'package:pyramid/core/livekit_token.dart';
|
||||||
|
import 'package:pyramid/core/matrix_client.dart';
|
||||||
|
import 'package:pyramid/core/settings_prefs.dart' show bottomNavProvider;
|
||||||
|
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
||||||
|
import 'package:pyramid/features/call_ui/voice_channel.dart' show CallBubblesDemo;
|
||||||
|
import 'package:pyramid/features/group_call/group_call.dart';
|
||||||
|
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
|
||||||
|
import 'package:pyramid/layout/mobile_tabs.dart';
|
||||||
|
import 'package:pyramid/main.dart' as app;
|
||||||
|
|
||||||
|
import 'support/test_partner.dart';
|
||||||
|
|
||||||
|
/// UI-Tour für das UX-Audit (Bernd 2026-10-08): fährt alle wichtigen
|
||||||
|
/// Zustände ab und speichert jeden als Screenshot im Handy- UND im
|
||||||
|
/// PC-Format nach build/ui_audit/ (p_* = Handy, d_* = PC).
|
||||||
|
///
|
||||||
|
/// Testkonten: App = pyramidtest1 (Testprofil), Gegenseite = pyramidtest2
|
||||||
|
/// (kurzlebiges Gerät). Legt beim ersten Lauf einen Test-Space mit Text- und
|
||||||
|
/// Sprachkanal an (bleibt für spätere Touren bestehen) und für den Anruf einen
|
||||||
|
/// Wegwerf-Raum. Nutzt kurz Mikrofon (Anruf, Sprachkanal).
|
||||||
|
///
|
||||||
|
/// $env:PYRAMID_PROFILE_DIR="$env:USERPROFILE\.pyramid-autopilot\profile1"
|
||||||
|
/// flutter test integration_test/ui_audit_tour_test.dart -d windows
|
||||||
|
const _spaceName = 'UI-Test Space';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
final profile = Platform.environment['PYRAMID_PROFILE_DIR'];
|
||||||
|
final accounts = loadTestAccounts();
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'UI-Tour: alle Zustände, Handy + PC',
|
||||||
|
(tester) async {
|
||||||
|
void phone() => tester.view
|
||||||
|
..physicalSize = const Size(1080, 2340)
|
||||||
|
..devicePixelRatio = 2.625
|
||||||
|
..padding = const FakeViewPadding(top: 63, bottom: 42)
|
||||||
|
..viewPadding = const FakeViewPadding(top: 63, bottom: 42);
|
||||||
|
void desktop() => tester.view
|
||||||
|
..physicalSize = const Size(1600, 1000)
|
||||||
|
..devicePixelRatio = 1.0
|
||||||
|
..padding = FakeViewPadding.zero
|
||||||
|
..viewPadding = FakeViewPadding.zero;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
phone();
|
||||||
|
|
||||||
|
// Flüssig pumpen (~60 Bilder/s), damit Animationen wie auf dem Gerät
|
||||||
|
// zur Ruhe kommen (pumpFor zeichnet nur alle 100 ms).
|
||||||
|
Future<void> smooth(Duration d) async {
|
||||||
|
final end = DateTime.now().add(d);
|
||||||
|
while (DateTime.now().isBefore(end)) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> both(String name, {Duration settle = const Duration(milliseconds: 1500)}) async {
|
||||||
|
phone();
|
||||||
|
await smooth(settle);
|
||||||
|
await _shot(tester, 'p_$name');
|
||||||
|
desktop();
|
||||||
|
await smooth(settle);
|
||||||
|
await _shot(tester, 'd_$name');
|
||||||
|
phone();
|
||||||
|
await pumpFor(tester, const Duration(milliseconds: 400));
|
||||||
|
}
|
||||||
|
|
||||||
|
// app.main() ersetzt FlutterError.onError – danach den Empfänger des
|
||||||
|
// Testrahmens zurücksetzen, sonst verschwinden Fehlermeldungen.
|
||||||
|
final testOnError = FlutterError.onError;
|
||||||
|
app.main();
|
||||||
|
await pumpUntil(tester, () => find.byType(MaterialApp).evaluate().isNotEmpty);
|
||||||
|
FlutterError.onError = testOnError;
|
||||||
|
final container = ProviderScope.containerOf(
|
||||||
|
tester.element(find.byType(MaterialApp).first),
|
||||||
|
);
|
||||||
|
final me = (await tester.runAsync(
|
||||||
|
() => container.read(matrixClientProvider.future),
|
||||||
|
))!;
|
||||||
|
await pumpFor(tester, const Duration(seconds: 5));
|
||||||
|
|
||||||
|
final partner = (await tester.runAsync(
|
||||||
|
() => TestPartner.start(accounts!['user2']!, accounts['pass2']!),
|
||||||
|
))!;
|
||||||
|
String? callRoomId;
|
||||||
|
addTearDown(() => tester.runAsync(() async {
|
||||||
|
await partner.dispose(callRoomId);
|
||||||
|
final r = callRoomId == null ? null : me.getRoomById(callRoomId);
|
||||||
|
await r?.leave().catchError((_) {});
|
||||||
|
await r?.forget().catchError((_) {});
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Fixture: Test-Space mit Text- und Sprachkanal (einmalig) ──
|
||||||
|
var space = me.rooms.where((r) => r.isSpace && r.name == _spaceName).firstOrNull;
|
||||||
|
if (space == null) {
|
||||||
|
logStep('lege Test-Space an');
|
||||||
|
final ids = (await tester.runAsync(() async {
|
||||||
|
final c = partner.client;
|
||||||
|
final spaceId = await c.createRoom(
|
||||||
|
name: _spaceName,
|
||||||
|
creationContent: {'type': 'm.space'},
|
||||||
|
preset: CreateRoomPreset.privateChat,
|
||||||
|
invite: [me.userID!],
|
||||||
|
);
|
||||||
|
final textId = await c.createRoom(
|
||||||
|
name: 'allgemein',
|
||||||
|
preset: CreateRoomPreset.privateChat,
|
||||||
|
invite: [me.userID!],
|
||||||
|
);
|
||||||
|
final voiceId = await c.createRoom(
|
||||||
|
name: 'Sprachkanal',
|
||||||
|
preset: CreateRoomPreset.privateChat,
|
||||||
|
creationContent: {'type': 'io.pyramid.voice_channel'},
|
||||||
|
invite: [me.userID!],
|
||||||
|
);
|
||||||
|
final pl = Map<String, dynamic>.from(
|
||||||
|
await c.getRoomStateWithKey(voiceId, EventTypes.RoomPowerLevels, ''),
|
||||||
|
);
|
||||||
|
pl['events'] = {
|
||||||
|
...Map<String, dynamic>.from(pl['events'] as Map? ?? {}),
|
||||||
|
'io.pyramid.voice.presence': 0,
|
||||||
|
};
|
||||||
|
await c.setRoomStateWithKey(voiceId, EventTypes.RoomPowerLevels, '', pl);
|
||||||
|
for (final child in [textId, voiceId]) {
|
||||||
|
await c.setRoomStateWithKey(spaceId, 'm.space.child', child, {
|
||||||
|
'via': ['steggi-matrix.work'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await c.sendMessage(textId, EventTypes.Message,
|
||||||
|
'm${DateTime.now().microsecondsSinceEpoch}',
|
||||||
|
{'msgtype': 'm.text', 'body': 'Willkommen im Test-Space 👋'});
|
||||||
|
return [spaceId, textId, voiceId];
|
||||||
|
}))!;
|
||||||
|
for (final id in ids) {
|
||||||
|
await pumpUntil(tester, () => me.getRoomById(id)?.membership == Membership.invite,
|
||||||
|
what: 'Einladung $id');
|
||||||
|
await tester.runAsync(() => me.getRoomById(id)!.join());
|
||||||
|
}
|
||||||
|
await pumpUntil(tester, () =>
|
||||||
|
me.rooms.any((r) => r.isSpace && r.name == _spaceName && r.membership == Membership.join),
|
||||||
|
what: 'Space beigetreten');
|
||||||
|
space = me.rooms.firstWhere((r) => r.isSpace && r.name == _spaceName);
|
||||||
|
await pumpFor(tester, const Duration(seconds: 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1. Listen ──
|
||||||
|
container.read(activeSpaceIdProvider.notifier).state = 'dms';
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = null;
|
||||||
|
await both('01_dms');
|
||||||
|
tester.state<ScaffoldState>(find.byType(Scaffold).first).openDrawer();
|
||||||
|
await pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
await _shot(tester, 'p_02_drawer');
|
||||||
|
await tester.tapAt(const Offset(390, 400));
|
||||||
|
await pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
final groupsChip = find.text('Gruppen');
|
||||||
|
if (groupsChip.evaluate().isNotEmpty) {
|
||||||
|
await tester.tap(groupsChip.first);
|
||||||
|
await both('03_chats_gruppen');
|
||||||
|
await tester.tap(find.text('Alle').first);
|
||||||
|
await pumpFor(tester, const Duration(milliseconds: 500));
|
||||||
|
}
|
||||||
|
container.read(activeSpaceIdProvider.notifier).state = space.id;
|
||||||
|
await both('04_space');
|
||||||
|
|
||||||
|
// ── 2. Chats ──
|
||||||
|
final dm = me.rooms.firstWhere((r) =>
|
||||||
|
r.membership == Membership.join &&
|
||||||
|
r.directChatMatrixID == '@pyramidtest2:steggi-matrix.work');
|
||||||
|
container.read(activeSpaceIdProvider.notifier).state = 'dms';
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = dm.id;
|
||||||
|
await both('05_dm_chat', settle: const Duration(seconds: 3));
|
||||||
|
final group = me.rooms.firstWhere((r) => r.getLocalizedDisplayname() == 'UI-Test Gruppe');
|
||||||
|
container.read(activeSpaceIdProvider.notifier).state = 'dms';
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = group.id;
|
||||||
|
await both('06_gruppe', settle: const Duration(seconds: 3));
|
||||||
|
container.read(membersPanelOpenProvider.notifier).state = true;
|
||||||
|
await both('07_mitglieder');
|
||||||
|
container.read(membersPanelOpenProvider.notifier).state = false;
|
||||||
|
final textRoom = space.spaceChildren
|
||||||
|
.map((c) => c.roomId == null ? null : me.getRoomById(c.roomId!))
|
||||||
|
.whereType<Room>()
|
||||||
|
.firstWhere((r) => r.getLocalizedDisplayname() == 'allgemein');
|
||||||
|
container.read(activeSpaceIdProvider.notifier).state = space.id;
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = textRoom.id;
|
||||||
|
await both('08_space_textkanal', settle: const Duration(seconds: 3));
|
||||||
|
|
||||||
|
// ── 3. Modale ──
|
||||||
|
container.read(activeModalProvider.notifier).state = ModalKind.settings;
|
||||||
|
await both('09_einstellungen');
|
||||||
|
container.read(activeModalProvider.notifier).state = ModalKind.search;
|
||||||
|
await both('10_suche');
|
||||||
|
container.read(activeModalProvider.notifier).state = ModalKind.none;
|
||||||
|
|
||||||
|
// ── 4. Sackgasse: Anruf-Ansicht ohne Anruf ──
|
||||||
|
container.read(viewModeProvider.notifier).state = ViewMode.voice;
|
||||||
|
await both('11_anrufansicht_ohne_anruf');
|
||||||
|
container.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||||
|
|
||||||
|
// ── 5. Sprachkanal beitreten (echter Tipp in der Space-Liste) ──
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = null;
|
||||||
|
container.read(activeSpaceIdProvider.notifier).state = space.id;
|
||||||
|
await pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
final voiceTile = find.text('Sprachkanal');
|
||||||
|
if (voiceTile.evaluate().isNotEmpty) {
|
||||||
|
await tester.tap(voiceTile.first);
|
||||||
|
final lk = container.read(voiceChannelProvider);
|
||||||
|
await pumpUntil(tester, () => lk.isActive && !lk.isConnecting,
|
||||||
|
timeout: const Duration(seconds: 30), what: 'Sprachkanal verbunden');
|
||||||
|
await both('12_sprachkanal_verbunden_liste', settle: const Duration(seconds: 2));
|
||||||
|
container.read(viewModeProvider.notifier).state = ViewMode.voice;
|
||||||
|
await both('13_sprachkanal_ansicht', settle: const Duration(seconds: 2));
|
||||||
|
container.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||||
|
await tester.runAsync(() => lk.hangUp());
|
||||||
|
await pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
} else {
|
||||||
|
logStep('Sprachkanal nicht in der Liste gefunden');
|
||||||
|
await _shot(tester, 'p_12_sprachkanal_fehlt');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 6. 1:1-Anruf: klingelt, läuft, minimiert ──
|
||||||
|
callRoomId = await tester.runAsync(() => partner.client.createRoom(
|
||||||
|
invite: [me.userID!],
|
||||||
|
preset: CreateRoomPreset.privateChat,
|
||||||
|
name: 'pyramidtest2 (Anruftest)',
|
||||||
|
));
|
||||||
|
await pumpUntil(tester, () => me.getRoomById(callRoomId!)?.membership == Membership.invite);
|
||||||
|
await tester.runAsync(() => me.getRoomById(callRoomId!)!.join());
|
||||||
|
await pumpFor(tester, const Duration(seconds: 3));
|
||||||
|
container.read(activeSpaceIdProvider.notifier).state = 'dms';
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = callRoomId;
|
||||||
|
await pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
final voip = container.read(callSignalingProvider);
|
||||||
|
await tester.runAsync(() => partner.run(() => partner.voip.inviteToCall(
|
||||||
|
partner.client.getRoomById(callRoomId!)!,
|
||||||
|
CallType.kVoice,
|
||||||
|
)));
|
||||||
|
final answerBtn = find.byWidgetPredicate(
|
||||||
|
(w) => w is FloatingActionButton && w.heroTag == 'answer_call',
|
||||||
|
);
|
||||||
|
await pumpUntil(tester, () => answerBtn.evaluate().isNotEmpty, what: 'klingelt');
|
||||||
|
await both('14_anruf_klingelt');
|
||||||
|
await tester.tap(answerBtn.first);
|
||||||
|
await pumpUntil(tester, () => voip.currentCall?.state == CallState.kConnected,
|
||||||
|
what: 'verbunden');
|
||||||
|
await both('15_anruf_laeuft', settle: const Duration(seconds: 2));
|
||||||
|
container.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||||
|
await both('16_anruf_minimiert_chat');
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = null;
|
||||||
|
await both('17_anruf_minimiert_liste');
|
||||||
|
// Gegenseite legt auf → wo landet man?
|
||||||
|
container.read(viewModeProvider.notifier).state = ViewMode.voice;
|
||||||
|
await pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
await tester.runAsync(() => partner.run(
|
||||||
|
() => partner.delegate.call!.hangup(reason: CallErrorCode.userHangup)));
|
||||||
|
await pumpUntil(tester, () => voip.currentCall == null, what: 'aufgelegt');
|
||||||
|
// Keine Sackgasse mehr: automatisch zurück zum Chat (UI-Audit S1).
|
||||||
|
await pumpUntil(
|
||||||
|
tester,
|
||||||
|
() => container.read(viewModeProvider) == ViewMode.chat,
|
||||||
|
timeout: const Duration(seconds: 10),
|
||||||
|
what: 'nach Auflegen zurück im Chat',
|
||||||
|
);
|
||||||
|
await pumpFor(tester, const Duration(seconds: 1)); // Ausgleiten abwarten
|
||||||
|
expect(find.text('Kein Anruf'), findsNothing);
|
||||||
|
await both('18_nach_auflegen_durch_gegenseite');
|
||||||
|
container.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||||
|
|
||||||
|
// ── 7. Gruppenanruf: Beitreten-Karte, Anruf mit zwei Personen ──
|
||||||
|
// Anwesenheit für alle erlauben (wer darf, ändert die Rechte).
|
||||||
|
await tester.runAsync(() async {
|
||||||
|
for (final c in [me, partner.client]) {
|
||||||
|
final r = c.getRoomById(group.id);
|
||||||
|
if (r != null && r.canChangePowerLevel) {
|
||||||
|
await allowVoicePresenceForAll(c, group.id).catchError((_) {});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
Future<void> partnerPresence(bool active) => tester.runAsync(() =>
|
||||||
|
partner.client.setRoomStateWithKey(
|
||||||
|
group.id,
|
||||||
|
'io.pyramid.voice.presence',
|
||||||
|
partner.client.userID!,
|
||||||
|
active
|
||||||
|
? {
|
||||||
|
'active': true,
|
||||||
|
'joined_at': DateTime.now().millisecondsSinceEpoch,
|
||||||
|
'display_name': 'pyramidtest2',
|
||||||
|
}
|
||||||
|
: {'active': false},
|
||||||
|
).then((_) {}));
|
||||||
|
var presenceWritten = false;
|
||||||
|
try {
|
||||||
|
await partnerPresence(true);
|
||||||
|
presenceWritten = true;
|
||||||
|
} catch (e) {
|
||||||
|
logStep('Gegenseite darf keine Anwesenheit schreiben: $e');
|
||||||
|
}
|
||||||
|
container.read(activeSpaceIdProvider.notifier).state = 'dms';
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = group.id;
|
||||||
|
if (presenceWritten) {
|
||||||
|
await pumpUntil(tester, () => find.text('Gruppenanruf läuft').evaluate().isNotEmpty,
|
||||||
|
timeout: const Duration(seconds: 20), what: 'Beitreten-Karte');
|
||||||
|
}
|
||||||
|
await both('19_gruppenanruf_karte', settle: const Duration(seconds: 2));
|
||||||
|
|
||||||
|
// Gegenseite tritt dem LiveKit-Raum wirklich bei (zweite Bubble).
|
||||||
|
lk.Room? partnerLk;
|
||||||
|
try {
|
||||||
|
partnerLk = await tester.runAsync(() async {
|
||||||
|
final t = await LiveKitTokenService.fetch(
|
||||||
|
room: group.id,
|
||||||
|
matrixToken: partner.client.accessToken!,
|
||||||
|
);
|
||||||
|
final r = lk.Room();
|
||||||
|
await r.connect(t.url, t.token);
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logStep('Gegenseite konnte LiveKit nicht beitreten: $e');
|
||||||
|
}
|
||||||
|
final lkCall = container.read(voiceChannelProvider);
|
||||||
|
await tester.tap(find.byTooltip('Gruppenanruf beitreten').evaluate().isNotEmpty
|
||||||
|
? find.byTooltip('Gruppenanruf beitreten').first
|
||||||
|
: find.byTooltip('Gruppenanruf').first);
|
||||||
|
await pumpUntil(tester, () => lkCall.isActive && !lkCall.isConnecting,
|
||||||
|
timeout: const Duration(seconds: 30), what: 'Gruppenanruf verbunden');
|
||||||
|
await both('20_gruppenanruf_bubbles', settle: const Duration(seconds: 3));
|
||||||
|
await tester.tap(find.byTooltip('Kachelansicht').first);
|
||||||
|
await both('21_gruppenanruf_kacheln', settle: const Duration(seconds: 2));
|
||||||
|
await tester.tap(find.byTooltip('Bubble-Ansicht').first);
|
||||||
|
await pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
container.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||||
|
await both('22_gruppenanruf_minimiert_chat', settle: const Duration(seconds: 2));
|
||||||
|
await tester.runAsync(() => lkCall.hangUp());
|
||||||
|
await tester.runAsync(() async => partnerLk?.disconnect());
|
||||||
|
if (presenceWritten) await partnerPresence(false);
|
||||||
|
await pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
|
||||||
|
// ── 8. Bubbles mit mehreren Personen (Vorführ-Daten) ──
|
||||||
|
final overlay = Overlay.of(
|
||||||
|
tester.element(find.byType(Scaffold).first),
|
||||||
|
rootOverlay: true,
|
||||||
|
);
|
||||||
|
final demo = OverlayEntry(builder: (_) => const CallBubblesDemo());
|
||||||
|
overlay.insert(demo);
|
||||||
|
await both('23_bubbles_5_personen', settle: const Duration(seconds: 3));
|
||||||
|
demo.remove();
|
||||||
|
final demo7 = OverlayEntry(builder: (_) => const CallBubblesDemo(count: 7));
|
||||||
|
overlay.insert(demo7);
|
||||||
|
await both('24_bubbles_7_personen', settle: const Duration(seconds: 3));
|
||||||
|
demo7.remove();
|
||||||
|
await pumpFor(tester, const Duration(milliseconds: 500));
|
||||||
|
|
||||||
|
// ── 9. Handy: untere Leiste statt Seitenmenü (Vergleich) ──
|
||||||
|
phone();
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = null;
|
||||||
|
await tester.runAsync(() => container.read(bottomNavProvider.notifier).set(true));
|
||||||
|
container.read(mobileTabProvider.notifier).state = MobileTab.chats;
|
||||||
|
container.read(activeSpaceIdProvider.notifier).state = 'dms';
|
||||||
|
await pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
await _shot(tester, 'p_30_unten_chats');
|
||||||
|
await tester.tap(find.text('Server').last);
|
||||||
|
await pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
await _shot(tester, 'p_31_unten_server');
|
||||||
|
await tester.tap(find.text(_spaceName).first);
|
||||||
|
await pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
await _shot(tester, 'p_32_unten_server_kanaele');
|
||||||
|
await tester.tap(find.text('Du').last);
|
||||||
|
await pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
await _shot(tester, 'p_33_unten_du');
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = group.id;
|
||||||
|
await pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
await _shot(tester, 'p_34_unten_chat_offen');
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = null;
|
||||||
|
await pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
await tester.tap(find.text('Chats').last);
|
||||||
|
await pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
await tester.runAsync(() => container.read(bottomNavProvider.notifier).set(false));
|
||||||
|
await pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
},
|
||||||
|
skip: profile == null || accounts == null,
|
||||||
|
timeout: const Timeout(Duration(minutes: 12)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _shot(WidgetTester tester, String name) async {
|
||||||
|
await tester.pump();
|
||||||
|
final renderView = tester.binding.renderViews.first;
|
||||||
|
final layer = renderView.debugLayer! as OffsetLayer;
|
||||||
|
final physical = tester.view.physicalSize;
|
||||||
|
final image = await tester.binding.runAsync(
|
||||||
|
() => layer.toImage(Offset.zero & physical),
|
||||||
|
);
|
||||||
|
final bytes = await tester.binding.runAsync(
|
||||||
|
() => image!.toByteData(format: ui.ImageByteFormat.png),
|
||||||
|
);
|
||||||
|
final file = File('build/ui_audit/$name.png');
|
||||||
|
file.parent.createSync(recursive: true);
|
||||||
|
file.writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||||
|
logStep('Screenshot $name');
|
||||||
|
}
|
||||||
@@ -69,6 +69,10 @@ enum ModalKind { none, settings, search }
|
|||||||
|
|
||||||
final activeModalProvider = StateProvider<ModalKind>((ref) => ModalKind.none);
|
final activeModalProvider = StateProvider<ModalKind>((ref) => ModalKind.none);
|
||||||
|
|
||||||
|
/// Einstellungen direkt in diesem Bereich öffnen (z. B. aus dem Tab „Du“);
|
||||||
|
/// SettingsModal liest und leert es beim Öffnen.
|
||||||
|
final settingsSectionRequestProvider = StateProvider<String?>((ref) => null);
|
||||||
|
|
||||||
// ─── Call state ───────────────────────────────────────────────────────────────
|
// ─── Call state ───────────────────────────────────────────────────────────────
|
||||||
// Die Call-Provider leben seit dem M2-Call-Pilot in ihren Modulen:
|
// Die Call-Provider leben seit dem M2-Call-Pilot in ihren Modulen:
|
||||||
// `callSignalingProvider` (features/call_signaling/call_signaling_service.dart)
|
// `callSignalingProvider` (features/call_signaling/call_signaling_service.dart)
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
/// IDs aller Räume, die Kanal eines beigetretenen Servers (Space) sind –
|
||||||
|
/// auch über Unter-Spaces. Diese Räume stehen nur in ihrem Server, nicht
|
||||||
|
/// zusätzlich in „Chats“ (Bernd 2026-10-09: „Alles in Chats“).
|
||||||
|
Set<String> spaceChannelIds(Client client) {
|
||||||
|
final ids = <String>{};
|
||||||
|
for (final space in client.rooms) {
|
||||||
|
if (!space.isSpace || space.membership != Membership.join) continue;
|
||||||
|
for (final child in space.spaceChildren) {
|
||||||
|
final id = child.roomId;
|
||||||
|
if (id != null) ids.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gehört [room] in die gemeinsame Chatliste? Direktnachrichten und Gruppen,
|
||||||
|
/// die zu keinem beigetretenen Server gehören. [channelIds] stammt aus
|
||||||
|
/// [spaceChannelIds] (einmal pro Liste berechnen, nicht pro Raum).
|
||||||
|
bool isChatListRoom(Room room, Set<String> channelIds) {
|
||||||
|
if (room.isSpace) return false;
|
||||||
|
if (room.isDirectChat) return true;
|
||||||
|
return !channelIds.contains(room.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gruppenchat = kein Space, keine Direktnachricht, kein Sprachkanal.
|
||||||
|
bool isGroupChatRoom(Room room) =>
|
||||||
|
!room.isSpace && !room.isDirectChat && !isVoiceChannelRoom(room);
|
||||||
@@ -68,6 +68,13 @@ class StringSetPref extends StateNotifier<Set<String>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Navigation (Handy) ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Handy: untere Leiste (Chats · Server · Du) statt Seitenmenü.
|
||||||
|
/// Standard: Seitenmenü (Bernd 2026-10-09: untere Leiste erst zum Vergleich).
|
||||||
|
final bottomNavProvider =
|
||||||
|
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('nav_bottom_bar', false));
|
||||||
|
|
||||||
// ── Notification preferences ─────────────────────────────────────────────────
|
// ── Notification preferences ─────────────────────────────────────────────────
|
||||||
|
|
||||||
final notifDesktopProvider =
|
final notifDesktopProvider =
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
/// Physik der Bubble-Anrufansicht (Bernd 2026-10-09: „animierte dynamische
|
||||||
|
/// Bubbles, die sich aneinander anpassen und aussehen, als würden sie
|
||||||
|
/// aneinander gedrückt“).
|
||||||
|
///
|
||||||
|
/// Jede Person ist ein Kreis. Eine Feder zieht alle Kreise zur Mitte, Kreise
|
||||||
|
/// stoßen sich ab, sobald sie sich mehr als [overlap] überlappen, und die
|
||||||
|
/// Ränder halten sie im Bild. Im Gleichgewicht drücken sie also leicht
|
||||||
|
/// gegeneinander; [clipPath] schneidet jeden Kreis an der Berührungslinie
|
||||||
|
/// (Potenzgerade) gerade ab – wie Seifenblasen in Schaum.
|
||||||
|
///
|
||||||
|
/// Wer spricht, wird größer und schiebt die anderen sanft beiseite; neue
|
||||||
|
/// Teilnehmer wachsen aus dem Nichts heraus, Gehende schrumpfen weg.
|
||||||
|
class BubbleBody {
|
||||||
|
final String id;
|
||||||
|
Offset pos;
|
||||||
|
Offset vel = Offset.zero;
|
||||||
|
double r;
|
||||||
|
double rVel = 0;
|
||||||
|
double targetR = 0;
|
||||||
|
bool leaving = false;
|
||||||
|
|
||||||
|
BubbleBody(this.id, this.pos, {this.r = 0});
|
||||||
|
}
|
||||||
|
|
||||||
|
class BubbleSim {
|
||||||
|
/// Anteil der Kreisdurchmesser, um den sich Nachbarn überlappen dürfen –
|
||||||
|
/// daraus entstehen die flachen Berührungsflächen.
|
||||||
|
static const overlap = 0.10;
|
||||||
|
|
||||||
|
/// Wie viel größer ein sprechender Kreis wird.
|
||||||
|
static const speakingScale = 1.22;
|
||||||
|
|
||||||
|
final Map<String, BubbleBody> bodies = {};
|
||||||
|
Size _size = Size.zero;
|
||||||
|
Size get size => _size;
|
||||||
|
Offset get _center => Offset(_size.width / 2, _size.height / 2);
|
||||||
|
|
||||||
|
/// Gleicht die Kreise mit den aktuellen Teilnehmern ab: neue kommen dazu,
|
||||||
|
/// fehlende schrumpfen weg, die Zielgrößen werden neu verteilt.
|
||||||
|
/// [speaking] = IDs, die gerade sprechen.
|
||||||
|
void sync(List<String> ids, Set<String> speaking, Size size) {
|
||||||
|
_size = size;
|
||||||
|
final present = ids.toSet();
|
||||||
|
for (final b in bodies.values) {
|
||||||
|
b.leaving = !present.contains(b.id);
|
||||||
|
}
|
||||||
|
for (final id in ids) {
|
||||||
|
if (bodies.containsKey(id)) continue;
|
||||||
|
// Neu: nahe der Mitte (leicht versetzt, damit es die anderen
|
||||||
|
// auseinanderschiebt statt exakt auf ihnen zu liegen).
|
||||||
|
final h = id.hashCode;
|
||||||
|
final angle = (h % 360) * math.pi / 180;
|
||||||
|
final spread = math.min(size.width, size.height) * 0.08;
|
||||||
|
bodies[id] = BubbleBody(
|
||||||
|
id,
|
||||||
|
_center + Offset(math.cos(angle), math.sin(angle)) * spread,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_assignTargets(ids, speaking);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _assignTargets(List<String> ids, Set<String> speaking) {
|
||||||
|
final w = _size.width, h = _size.height;
|
||||||
|
if (w <= 0 || h <= 0) return;
|
||||||
|
final shortSide = math.min(w, h);
|
||||||
|
double weight(String id) => speaking.contains(id) ? speakingScale : 1.0;
|
||||||
|
final active = ids.where((id) => bodies.containsKey(id)).toList();
|
||||||
|
double base;
|
||||||
|
if (active.length <= 1) {
|
||||||
|
base = shortSide * 0.27;
|
||||||
|
} else {
|
||||||
|
// Kreise sollen zusammen gut ein Drittel der Fläche bedecken.
|
||||||
|
final sumW2 = active.fold<double>(0, (s, id) => s + weight(id) * weight(id));
|
||||||
|
base = math.sqrt(0.36 * w * h / (math.pi * sumW2));
|
||||||
|
}
|
||||||
|
final maxR = shortSide * (active.length <= 2 ? 0.27 : 0.36);
|
||||||
|
for (final b in bodies.values) {
|
||||||
|
if (b.leaving) {
|
||||||
|
b.targetR = 0;
|
||||||
|
} else {
|
||||||
|
b.targetR = (base * weight(b.id)).clamp(28.0, maxR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Zeitschritt in Sekunden. Liefert `true`, solange sich noch etwas
|
||||||
|
/// bewegt (sonst kann die Animation pausieren).
|
||||||
|
bool step(double dt) {
|
||||||
|
if (_size.isEmpty) return false;
|
||||||
|
dt = dt.clamp(0.0, 1 / 30);
|
||||||
|
// Zwei Unterschritte: stabil auch bei großen Kräften.
|
||||||
|
var moving = false;
|
||||||
|
for (var i = 0; i < 2; i++) {
|
||||||
|
moving = _substep(dt / 2) || moving;
|
||||||
|
}
|
||||||
|
bodies.removeWhere((_, b) => b.leaving && b.r < 1);
|
||||||
|
return moving;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _substep(double dt) {
|
||||||
|
const centerK = 18.0; // Zug zur Mitte
|
||||||
|
const pushK = 260.0; // Abstoßung bei zu starker Überlappung
|
||||||
|
const wallK = 160.0;
|
||||||
|
const damping = 9.0;
|
||||||
|
const radiusK = 110.0;
|
||||||
|
const radiusDamping = 13.0;
|
||||||
|
const margin = 10.0;
|
||||||
|
|
||||||
|
final list = bodies.values.toList();
|
||||||
|
final forces = List<Offset>.filled(list.length, Offset.zero);
|
||||||
|
final c = _center;
|
||||||
|
for (var i = 0; i < list.length; i++) {
|
||||||
|
final a = list[i];
|
||||||
|
// Die Mitte zieht, aber gestaucht in der langen Richtung, damit sich
|
||||||
|
// die Kreise im Hochformat übereinander, im Querformat nebeneinander
|
||||||
|
// anordnen.
|
||||||
|
final d = a.pos - c;
|
||||||
|
final ax = _size.width >= _size.height ? 0.55 : 1.0;
|
||||||
|
final ay = _size.height > _size.width ? 0.55 : 1.0;
|
||||||
|
forces[i] += Offset(-d.dx * centerK * ax, -d.dy * centerK * ay);
|
||||||
|
|
||||||
|
// Ränder
|
||||||
|
final left = a.pos.dx - a.r - margin;
|
||||||
|
final right = _size.width - margin - (a.pos.dx + a.r);
|
||||||
|
final top = a.pos.dy - a.r - margin;
|
||||||
|
final bottom = _size.height - margin - (a.pos.dy + a.r);
|
||||||
|
if (left < 0) forces[i] += Offset(-left * wallK, 0);
|
||||||
|
if (right < 0) forces[i] += Offset(right * wallK, 0);
|
||||||
|
if (top < 0) forces[i] += Offset(0, -top * wallK);
|
||||||
|
if (bottom < 0) forces[i] += Offset(0, bottom * wallK);
|
||||||
|
|
||||||
|
for (var j = i + 1; j < list.length; j++) {
|
||||||
|
final b = list[j];
|
||||||
|
var delta = b.pos - a.pos;
|
||||||
|
var dist = delta.distance;
|
||||||
|
if (dist < 0.01) {
|
||||||
|
// exakt übereinander: in eine feste Richtung auseinander
|
||||||
|
delta = Offset(math.cos(i + j * 1.7), math.sin(i + j * 1.7));
|
||||||
|
dist = 0.01;
|
||||||
|
}
|
||||||
|
final rest = (a.r + b.r) * (1 - overlap);
|
||||||
|
if (dist < rest) {
|
||||||
|
final u = delta / dist;
|
||||||
|
final f = u * ((rest - dist) * pushK);
|
||||||
|
forces[i] -= f;
|
||||||
|
forces[j] += f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var moving = false;
|
||||||
|
final decay = math.exp(-damping * dt);
|
||||||
|
for (var i = 0; i < list.length; i++) {
|
||||||
|
final b = list[i];
|
||||||
|
b.vel = (b.vel + forces[i] * dt) * decay;
|
||||||
|
b.pos += b.vel * dt;
|
||||||
|
// Größe federt zum Ziel (leichtes Überschwingen beim Erscheinen).
|
||||||
|
b.rVel += (radiusK * (b.targetR - b.r) - radiusDamping * b.rVel) * dt;
|
||||||
|
b.r = math.max(0, b.r + b.rVel * dt);
|
||||||
|
if (b.vel.distance > 2 ||
|
||||||
|
b.rVel.abs() > 2 ||
|
||||||
|
(b.targetR - b.r).abs() > 0.5) {
|
||||||
|
moving = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return moving;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Umriss von [id] in Bühnen-Koordinaten: der Kreis, an jeder Berührung
|
||||||
|
/// mit einem Nachbarn gerade abgeschnitten (mit [seam] Pixel Fuge).
|
||||||
|
Path clipPath(String id, {double seam = 3}) {
|
||||||
|
final a = bodies[id]!;
|
||||||
|
final circle = Rect.fromCircle(center: a.pos, radius: math.max(a.r, 0.01));
|
||||||
|
var path = Path()..addOval(circle);
|
||||||
|
for (final b in bodies.values) {
|
||||||
|
if (identical(a, b) || b.r < 1) continue;
|
||||||
|
final delta = b.pos - a.pos;
|
||||||
|
final d = delta.distance;
|
||||||
|
if (d < 0.01 || d >= a.r + b.r) continue;
|
||||||
|
final u = delta / d;
|
||||||
|
// Abstand von a zur Potenzgerade der beiden Kreise
|
||||||
|
final cut = (d * d + a.r * a.r - b.r * b.r) / (2 * d) - seam / 2;
|
||||||
|
if (cut >= a.r) continue;
|
||||||
|
final n = Offset(-u.dy, u.dx);
|
||||||
|
final big = a.r * 4 + 10;
|
||||||
|
final base = a.pos + u * cut;
|
||||||
|
final half = Path()
|
||||||
|
..addPolygon([
|
||||||
|
base + n * big,
|
||||||
|
base - n * big,
|
||||||
|
base - n * big - u * big,
|
||||||
|
base + n * big - u * big,
|
||||||
|
], true);
|
||||||
|
path = Path.combine(PathOperation.intersect, path, half);
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,362 @@
|
|||||||
|
part of '../voice_channel.dart';
|
||||||
|
|
||||||
|
/// Anrufansicht als aneinandergedrückte Bubbles (Physik: bubble_physics.dart).
|
||||||
|
/// Jede Person ist eine Blase mit Profilbild (oder Kamera-Bild), wer spricht,
|
||||||
|
/// wächst und leuchtet; Blasen weichen sich federnd aus.
|
||||||
|
class _BubbleStage extends StatefulWidget {
|
||||||
|
final List<_ParticipantData> participants;
|
||||||
|
final Client? client;
|
||||||
|
final PyramidTheme pt;
|
||||||
|
final ValueChanged<_ParticipantData> onSelect;
|
||||||
|
|
||||||
|
const _BubbleStage({
|
||||||
|
super.key,
|
||||||
|
required this.participants,
|
||||||
|
required this.client,
|
||||||
|
required this.pt,
|
||||||
|
required this.onSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_BubbleStage> createState() => _BubbleStageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BubbleStageState extends State<_BubbleStage>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
final _sim = BubbleSim();
|
||||||
|
// Letzter bekannter Stand je Person – auch für Gehende, die noch
|
||||||
|
// wegschrumpfen.
|
||||||
|
final Map<String, _ParticipantData> _data = {};
|
||||||
|
late final Ticker _ticker = createTicker(_tick);
|
||||||
|
Duration? _last;
|
||||||
|
double _time = 0;
|
||||||
|
bool _needsSync = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(_BubbleStage old) {
|
||||||
|
super.didUpdateWidget(old);
|
||||||
|
_needsSync = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_ticker.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _tick(Duration elapsed) {
|
||||||
|
final dt = _last == null
|
||||||
|
? 1 / 60
|
||||||
|
: (elapsed - _last!).inMicroseconds / Duration.microsecondsPerSecond;
|
||||||
|
_last = elapsed;
|
||||||
|
_time += dt;
|
||||||
|
final moving = _sim.step(dt);
|
||||||
|
_data.removeWhere((id, _) => !_sim.bodies.containsKey(id));
|
||||||
|
final speaking = widget.participants.any((p) => p.isSpeaking);
|
||||||
|
setState(() {});
|
||||||
|
// Ruhe = keine Animation (spart Akku); Sprechen hält das Leuchten an.
|
||||||
|
if (!moving && !speaking) {
|
||||||
|
_ticker.stop();
|
||||||
|
_last = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _sync(Size size) {
|
||||||
|
for (final p in widget.participants) {
|
||||||
|
_data[p.id] = p;
|
||||||
|
}
|
||||||
|
_sim.sync(
|
||||||
|
widget.participants.map((p) => p.id).toList(),
|
||||||
|
widget.participants.where((p) => p.isSpeaking).map((p) => p.id).toSet(),
|
||||||
|
size,
|
||||||
|
);
|
||||||
|
if (!_ticker.isActive) {
|
||||||
|
_last = null;
|
||||||
|
_ticker.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final pt = widget.pt;
|
||||||
|
return LayoutBuilder(builder: (context, box) {
|
||||||
|
final size = box.biggest;
|
||||||
|
if (_needsSync || size != _sim.size) {
|
||||||
|
_needsSync = false;
|
||||||
|
_sync(size);
|
||||||
|
}
|
||||||
|
final glow = 0.5 + 0.5 * math.sin(_time * 6);
|
||||||
|
final children = <Widget>[];
|
||||||
|
for (final b in _sim.bodies.values) {
|
||||||
|
final d = _data[b.id];
|
||||||
|
if (d == null || b.r < 2) continue;
|
||||||
|
final rect = Rect.fromCircle(center: b.pos, radius: b.r);
|
||||||
|
children.add(Positioned.fromRect(
|
||||||
|
key: ValueKey('bubble:${b.id}'),
|
||||||
|
rect: rect,
|
||||||
|
child: _Bubble(
|
||||||
|
data: d,
|
||||||
|
path: _sim.clipPath(b.id).shift(-rect.topLeft),
|
||||||
|
radius: b.r,
|
||||||
|
client: widget.client,
|
||||||
|
pt: pt,
|
||||||
|
glow: glow,
|
||||||
|
onTap: () => widget.onSelect(d),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: RadialGradient(
|
||||||
|
colors: [pt.bg2, pt.bg1],
|
||||||
|
radius: 0.9,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Stack(clipBehavior: Clip.hardEdge, children: children),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Bubble extends StatelessWidget {
|
||||||
|
final _ParticipantData data;
|
||||||
|
final Path path;
|
||||||
|
final double radius;
|
||||||
|
final Client? client;
|
||||||
|
final PyramidTheme pt;
|
||||||
|
final double glow;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _Bubble({
|
||||||
|
required this.data,
|
||||||
|
required this.path,
|
||||||
|
required this.radius,
|
||||||
|
required this.client,
|
||||||
|
required this.pt,
|
||||||
|
required this.glow,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final showName = radius > 38;
|
||||||
|
return Semantics(
|
||||||
|
label: data.isMuted ? '${data.name}, stumm' : data.name,
|
||||||
|
button: data.hasVideo,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: CustomPaint(
|
||||||
|
foregroundPainter: _BubbleRingPainter(
|
||||||
|
path: path,
|
||||||
|
speaking: data.isSpeaking,
|
||||||
|
glow: glow,
|
||||||
|
color: pt.online,
|
||||||
|
),
|
||||||
|
child: ClipPath(
|
||||||
|
clipper: _PathClipper(path),
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
_face(),
|
||||||
|
if (data.hasVideo && data.coverVideoWidget != null)
|
||||||
|
_MirrorWrapper(
|
||||||
|
isLocal: data.isLocal,
|
||||||
|
isScreenShare: data.isScreenShare,
|
||||||
|
child: data.coverVideoWidget!,
|
||||||
|
),
|
||||||
|
const IgnorePointer(
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: RadialGradient(
|
||||||
|
center: Alignment(-0.45, -0.55),
|
||||||
|
radius: 0.95,
|
||||||
|
colors: [Color(0x2EFFFFFF), Color(0x00FFFFFF)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showName)
|
||||||
|
Align(
|
||||||
|
alignment: const Alignment(0, 0.62),
|
||||||
|
child: _nameTag(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Profilbild oder Farbverlauf mit Anfangsbuchstaben – fest in 256 px
|
||||||
|
/// gebaut und skaliert, damit das Bild beim Wachsen nicht neu lädt.
|
||||||
|
Widget _face() {
|
||||||
|
Widget initial(BuildContext _) => Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [data.avatarColor, data.avatarColor.withAlpha(150)],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
alignment: const Alignment(0, -0.12),
|
||||||
|
child: Text(data.initial,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 104,
|
||||||
|
fontWeight: FontWeight.w700)),
|
||||||
|
);
|
||||||
|
final c = client;
|
||||||
|
final face = (c != null && data.avatarUrl != null)
|
||||||
|
? MxcAvatar(
|
||||||
|
mxcUri: data.avatarUrl,
|
||||||
|
client: c,
|
||||||
|
size: 256,
|
||||||
|
borderRadius: BorderRadius.zero,
|
||||||
|
placeholder: initial,
|
||||||
|
)
|
||||||
|
: Builder(builder: initial);
|
||||||
|
return FittedBox(
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
child: SizedBox(width: 256, height: 256, child: face),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _nameTag() {
|
||||||
|
final fontSize = (radius * 0.16).clamp(10.0, 15.0);
|
||||||
|
return Container(
|
||||||
|
constraints: BoxConstraints(maxWidth: radius * 1.5),
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: fontSize * 0.7, vertical: fontSize * 0.25),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.45),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (data.isMuted) ...[
|
||||||
|
Icon(Icons.mic_off_rounded, size: fontSize + 1, color: pt.danger),
|
||||||
|
SizedBox(width: fontSize * 0.3),
|
||||||
|
],
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
data.name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: fontSize,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PathClipper extends CustomClipper<Path> {
|
||||||
|
final Path path;
|
||||||
|
_PathClipper(this.path);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Path getClip(Size size) => path;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldReclip(_PathClipper old) => !identical(old.path, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BubbleRingPainter extends CustomPainter {
|
||||||
|
final Path path;
|
||||||
|
final bool speaking;
|
||||||
|
final double glow;
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
_BubbleRingPainter({
|
||||||
|
required this.path,
|
||||||
|
required this.speaking,
|
||||||
|
required this.glow,
|
||||||
|
required this.color,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
if (speaking) {
|
||||||
|
canvas.drawPath(
|
||||||
|
path,
|
||||||
|
Paint()
|
||||||
|
..color = color.withValues(alpha: 0.30 + 0.30 * glow)
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 7
|
||||||
|
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 6),
|
||||||
|
);
|
||||||
|
canvas.drawPath(
|
||||||
|
path,
|
||||||
|
Paint()
|
||||||
|
..color = color
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 3
|
||||||
|
..strokeJoin = StrokeJoin.round,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
canvas.drawPath(
|
||||||
|
path,
|
||||||
|
Paint()
|
||||||
|
..color = Colors.white.withValues(alpha: 0.10)
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 1.2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(_BubbleRingPainter old) => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vorführ-Ansicht der Bubbles mit erfundenen Teilnehmern – nur für
|
||||||
|
/// Screenshots/Tests (UI-Tour), damit man die Ansicht mit mehreren Personen
|
||||||
|
/// sieht, ohne dass so viele Testkonten anrufen müssen.
|
||||||
|
@visibleForTesting
|
||||||
|
class CallBubblesDemo extends StatelessWidget {
|
||||||
|
final int count;
|
||||||
|
const CallBubblesDemo({super.key, this.count = 5});
|
||||||
|
|
||||||
|
static const _names = ['Du', 'Anna', 'Ben', 'Clara', 'David', 'Ela', 'Finn'];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final pt = PyramidTheme.of(context);
|
||||||
|
final participants = [
|
||||||
|
for (var i = 0; i < count.clamp(1, _names.length); i++)
|
||||||
|
_ParticipantData(
|
||||||
|
id: 'demo$i',
|
||||||
|
name: _names[i],
|
||||||
|
isLocal: i == 0,
|
||||||
|
isSpeaking: i == 2,
|
||||||
|
isMuted: i == 3,
|
||||||
|
avatarColor: VoiceChannelView._avatarColors[i % VoiceChannelView._avatarColors.length],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
return _UnifiedCallView(
|
||||||
|
participants: participants,
|
||||||
|
client: null,
|
||||||
|
startedAt: DateTime.now().subtract(const Duration(minutes: 3, seconds: 12)),
|
||||||
|
isLiveKit: true,
|
||||||
|
displayName: 'Familie',
|
||||||
|
isConnecting: false,
|
||||||
|
onHangUp: () {},
|
||||||
|
onMinimize: () {},
|
||||||
|
onToggleMic: () {},
|
||||||
|
onToggleDeafen: () {},
|
||||||
|
onToggleCam: () {},
|
||||||
|
onToggleScreen: () {},
|
||||||
|
isMuted: false,
|
||||||
|
isDeafened: false,
|
||||||
|
isCameraOff: true,
|
||||||
|
isScreenSharing: false,
|
||||||
|
pt: pt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
part of '../voice_channel.dart';
|
part of '../voice_channel.dart';
|
||||||
|
|
||||||
|
/// Darstellung der Teilnehmer: Bubbles (Standard), Kacheln, oder ein großes
|
||||||
|
/// Bild (Bildschirm/Kamera) mit Leiste.
|
||||||
|
enum _CallLayout { bubbles, grid, hero }
|
||||||
|
|
||||||
class _UnifiedCallView extends StatefulWidget {
|
class _UnifiedCallView extends StatefulWidget {
|
||||||
final List<_ParticipantData> participants;
|
final List<_ParticipantData> participants;
|
||||||
|
final Client? client;
|
||||||
|
final DateTime startedAt;
|
||||||
final bool isLiveKit;
|
final bool isLiveKit;
|
||||||
final String displayName;
|
final String displayName;
|
||||||
final bool isConnecting;
|
final bool isConnecting;
|
||||||
@@ -22,6 +28,8 @@ class _UnifiedCallView extends StatefulWidget {
|
|||||||
|
|
||||||
const _UnifiedCallView({
|
const _UnifiedCallView({
|
||||||
required this.participants,
|
required this.participants,
|
||||||
|
required this.client,
|
||||||
|
required this.startedAt,
|
||||||
required this.isLiveKit,
|
required this.isLiveKit,
|
||||||
required this.displayName,
|
required this.displayName,
|
||||||
required this.isConnecting,
|
required this.isConnecting,
|
||||||
@@ -46,31 +54,52 @@ class _UnifiedCallView extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _UnifiedCallViewState extends State<_UnifiedCallView> {
|
class _UnifiedCallViewState extends State<_UnifiedCallView> {
|
||||||
|
/// Zuletzt gewählte Ansicht (Bubbles/Kacheln) – bleibt beim Minimieren
|
||||||
|
/// und für den nächsten Anruf erhalten.
|
||||||
|
static _CallLayout _preferred = _CallLayout.bubbles;
|
||||||
|
_CallLayout _mode = _preferred;
|
||||||
String? _heroId;
|
String? _heroId;
|
||||||
bool _isGridView = false;
|
|
||||||
bool _stripCollapsed = false;
|
bool _stripCollapsed = false;
|
||||||
|
Set<String> _knownScreens = {};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_autoSelectHero();
|
_followScreenShares();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(_UnifiedCallView oldWidget) {
|
void didUpdateWidget(_UnifiedCallView oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
_autoSelectHero();
|
_followScreenShares();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _autoSelectHero() {
|
/// Neue Bildschirmübertragung → groß zeigen. Verschwindet das große Bild
|
||||||
if (_isGridView) return;
|
/// (Person weg, Kamera aus), zurück zu den Bubbles.
|
||||||
final screenshare = widget.participants.firstWhereOrNull((p) => p.isScreenShare);
|
void _followScreenShares() {
|
||||||
if (screenshare != null && (_heroId == null || !widget.participants.any((p) => p.id == _heroId && p.hasVideo))) {
|
final screens = widget.participants
|
||||||
_heroId = screenshare.id;
|
.where((p) => p.isScreenShare)
|
||||||
} else if (_heroId == null && widget.participants.isNotEmpty) {
|
.map((p) => p.id)
|
||||||
final firstVideo = widget.participants.firstWhereOrNull((p) => p.hasVideo);
|
.toSet();
|
||||||
if (firstVideo != null) _heroId = firstVideo.id;
|
final fresh = screens.difference(_knownScreens);
|
||||||
|
if (fresh.isNotEmpty) {
|
||||||
|
_mode = _CallLayout.hero;
|
||||||
|
_heroId = fresh.first;
|
||||||
}
|
}
|
||||||
|
_knownScreens = screens;
|
||||||
|
if (_mode == _CallLayout.hero &&
|
||||||
|
!widget.participants.any((p) => p.id == _heroId && p.hasVideo)) {
|
||||||
|
_mode = _preferred;
|
||||||
|
_heroId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showLarge(_ParticipantData p) {
|
||||||
|
if (!p.hasVideo) return;
|
||||||
|
setState(() {
|
||||||
|
_mode = _CallLayout.hero;
|
||||||
|
_heroId = p.id;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
List<_ParticipantData> _sorted(List<_ParticipantData> all) {
|
List<_ParticipantData> _sorted(List<_ParticipantData> all) {
|
||||||
@@ -87,14 +116,9 @@ class _UnifiedCallViewState extends State<_UnifiedCallView> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final pt = widget.pt;
|
final pt = widget.pt;
|
||||||
final all = _sorted(widget.participants);
|
final all = _sorted(widget.participants);
|
||||||
// Ohne gewählten Hero nicht nach „spricht gerade“ wählen – sonst springt
|
final hero = _mode == _CallLayout.hero
|
||||||
// das große Bild bei jedem Sprechwechsel hin und her.
|
? widget.participants.firstWhereOrNull((p) => p.id == _heroId)
|
||||||
final stable = [...widget.participants]..sort((a, b) {
|
: null;
|
||||||
int r(_ParticipantData p) => p.isScreenShare ? 0 : (p.hasVideo ? 1 : 2);
|
|
||||||
return r(a).compareTo(r(b));
|
|
||||||
});
|
|
||||||
final hero = all.firstWhereOrNull((p) => p.id == _heroId) ??
|
|
||||||
(stable.isNotEmpty ? stable.first : null);
|
|
||||||
|
|
||||||
if (widget.isConnecting) {
|
if (widget.isConnecting) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -126,23 +150,45 @@ class _UnifiedCallViewState extends State<_UnifiedCallView> {
|
|||||||
children: [
|
children: [
|
||||||
_CallHeader(
|
_CallHeader(
|
||||||
displayName: widget.displayName,
|
displayName: widget.displayName,
|
||||||
isGridView: _isGridView,
|
startedAt: widget.startedAt,
|
||||||
onToggleView: () => setState(() => _isGridView = !_isGridView),
|
count: widget.participants.length,
|
||||||
|
layout: _mode,
|
||||||
|
onToggleView: () => setState(() {
|
||||||
|
_mode = _mode == _CallLayout.bubbles
|
||||||
|
? _CallLayout.grid
|
||||||
|
: _CallLayout.bubbles;
|
||||||
|
_preferred = _mode;
|
||||||
|
_heroId = null;
|
||||||
|
}),
|
||||||
onMinimize: widget.onMinimize,
|
onMinimize: widget.onMinimize,
|
||||||
pt: pt,
|
pt: pt,
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: AnimatedSwitcher(
|
child: AnimatedSwitcher(
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: motionDuration(context, 300),
|
||||||
child: _isGridView || hero == null
|
child: hero == null
|
||||||
? _ParticipantGrid(participants: all, pt: pt, onSelect: (p) => setState(() { _heroId = p.id; _isGridView = false; }))
|
? (_mode == _CallLayout.grid
|
||||||
|
? _ParticipantGrid(
|
||||||
|
key: const ValueKey('grid'),
|
||||||
|
participants: all,
|
||||||
|
pt: pt,
|
||||||
|
onSelect: _showLarge)
|
||||||
|
: _BubbleStage(
|
||||||
|
key: const ValueKey('bubbles'),
|
||||||
|
participants: widget.participants,
|
||||||
|
client: widget.client,
|
||||||
|
pt: pt,
|
||||||
|
onSelect: _showLarge))
|
||||||
: _HeroLayout(
|
: _HeroLayout(
|
||||||
|
key: const ValueKey('hero'),
|
||||||
hero: hero,
|
hero: hero,
|
||||||
others: all.where((p) => p.id != hero.id).toList(),
|
others: all.where((p) => p.id != hero.id).toList(),
|
||||||
stripCollapsed: _stripCollapsed,
|
stripCollapsed: _stripCollapsed,
|
||||||
onToggleStrip: () => setState(() => _stripCollapsed = !_stripCollapsed),
|
onToggleStrip: () => setState(() => _stripCollapsed = !_stripCollapsed),
|
||||||
onSetStripCollapsed: (v) => setState(() => _stripCollapsed = v),
|
onSetStripCollapsed: (v) => setState(() => _stripCollapsed = v),
|
||||||
onSelectOther: (p) => setState(() => _heroId = p.id),
|
onSelectOther: (p) => p.hasVideo
|
||||||
|
? setState(() => _heroId = p.id)
|
||||||
|
: null,
|
||||||
onQualityMenu: widget.onQualityMenu,
|
onQualityMenu: widget.onQualityMenu,
|
||||||
pt: pt,
|
pt: pt,
|
||||||
),
|
),
|
||||||
@@ -170,30 +216,55 @@ class _UnifiedCallViewState extends State<_UnifiedCallView> {
|
|||||||
|
|
||||||
class _CallHeader extends StatelessWidget {
|
class _CallHeader extends StatelessWidget {
|
||||||
final String displayName;
|
final String displayName;
|
||||||
final bool isGridView;
|
final DateTime startedAt;
|
||||||
|
final int count;
|
||||||
|
final _CallLayout layout;
|
||||||
final VoidCallback onToggleView;
|
final VoidCallback onToggleView;
|
||||||
final VoidCallback onMinimize;
|
final VoidCallback onMinimize;
|
||||||
final PyramidTheme pt;
|
final PyramidTheme pt;
|
||||||
|
|
||||||
const _CallHeader({required this.displayName, required this.isGridView, required this.onToggleView, required this.onMinimize, required this.pt});
|
const _CallHeader({
|
||||||
|
required this.displayName,
|
||||||
|
required this.startedAt,
|
||||||
|
required this.count,
|
||||||
|
required this.layout,
|
||||||
|
required this.onToggleView,
|
||||||
|
required this.onMinimize,
|
||||||
|
required this.pt,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final toBubbles = layout != _CallLayout.bubbles;
|
||||||
return Container(
|
return Container(
|
||||||
height: 52,
|
height: 56,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: pt.border))),
|
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: pt.border))),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.headphones_rounded, size: 16, color: pt.fgMuted),
|
Expanded(
|
||||||
const SizedBox(width: 8),
|
child: Column(
|
||||||
Text(displayName, style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)),
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
const SizedBox(width: 8),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(displayName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Row(children: [
|
||||||
_LiveBadge(pt: pt),
|
_LiveBadge(pt: pt),
|
||||||
const Spacer(),
|
const SizedBox(width: 8),
|
||||||
|
_CallDuration(startedAt: startedAt, pt: pt),
|
||||||
|
Text(count == 1 ? ' · nur du' : ' · $count Personen',
|
||||||
|
style: TextStyle(color: pt.fgMuted, fontSize: 12)),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
PyrIconBtn(
|
PyrIconBtn(
|
||||||
icon: Icon(isGridView ? Icons.grid_view_rounded : Icons.featured_video_rounded, color: isGridView ? pt.accent : pt.fg),
|
icon: Icon(toBubbles ? Icons.bubble_chart_rounded : Icons.grid_view_rounded, color: pt.fg),
|
||||||
tooltip: isGridView ? 'Hero mode' : 'Grid mode',
|
tooltip: toBubbles ? 'Bubble-Ansicht' : 'Kachelansicht',
|
||||||
onPressed: onToggleView,
|
onPressed: onToggleView,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
@@ -208,6 +279,47 @@ class _CallHeader extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Gesprächsdauer „m:ss“ bzw. „h:mm:ss“, tickt jede Sekunde.
|
||||||
|
class _CallDuration extends StatefulWidget {
|
||||||
|
final DateTime startedAt;
|
||||||
|
final PyramidTheme pt;
|
||||||
|
const _CallDuration({required this.startedAt, required this.pt});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_CallDuration> createState() => _CallDurationState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CallDurationState extends State<_CallDuration> {
|
||||||
|
late final Timer _timer =
|
||||||
|
Timer.periodic(const Duration(seconds: 1), (_) => setState(() {}));
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_timer; // starten
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_timer.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final d = DateTime.now().difference(widget.startedAt);
|
||||||
|
String two(int n) => n.toString().padLeft(2, '0');
|
||||||
|
final text = d.inHours > 0
|
||||||
|
? '${d.inHours}:${two(d.inMinutes % 60)}:${two(d.inSeconds % 60)}'
|
||||||
|
: '${d.inMinutes}:${two(d.inSeconds % 60)}';
|
||||||
|
return Text(text,
|
||||||
|
style: TextStyle(
|
||||||
|
color: widget.pt.fgMuted,
|
||||||
|
fontSize: 12,
|
||||||
|
fontFeatures: const [FontFeature.tabularFigures()]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _LiveBadge extends StatelessWidget {
|
class _LiveBadge extends StatelessWidget {
|
||||||
final PyramidTheme pt;
|
final PyramidTheme pt;
|
||||||
const _LiveBadge({required this.pt});
|
const _LiveBadge({required this.pt});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ class _ParticipantGrid extends StatelessWidget {
|
|||||||
final PyramidTheme pt;
|
final PyramidTheme pt;
|
||||||
final Function(_ParticipantData) onSelect;
|
final Function(_ParticipantData) onSelect;
|
||||||
|
|
||||||
const _ParticipantGrid({required this.participants, required this.pt, required this.onSelect});
|
const _ParticipantGrid({super.key, required this.participants, required this.pt, required this.onSelect});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -41,6 +41,7 @@ class _HeroLayout extends StatelessWidget {
|
|||||||
final PyramidTheme pt;
|
final PyramidTheme pt;
|
||||||
|
|
||||||
const _HeroLayout({
|
const _HeroLayout({
|
||||||
|
super.key,
|
||||||
required this.hero,
|
required this.hero,
|
||||||
required this.others,
|
required this.others,
|
||||||
required this.stripCollapsed,
|
required this.stripCollapsed,
|
||||||
|
|||||||
@@ -12,6 +12,19 @@ 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;
|
||||||
|
/// Profilbild (mxc://) der Person, falls bekannt.
|
||||||
|
final Uri? avatarUrl;
|
||||||
|
/// Bin das ich?
|
||||||
|
final bool isLocal;
|
||||||
|
/// Kamera-/Bildschirmbild formatfüllend (für die runden Bubbles).
|
||||||
|
final Widget? coverVideoWidget;
|
||||||
|
|
||||||
|
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,9 +37,22 @@ 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,
|
||||||
|
this.avatarUrl,
|
||||||
|
this.isLocal = false,
|
||||||
|
this.coverVideoWidget,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Startzeit je Anruf (für die Dauer im Kopf). Schlüssel bleibt beim
|
||||||
|
/// Minimieren gleich, damit die Uhr nicht neu anfängt.
|
||||||
|
final _callStarts = <String, DateTime>{};
|
||||||
|
|
||||||
|
DateTime _callStartFor(String key) {
|
||||||
|
if (_callStarts.length > 20) _callStarts.clear();
|
||||||
|
return _callStarts.putIfAbsent(key, DateTime.now);
|
||||||
|
}
|
||||||
|
|
||||||
class VoiceChannelView extends ConsumerWidget {
|
class VoiceChannelView extends ConsumerWidget {
|
||||||
const VoiceChannelView({super.key});
|
const VoiceChannelView({super.key});
|
||||||
|
|
||||||
@@ -35,10 +61,13 @@ class VoiceChannelView extends ConsumerWidget {
|
|||||||
final pt = PyramidTheme.of(context);
|
final pt = PyramidTheme.of(context);
|
||||||
final call = ref.watch(voiceChannelProvider);
|
final call = ref.watch(voiceChannelProvider);
|
||||||
final voip = ref.watch(callSignalingProvider);
|
final voip = ref.watch(callSignalingProvider);
|
||||||
|
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||||
|
|
||||||
if (voip.currentCall != null && voip.currentCall!.state != CallState.kEnded) {
|
if (voip.currentCall != null && voip.currentCall!.state != CallState.kEnded) {
|
||||||
return _UnifiedCallView(
|
return _UnifiedCallView(
|
||||||
participants: _getVoipParticipants(voip),
|
participants: _getVoipParticipants(voip),
|
||||||
|
client: client,
|
||||||
|
startedAt: _callStartFor('voip:${voip.currentCall!.callId}'),
|
||||||
isLiveKit: false,
|
isLiveKit: false,
|
||||||
displayName: voip.currentCall!.room.getLocalizedDisplayname(),
|
displayName: voip.currentCall!.room.getLocalizedDisplayname(),
|
||||||
isConnecting: false,
|
isConnecting: false,
|
||||||
@@ -68,7 +97,9 @@ class VoiceChannelView extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return _UnifiedCallView(
|
return _UnifiedCallView(
|
||||||
participants: _getLiveKitParticipants(call),
|
participants: _getLiveKitParticipants(call, client),
|
||||||
|
client: client,
|
||||||
|
startedAt: _callStartFor('lk:${call.currentRoomId ?? call.displayName}'),
|
||||||
isLiveKit: true,
|
isLiveKit: true,
|
||||||
displayName: call.displayName,
|
displayName: call.displayName,
|
||||||
isConnecting: call.isConnecting,
|
isConnecting: call.isConnecting,
|
||||||
@@ -93,9 +124,26 @@ class VoiceChannelView extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<_ParticipantData> _getLiveKitParticipants(VoiceChannelService call) {
|
List<_ParticipantData> _getLiveKitParticipants(
|
||||||
|
VoiceChannelService call, Client? client) {
|
||||||
final room = call.room;
|
final room = call.room;
|
||||||
if (room == null) return [];
|
if (room == null) return [];
|
||||||
|
// LiveKit-Identität = Matrix-ID (vom Token-Server gesetzt) → Name und
|
||||||
|
// Profilbild aus dem Matrix-Raum des Anrufs.
|
||||||
|
final matrixRoomId = call.currentRoomId;
|
||||||
|
final matrixRoom =
|
||||||
|
matrixRoomId == null ? null : client?.getRoomById(matrixRoomId);
|
||||||
|
User? member(String id) {
|
||||||
|
if (!id.startsWith('@')) return null;
|
||||||
|
if (matrixRoom != null) {
|
||||||
|
return matrixRoom.unsafeGetUserFromMemoryOrFallback(id);
|
||||||
|
}
|
||||||
|
for (final r in client?.rooms ?? const []) {
|
||||||
|
final u = r.unsafeGetUserFromMemoryOrFallback(id);
|
||||||
|
if (u.avatarUrl != null || u.displayName != null) return u;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
final all = <Participant>[
|
final all = <Participant>[
|
||||||
if (room.localParticipant != null) room.localParticipant!,
|
if (room.localParticipant != null) room.localParticipant!,
|
||||||
...room.remoteParticipants.values,
|
...room.remoteParticipants.values,
|
||||||
@@ -119,10 +167,20 @@ class VoiceChannelView extends ConsumerWidget {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final isLocal = p.identity == room.localParticipant?.identity;
|
||||||
|
final user = member(p.identity);
|
||||||
|
final name = isLocal
|
||||||
|
? 'Du'
|
||||||
|
: (user?.calcDisplayname() ??
|
||||||
|
(p.name.isNotEmpty ? p.name : p.identity));
|
||||||
return _ParticipantData(
|
return _ParticipantData(
|
||||||
id: p.identity,
|
id: p.identity,
|
||||||
name: p.identity == room.localParticipant?.identity ? 'Du' : p.identity,
|
name: name,
|
||||||
|
initialFrom: isLocal ? user?.calcDisplayname() ?? p.identity : null,
|
||||||
|
avatarUrl: user?.avatarUrl,
|
||||||
|
isLocal: isLocal,
|
||||||
videoWidget: videoTrack != null ? VideoTrackRenderer(videoTrack, fit: VideoViewFit.contain) : null,
|
videoWidget: videoTrack != null ? VideoTrackRenderer(videoTrack, fit: VideoViewFit.contain) : null,
|
||||||
|
coverVideoWidget: videoTrack != null ? VideoTrackRenderer(videoTrack, fit: VideoViewFit.cover) : null,
|
||||||
isSpeaking: p.isSpeaking,
|
isSpeaking: p.isSpeaking,
|
||||||
isMuted: !p.isMicrophoneEnabled(),
|
isMuted: !p.isMicrophoneEnabled(),
|
||||||
hasVideo: videoTrack != null,
|
hasVideo: videoTrack != null,
|
||||||
@@ -143,13 +201,21 @@ class VoiceChannelView extends ConsumerWidget {
|
|||||||
return (w > 0 && h > 0) ? w / h : 16.0 / 9.0;
|
return (w > 0 && h > 0) ? w / h : 16.0 / 9.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final mroom = voip.currentCall?.room;
|
||||||
|
final partnerId = mroom?.directChatMatrixID;
|
||||||
|
final myId = mroom?.client.userID;
|
||||||
|
|
||||||
// Remote
|
// Remote
|
||||||
final isRemoteScreen = voip.isRemoteScreensharing;
|
final isRemoteScreen = voip.isRemoteScreensharing;
|
||||||
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,
|
||||||
|
coverVideoWidget: hasRemoteVideo ? rtc.RTCVideoView(voip.remoteRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitCover) : null,
|
||||||
|
avatarUrl: partnerId == null
|
||||||
|
? mroom?.avatar
|
||||||
|
: mroom?.unsafeGetUserFromMemoryOrFallback(partnerId).avatarUrl,
|
||||||
isMuted: false,
|
isMuted: false,
|
||||||
isSpeaking: voip.isRemoteSpeaking,
|
isSpeaking: voip.isRemoteSpeaking,
|
||||||
hasVideo: hasRemoteVideo,
|
hasVideo: hasRemoteVideo,
|
||||||
@@ -164,7 +230,13 @@ 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,
|
||||||
|
coverVideoWidget: hasLocalVideo ? rtc.RTCVideoView(voip.localRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitCover) : null,
|
||||||
|
avatarUrl: myId == null
|
||||||
|
? null
|
||||||
|
: mroom?.unsafeGetUserFromMemoryOrFallback(myId).avatarUrl,
|
||||||
|
isLocal: true,
|
||||||
isMuted: voip.isMicMuted,
|
isMuted: voip.isMicMuted,
|
||||||
isSpeaking: voip.isLocalSpeaking,
|
isSpeaking: voip.isLocalSpeaking,
|
||||||
hasVideo: hasLocalVideo,
|
hasVideo: hasLocalVideo,
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/scheduler.dart' show Ticker;
|
||||||
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:livekit_client/livekit_client.dart';
|
import 'package:livekit_client/livekit_client.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:pyramid/core/app_state.dart';
|
import 'package:pyramid/core/app_state.dart';
|
||||||
|
import 'package:pyramid/core/matrix_client.dart';
|
||||||
|
import 'package:pyramid/features/call_ui/bubble_physics.dart';
|
||||||
|
import 'package:pyramid/widgets/mxc_image.dart';
|
||||||
|
import 'package:pyramid/widgets/spring_press.dart' show motionDuration;
|
||||||
import 'package:pyramid/widgets/screen_share_picker.dart';
|
import 'package:pyramid/widgets/screen_share_picker.dart';
|
||||||
import 'package:pyramid/core/theme.dart';
|
import 'package:pyramid/core/theme.dart';
|
||||||
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
||||||
@@ -17,3 +25,4 @@ part 'view/call_view.dart';
|
|||||||
part 'view/participant_layout.dart';
|
part 'view/participant_layout.dart';
|
||||||
part 'view/avatars_controls.dart';
|
part 'view/avatars_controls.dart';
|
||||||
part 'view/quality_menu.dart';
|
part 'view/quality_menu.dart';
|
||||||
|
part 'view/bubble_stage.dart';
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import 'package:path_provider/path_provider.dart';
|
|||||||
import 'package:pyramid/core/settings_prefs.dart';
|
import 'package:pyramid/core/settings_prefs.dart';
|
||||||
import 'package:pyramid/core/appearance.dart';
|
import 'package:pyramid/core/appearance.dart';
|
||||||
import 'package:pyramid/core/theme.dart';
|
import 'package:pyramid/core/theme.dart';
|
||||||
|
import 'package:pyramid/features/rooms/rooms_provider.dart'
|
||||||
|
show spaceChannelIdsProvider;
|
||||||
import 'package:pyramid/widgets/spring_press.dart';
|
import 'package:pyramid/widgets/spring_press.dart';
|
||||||
import 'package:pyramid/features/chat/attachment_dialog.dart';
|
import 'package:pyramid/features/chat/attachment_dialog.dart';
|
||||||
import 'package:pyramid/features/chat/emoji_picker.dart';
|
import 'package:pyramid/features/chat/emoji_picker.dart';
|
||||||
@@ -319,9 +321,12 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
|
|||||||
final mobile = isMobileLayout(size);
|
final mobile = isMobileLayout(size);
|
||||||
|
|
||||||
final roomName = widget.room.getLocalizedDisplayname();
|
final roomName = widget.room.getLocalizedDisplayname();
|
||||||
|
// „#“ nur bei Server-Kanälen; Gruppen heißen wie die Gruppe.
|
||||||
final placeholder = widget.room.isDirectChat
|
final placeholder = widget.room.isDirectChat
|
||||||
? 'Nachricht…'
|
? 'Nachricht…'
|
||||||
: 'Nachricht an #$roomName';
|
: ref.watch(spaceChannelIdsProvider).contains(widget.room.id)
|
||||||
|
? 'Nachricht an #$roomName'
|
||||||
|
: 'Nachricht an $roomName';
|
||||||
|
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
@@ -19,10 +19,14 @@ import 'package:pyramid/features/chat/chat_composer.dart';
|
|||||||
import 'package:pyramid/features/chat/chat_provider.dart';
|
import 'package:pyramid/features/chat/chat_provider.dart';
|
||||||
import 'package:pyramid/features/chat/message_group.dart';
|
import 'package:pyramid/features/chat/message_group.dart';
|
||||||
import 'package:pyramid/features/chat/pinned_messages_panel.dart';
|
import 'package:pyramid/features/chat/pinned_messages_panel.dart';
|
||||||
|
import 'package:pyramid/features/group_call/group_call.dart';
|
||||||
import 'package:pyramid/widgets/hover_region.dart';
|
import 'package:pyramid/widgets/hover_region.dart';
|
||||||
import 'package:pyramid/widgets/measure_size.dart';
|
import 'package:pyramid/widgets/measure_size.dart';
|
||||||
import 'package:pyramid/widgets/spring_press.dart';
|
import 'package:pyramid/widgets/spring_press.dart';
|
||||||
import 'package:pyramid/core/read_markers.dart';
|
import 'package:pyramid/core/read_markers.dart';
|
||||||
|
import 'package:pyramid/core/room_kinds.dart';
|
||||||
|
import 'package:pyramid/features/rooms/rooms_provider.dart'
|
||||||
|
show spaceChannelIdsProvider;
|
||||||
import 'package:pyramid/widgets/mxc_image.dart';
|
import 'package:pyramid/widgets/mxc_image.dart';
|
||||||
|
|
||||||
import 'package:pyramid/core/notification_service.dart';
|
import 'package:pyramid/core/notification_service.dart';
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ class _MessageContent extends StatelessWidget {
|
|||||||
final displayEvent = _displayEvent;
|
final displayEvent = _displayEvent;
|
||||||
final isEdited = displayEvent.eventId != event.eventId;
|
final isEdited = displayEvent.eventId != event.eventId;
|
||||||
|
|
||||||
|
if (GroupCallNotice.matches(displayEvent)) {
|
||||||
|
return GroupCallNotice(event: displayEvent);
|
||||||
|
}
|
||||||
|
|
||||||
// Still encrypted (decryption pending or failed)
|
// Still encrypted (decryption pending or failed)
|
||||||
if (displayEvent.type == EventTypes.Encrypted) {
|
if (displayEvent.type == EventTypes.Encrypted) {
|
||||||
return Padding(
|
return Padding(
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import 'package:flutter_svg/flutter_svg.dart';
|
|||||||
import 'package:pyramid/features/chat/document_viewer.dart';
|
import 'package:pyramid/features/chat/document_viewer.dart';
|
||||||
import 'package:pyramid/features/chat/media_viewer.dart';
|
import 'package:pyramid/features/chat/media_viewer.dart';
|
||||||
import 'package:pyramid/features/chat/reaction_service.dart';
|
import 'package:pyramid/features/chat/reaction_service.dart';
|
||||||
|
import 'package:pyramid/features/group_call/group_call.dart';
|
||||||
import 'package:pyramid/utils/gif_favorite_service.dart';
|
import 'package:pyramid/utils/gif_favorite_service.dart';
|
||||||
import 'package:pyramid/widgets/mxc_image.dart';
|
import 'package:pyramid/widgets/mxc_image.dart';
|
||||||
import 'package:pyramid/widgets/spring_press.dart';
|
import 'package:pyramid/widgets/spring_press.dart';
|
||||||
|
|||||||
@@ -26,10 +26,19 @@ class _ChatHeader extends ConsumerWidget {
|
|||||||
final name = room?.getLocalizedDisplayname() ?? roomId;
|
final name = room?.getLocalizedDisplayname() ?? roomId;
|
||||||
final isDm = room?.isDirectChat ?? false;
|
final isDm = room?.isDirectChat ?? false;
|
||||||
final isEncrypted = room?.encrypted ?? false;
|
final isEncrypted = room?.encrypted ?? false;
|
||||||
final topic = room?.topic ?? (isDm ? '' : 'Welcome to #$name');
|
final topic = room?.topic ?? '';
|
||||||
final client = ref.watch(matrixClientProvider).valueOrNull;
|
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||||
final size = MediaQuery.sizeOf(context);
|
final size = MediaQuery.sizeOf(context);
|
||||||
final isMobile = size.width < 900 || size.height < 500;
|
final isMobile = size.width < 900 || size.height < 500;
|
||||||
|
// Gruppenchat (kein Server-Kanal, kein Sprachkanal): Kopf mit Avatar +
|
||||||
|
// Mitgliederzahl und Gruppenanruf (UI-Audit S3, Bernd 2026-10-09).
|
||||||
|
final isGroup = room != null &&
|
||||||
|
isGroupChatRoom(room!) &&
|
||||||
|
!ref.watch(spaceChannelIdsProvider).contains(room!.id);
|
||||||
|
final groupCallRunning = isGroup &&
|
||||||
|
otherCallParticipants(
|
||||||
|
ref.watch(voiceParticipantsProvider(room!.id)), room!)
|
||||||
|
.isNotEmpty;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
height: 52,
|
height: 52,
|
||||||
@@ -53,6 +62,8 @@ class _ChatHeader extends ConsumerWidget {
|
|||||||
// Room icon / avatar
|
// Room icon / avatar
|
||||||
if (isDm && client != null) ...[
|
if (isDm && client != null) ...[
|
||||||
_DmHeaderAvatar(room: room, name: name, client: client),
|
_DmHeaderAvatar(room: room, name: name, client: client),
|
||||||
|
] else if (isGroup && client != null) ...[
|
||||||
|
_GroupHeaderAvatar(room: room!, client: client, pt: pt),
|
||||||
] else if (!isDm) ...[
|
] else if (!isDm) ...[
|
||||||
Icon(
|
Icon(
|
||||||
isEncrypted ? Icons.lock_outline_rounded : Icons.tag_rounded,
|
isEncrypted ? Icons.lock_outline_rounded : Icons.tag_rounded,
|
||||||
@@ -85,6 +96,28 @@ class _ChatHeader extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
else if (isGroup)
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.fg,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w600),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
_groupSubtitle(room!),
|
||||||
|
style: TextStyle(color: pt.fgDim, fontSize: 12),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
else
|
else
|
||||||
Text(
|
Text(
|
||||||
name,
|
name,
|
||||||
@@ -92,7 +125,9 @@ class _ChatHeader extends ConsumerWidget {
|
|||||||
color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600),
|
color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
if (!isDm && topic.isNotEmpty) ...[
|
if (isGroup)
|
||||||
|
const SizedBox.shrink()
|
||||||
|
else if (!isDm && topic.isNotEmpty) ...[
|
||||||
Container(width: 1, height: 18, color: pt.border),
|
Container(width: 1, height: 18, color: pt.border),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -107,18 +142,25 @@ class _ChatHeader extends ConsumerWidget {
|
|||||||
// Right actions
|
// Right actions
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
if (isDm) ...[
|
if (isDm || isGroup) ...[
|
||||||
PyrIconBtn(
|
PyrIconBtn(
|
||||||
size: 28,
|
size: 28,
|
||||||
icon: const Icon(Icons.phone_rounded),
|
icon: Icon(Icons.phone_rounded,
|
||||||
tooltip: 'Voice call',
|
color: groupCallRunning ? pt.online : null),
|
||||||
onPressed: () => _startCall(ref, room, cam: false),
|
tooltip: groupCallRunning
|
||||||
|
? 'Gruppenanruf beitreten'
|
||||||
|
: (isGroup ? 'Gruppenanruf' : 'Anrufen'),
|
||||||
|
onPressed: () => isGroup
|
||||||
|
? startOrJoinGroupCall(context, ref, room!)
|
||||||
|
: _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: () => isGroup
|
||||||
|
? startOrJoinGroupCall(context, ref, room!, video: true)
|
||||||
|
: _startCall(ref, room, cam: true),
|
||||||
),
|
),
|
||||||
if (size.width > 700)
|
if (size.width > 700)
|
||||||
Container(
|
Container(
|
||||||
@@ -129,12 +171,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 +593,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)
|
||||||
@@ -641,6 +677,43 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Untertitel im Gruppen-Kopf: Mitgliederzahl, dahinter das Thema.
|
||||||
|
String _groupSubtitle(Room room) {
|
||||||
|
final count = room.summary.mJoinedMemberCount ?? room.getParticipants().length;
|
||||||
|
final members = count == 1 ? '1 Mitglied' : '$count Mitglieder';
|
||||||
|
final topic = room.topic.trim();
|
||||||
|
return topic.isEmpty ? members : '$members · $topic';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runder Gruppen-Avatar (Raumbild oder Gruppen-Symbol).
|
||||||
|
class _GroupHeaderAvatar extends StatelessWidget {
|
||||||
|
final Room room;
|
||||||
|
final Client client;
|
||||||
|
final PyramidTheme pt;
|
||||||
|
const _GroupHeaderAvatar(
|
||||||
|
{required this.room, required this.client, required this.pt});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
const size = 32.0;
|
||||||
|
Widget fallback(BuildContext _) => Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration:
|
||||||
|
BoxDecoration(color: pt.accentSoft, shape: BoxShape.circle),
|
||||||
|
child: Icon(Icons.groups_rounded, size: 18, color: pt.accent),
|
||||||
|
);
|
||||||
|
if (room.avatar == null) return Builder(builder: fallback);
|
||||||
|
return MxcAvatar(
|
||||||
|
mxcUri: room.avatar,
|
||||||
|
client: client,
|
||||||
|
size: size,
|
||||||
|
borderRadius: BorderRadius.circular(size / 2),
|
||||||
|
placeholder: fallback,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _DmHeaderAvatar extends StatelessWidget {
|
class _DmHeaderAvatar extends StatelessWidget {
|
||||||
final Room? room;
|
final Room? room;
|
||||||
final String name;
|
final String name;
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -521,6 +521,8 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
),
|
),
|
||||||
const _ConnectionBanner(),
|
const _ConnectionBanner(),
|
||||||
const _ActiveCallBar(),
|
const _ActiveCallBar(),
|
||||||
|
if (room != null && !isInvite)
|
||||||
|
GroupCallBanner(room: room),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: isInvite
|
child: isInvite
|
||||||
? _InvitePanel(room: room!, pt: pt)
|
? _InvitePanel(room: room!, pt: pt)
|
||||||
@@ -535,7 +537,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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,362 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
import 'package:pyramid/core/app_state.dart';
|
||||||
|
import 'package:pyramid/core/theme.dart';
|
||||||
|
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
||||||
|
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
|
||||||
|
import 'package:pyramid/widgets/mxc_image.dart';
|
||||||
|
import 'package:pyramid/widgets/spring_press.dart';
|
||||||
|
|
||||||
|
/// Gruppenanrufe in Gruppenchats (Bernd 2026-10-09: „Anruf + Beitreten-Karte“).
|
||||||
|
///
|
||||||
|
/// - Technik wie bei Sprachkanälen: LiveKit-Raum = Matrix-Raum-ID, Anwesenheit
|
||||||
|
/// über `io.pyramid.voice.presence`-State-Events (Herzschlag alle 30 s).
|
||||||
|
/// - Wer einen Anruf eröffnet (sonst niemand drin), schickt die Nachricht
|
||||||
|
/// „📞 Gruppenanruf gestartet“ mit [groupCallContentKey]. Sie löst bei den
|
||||||
|
/// Mitgliedern eine normale Push-Benachrichtigung aus (eine m.notice würde
|
||||||
|
/// von den Standard-Push-Regeln stumm geschaltet) und erscheint in Pyramid
|
||||||
|
/// als Anruf-Karte; andere Apps zeigen den Text.
|
||||||
|
/// - Damit alle Mitglieder ihre Anwesenheit schreiben dürfen, wird die nötige
|
||||||
|
/// Berechtigung für dieses eine State-Event auf „alle“ gesenkt – nur wer
|
||||||
|
/// Rechte ändern darf (neue Gruppen bekommen das schon beim Erstellen).
|
||||||
|
const groupCallContentKey = 'io.pyramid.group_call';
|
||||||
|
|
||||||
|
/// Erlaubt allen Mitgliedern von [roomId], ihre Anruf-Anwesenheit zu
|
||||||
|
/// schreiben (sonst sieht man im Anruf nur Admins). Ändert nur diesen einen
|
||||||
|
/// Eintrag der Berechtigungen.
|
||||||
|
Future<void> allowVoicePresenceForAll(Client client, String roomId) async {
|
||||||
|
final plRaw =
|
||||||
|
await client.getRoomStateWithKey(roomId, EventTypes.RoomPowerLevels, '');
|
||||||
|
final pl = Map<String, dynamic>.from(plRaw);
|
||||||
|
final events = Map<String, dynamic>.from(pl['events'] as Map? ?? {});
|
||||||
|
final usersDefault = pl['users_default'] is int ? pl['users_default'] as int : 0;
|
||||||
|
if (events[kVoicePresenceType] == usersDefault) return;
|
||||||
|
events[kVoicePresenceType] = usersDefault;
|
||||||
|
pl['events'] = events;
|
||||||
|
await client.setRoomStateWithKey(roomId, EventTypes.RoomPowerLevels, '', pl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie [allowVoicePresenceForAll], aber nur wenn nötig UND erlaubt.
|
||||||
|
Future<void> ensureVoicePresenceWritable(Room room) async {
|
||||||
|
final pl = room.getState(EventTypes.RoomPowerLevels)?.content;
|
||||||
|
if (pl == null) return; // keine Rechte-Tabelle: jeder darf
|
||||||
|
final usersDefault = pl['users_default'] is int ? pl['users_default'] as int : 0;
|
||||||
|
if (room.powerForChangingStateEvent(kVoicePresenceType) <= usersDefault) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!room.canChangePowerLevel) return;
|
||||||
|
await allowVoicePresenceForAll(room.client, room.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bin ich gerade (oder gleich) im Gruppenanruf von [roomId]?
|
||||||
|
bool isInGroupCall(VoiceChannelService call, String roomId) =>
|
||||||
|
(call.isActive || call.isConnecting) && call.currentRoomId == roomId;
|
||||||
|
|
||||||
|
/// Teilnehmer des Anrufs in [room] ohne mich selbst.
|
||||||
|
List<Map<String, dynamic>> otherCallParticipants(
|
||||||
|
List<Map<String, dynamic>> all, Room room) =>
|
||||||
|
all.where((p) => p['userId'] != room.client.userID).toList();
|
||||||
|
|
||||||
|
/// Anruf-Knopf im Gruppenchat und „Beitreten“: läuft schon ein Anruf, tritt
|
||||||
|
/// man bei; sonst startet man ihn und benachrichtigt die Mitglieder.
|
||||||
|
Future<void> startOrJoinGroupCall(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
Room room, {
|
||||||
|
bool video = false,
|
||||||
|
}) async {
|
||||||
|
final call = ref.read(voiceChannelProvider);
|
||||||
|
if (isInGroupCall(call, room.id)) {
|
||||||
|
ref.read(viewModeProvider.notifier).state = ViewMode.voice;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final voip = ref.read(callSignalingProvider).currentCall;
|
||||||
|
if (voip != null && voip.state != CallState.kEnded) {
|
||||||
|
ScaffoldMessenger.maybeOf(context)?.showSnackBar(const SnackBar(
|
||||||
|
content: Text('Beende zuerst den laufenden Anruf.')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final othersBefore = otherCallParticipants(
|
||||||
|
ref.read(voiceParticipantsProvider(room.id)), room)
|
||||||
|
.length;
|
||||||
|
if (call.isActive || call.isConnecting) await call.hangUp();
|
||||||
|
|
||||||
|
ref.read(activeVoiceRoomIdProvider.notifier).state = room.id;
|
||||||
|
ref.read(viewModeProvider.notifier).state = ViewMode.voice;
|
||||||
|
try {
|
||||||
|
await ensureVoicePresenceWritable(room);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[GroupCall] Berechtigung für Anwesenheit: $e');
|
||||||
|
}
|
||||||
|
await call.startCall(
|
||||||
|
roomName: room.id,
|
||||||
|
roomDisplayName: room.getLocalizedDisplayname(),
|
||||||
|
identity: room.client.userID ?? '',
|
||||||
|
audioOnly: !video,
|
||||||
|
matrixClient: room.client,
|
||||||
|
matrixRoomId: room.id,
|
||||||
|
);
|
||||||
|
if (othersBefore == 0 &&
|
||||||
|
call.isActive &&
|
||||||
|
call.error == null &&
|
||||||
|
call.currentRoomId == room.id) {
|
||||||
|
try {
|
||||||
|
await room.sendEvent({
|
||||||
|
'msgtype': MessageTypes.Text,
|
||||||
|
'body': video ? '📹 Videoanruf gestartet' : '📞 Gruppenanruf gestartet',
|
||||||
|
groupCallContentKey: {'action': 'start', 'video': video},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[GroupCall] Benachrichtigung: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Avatare der Teilnehmer, leicht überlappend (max. [max]).
|
||||||
|
class CallAvatarStack extends StatelessWidget {
|
||||||
|
final List<Map<String, dynamic>> participants;
|
||||||
|
final Client client;
|
||||||
|
final double size;
|
||||||
|
final int max;
|
||||||
|
final Color ringColor;
|
||||||
|
|
||||||
|
const CallAvatarStack({
|
||||||
|
super.key,
|
||||||
|
required this.participants,
|
||||||
|
required this.client,
|
||||||
|
required this.ringColor,
|
||||||
|
this.size = 30,
|
||||||
|
this.max = 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final pt = PyramidTheme.of(context);
|
||||||
|
final shown = participants.take(max).toList();
|
||||||
|
final step = size * 0.62;
|
||||||
|
return SizedBox(
|
||||||
|
width: shown.isEmpty ? 0 : size + step * (shown.length - 1),
|
||||||
|
height: size,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
for (var i = shown.length - 1; i >= 0; i--)
|
||||||
|
Positioned(
|
||||||
|
left: step * i,
|
||||||
|
child: Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: ringColor, width: 2),
|
||||||
|
),
|
||||||
|
child: ClipOval(child: _avatar(shown[i], pt)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _avatar(Map<String, dynamic> p, PyramidTheme pt) {
|
||||||
|
final name = (p['displayName'] as String? ?? '?').replaceFirst('@', '');
|
||||||
|
final initial = name.isEmpty ? '?' : name.characters.first.toUpperCase();
|
||||||
|
Widget fallback(BuildContext _) => Container(
|
||||||
|
color: pt.accentSoft,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(initial,
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.accent,
|
||||||
|
fontSize: size * 0.42,
|
||||||
|
fontWeight: FontWeight.w700)),
|
||||||
|
);
|
||||||
|
final url = p['avatarUrl'] as String?;
|
||||||
|
if (url == null || url.isEmpty) return Builder(builder: fallback);
|
||||||
|
return MxcAvatar(
|
||||||
|
mxcUri: Uri.tryParse(url),
|
||||||
|
client: client,
|
||||||
|
size: size,
|
||||||
|
borderRadius: BorderRadius.circular(size / 2),
|
||||||
|
placeholder: fallback,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _participantSummary(List<Map<String, dynamic>> others) {
|
||||||
|
final names = others
|
||||||
|
.map((p) => (p['displayName'] as String? ?? '').split(' ').first)
|
||||||
|
.where((n) => n.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
if (names.isEmpty) return '${others.length} dabei';
|
||||||
|
if (names.length <= 2) return '${names.join(' und ')} ${names.length == 1 ? 'ist' : 'sind'} dabei';
|
||||||
|
return '${names.take(2).join(', ')} und ${names.length - 2} weitere';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Karte über dem Chatverlauf: „Gruppenanruf läuft · N dabei · Beitreten“.
|
||||||
|
/// Unsichtbar, solange niemand im Anruf ist oder man selbst schon drin ist.
|
||||||
|
class GroupCallBanner extends ConsumerWidget {
|
||||||
|
final Room room;
|
||||||
|
const GroupCallBanner({super.key, required this.room});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final pt = PyramidTheme.of(context);
|
||||||
|
final others =
|
||||||
|
otherCallParticipants(ref.watch(voiceParticipantsProvider(room.id)), room);
|
||||||
|
final call = ref.watch(voiceChannelProvider);
|
||||||
|
final show = !room.isDirectChat &&
|
||||||
|
others.isNotEmpty &&
|
||||||
|
!isInGroupCall(call, room.id);
|
||||||
|
|
||||||
|
return AnimatedSize(
|
||||||
|
duration: motionDuration(context, 260),
|
||||||
|
curve: springyCurve,
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: !show
|
||||||
|
? const SizedBox(width: double.infinity)
|
||||||
|
: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 10, 12, 4),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 10, 10, 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(colors: [
|
||||||
|
pt.online.withValues(alpha: 0.20),
|
||||||
|
pt.online.withValues(alpha: 0.08),
|
||||||
|
]),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: pt.online.withValues(alpha: 0.35)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
CallAvatarStack(
|
||||||
|
participants: others,
|
||||||
|
client: room.client,
|
||||||
|
ringColor: pt.bg1,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text('Gruppenanruf läuft',
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.fg,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700)),
|
||||||
|
const SizedBox(height: 1),
|
||||||
|
Text(_participantSummary(others),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style:
|
||||||
|
TextStyle(color: pt.fgMuted, fontSize: 12.5)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
SpringPress(
|
||||||
|
child: FilledButton.icon(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: pt.online,
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 14, vertical: 10),
|
||||||
|
shape: const StadiumBorder(),
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.call_rounded, size: 18),
|
||||||
|
label: const Text('Beitreten',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.w700)),
|
||||||
|
onPressed: () => startOrJoinGroupCall(context, ref, room),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Darstellung der „Gruppenanruf gestartet“-Nachricht im Verlauf.
|
||||||
|
class GroupCallNotice extends ConsumerWidget {
|
||||||
|
final Event event;
|
||||||
|
const GroupCallNotice({super.key, required this.event});
|
||||||
|
|
||||||
|
/// Ist [event] eine Gruppenanruf-Nachricht von Pyramid?
|
||||||
|
static bool matches(Event event) =>
|
||||||
|
event.content[groupCallContentKey] is Map;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final pt = PyramidTheme.of(context);
|
||||||
|
final room = event.room;
|
||||||
|
final info = event.content[groupCallContentKey] as Map;
|
||||||
|
final video = info['video'] == true;
|
||||||
|
final all = ref.watch(voiceParticipantsProvider(room.id));
|
||||||
|
final call = ref.watch(voiceChannelProvider);
|
||||||
|
final inThis = isInGroupCall(call, room.id);
|
||||||
|
// Alte Karten (gestern und früher) gehören zu längst beendeten Anrufen.
|
||||||
|
final recent = DateTime.now().difference(event.originServerTs) <
|
||||||
|
const Duration(hours: 12);
|
||||||
|
final running = recent && (all.isNotEmpty || inThis);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
padding: const EdgeInsets.fromLTRB(10, 8, 8, 8),
|
||||||
|
constraints: const BoxConstraints(maxWidth: 340),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: running ? pt.online.withValues(alpha: 0.12) : pt.bg2,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(
|
||||||
|
color: running ? pt.online.withValues(alpha: 0.35) : pt.border),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: running ? pt.online : pt.bg3,
|
||||||
|
),
|
||||||
|
child: Icon(video ? Icons.videocam_rounded : Icons.call_rounded,
|
||||||
|
size: 18, color: running ? Colors.black : pt.fgMuted),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Flexible(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(video ? 'Videoanruf gestartet' : 'Gruppenanruf gestartet',
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.fg,
|
||||||
|
fontSize: 13.5,
|
||||||
|
fontWeight: FontWeight.w700)),
|
||||||
|
Text(
|
||||||
|
running
|
||||||
|
? (inThis ? 'Du bist dabei' : 'Läuft · ${all.length} dabei')
|
||||||
|
: 'Beendet',
|
||||||
|
style: TextStyle(color: pt.fgMuted, fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (running && !inThis) ...[
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
TextButton(
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: pt.online,
|
||||||
|
textStyle: const TextStyle(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
onPressed: () =>
|
||||||
|
startOrJoinGroupCall(context, ref, room, video: video),
|
||||||
|
child: const Text('Beitreten'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class _Header extends ConsumerWidget {
|
|||||||
final isRealSpace = spaceId != null && spaceId != 'dms' && spaceId != 'rooms';
|
final isRealSpace = spaceId != null && spaceId != 'dms' && spaceId != 'rooms';
|
||||||
final size = MediaQuery.sizeOf(context);
|
final size = MediaQuery.sizeOf(context);
|
||||||
final isMobile = size.width < 900 || size.height < 500;
|
final isMobile = size.width < 900 || size.height < 500;
|
||||||
|
final bottomNav = ref.watch(bottomNavProvider);
|
||||||
|
|
||||||
final spaces = ref.watch(spacesProvider).valueOrNull ?? [];
|
final spaces = ref.watch(spacesProvider).valueOrNull ?? [];
|
||||||
Room? activeSpace;
|
Room? activeSpace;
|
||||||
@@ -29,7 +30,7 @@ class _Header extends ConsumerWidget {
|
|||||||
|
|
||||||
final name = isRealSpace
|
final name = isRealSpace
|
||||||
? (activeSpace?.getLocalizedDisplayname() ?? 'Space')
|
? (activeSpace?.getLocalizedDisplayname() ?? 'Space')
|
||||||
: (spaceId == 'dms' ? 'Chats' : 'Räume');
|
: 'Chats';
|
||||||
|
|
||||||
void handleMenu(_SpaceMenuItem item) {
|
void handleMenu(_SpaceMenuItem item) {
|
||||||
switch (item) {
|
switch (item) {
|
||||||
@@ -101,7 +102,7 @@ class _Header extends ConsumerWidget {
|
|||||||
).then((confirmed) {
|
).then((confirmed) {
|
||||||
if (confirmed == true) {
|
if (confirmed == true) {
|
||||||
space.leave().then((_) {
|
space.leave().then((_) {
|
||||||
ref.read(activeSpaceIdProvider.notifier).state = 'rooms';
|
ref.read(activeSpaceIdProvider.notifier).state = 'dms';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -110,7 +111,7 @@ class _Header extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<PopupMenuEntry<_SpaceMenuItem>> buildMenuItems() {
|
List<PopupMenuEntry<_SpaceMenuItem>> buildMenuItems() {
|
||||||
if (spaceId == 'dms' || spaceId == null) {
|
if (!isRealSpace) {
|
||||||
return [
|
return [
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: _SpaceMenuItem.newDm,
|
value: _SpaceMenuItem.newDm,
|
||||||
@@ -119,15 +120,10 @@ class _Header extends ConsumerWidget {
|
|||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: _SpaceMenuItem.createGroup,
|
value: _SpaceMenuItem.createGroup,
|
||||||
child: _MenuRow(Icons.group_add_rounded, 'Gruppe erstellen', pt)),
|
child: _MenuRow(Icons.group_add_rounded, 'Gruppe erstellen', pt)),
|
||||||
];
|
const PopupMenuDivider(),
|
||||||
} else if (spaceId == 'rooms') {
|
|
||||||
return [
|
|
||||||
PopupMenuItem(
|
|
||||||
value: _SpaceMenuItem.createRoom,
|
|
||||||
child: _MenuRow(Icons.add_rounded, 'Raum erstellen', pt)),
|
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: _SpaceMenuItem.joinRoom,
|
value: _SpaceMenuItem.joinRoom,
|
||||||
child: _MenuRow(Icons.login_rounded, 'Beitreten', pt)),
|
child: _MenuRow(Icons.login_rounded, 'Raum beitreten', pt)),
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: _SpaceMenuItem.discoverRooms,
|
value: _SpaceMenuItem.discoverRooms,
|
||||||
child: _MenuRow(Icons.explore_rounded, 'Entdecken', pt)),
|
child: _MenuRow(Icons.explore_rounded, 'Entdecken', pt)),
|
||||||
@@ -167,7 +163,18 @@ class _Header extends ConsumerWidget {
|
|||||||
|
|
||||||
Widget controlsRow = Row(
|
Widget controlsRow = Row(
|
||||||
children: [
|
children: [
|
||||||
if (isMobile)
|
if (isMobile && bottomNav) ...[
|
||||||
|
if (isRealSpace)
|
||||||
|
PyrIconBtn(
|
||||||
|
icon: Icon(Icons.arrow_back_rounded,
|
||||||
|
color: hasBanner ? Colors.white : null),
|
||||||
|
tooltip: 'Alle Server',
|
||||||
|
onPressed: () =>
|
||||||
|
ref.read(activeSpaceIdProvider.notifier).state = null,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const SizedBox(width: 2),
|
||||||
|
] else if (isMobile)
|
||||||
PyrIconBtn(
|
PyrIconBtn(
|
||||||
icon: const Icon(Icons.menu_rounded),
|
icon: const Icon(Icons.menu_rounded),
|
||||||
tooltip: 'Spaces',
|
tooltip: 'Spaces',
|
||||||
@@ -176,7 +183,7 @@ class _Header extends ConsumerWidget {
|
|||||||
else ...[
|
else ...[
|
||||||
PyrIconBtn(
|
PyrIconBtn(
|
||||||
icon: const Icon(Icons.menu_rounded),
|
icon: const Icon(Icons.menu_rounded),
|
||||||
tooltip: 'Toggle sidebar',
|
tooltip: 'Seitenleiste ein/aus',
|
||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
ref.read(railCollapsedProvider.notifier).update((s) => !s),
|
ref.read(railCollapsedProvider.notifier).update((s) => !s),
|
||||||
),
|
),
|
||||||
@@ -316,7 +323,7 @@ class _Header extends ConsumerWidget {
|
|||||||
height: isRealSpace ? 56 : 52,
|
height: isRealSpace ? 56 : 52,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isRealSpace ? pt.bg0 : pt.bg1,
|
color: isRealSpace && !isMobile ? pt.bg0 : pt.bg1,
|
||||||
border: Border(bottom: BorderSide(color: pt.border)),
|
border: Border(bottom: BorderSide(color: pt.border)),
|
||||||
),
|
),
|
||||||
child: controlsRow,
|
child: controlsRow,
|
||||||
@@ -458,9 +465,13 @@ class _NotificationsButton extends ConsumerWidget {
|
|||||||
class _FilterInput extends StatelessWidget {
|
class _FilterInput extends StatelessWidget {
|
||||||
final TextEditingController controller;
|
final TextEditingController controller;
|
||||||
final PyramidTheme pt;
|
final PyramidTheme pt;
|
||||||
|
final String hint;
|
||||||
final ValueChanged<String> onChanged;
|
final ValueChanged<String> onChanged;
|
||||||
const _FilterInput(
|
const _FilterInput(
|
||||||
{required this.controller, required this.pt, required this.onChanged});
|
{required this.controller,
|
||||||
|
required this.pt,
|
||||||
|
required this.hint,
|
||||||
|
required this.onChanged});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -469,18 +480,17 @@ class _FilterInput extends StatelessWidget {
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(left: 8),
|
|
||||||
child: Icon(Icons.search_rounded, size: 14, color: pt.fgDim),
|
|
||||||
),
|
|
||||||
TextField(
|
TextField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
onChanged: onChanged,
|
onChanged: onChanged,
|
||||||
style: TextStyle(color: pt.fg, fontSize: 13),
|
style: TextStyle(color: pt.fg, fontSize: 13),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Chats durchsuchen',
|
hintText: hint,
|
||||||
hintStyle: TextStyle(color: pt.fgDim, fontSize: 13),
|
hintStyle: TextStyle(color: pt.fgDim, fontSize: 13),
|
||||||
contentPadding: const EdgeInsets.fromLTRB(28, 7, 10, 7),
|
prefixIcon: Icon(Icons.search_rounded, size: 16, color: pt.fgDim),
|
||||||
|
prefixIconConstraints:
|
||||||
|
const BoxConstraints(minWidth: 32, minHeight: 20),
|
||||||
|
contentPadding: const EdgeInsets.fromLTRB(0, 7, 10, 7),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: pt.bg2,
|
fillColor: pt.bg2,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
|
|||||||
@@ -59,56 +59,66 @@ class _DraggableRoomItemState extends State<_DraggableRoomItem> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Filter der gemeinsamen Chatliste (Chips über der Liste).
|
||||||
|
enum _ChatFilter { all, unread, people, groups }
|
||||||
|
|
||||||
|
final _chatFilterProvider =
|
||||||
|
StateProvider<_ChatFilter>((ref) => _ChatFilter.all);
|
||||||
|
|
||||||
|
/// „Chats“: Direktnachrichten und Gruppen zusammen, nach letzter Aktivität
|
||||||
|
/// (Bernd 2026-10-09). Kanäle eines Servers stehen nur in ihrem Server.
|
||||||
class _RoomsList extends ConsumerWidget {
|
class _RoomsList extends ConsumerWidget {
|
||||||
final List<Room> rooms;
|
final List<Room> rooms;
|
||||||
final String filter;
|
final String filter;
|
||||||
final String? spaceId;
|
|
||||||
final Map<String, bool> collapsed;
|
|
||||||
final PyramidTheme pt;
|
final PyramidTheme pt;
|
||||||
final ValueChanged<String> onToggleSection;
|
|
||||||
|
|
||||||
const _RoomsList({
|
const _RoomsList({
|
||||||
required this.rooms,
|
required this.rooms,
|
||||||
required this.filter,
|
required this.filter,
|
||||||
required this.spaceId,
|
|
||||||
required this.collapsed,
|
|
||||||
required this.pt,
|
required this.pt,
|
||||||
required this.onToggleSection,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final activeRoomId = ref.watch(activeRoomIdProvider);
|
final activeRoomId = ref.watch(activeRoomIdProvider);
|
||||||
final isDms = spaceId == 'dms';
|
final channels = ref.watch(spaceChannelIdsProvider);
|
||||||
|
final chatFilter = ref.watch(_chatFilterProvider);
|
||||||
|
final query = filter.toLowerCase();
|
||||||
|
|
||||||
final invites = rooms
|
final invites =
|
||||||
.where((r) =>
|
rooms.where((r) => r.membership == Membership.invite).toList();
|
||||||
r.membership == Membership.invite &&
|
|
||||||
isDms == r.isDirectChat)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
final filtered = rooms.where((r) {
|
bool matches(Room r) {
|
||||||
if (r.membership == Membership.invite) return false;
|
switch (chatFilter) {
|
||||||
if (isDms != r.isDirectChat) return false;
|
case _ChatFilter.all:
|
||||||
if (filter.isEmpty) return true;
|
break;
|
||||||
return r.getLocalizedDisplayname()
|
case _ChatFilter.unread:
|
||||||
.toLowerCase()
|
if (!r.isUnread) return false;
|
||||||
.contains(filter.toLowerCase());
|
case _ChatFilter.people:
|
||||||
}).toList();
|
if (!r.isDirectChat) return false;
|
||||||
|
case _ChatFilter.groups:
|
||||||
if (filtered.isEmpty && invites.isEmpty) {
|
if (r.isDirectChat) return false;
|
||||||
return Center(
|
}
|
||||||
child:
|
if (query.isEmpty) return true;
|
||||||
Text(isDms ? 'Noch keine Chats' : 'Keine Räume', style: TextStyle(color: pt.fgDim, fontSize: 13)));
|
return r.getLocalizedDisplayname().toLowerCase().contains(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
final sectionTitle = isDms ? 'Direktnachrichten' : 'Räume';
|
final chats = rooms
|
||||||
final sectionId = isDms ? 'dms' : 'rooms';
|
.where((r) =>
|
||||||
final isCollapsed = collapsed[sectionId] ?? false;
|
r.membership == Membership.join && isChatListRoom(r, channels))
|
||||||
|
.toList();
|
||||||
|
final filtered = chats.where(matches).toList();
|
||||||
|
final unreadCount = chats.where((r) => r.isUnread).length;
|
||||||
|
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(8, 4, 8, 12),
|
padding: const EdgeInsets.fromLTRB(8, 0, 8, 12),
|
||||||
children: [
|
children: [
|
||||||
|
_ChatFilterChips(
|
||||||
|
value: chatFilter,
|
||||||
|
unreadCount: unreadCount,
|
||||||
|
pt: pt,
|
||||||
|
onChanged: (f) => ref.read(_chatFilterProvider.notifier).state = f,
|
||||||
|
),
|
||||||
if (invites.isNotEmpty) ...[
|
if (invites.isNotEmpty) ...[
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 2),
|
padding: const EdgeInsets.fromLTRB(8, 6, 8, 2),
|
||||||
@@ -124,22 +134,18 @@ class _RoomsList extends ConsumerWidget {
|
|||||||
child: _InviteItem(room: r, pt: pt))),
|
child: _InviteItem(room: r, pt: pt))),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
],
|
],
|
||||||
_SectionHeader(
|
if (filtered.isEmpty)
|
||||||
title: sectionTitle,
|
_ChatListEmpty(
|
||||||
count: filtered.length,
|
filter: chatFilter,
|
||||||
isCollapsed: isCollapsed,
|
searching: query.isNotEmpty,
|
||||||
|
hasInvites: invites.isNotEmpty,
|
||||||
pt: pt,
|
pt: pt,
|
||||||
onToggle: () => onToggleSection(sectionId),
|
)
|
||||||
onAddTap: isDms
|
else
|
||||||
? () => showDialog(
|
|
||||||
context: context, builder: (_) => const NewDmDialog())
|
|
||||||
: () => showDialog(
|
|
||||||
context: context, builder: (_) => const CreateJoinDialog()),
|
|
||||||
),
|
|
||||||
if (!isCollapsed)
|
|
||||||
...filtered.map((room) {
|
...filtered.map((room) {
|
||||||
final isVoice = _isVoiceRoom(room);
|
final isVoice = _isVoiceRoom(room);
|
||||||
return _RoomItem(
|
return _RoomItem(
|
||||||
|
key: ValueKey('chat:${room.id}'),
|
||||||
room: room,
|
room: room,
|
||||||
isActive: isVoice ? false : activeRoomId == room.id,
|
isActive: isVoice ? false : activeRoomId == room.id,
|
||||||
isInVoice:
|
isInVoice:
|
||||||
@@ -163,6 +169,145 @@ class _RoomsList extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ChatFilterChips extends StatelessWidget {
|
||||||
|
final _ChatFilter value;
|
||||||
|
final int unreadCount;
|
||||||
|
final PyramidTheme pt;
|
||||||
|
final ValueChanged<_ChatFilter> onChanged;
|
||||||
|
|
||||||
|
const _ChatFilterChips({
|
||||||
|
required this.value,
|
||||||
|
required this.unreadCount,
|
||||||
|
required this.pt,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
Widget chip(_ChatFilter f, String label) {
|
||||||
|
final selected = value == f;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 6),
|
||||||
|
child: Semantics(
|
||||||
|
button: true,
|
||||||
|
selected: selected,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => onChanged(selected ? _ChatFilter.all : f),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: selected ? pt.accentSoft : pt.bg2,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
border: Border.all(
|
||||||
|
color: selected ? pt.accent.withAlpha(120) : pt.border),
|
||||||
|
),
|
||||||
|
child: Text(label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: selected ? pt.accent : pt.fgMuted,
|
||||||
|
fontSize: 12.5,
|
||||||
|
fontWeight: selected ? FontWeight.w700 : FontWeight.w500)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final chips = [
|
||||||
|
chip(_ChatFilter.all, 'Alle'),
|
||||||
|
chip(_ChatFilter.unread,
|
||||||
|
unreadCount > 0 ? 'Ungelesen · $unreadCount' : 'Ungelesen'),
|
||||||
|
chip(_ChatFilter.people, 'Personen'),
|
||||||
|
chip(_ChatFilter.groups, 'Gruppen'),
|
||||||
|
];
|
||||||
|
// Handy: wischbare Zeile; PC (schmale Spalte): umbrechen statt
|
||||||
|
// abschneiden – mit der Maus wischt niemand seitwärts.
|
||||||
|
if (!isMobileLayout(MediaQuery.sizeOf(context))) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(4, 2, 4, 6),
|
||||||
|
child: Wrap(runSpacing: 6, children: chips),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return SizedBox(
|
||||||
|
height: 40,
|
||||||
|
child: ListView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.fromLTRB(4, 2, 4, 6),
|
||||||
|
children: chips,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Leerzustand der Chatliste – mit Handlung statt nur „nichts da“.
|
||||||
|
class _ChatListEmpty extends StatelessWidget {
|
||||||
|
final _ChatFilter filter;
|
||||||
|
final bool searching;
|
||||||
|
final bool hasInvites;
|
||||||
|
final PyramidTheme pt;
|
||||||
|
|
||||||
|
const _ChatListEmpty({
|
||||||
|
required this.filter,
|
||||||
|
required this.searching,
|
||||||
|
required this.hasInvites,
|
||||||
|
required this.pt,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (hasInvites && filter == _ChatFilter.all && !searching) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
final (IconData icon, String text) = searching
|
||||||
|
? (Icons.search_off_rounded, 'Nichts gefunden')
|
||||||
|
: switch (filter) {
|
||||||
|
_ChatFilter.unread => (Icons.done_all_rounded, 'Alles gelesen'),
|
||||||
|
_ChatFilter.people => (
|
||||||
|
Icons.person_outline_rounded,
|
||||||
|
'Noch keine Unterhaltungen'
|
||||||
|
),
|
||||||
|
_ChatFilter.groups => (Icons.groups_outlined, 'Noch keine Gruppen'),
|
||||||
|
_ChatFilter.all => (
|
||||||
|
Icons.chat_bubble_outline_rounded,
|
||||||
|
'Noch keine Chats'
|
||||||
|
),
|
||||||
|
};
|
||||||
|
final showAction = !searching && filter != _ChatFilter.unread;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 40, 16, 16),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 34, color: pt.fgDim),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(text, style: TextStyle(color: pt.fgMuted, fontSize: 14)),
|
||||||
|
if (showAction) ...[
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
FilledButton.tonalIcon(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: pt.accentSoft,
|
||||||
|
foregroundColor: pt.accent,
|
||||||
|
),
|
||||||
|
icon: Icon(filter == _ChatFilter.groups
|
||||||
|
? Icons.group_add_rounded
|
||||||
|
: Icons.edit_rounded, size: 18),
|
||||||
|
label: Text(filter == _ChatFilter.groups
|
||||||
|
? 'Gruppe erstellen'
|
||||||
|
: 'Neue Unterhaltung'),
|
||||||
|
onPressed: () => showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => filter == _ChatFilter.groups
|
||||||
|
? const CreateJoinDialog()
|
||||||
|
: const NewDmDialog(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Beitreten/Verlassen eines Voice-Channels aus der Raumliste – exakt das
|
/// Beitreten/Verlassen eines Voice-Channels aus der Raumliste – exakt das
|
||||||
/// Verhalten von `joinVoiceChannel` in rooms_space_list.dart (Toggle beim
|
/// Verhalten von `joinVoiceChannel` in rooms_space_list.dart (Toggle beim
|
||||||
/// aktiven Kanal, Wechsel legt vorher auf).
|
/// aktiven Kanal, Wechsel legt vorher auf).
|
||||||
@@ -187,84 +332,6 @@ Future<void> _toggleVoiceChannel(WidgetRef ref, Room room) async {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SectionHeader extends StatefulWidget {
|
|
||||||
final String title;
|
|
||||||
final int count;
|
|
||||||
final bool isCollapsed;
|
|
||||||
final PyramidTheme pt;
|
|
||||||
final VoidCallback onToggle;
|
|
||||||
final VoidCallback? onAddTap;
|
|
||||||
|
|
||||||
const _SectionHeader({
|
|
||||||
required this.title,
|
|
||||||
required this.count,
|
|
||||||
required this.isCollapsed,
|
|
||||||
required this.pt,
|
|
||||||
required this.onToggle,
|
|
||||||
this.onAddTap,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_SectionHeader> createState() => _SectionHeaderState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SectionHeaderState extends State<_SectionHeader> {
|
|
||||||
bool _hovered = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final pt = widget.pt;
|
|
||||||
return MouseRegion(
|
|
||||||
onEnter: (_) => setState(() => _hovered = true),
|
|
||||||
onExit: (_) => setState(() => _hovered = false),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 4),
|
|
||||||
child: Row(children: [
|
|
||||||
Expanded(
|
|
||||||
child: GestureDetector(
|
|
||||||
onTap: widget.onToggle,
|
|
||||||
child: Row(children: [
|
|
||||||
AnimatedRotation(
|
|
||||||
turns: widget.isCollapsed ? -0.25 : 0,
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
curve: Curves.easeOutBack,
|
|
||||||
child: Icon(Icons.keyboard_arrow_down_rounded,
|
|
||||||
size: 14,
|
|
||||||
color: _hovered ? pt.fgMuted : pt.fgDim),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(widget.title.toUpperCase(),
|
|
||||||
style: TextStyle(
|
|
||||||
color: _hovered ? pt.fgMuted : pt.fgDim,
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
letterSpacing: 0.04 * 11)),
|
|
||||||
const Spacer(),
|
|
||||||
Text('${widget.count}',
|
|
||||||
style: TextStyle(color: pt.fgDim, fontSize: 11)),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (_hovered && widget.onAddTap != null) ...[
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
GestureDetector(
|
|
||||||
behavior: HitTestBehavior.opaque,
|
|
||||||
onTap: widget.onAddTap,
|
|
||||||
child: SizedBox(
|
|
||||||
width: 18,
|
|
||||||
height: 18,
|
|
||||||
child: Center(
|
|
||||||
child: Icon(Icons.add, size: 12, color: pt.fgMuted)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
] else
|
|
||||||
const SizedBox(width: 22),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _RoomItem extends ConsumerStatefulWidget {
|
class _RoomItem extends ConsumerStatefulWidget {
|
||||||
final Room room;
|
final Room room;
|
||||||
final bool isActive;
|
final bool isActive;
|
||||||
@@ -513,6 +580,19 @@ class _RoomItemState extends ConsumerState<_RoomItem> {
|
|||||||
final voiceParticipants = isVoice ? ref.watch(voiceParticipantsProvider(room.id)) : <Map<String, dynamic>>[];
|
final voiceParticipants = isVoice ? ref.watch(voiceParticipantsProvider(room.id)) : <Map<String, dynamic>>[];
|
||||||
final client = ref.watch(matrixClientProvider).valueOrNull;
|
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||||
|
|
||||||
|
// Handy: Sprachkanal als Kachel mit Teilnehmern (rooms_mobile_tile.dart).
|
||||||
|
if (isVoice && isMobileLayout(MediaQuery.sizeOf(context))) {
|
||||||
|
return _MobileVoiceTile(
|
||||||
|
room: room,
|
||||||
|
inVoice: inVoice,
|
||||||
|
participants: voiceParticipants,
|
||||||
|
client: client,
|
||||||
|
pt: pt,
|
||||||
|
onTap: widget.onTap,
|
||||||
|
onLongPress: _showContextMenu,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Handy: Messenger-Kachel mit Vorschau (rooms_mobile_tile.dart).
|
// Handy: Messenger-Kachel mit Vorschau (rooms_mobile_tile.dart).
|
||||||
if (!isVoice && isMobileLayout(MediaQuery.sizeOf(context))) {
|
if (!isVoice && isMobileLayout(MediaQuery.sizeOf(context))) {
|
||||||
return _MobileRoomTile(
|
return _MobileRoomTile(
|
||||||
@@ -574,6 +654,11 @@ class _RoomItemState extends ConsumerState<_RoomItem> {
|
|||||||
children: [
|
children: [
|
||||||
if (isDm) ...[
|
if (isDm) ...[
|
||||||
_DmAvatar(room: room, pt: pt),
|
_DmAvatar(room: room, pt: pt),
|
||||||
|
] else if (!isVoice &&
|
||||||
|
!ref
|
||||||
|
.watch(spaceChannelIdsProvider)
|
||||||
|
.contains(room.id)) ...[
|
||||||
|
_RoomTileAvatar(room: room, pt: pt, size: 22),
|
||||||
] else ...[
|
] else ...[
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 16,
|
width: 16,
|
||||||
|
|||||||
@@ -119,34 +119,41 @@ class _MobileRoomTile extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Avatar für Räume (kein DM): Raumbild oder abgerundetes Symbol.
|
/// Avatar für Räume (kein DM): Raumbild oder Symbol. Gruppen (kein
|
||||||
|
/// Server-Kanal) sind rund wie Personen, Server-Kanäle abgerundet mit „#“.
|
||||||
class _RoomTileAvatar extends ConsumerWidget {
|
class _RoomTileAvatar extends ConsumerWidget {
|
||||||
final Room room;
|
final Room room;
|
||||||
final PyramidTheme pt;
|
final PyramidTheme pt;
|
||||||
const _RoomTileAvatar({required this.room, required this.pt});
|
final double size;
|
||||||
|
const _RoomTileAvatar(
|
||||||
|
{required this.room, required this.pt, this.size = _kTileAvatar});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final client = ref.watch(matrixClientProvider).valueOrNull;
|
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||||
|
final isChannel = ref.watch(spaceChannelIdsProvider).contains(room.id);
|
||||||
|
final radius = BorderRadius.circular(isChannel ? size * 0.3 : size / 2);
|
||||||
final fallback = Container(
|
final fallback = Container(
|
||||||
width: _kTileAvatar,
|
width: size,
|
||||||
height: _kTileAvatar,
|
height: size,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: pt.bg3,
|
color: isChannel ? pt.bg3 : pt.accentSoft,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: radius,
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
room.encrypted ? Icons.lock_outline_rounded : Icons.tag_rounded,
|
isChannel
|
||||||
size: 22,
|
? (room.encrypted ? Icons.lock_outline_rounded : Icons.tag_rounded)
|
||||||
color: pt.fgMuted,
|
: Icons.groups_rounded,
|
||||||
|
size: size * 0.5,
|
||||||
|
color: isChannel ? pt.fgMuted : pt.accent,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (client == null || room.avatar == null) return fallback;
|
if (client == null || room.avatar == null) return fallback;
|
||||||
return MxcAvatar(
|
return MxcAvatar(
|
||||||
mxcUri: room.avatar,
|
mxcUri: room.avatar,
|
||||||
client: client,
|
client: client,
|
||||||
size: _kTileAvatar,
|
size: size,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: radius,
|
||||||
placeholder: (_) => fallback,
|
placeholder: (_) => fallback,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -165,3 +172,103 @@ String? _roomPreview(Room room, String? myUserId) {
|
|||||||
if (room.isDirectChat) return body;
|
if (room.isDirectChat) return body;
|
||||||
return '${e.senderFromMemoryOrFallback.calcDisplayname()}: $body';
|
return '${e.senderFromMemoryOrFallback.calcDisplayname()}: $body';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sprachkanal im Handy-Layout: Kachel wie ein Chat, darunter wer gerade drin
|
||||||
|
/// ist (Avatare + Namen) bzw. „Antippen zum Beitreten“.
|
||||||
|
class _MobileVoiceTile extends StatelessWidget {
|
||||||
|
final Room room;
|
||||||
|
final bool inVoice;
|
||||||
|
final List<Map<String, dynamic>> participants;
|
||||||
|
final Client? client;
|
||||||
|
final PyramidTheme pt;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final VoidCallback onLongPress;
|
||||||
|
|
||||||
|
const _MobileVoiceTile({
|
||||||
|
required this.room,
|
||||||
|
required this.inVoice,
|
||||||
|
required this.participants,
|
||||||
|
required this.client,
|
||||||
|
required this.pt,
|
||||||
|
required this.onTap,
|
||||||
|
required this.onLongPress,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final green = pt.online;
|
||||||
|
final names = participants
|
||||||
|
.map((p) => (p['displayName'] as String? ?? '').split(' ').first)
|
||||||
|
.where((n) => n.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
final subtitle = inVoice
|
||||||
|
? 'Du bist drin · antippen zum Verlassen'
|
||||||
|
: participants.isEmpty
|
||||||
|
? 'Sprachkanal · antippen zum Beitreten'
|
||||||
|
: names.length <= 2
|
||||||
|
? '${names.join(' und ')} ${names.length == 1 ? 'ist' : 'sind'} drin'
|
||||||
|
: '${names.take(2).join(', ')} und ${names.length - 2} weitere';
|
||||||
|
return GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: onTap,
|
||||||
|
onLongPress: onLongPress,
|
||||||
|
child: Container(
|
||||||
|
color: inVoice ? green.withValues(alpha: 0.10) : Colors.transparent,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: _kTileAvatar,
|
||||||
|
height: _kTileAvatar,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: green.withValues(alpha: inVoice ? 0.30 : 0.14),
|
||||||
|
borderRadius: BorderRadius.circular(_kTileAvatar * 0.3),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
participants.isEmpty
|
||||||
|
? Icons.volume_up_rounded
|
||||||
|
: Icons.graphic_eq_rounded,
|
||||||
|
size: 22,
|
||||||
|
color: green,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(room.getLocalizedDisplayname(),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.fg,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Text(subtitle,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: participants.isEmpty && !inVoice
|
||||||
|
? pt.fgDim
|
||||||
|
: green,
|
||||||
|
fontSize: 13.5)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (participants.isNotEmpty && client != null) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
CallAvatarStack(
|
||||||
|
participants: participants,
|
||||||
|
client: client!,
|
||||||
|
ringColor: pt.bg1,
|
||||||
|
size: 26,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
|
|||||||
});
|
});
|
||||||
final roomsAsync = ref.watch(roomListProvider);
|
final roomsAsync = ref.watch(roomListProvider);
|
||||||
final activeSpaceId = ref.watch(activeSpaceIdProvider);
|
final activeSpaceId = ref.watch(activeSpaceIdProvider);
|
||||||
final call = ref.watch(voiceChannelProvider);
|
final bottomNav = ref.watch(bottomNavProvider) &&
|
||||||
final voip = ref.watch(callSignalingProvider);
|
isMobileLayout(MediaQuery.sizeOf(context));
|
||||||
|
|
||||||
final isRealSpace = activeSpaceId != null &&
|
final isRealSpace = activeSpaceId != null &&
|
||||||
activeSpaceId != 'dms' &&
|
activeSpaceId != 'dms' &&
|
||||||
@@ -87,6 +87,7 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
|
|||||||
_FilterInput(
|
_FilterInput(
|
||||||
controller: _filterCtrl,
|
controller: _filterCtrl,
|
||||||
pt: pt,
|
pt: pt,
|
||||||
|
hint: isRealSpace ? 'Kanäle durchsuchen' : 'Chats durchsuchen',
|
||||||
onChanged: (v) => setState(() => _filter = v),
|
onChanged: (v) => setState(() => _filter = v),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -112,63 +113,90 @@ 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(
|
||||||
rooms: rooms,
|
rooms: rooms,
|
||||||
filter: _filter,
|
filter: _filter,
|
||||||
spaceId: activeSpaceId,
|
|
||||||
collapsed: _collapsed,
|
|
||||||
pt: pt,
|
pt: pt,
|
||||||
onToggleSection: (id) => setState(() {
|
|
||||||
_collapsed[id] = !(_collapsed[id] ?? false);
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Call-Dock: immer ÜBER dem Konto-Balken, animiert ein-/ausblenden.
|
// Call-Dock + Profilbalken. Mit der unteren Leiste (Handy) liegen
|
||||||
// Im Mobil-Querformat (Höhe knapp) liegen Call-Balken und
|
// beide dort (layout/mobile_tabs.dart).
|
||||||
// Konto-Balken nebeneinander statt übereinander.
|
if (!bottomNav) const _PanelFooter(),
|
||||||
Builder(builder: (context) {
|
],
|
||||||
// Klingeln (noch nicht angenommen) zählt nicht – dafür gibt es
|
),
|
||||||
// das Annehmen-Fenster (Bernd 2026-10-08).
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Laufender Anruf als kompakte Leiste, animiert ein-/ausgeblendet.
|
||||||
|
class CallDock extends ConsumerWidget {
|
||||||
|
const CallDock({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final call = ref.watch(voiceChannelProvider);
|
||||||
|
final voip = ref.watch(callSignalingProvider);
|
||||||
|
// Klingeln (noch nicht angenommen) zählt nicht – dafür gibt es das
|
||||||
|
// Annehmen-Fenster (Bernd 2026-10-08).
|
||||||
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
|
||||||
final isLandscapeCompact =
|
// Handy liegt die Anrufansicht ohnehin darüber.
|
||||||
MediaQuery.sizeOf(context).height < 500;
|
final callViewOpen = ref.watch(viewModeProvider) == ViewMode.voice &&
|
||||||
|
!isMobileLayout(MediaQuery.sizeOf(context));
|
||||||
final callDock = AnimatedSize(
|
return AnimatedSize(
|
||||||
duration: const Duration(milliseconds: 220),
|
duration: const Duration(milliseconds: 220),
|
||||||
curve: Curves.easeOutCubic,
|
curve: Curves.easeOutCubic,
|
||||||
alignment: Alignment.bottomCenter,
|
alignment: Alignment.bottomCenter,
|
||||||
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),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ist gerade ein Anruf im Dock sichtbar?
|
||||||
|
static bool visible(WidgetRef ref, BuildContext context) {
|
||||||
|
final call = ref.watch(voiceChannelProvider);
|
||||||
|
final voip = ref.watch(callSignalingProvider);
|
||||||
|
final hasVoipCall = voip.currentCall != null &&
|
||||||
|
voip.currentCall!.state != CallState.kEnded &&
|
||||||
|
voip.currentCall!.state != CallState.kRinging;
|
||||||
|
final callViewOpen = ref.watch(viewModeProvider) == ViewMode.voice &&
|
||||||
|
!isMobileLayout(MediaQuery.sizeOf(context));
|
||||||
|
return !callViewOpen && (call.isActive || hasVoipCall);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Call-Dock immer ÜBER dem Konto-Balken. Im Mobil-Querformat (Höhe knapp)
|
||||||
|
/// liegen beide nebeneinander statt übereinander.
|
||||||
|
class _PanelFooter extends ConsumerWidget {
|
||||||
|
const _PanelFooter();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final hasCallDock = CallDock.visible(ref, context);
|
||||||
|
final isLandscapeCompact = MediaQuery.sizeOf(context).height < 500;
|
||||||
if (isLandscapeCompact && hasCallDock) {
|
if (isLandscapeCompact && hasCallDock) {
|
||||||
return Row(
|
return const Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: callDock),
|
Expanded(child: CallDock()),
|
||||||
const Expanded(child: UserPanel()),
|
Expanded(child: UserPanel()),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return Column(
|
return const Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [callDock, const UserPanel()],
|
children: [CallDock(), UserPanel()],
|
||||||
);
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class _SpaceInviteViewState extends ConsumerState<_SpaceInviteView> {
|
|||||||
try {
|
try {
|
||||||
await widget.space.leave();
|
await widget.space.leave();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ref.read(activeSpaceIdProvider.notifier).state = 'rooms';
|
ref.read(activeSpaceIdProvider.notifier).state = 'dms';
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (mounted) setState(() => _declining = false);
|
if (mounted) setState(() => _declining = false);
|
||||||
|
|||||||
@@ -10,11 +10,14 @@ import 'package:pyramid/core/app_state.dart';
|
|||||||
import 'package:pyramid/core/appearance.dart';
|
import 'package:pyramid/core/appearance.dart';
|
||||||
import 'package:pyramid/core/matrix_client.dart';
|
import 'package:pyramid/core/matrix_client.dart';
|
||||||
import 'package:pyramid/core/notification_content.dart' show notificationBody;
|
import 'package:pyramid/core/notification_content.dart' show notificationBody;
|
||||||
|
import 'package:pyramid/core/room_kinds.dart';
|
||||||
|
import 'package:pyramid/core/settings_prefs.dart' show bottomNavProvider;
|
||||||
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/features/chat/chat_provider.dart' show acceptInvite;
|
import 'package:pyramid/features/chat/chat_provider.dart' show acceptInvite;
|
||||||
import 'package:pyramid/features/call_ui/mini_call_widget.dart';
|
import 'package:pyramid/features/call_ui/mini_call_widget.dart';
|
||||||
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
||||||
|
import 'package:pyramid/features/group_call/group_call.dart' show CallAvatarStack;
|
||||||
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
|
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
|
||||||
import 'package:pyramid/features/rooms/user_panel.dart';
|
import 'package:pyramid/features/rooms/user_panel.dart';
|
||||||
import 'package:pyramid/features/spaces/space_admin_dialog.dart';
|
import 'package:pyramid/features/spaces/space_admin_dialog.dart';
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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/matrix_client.dart';
|
import 'package:pyramid/core/matrix_client.dart';
|
||||||
|
import 'package:pyramid/core/room_kinds.dart';
|
||||||
|
|
||||||
final roomListProvider = StreamProvider<List<Room>>((ref) async* {
|
final roomListProvider = StreamProvider<List<Room>>((ref) async* {
|
||||||
final client = await ref.watch(matrixClientProvider.future);
|
final client = await ref.watch(matrixClientProvider.future);
|
||||||
@@ -12,19 +13,33 @@ final roomListProvider = StreamProvider<List<Room>>((ref) async* {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// IDs aller Server-Kanäle (siehe [spaceChannelIds]); wird mit jeder
|
||||||
|
/// Synchronisation neu berechnet.
|
||||||
|
final spaceChannelIdsProvider = Provider<Set<String>>((ref) {
|
||||||
|
ref.watch(roomListProvider);
|
||||||
|
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||||
|
return client == null ? const {} : spaceChannelIds(client);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Ungelesene Unterhaltungen in „Chats“ (Direktnachrichten + Gruppen ohne
|
||||||
|
/// Server) – für das Abzeichen am Chats-Symbol.
|
||||||
final dmUnreadCountProvider = StreamProvider<int>((ref) async* {
|
final dmUnreadCountProvider = StreamProvider<int>((ref) async* {
|
||||||
final client = await ref.watch(matrixClientProvider.future);
|
final client = await ref.watch(matrixClientProvider.future);
|
||||||
|
|
||||||
yield _countDmUnread(client);
|
yield _countChatUnread(client);
|
||||||
|
|
||||||
await for (final _ in client.onSync.stream) {
|
await for (final _ in client.onSync.stream) {
|
||||||
yield _countDmUnread(client);
|
yield _countChatUnread(client);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
int _countDmUnread(Client client) {
|
int _countChatUnread(Client client) {
|
||||||
|
final channels = spaceChannelIds(client);
|
||||||
return client.rooms
|
return client.rooms
|
||||||
.where((r) => !r.isSpace && r.isDirectChat && r.notificationCount > 0)
|
.where((r) =>
|
||||||
|
r.membership == Membership.join &&
|
||||||
|
isChatListRoom(r, channels) &&
|
||||||
|
r.notificationCount > 0)
|
||||||
.length;
|
.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -48,7 +67,9 @@ class SpacesRail extends ConsumerWidget {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_HomeButton(
|
_HomeButton(
|
||||||
pt: pt,
|
pt: pt,
|
||||||
active: activeSpaceId == 'dms' || activeSpaceId == null,
|
active: activeSpaceId == 'dms' ||
|
||||||
|
activeSpaceId == 'rooms' ||
|
||||||
|
activeSpaceId == null,
|
||||||
unreadCount: dmUnread,
|
unreadCount: dmUnread,
|
||||||
onTap: () => selectSpace('dms'),
|
onTap: () => selectSpace('dms'),
|
||||||
),
|
),
|
||||||
@@ -67,18 +88,8 @@ class SpacesRail extends ConsumerWidget {
|
|||||||
child: ListView(
|
child: ListView(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
children: [
|
children: [
|
||||||
_VirtualSpaceItem(
|
|
||||||
id: 'rooms',
|
|
||||||
label: 'R',
|
|
||||||
color: const Color(0xFFF59E0B),
|
|
||||||
name: 'Räume',
|
|
||||||
active: activeSpaceId == 'rooms',
|
|
||||||
unread: 0,
|
|
||||||
pt: pt,
|
|
||||||
onTap: () => selectSpace('rooms'),
|
|
||||||
),
|
|
||||||
...spaces.map((space) => Padding(
|
...spaces.map((space) => Padding(
|
||||||
padding: const EdgeInsets.only(top: 6),
|
padding: const EdgeInsets.only(bottom: 6),
|
||||||
child: _SpaceItem(
|
child: _SpaceItem(
|
||||||
space: space,
|
space: space,
|
||||||
active: activeSpaceId == space.id,
|
active: activeSpaceId == space.id,
|
||||||
@@ -238,99 +249,6 @@ class _HomeButtonState extends State<_HomeButton> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _VirtualSpaceItem extends StatefulWidget {
|
|
||||||
final String id;
|
|
||||||
final String label;
|
|
||||||
final Color color;
|
|
||||||
final String name;
|
|
||||||
final bool active;
|
|
||||||
final int unread;
|
|
||||||
final PyramidTheme pt;
|
|
||||||
final VoidCallback onTap;
|
|
||||||
|
|
||||||
const _VirtualSpaceItem({
|
|
||||||
required this.id,
|
|
||||||
required this.label,
|
|
||||||
required this.color,
|
|
||||||
required this.name,
|
|
||||||
required this.active,
|
|
||||||
required this.unread,
|
|
||||||
required this.pt,
|
|
||||||
required this.onTap,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_VirtualSpaceItem> createState() => _VirtualSpaceItemState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _VirtualSpaceItemState extends State<_VirtualSpaceItem> {
|
|
||||||
bool _hovered = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final pt = widget.pt;
|
|
||||||
final active = widget.active;
|
|
||||||
final radius = active
|
|
||||||
? pt.rLg * 0.6
|
|
||||||
: _hovered ? pt.rLg * 0.7 : pt.rLg;
|
|
||||||
|
|
||||||
return Center(
|
|
||||||
child: MouseRegion(
|
|
||||||
cursor: SystemMouseCursors.click,
|
|
||||||
onEnter: (_) => setState(() => _hovered = true),
|
|
||||||
onExit: (_) => setState(() => _hovered = false),
|
|
||||||
child: GestureDetector(
|
|
||||||
onTap: widget.onTap,
|
|
||||||
child: Stack(
|
|
||||||
clipBehavior: Clip.none,
|
|
||||||
children: [
|
|
||||||
// Active indicator
|
|
||||||
if (active)
|
|
||||||
Positioned(
|
|
||||||
left: -18,
|
|
||||||
top: 10,
|
|
||||||
child: Container(
|
|
||||||
width: 4,
|
|
||||||
height: 28,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: pt.accent,
|
|
||||||
borderRadius: const BorderRadius.only(
|
|
||||||
topRight: Radius.circular(3),
|
|
||||||
bottomRight: Radius.circular(3),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Tooltip(
|
|
||||||
message: widget.name,
|
|
||||||
waitDuration: const Duration(milliseconds: 400),
|
|
||||||
preferBelow: false,
|
|
||||||
child: AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 250),
|
|
||||||
curve: Curves.easeOutBack,
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: _hovered ? pt.bg3 : pt.bg2,
|
|
||||||
borderRadius: BorderRadius.circular(radius),
|
|
||||||
),
|
|
||||||
child: Center(
|
|
||||||
child: PyramidGlyph(
|
|
||||||
size: 30,
|
|
||||||
color: widget.color,
|
|
||||||
label: widget.label,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SpaceItem extends ConsumerStatefulWidget {
|
class _SpaceItem extends ConsumerStatefulWidget {
|
||||||
final Room space;
|
final Room space;
|
||||||
final bool active;
|
final bool active;
|
||||||
|
|||||||
@@ -287,7 +287,9 @@ class LiveKitCallManager extends VoiceChannelService {
|
|||||||
// Android: Mikrofon im Hintergrund + „Anruf läuft“-Benachrichtigung.
|
// Android: Mikrofon im Hintergrund + „Anruf läuft“-Benachrichtigung.
|
||||||
unawaited(CallForeground.callStarted('livekit', title: roomDisplayName));
|
unawaited(CallForeground.callStarted('livekit', title: roomDisplayName));
|
||||||
|
|
||||||
if (voiceChannel && matrixClient != null && matrixRoomId != null) {
|
// Sprachkanäle UND Gruppenanrufe: Anwesenheit im Matrix-Raum, damit
|
||||||
|
// die anderen sehen, wer drin ist (Gruppen: „Beitreten“-Karte).
|
||||||
|
if (matrixClient != null && matrixRoomId != null) {
|
||||||
await _writePresence(true);
|
await _writePresence(true);
|
||||||
_presenceHeartbeat = Timer.periodic(const Duration(seconds: 30), (_) {
|
_presenceHeartbeat = Timer.periodic(const Duration(seconds: 30), (_) {
|
||||||
_writePresence(true).catchError((_) {});
|
_writePresence(true).catchError((_) {});
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ import 'package:pyramid/features/members/members_panel.dart';
|
|||||||
import 'package:pyramid/features/rooms/rooms_panel.dart';
|
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/layout/mobile_tabs.dart';
|
||||||
|
import 'package:pyramid/core/settings_prefs.dart' show bottomNavProvider;
|
||||||
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 +154,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 +205,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 +223,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;
|
||||||
@@ -344,11 +397,17 @@ class _MobileShell extends ConsumerWidget {
|
|||||||
: activeRoomId != null
|
: activeRoomId != null
|
||||||
? ChatView(roomId: activeRoomId!)
|
? ChatView(roomId: activeRoomId!)
|
||||||
: null;
|
: null;
|
||||||
|
final bottomNav = ref.watch(bottomNavProvider);
|
||||||
|
final tab = ref.watch(mobileTabProvider);
|
||||||
final body = MobilePageStack(
|
final body = MobilePageStack(
|
||||||
base: const RoomsPanel(),
|
base: bottomNav ? const MobileTabsBase() : const RoomsPanel(),
|
||||||
front: front,
|
front: front,
|
||||||
background: pt.bg1,
|
background: pt.bg1,
|
||||||
);
|
);
|
||||||
|
// Untere Leiste: Zurück führt erst über die Tabs (Server → Übersicht →
|
||||||
|
// Chats), bevor die App geschlossen wird.
|
||||||
|
final tabsAtRoot = !bottomNav ||
|
||||||
|
(tab == MobileTab.chats);
|
||||||
|
|
||||||
// Symbole der Statusleiste passend zum Theme (hell auf dunkel und umgekehrt).
|
// Symbole der Statusleiste passend zum Theme (hell auf dunkel und umgekehrt).
|
||||||
final overlayStyle = (pt.isDark
|
final overlayStyle = (pt.isDark
|
||||||
@@ -362,7 +421,10 @@ class _MobileShell extends ConsumerWidget {
|
|||||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||||
value: overlayStyle,
|
value: overlayStyle,
|
||||||
child: PopScope(
|
child: PopScope(
|
||||||
canPop: activeRoomId == null && viewMode == ViewMode.chat && !membersOpen,
|
canPop: activeRoomId == null &&
|
||||||
|
viewMode == ViewMode.chat &&
|
||||||
|
!membersOpen &&
|
||||||
|
tabsAtRoot,
|
||||||
onPopInvokedWithResult: (didPop, _) {
|
onPopInvokedWithResult: (didPop, _) {
|
||||||
if (!didPop) {
|
if (!didPop) {
|
||||||
if (membersOpen) {
|
if (membersOpen) {
|
||||||
@@ -371,6 +433,8 @@ class _MobileShell extends ConsumerWidget {
|
|||||||
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
|
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||||
} else if (activeRoomId != null) {
|
} else if (activeRoomId != null) {
|
||||||
ref.read(activeRoomIdProvider.notifier).state = null;
|
ref.read(activeRoomIdProvider.notifier).state = null;
|
||||||
|
} else if (bottomNav) {
|
||||||
|
handleMobileTabBack(ref);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -378,7 +442,9 @@ class _MobileShell extends ConsumerWidget {
|
|||||||
// bg1 = Farbe der Kopfzeilen → der Streifen hinter der Statusleiste
|
// bg1 = Farbe der Kopfzeilen → der Streifen hinter der Statusleiste
|
||||||
// geht nahtlos in sie über.
|
// geht nahtlos in sie über.
|
||||||
backgroundColor: pt.bg1,
|
backgroundColor: pt.bg1,
|
||||||
drawer: Drawer(
|
drawer: bottomNav
|
||||||
|
? null
|
||||||
|
: Drawer(
|
||||||
width: 80,
|
width: 80,
|
||||||
backgroundColor: pt.bg0,
|
backgroundColor: pt.bg0,
|
||||||
child: const SafeArea(child: SpacesRail()),
|
child: const SafeArea(child: SpacesRail()),
|
||||||
@@ -519,18 +585,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,
|
||||||
|
size: 88,
|
||||||
|
placeholder: (_) => Container(
|
||||||
|
width: 88,
|
||||||
|
height: 88,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: pt.accent,
|
color: pt.accent,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.person_rounded, size: 40, color: Colors.white),
|
alignment: Alignment.center,
|
||||||
|
child: Text(
|
||||||
|
call.room.getLocalizedDisplayname().characters.firstOrNull?.toUpperCase() ?? '?',
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 36, fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
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),
|
||||||
|
|||||||
@@ -0,0 +1,623 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
import 'package:pyramid/core/app_state.dart';
|
||||||
|
import 'package:pyramid/core/matrix_client.dart';
|
||||||
|
import 'package:pyramid/core/room_kinds.dart';
|
||||||
|
import 'package:pyramid/core/theme.dart';
|
||||||
|
import 'package:pyramid/features/rooms/rooms_panel.dart';
|
||||||
|
import 'package:pyramid/features/rooms/rooms_provider.dart';
|
||||||
|
import 'package:pyramid/widgets/create_join_dialog.dart';
|
||||||
|
import 'package:pyramid/widgets/mxc_image.dart';
|
||||||
|
import 'package:pyramid/widgets/spring_press.dart';
|
||||||
|
|
||||||
|
/// Handy-Navigation mit unterer Leiste (Einstellung „Navigation: Untere
|
||||||
|
/// Leiste“, Bernd 2026-10-09 zum Vergleich mit dem Seitenmenü):
|
||||||
|
/// **Chats** (Direktnachrichten + Gruppen), **Server** (Übersicht → Kanäle),
|
||||||
|
/// **Du** (Profil + Einstellungen). Chat und Anruf gleiten wie gewohnt
|
||||||
|
/// darüber; die Leiste liegt darunter und verschwindet mit.
|
||||||
|
enum MobileTab { chats, servers, me }
|
||||||
|
|
||||||
|
final mobileTabProvider = StateProvider<MobileTab>((ref) => MobileTab.chats);
|
||||||
|
|
||||||
|
/// Zuletzt geöffneter Server – „Server“ öffnet wieder dort.
|
||||||
|
final _lastServerProvider = StateProvider<String?>((ref) => null);
|
||||||
|
|
||||||
|
bool _isRealSpace(String? id) => id != null && id != 'dms' && id != 'rooms';
|
||||||
|
|
||||||
|
/// Wechselt den Tab (und die passende Liste).
|
||||||
|
void selectMobileTab(WidgetRef ref, MobileTab tab) {
|
||||||
|
final current = ref.read(mobileTabProvider);
|
||||||
|
final space = ref.read(activeSpaceIdProvider);
|
||||||
|
if (_isRealSpace(space)) ref.read(_lastServerProvider.notifier).state = space;
|
||||||
|
switch (tab) {
|
||||||
|
case MobileTab.chats:
|
||||||
|
ref.read(activeSpaceIdProvider.notifier).state = 'dms';
|
||||||
|
case MobileTab.servers:
|
||||||
|
// Nochmal auf „Server“ tippen = zurück zur Übersicht.
|
||||||
|
ref.read(activeSpaceIdProvider.notifier).state =
|
||||||
|
current == MobileTab.servers ? null : ref.read(_lastServerProvider);
|
||||||
|
case MobileTab.me:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
ref.read(mobileTabProvider.notifier).state = tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zurück-Taste mit unterer Leiste: Server → Übersicht → Chats.
|
||||||
|
/// Liefert `true`, wenn sie etwas getan hat.
|
||||||
|
bool handleMobileTabBack(WidgetRef ref) {
|
||||||
|
final tab = ref.read(mobileTabProvider);
|
||||||
|
if (tab == MobileTab.servers &&
|
||||||
|
_isRealSpace(ref.read(activeSpaceIdProvider))) {
|
||||||
|
ref.read(activeSpaceIdProvider.notifier).state = null;
|
||||||
|
ref.read(_lastServerProvider.notifier).state = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (tab != MobileTab.chats) {
|
||||||
|
selectMobileTab(ref, MobileTab.chats);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Grundseite unter Chat/Anruf: Inhalt des Tabs, Anruf-Leiste, Tab-Leiste.
|
||||||
|
class MobileTabsBase extends ConsumerWidget {
|
||||||
|
const MobileTabsBase({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final pt = PyramidTheme.of(context);
|
||||||
|
final tab = ref.watch(mobileTabProvider);
|
||||||
|
final space = ref.watch(activeSpaceIdProvider);
|
||||||
|
final Widget content = switch (tab) {
|
||||||
|
MobileTab.chats => const RoomsPanel(key: ValueKey('tab-chats')),
|
||||||
|
MobileTab.servers => _isRealSpace(space)
|
||||||
|
? RoomsPanel(key: ValueKey('tab-server-$space'))
|
||||||
|
: const ServerOverview(key: ValueKey('tab-servers')),
|
||||||
|
MobileTab.me => const MeTab(key: ValueKey('tab-me')),
|
||||||
|
};
|
||||||
|
return ColoredBox(
|
||||||
|
color: pt.bg1,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: AnimatedSwitcher(
|
||||||
|
duration: motionDuration(context, 220),
|
||||||
|
switchInCurve: Curves.easeOutCubic,
|
||||||
|
switchOutCurve: Curves.easeInCubic,
|
||||||
|
transitionBuilder: (child, anim) => FadeTransition(
|
||||||
|
opacity: anim,
|
||||||
|
child: SlideTransition(
|
||||||
|
position: Tween(
|
||||||
|
begin: const Offset(0, 0.015),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(anim),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: content,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const CallDock(),
|
||||||
|
const _TabBar(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TabBar extends ConsumerWidget {
|
||||||
|
const _TabBar();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final pt = PyramidTheme.of(context);
|
||||||
|
final tab = ref.watch(mobileTabProvider);
|
||||||
|
final chatUnread = ref.watch(dmUnreadCountProvider).valueOrNull ?? 0;
|
||||||
|
final serverUnread = ref.watch(_serverUnreadProvider);
|
||||||
|
|
||||||
|
Widget item(MobileTab t, IconData icon, IconData activeIcon, String label,
|
||||||
|
int badge) {
|
||||||
|
final selected = tab == t;
|
||||||
|
return Expanded(
|
||||||
|
child: Semantics(
|
||||||
|
button: true,
|
||||||
|
selected: selected,
|
||||||
|
label: label,
|
||||||
|
child: SpringPress(
|
||||||
|
child: InkResponse(
|
||||||
|
onTap: () => selectMobileTab(ref, t),
|
||||||
|
radius: 40,
|
||||||
|
highlightShape: BoxShape.rectangle,
|
||||||
|
containedInkWell: false,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
AnimatedContainer(
|
||||||
|
duration: motionDuration(context, 240),
|
||||||
|
curve: springyCurve,
|
||||||
|
width: selected ? 60 : 44,
|
||||||
|
height: 30,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: selected ? pt.accentSoft : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Icon(selected ? activeIcon : icon,
|
||||||
|
size: 22,
|
||||||
|
color: selected ? pt.accent : pt.fgMuted),
|
||||||
|
),
|
||||||
|
if (badge > 0)
|
||||||
|
Positioned(
|
||||||
|
right: selected ? 6 : 0,
|
||||||
|
top: -3,
|
||||||
|
child: SpringBump(
|
||||||
|
value: badge,
|
||||||
|
child: Container(
|
||||||
|
constraints: const BoxConstraints(minWidth: 18),
|
||||||
|
height: 18,
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 5),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: pt.danger,
|
||||||
|
borderRadius: BorderRadius.circular(9),
|
||||||
|
border: Border.all(color: pt.bg1, width: 2),
|
||||||
|
),
|
||||||
|
child: Text(badge > 99 ? '99+' : '$badge',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w700)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Text(label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: selected ? pt.fg : pt.fgMuted,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight:
|
||||||
|
selected ? FontWeight.w700 : FontWeight.w500)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: pt.bg1,
|
||||||
|
border: Border(top: BorderSide(color: pt.border)),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.fromLTRB(8, 4, 8, 6),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
item(MobileTab.chats, Icons.chat_bubble_outline_rounded,
|
||||||
|
Icons.chat_bubble_rounded, 'Chats', chatUnread),
|
||||||
|
item(MobileTab.servers, Icons.dns_outlined, Icons.dns_rounded,
|
||||||
|
'Server', serverUnread),
|
||||||
|
item(MobileTab.me, Icons.person_outline_rounded,
|
||||||
|
Icons.person_rounded, 'Du', 0),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ungelesene Kanäle über alle Server (für das Abzeichen am „Server“-Tab).
|
||||||
|
final _serverUnreadProvider = Provider<int>((ref) {
|
||||||
|
ref.watch(roomListProvider);
|
||||||
|
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||||
|
if (client == null) return 0;
|
||||||
|
final channels = spaceChannelIds(client);
|
||||||
|
return client.rooms
|
||||||
|
.where((r) =>
|
||||||
|
channels.contains(r.id) &&
|
||||||
|
r.membership == Membership.join &&
|
||||||
|
r.notificationCount > 0)
|
||||||
|
.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Übersicht aller Server als Karten (Tab „Server“).
|
||||||
|
class ServerOverview extends ConsumerWidget {
|
||||||
|
const ServerOverview({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final pt = PyramidTheme.of(context);
|
||||||
|
ref.watch(roomListProvider); // bei jeder Synchronisation neu
|
||||||
|
final spaces = ref.watch(spacesProvider).valueOrNull ?? [];
|
||||||
|
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
_TabHeader(
|
||||||
|
title: 'Server',
|
||||||
|
pt: pt,
|
||||||
|
action: IconButton(
|
||||||
|
tooltip: 'Server erstellen oder beitreten',
|
||||||
|
icon: Icon(Icons.add_rounded, color: pt.fgMuted),
|
||||||
|
onPressed: () => showDialog(
|
||||||
|
context: context, builder: (_) => const CreateJoinDialog()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: spaces.isEmpty || client == null
|
||||||
|
? _EmptyServers(pt: pt)
|
||||||
|
: ListView.separated(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 12, 12, 16),
|
||||||
|
itemCount: spaces.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
||||||
|
itemBuilder: (context, i) =>
|
||||||
|
_ServerCard(space: spaces[i], client: client, pt: pt),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ServerCard extends ConsumerWidget {
|
||||||
|
final Room space;
|
||||||
|
final Client client;
|
||||||
|
final PyramidTheme pt;
|
||||||
|
const _ServerCard(
|
||||||
|
{required this.space, required this.client, required this.pt});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final name = space.getLocalizedDisplayname();
|
||||||
|
final invited = space.membership == Membership.invite;
|
||||||
|
var channels = 0, unread = 0, inVoice = 0;
|
||||||
|
for (final child in space.spaceChildren) {
|
||||||
|
final id = child.roomId;
|
||||||
|
if (id == null) continue;
|
||||||
|
final r = client.getRoomById(id);
|
||||||
|
if (r == null || r.isSpace) continue;
|
||||||
|
channels++;
|
||||||
|
if (r.membership == Membership.join && r.notificationCount > 0) unread++;
|
||||||
|
if (isVoiceChannelRoom(r)) {
|
||||||
|
inVoice += ref.watch(voiceParticipantsProvider(r.id)).length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final subtitle = invited
|
||||||
|
? 'Einladung – antippen zum Ansehen'
|
||||||
|
: [
|
||||||
|
channels == 1 ? '1 Kanal' : '$channels Kanäle',
|
||||||
|
if (unread > 0) '$unread ungelesen',
|
||||||
|
].join(' · ');
|
||||||
|
|
||||||
|
final initial = name.isEmpty ? '?' : name.characters.first.toUpperCase();
|
||||||
|
Widget fallback(BuildContext _) => Container(
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: pt.accentSoft,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(initial,
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.accent, fontSize: 22, fontWeight: FontWeight.w700)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return SpringPress(
|
||||||
|
pressedScale: 0.97,
|
||||||
|
child: Material(
|
||||||
|
color: pt.bg2,
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
onTap: () {
|
||||||
|
ref.read(activeSpaceIdProvider.notifier).state = space.id;
|
||||||
|
ref.read(activeRoomIdProvider.notifier).state = null;
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
border: Border.all(
|
||||||
|
color: invited ? pt.accent.withAlpha(120) : pt.border),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
space.avatar == null
|
||||||
|
? Builder(builder: fallback)
|
||||||
|
: MxcAvatar(
|
||||||
|
mxcUri: space.avatar,
|
||||||
|
client: client,
|
||||||
|
size: 52,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
placeholder: fallback,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.fg,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: unread > 0
|
||||||
|
? FontWeight.w700
|
||||||
|
: FontWeight.w600)),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(subtitle,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: invited ? pt.accent : pt.fgMuted,
|
||||||
|
fontSize: 13)),
|
||||||
|
if (inVoice > 0) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(children: [
|
||||||
|
Icon(Icons.graphic_eq_rounded,
|
||||||
|
size: 14, color: pt.online),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
inVoice == 1
|
||||||
|
? '1 Person im Sprachkanal'
|
||||||
|
: '$inVoice Personen im Sprachkanal',
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.online,
|
||||||
|
fontSize: 12.5,
|
||||||
|
fontWeight: FontWeight.w600)),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(Icons.chevron_right_rounded, color: pt.fgDim),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EmptyServers extends StatelessWidget {
|
||||||
|
final PyramidTheme pt;
|
||||||
|
const _EmptyServers({required this.pt});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.dns_outlined, size: 40, color: pt.fgDim),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text('Noch keine Server',
|
||||||
|
style: TextStyle(color: pt.fgMuted, fontSize: 15)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text('Server sind wie Discord: Kanäle für Text und Sprache.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: pt.fgDim, fontSize: 13)),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
FilledButton.tonalIcon(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: pt.accentSoft,
|
||||||
|
foregroundColor: pt.accent,
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.add_rounded, size: 18),
|
||||||
|
label: const Text('Server erstellen oder beitreten'),
|
||||||
|
onPressed: () => showDialog(
|
||||||
|
context: context, builder: (_) => const CreateJoinDialog()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TabHeader extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final PyramidTheme pt;
|
||||||
|
final Widget? action;
|
||||||
|
const _TabHeader({required this.title, required this.pt, this.action});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
height: 52,
|
||||||
|
padding: const EdgeInsets.only(left: 18, right: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: pt.bg1,
|
||||||
|
border: Border(bottom: BorderSide(color: pt.border)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(title,
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.fg, fontSize: 17, fontWeight: FontWeight.w700)),
|
||||||
|
),
|
||||||
|
?action,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final _myProfileProvider = FutureProvider.autoDispose<Profile?>((ref) async {
|
||||||
|
final client = await ref.watch(matrixClientProvider.future);
|
||||||
|
final id = client.userID;
|
||||||
|
if (id == null) return null;
|
||||||
|
try {
|
||||||
|
return await client.getProfileFromUserId(id);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Tab „Du“: eigenes Profil und Einstellungen.
|
||||||
|
class MeTab extends ConsumerWidget {
|
||||||
|
const MeTab({super.key});
|
||||||
|
|
||||||
|
static const _entries = <(String, String, IconData)>[
|
||||||
|
('profile', 'Mein Profil', Icons.person_outline_rounded),
|
||||||
|
('notifications', 'Benachrichtigungen', Icons.notifications_none_rounded),
|
||||||
|
('appearance', 'Erscheinungsbild', Icons.palette_outlined),
|
||||||
|
('voice', 'Sprache & Video', Icons.headphones_rounded),
|
||||||
|
('privacy', 'Datenschutz', Icons.lock_outline_rounded),
|
||||||
|
('sessions', 'Sitzungen', Icons.devices_rounded),
|
||||||
|
('encryption', 'Verschlüsselung', Icons.shield_outlined),
|
||||||
|
('about', 'Über Pyramid', Icons.info_outline_rounded),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final pt = PyramidTheme.of(context);
|
||||||
|
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||||
|
final profile = ref.watch(_myProfileProvider).valueOrNull;
|
||||||
|
final userId = client?.userID ?? '';
|
||||||
|
final name = profile?.displayName ??
|
||||||
|
userId.split(':').first.replaceFirst('@', '');
|
||||||
|
final initial = name.isEmpty ? '?' : name.characters.first.toUpperCase();
|
||||||
|
|
||||||
|
void open(String section) {
|
||||||
|
ref.read(settingsSectionRequestProvider.notifier).state = section;
|
||||||
|
ref.read(activeModalProvider.notifier).state = ModalKind.settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget fallback(BuildContext _) => Container(
|
||||||
|
width: 76,
|
||||||
|
height: 76,
|
||||||
|
decoration: BoxDecoration(color: pt.accentSoft, shape: BoxShape.circle),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(initial,
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.accent, fontSize: 32, fontWeight: FontWeight.w700)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
_TabHeader(title: 'Du', pt: pt),
|
||||||
|
Expanded(
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 16, 12, 16),
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: pt.bg2,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: pt.border),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
profile?.avatarUrl == null || client == null
|
||||||
|
? Builder(builder: fallback)
|
||||||
|
: MxcAvatar(
|
||||||
|
mxcUri: profile!.avatarUrl,
|
||||||
|
client: client,
|
||||||
|
size: 76,
|
||||||
|
placeholder: fallback,
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
right: 2,
|
||||||
|
bottom: 2,
|
||||||
|
child: Container(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: pt.online,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: pt.bg2, width: 3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.fg,
|
||||||
|
fontSize: 19,
|
||||||
|
fontWeight: FontWeight.w700)),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(userId,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style:
|
||||||
|
TextStyle(color: pt.fgMuted, fontSize: 13)),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: pt.fg,
|
||||||
|
side: BorderSide(color: pt.border),
|
||||||
|
shape: const StadiumBorder(),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.edit_rounded, size: 16),
|
||||||
|
label: const Text('Profil bearbeiten'),
|
||||||
|
onPressed: () => open('profile'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: pt.bg2,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: pt.border),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Material(
|
||||||
|
type: MaterialType.transparency,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
for (var i = 1; i < _entries.length; i++) ...[
|
||||||
|
if (i > 1)
|
||||||
|
Divider(height: 1, indent: 56, color: pt.border),
|
||||||
|
ListTile(
|
||||||
|
leading: Icon(_entries[i].$3, color: pt.fgMuted),
|
||||||
|
title: Text(_entries[i].$2,
|
||||||
|
style: TextStyle(color: pt.fg, fontSize: 15)),
|
||||||
|
trailing: Icon(Icons.chevron_right_rounded,
|
||||||
|
color: pt.fgDim),
|
||||||
|
onTap: () => open(_entries[i].$1),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import 'package:matrix/matrix.dart';
|
|||||||
import 'package:pyramid/core/app_state.dart';
|
import 'package:pyramid/core/app_state.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/features/group_call/group_call.dart'
|
||||||
|
show allowVoicePresenceForAll;
|
||||||
import 'package:pyramid/widgets/mxc_image.dart';
|
import 'package:pyramid/widgets/mxc_image.dart';
|
||||||
|
|
||||||
enum CreateJoinTab { create, join, discover }
|
enum CreateJoinTab { create, join, discover }
|
||||||
@@ -100,15 +102,7 @@ class _CreateJoinDialogState extends ConsumerState<CreateJoinDialog> {
|
|||||||
// weil Continuwuity power_level_content_override beim createRoom
|
// weil Continuwuity power_level_content_override beim createRoom
|
||||||
// ignoriert.
|
// ignoriert.
|
||||||
try {
|
try {
|
||||||
final plRaw = await client.getRoomStateWithKey(
|
await allowVoicePresenceForAll(client, roomId);
|
||||||
roomId, EventTypes.RoomPowerLevels, '');
|
|
||||||
final pl = Map<String, dynamic>.from(plRaw);
|
|
||||||
final events =
|
|
||||||
Map<String, dynamic>.from(pl['events'] as Map? ?? {});
|
|
||||||
events['io.pyramid.voice.presence'] = 0;
|
|
||||||
pl['events'] = events;
|
|
||||||
await client.setRoomStateWithKey(
|
|
||||||
roomId, EventTypes.RoomPowerLevels, '', pl);
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Nicht kritisch — Presence-Anzeige funktioniert dann nur für Admins.
|
// Nicht kritisch — Presence-Anzeige funktioniert dann nur für Admins.
|
||||||
}
|
}
|
||||||
@@ -123,6 +117,10 @@ class _CreateJoinDialogState extends ConsumerState<CreateJoinDialog> {
|
|||||||
? [StateEvent(content: {'algorithm': 'm.megolm.v1.aes-sha2'}, type: EventTypes.Encryption)]
|
? [StateEvent(content: {'algorithm': 'm.megolm.v1.aes-sha2'}, type: EventTypes.Encryption)]
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
|
// Gruppenanrufe: alle Mitglieder dürfen ihre Anwesenheit zeigen.
|
||||||
|
try {
|
||||||
|
await allowVoicePresenceForAll(client, roomId);
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ref.read(activeRoomIdProvider.notifier).state = roomId;
|
ref.read(activeRoomIdProvider.notifier).state = roomId;
|
||||||
@@ -581,7 +579,7 @@ class _SpaceEditDialogState extends ConsumerState<SpaceEditDialog> {
|
|||||||
try {
|
try {
|
||||||
await widget.space.leave();
|
await widget.space.leave();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ref.read(activeSpaceIdProvider.notifier).state = 'rooms';
|
ref.read(activeSpaceIdProvider.notifier).state = 'dms';
|
||||||
Navigator.of(context).pop(true);
|
Navigator.of(context).pop(true);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -169,6 +169,26 @@ class _AppearanceSection extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
if (isMobileLayout(MediaQuery.sizeOf(context))) ...[
|
||||||
|
_SettingsGroup(title: 'Navigation', pt: pt, children: [
|
||||||
|
Consumer(builder: (context, ref, _) {
|
||||||
|
final bottom = ref.watch(bottomNavProvider);
|
||||||
|
return _AdaptiveSettingsRow(
|
||||||
|
title: 'Hauptmenü',
|
||||||
|
desc: 'Seitenmenü (☰) oder Leiste unten mit Chats · Server · Du',
|
||||||
|
pt: pt,
|
||||||
|
control: _SegControl(
|
||||||
|
options: const ['Seitenmenü', 'Untere Leiste'],
|
||||||
|
selected: bottom ? 1 : 0,
|
||||||
|
pt: pt,
|
||||||
|
onSelect: (i) =>
|
||||||
|
ref.read(bottomNavProvider.notifier).set(i == 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
],
|
||||||
_SettingsGroup(title: 'Form & Bewegung', pt: pt, children: [
|
_SettingsGroup(title: 'Form & Bewegung', pt: pt, children: [
|
||||||
_SettingsRow(
|
_SettingsRow(
|
||||||
title: 'Eckenrundung',
|
title: 'Eckenrundung',
|
||||||
|
|||||||
@@ -82,6 +82,20 @@ class _SettingsModalState extends ConsumerState<SettingsModal> {
|
|||||||
('about', 'Über Pyramid', Icons.info_outline_rounded),
|
('about', 'Über Pyramid', Icons.info_outline_rounded),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final requested = ref.read(settingsSectionRequestProvider);
|
||||||
|
if (requested != null) {
|
||||||
|
_section = requested;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
ref.read(settingsSectionRequestProvider.notifier).state = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
String _titleFor(String id) =>
|
String _titleFor(String id) =>
|
||||||
_categories.firstWhere((c) => c.$1 == id, orElse: () => (id, id, Icons.settings)).$2;
|
_categories.firstWhere((c) => c.$1 == id, orElse: () => (id, id, Icons.settings)).$2;
|
||||||
|
|
||||||
@@ -214,7 +228,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,
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: pyramid
|
name: pyramid
|
||||||
description: A flexible Matrix client with resizable panel layout.
|
description: A flexible Matrix client with resizable panel layout.
|
||||||
publish_to: none
|
publish_to: none
|
||||||
version: 0.1.41+42
|
version: 0.1.42+43
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.11.1 <4.0.0"
|
sdk: ">=3.11.1 <4.0.0"
|
||||||
|
|
||||||
|
|||||||
@@ -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)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:pyramid/features/call_ui/bubble_physics.dart';
|
||||||
|
|
||||||
|
/// Lässt die Simulation [seconds] lang laufen (60 Schritte pro Sekunde).
|
||||||
|
bool _run(BubbleSim sim, double seconds) {
|
||||||
|
var moving = true;
|
||||||
|
for (var i = 0; i < (seconds * 60).round(); i++) {
|
||||||
|
moving = sim.step(1 / 60);
|
||||||
|
}
|
||||||
|
return moving;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
const phone = Size(390, 620);
|
||||||
|
const desktop = Size(1200, 700);
|
||||||
|
|
||||||
|
test('zwei Bubbles drücken sich leicht aneinander und kommen zur Ruhe', () {
|
||||||
|
final sim = BubbleSim()..sync(['a', 'b'], {}, desktop);
|
||||||
|
final moving = _run(sim, 6);
|
||||||
|
expect(moving, isFalse, reason: 'Simulation kommt nicht zur Ruhe');
|
||||||
|
|
||||||
|
final a = sim.bodies['a']!, b = sim.bodies['b']!;
|
||||||
|
final dist = (a.pos - b.pos).distance;
|
||||||
|
// Sie berühren sich (überlappen leicht) statt frei zu schweben …
|
||||||
|
expect(dist, lessThan(a.r + b.r));
|
||||||
|
// … liegen aber nicht ineinander.
|
||||||
|
expect(dist, greaterThan((a.r + b.r) * 0.8));
|
||||||
|
// Querformat: nebeneinander statt übereinander
|
||||||
|
expect((a.pos.dy - b.pos.dy).abs(), lessThan((a.pos.dx - b.pos.dx).abs()));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wer spricht, wird größer', () {
|
||||||
|
final sim = BubbleSim()..sync(['a', 'b', 'c'], {}, phone);
|
||||||
|
_run(sim, 4);
|
||||||
|
final before = sim.bodies['b']!.r;
|
||||||
|
sim.sync(['a', 'b', 'c'], {'b'}, phone);
|
||||||
|
_run(sim, 4);
|
||||||
|
final b = sim.bodies['b']!.r;
|
||||||
|
expect(b, greaterThan(before * 1.1));
|
||||||
|
expect(b, greaterThan(sim.bodies['a']!.r * 1.1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('viele Teilnehmer bleiben im Bild (Handy hochkant)', () {
|
||||||
|
final ids = [for (var i = 0; i < 8; i++) 'p$i'];
|
||||||
|
final sim = BubbleSim()..sync(ids, {}, phone);
|
||||||
|
_run(sim, 8);
|
||||||
|
for (final b in sim.bodies.values) {
|
||||||
|
expect(b.pos.dx - b.r, greaterThan(-2), reason: '${b.id} links raus');
|
||||||
|
expect(b.pos.dx + b.r, lessThan(phone.width + 2), reason: '${b.id} rechts raus');
|
||||||
|
expect(b.pos.dy - b.r, greaterThan(-2), reason: '${b.id} oben raus');
|
||||||
|
expect(b.pos.dy + b.r, lessThan(phone.height + 2), reason: '${b.id} unten raus');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Gehende schrumpfen weg und verschwinden', () {
|
||||||
|
final sim = BubbleSim()..sync(['a', 'b'], {}, phone);
|
||||||
|
_run(sim, 3);
|
||||||
|
sim.sync(['a'], {}, phone);
|
||||||
|
_run(sim, 3);
|
||||||
|
expect(sim.bodies.keys, ['a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Berührungsfläche ist abgeflacht, freier Rand bleibt rund', () {
|
||||||
|
final sim = BubbleSim()..sync(['a', 'b'], {}, desktop);
|
||||||
|
_run(sim, 6);
|
||||||
|
final a = sim.bodies['a']!, b = sim.bodies['b']!;
|
||||||
|
final bounds = sim.clipPath('a').getBounds();
|
||||||
|
final toB = (b.pos - a.pos);
|
||||||
|
// In Richtung des Nachbarn endet die Bubble vor ihrem Kreisrand …
|
||||||
|
if (toB.dx > 0) {
|
||||||
|
expect(bounds.right, lessThan(a.pos.dx + a.r - 1));
|
||||||
|
// … auf der freien Seite reicht sie bis zum Kreisrand.
|
||||||
|
expect(bounds.left, closeTo(a.pos.dx - a.r, 1));
|
||||||
|
} else {
|
||||||
|
expect(bounds.left, greaterThan(a.pos.dx - a.r + 1));
|
||||||
|
expect(bounds.right, closeTo(a.pos.dx + a.r, 1));
|
||||||
|
}
|
||||||
|
// Mittelpunkt liegt im Umriss, der Nachbar-Mittelpunkt nicht.
|
||||||
|
expect(sim.clipPath('a').contains(a.pos), isTrue);
|
||||||
|
expect(sim.clipPath('a').contains(b.pos), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('einzelne Bubble: rund, mittig', () {
|
||||||
|
final sim = BubbleSim()..sync(['solo'], {}, phone);
|
||||||
|
_run(sim, 4);
|
||||||
|
final s = sim.bodies['solo']!;
|
||||||
|
expect(s.pos.dx, closeTo(phone.width / 2, 2));
|
||||||
|
expect(s.pos.dy, closeTo(phone.height / 2, 2));
|
||||||
|
final bounds = sim.clipPath('solo').getBounds();
|
||||||
|
expect(bounds.width, closeTo(s.r * 2, 1));
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user