Compare commits

...
7 Commits
Author SHA1 Message Date
Bernd SteckmeisterandClaude Opus 5.5 1119c83af6 docs: aufgeblasene Anruf-Formen und 0.1.43 protokolliert
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 14:58:46 +02:00
Bernd SteckmeisterandClaude Opus 5.5 ab84701e73 feat(call): aufgeblasene Formen statt runder Bubbles (Bernds Referenzbild)
- Neues Layout lib/features/call_ui/inflated_layout.dart: gewichtetes
  Voronoi-/Potenzdiagramm im Anrufbereich, Zellen fuellen das Fenster bis an
  die Raender, an Nachbarn plattgedrueckt, weich gerundete Ecken;
  Lloyd-Relaxation + sanfter Zug ins Raster (aufgeraeumt), Flaechen per
  Gewicht nachgefuehrt (Sprecher 1,7-fach), Neue pumpen sich auf, Gehende
  schrumpfen weg, je mehr Personen desto kleiner
- Darstellung nur als Kontur mit weichem Innenleuchten, Profilbild mit
  Kontur + Name in der Mitte, Sprecher gruen pulsierend, Kamera-Bild
  fuellt die Form
- Tests: test/inflated_layout_test.dart (7), Vorschau-/Animationsbilder
  integration_test/call_bubbles_demo_test.dart; alte Kugel-Physik entfernt
- Version 0.1.43

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 14:54:45 +02:00
Bernd SteckmeisterandClaude Opus 5.5 d4473c7d64 docs: UI-Neuanstrich und Test-Version 0.1.42 protokolliert
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 14:32:03 +02:00
Bernd SteckmeisterandClaude Opus 5.5 391ebf0ffa feat(ui): untere Leiste (Chats/Server/Du) als Option, Server-Uebersicht, Du-Tab, Feinschliff
- Einstellung 'Navigation: Seitenmenue | Untere Leiste' (Handy, Standard
  bleibt Seitenmenue): Tabs Chats/Server/Du, Server-Uebersicht als Karten,
  Du-Tab mit Profil + Einstellungen, Zurueck-Taste ueber die Tabs
- CallDock als eigenes Widget (Anrufleiste auch ueber der Tab-Leiste)
- Sprachkanaele am Handy als Kachel mit 'wer ist drin'
- Suchfeld mit Lupe, 'Kanaele durchsuchen' im Server, Filter-Chips am PC
  umbrechend, '#' nur bei Server-Kanaelen, englische Reste entfernt
- Anrufansicht merkt sich Bubbles/Kacheln, Bubbles mit leichtem Glanz
- UI-Tour: Gruppenanruf (echter zweiter LiveKit-Teilnehmer), Bubbles mit
  5/7 Personen, untere Leiste; Version 0.1.42

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 14:22:02 +02:00
Bernd SteckmeisterandClaude Opus 5.5 de24053167 feat(ui): gemeinsame Chatliste, Gruppenanrufe mit Beitreten-Karte, Bubble-Anrufansicht
- Chats: Direktnachrichten + Gruppen ohne Server zusammen, Filter-Chips
  (Alle/Ungelesen/Personen/Gruppen), Leerzustaende mit Handlung; virtuelles
  'Raeume'-Symbol entfaellt, Server-Kanaele nur noch im Server
- Gruppen: runder Gruppen-Avatar, Kopf mit Mitgliederzahl, Anruf-/Video-Knopf
- Gruppenanruf: LiveKit + Anwesenheit, Start-Nachricht (Push), Karte
  'Gruppenanruf laeuft · Beitreten', Hinweis-Karte im Verlauf
- Anrufansicht: aneinandergedrueckte Bubbles (Federphysik, Sprecher waechst),
  Kacheln umschaltbar, Bildschirm automatisch gross, Gespraechsdauer,
  Matrix-Namen/-Avatare statt LiveKit-IDs
- Tests: test/bubble_physics_test.dart

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 11:58:31 +02:00
Bernd SteckmeisterandClaude Opus 5.5 87b472a373 fix(ui): UX-Audit – Sackgassen, tote Knöpfe, englische Reste; Screenshot-Tour
Bernds Wunsch 2026-10-08: UI grundsätzlich prüfen (Navigation, Sackgassen,
Anruf-Logik). Audit + Konzept in docs/UI_AUDIT.md, belegt mit neuer
Screenshot-Tour integration_test/ui_audit_tour_test.dart (35 Zustände,
Handy + PC, zwei Testkonten, Test-Space mit Sprachkanal).

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 11:35:40 +02:00
Bernd SteckmeisterandClaude Opus 5.5 842dd61031 docs: PROGRESS/ROADMAP – Taub/Stumm, HDR-Befund, 0.1.41
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 16:25:01 +02:00
48 changed files with 4041 additions and 750 deletions
+93
View File
@@ -13,6 +13,99 @@ Schritt (und beim Abbruch mitten im Schritt den Zwischenstand). Format:
--- ---
## 2026-10-10 (Teil 2) – Anrufansicht als „aufgeblasene“ Formen → 0.1.43
**Erledigt (ab84701, Gitea-Vorabversion v0.1.43):** Bernd wollte statt runder
Kugeln Formen, die „in die Grenzen ihres Fensters aufgeblasen“ sind (Referenz:
aufgepumpte Buchstaben in einer Kiste), vorerst nur als Kontur, animiert,
kleiner je mehr Leute. Umgesetzt mit `lib/features/call_ui/inflated_layout.dart`
(Potenzdiagramm im Anrufbereich, Lloyd + sanfter Zug ins Raster, Flächen per
Gewicht nachgeführt, Sprecher 1,7-fach, Neue pumpen sich aus winzig auf,
Gehende schrumpfen weg, gerundete Ecken) und `view/bubble_stage.dart`
(Kontur + Innenleuchten, Profilbild mit Kontur + Name, Sprecher grün,
Kamera füllt die Form). Tests: `test/inflated_layout_test.dart` (7);
Vorschaubilder + Animations-GIF: `integration_test/call_bubbles_demo_test.dart`
→ `build/call_demo/` (GIF per scratchpad `make_gif.py`). Alte Kugel-Physik
entfernt. Analyse + 76 Unit-Tests + UI-Tour grün.
**Stolperfalle:** Multiplikative Gewichtsanpassung (Voronoi-Treemap-Rezept)
startet bei Gewicht ≈ 0 extrem langsam → Sprecher wurde erst nach Sekunden
größer. Additiv (Gewicht += Flächenfehler, gedämpft) konvergiert in ~1 s.
**Offen:** Bernds Urteil zum neuen Look (Glanz/Füllung später?) und zur
Navigation; Gruppenanruf auf echten Geräten.
---
## 2026-10-10 – UI-Neuanstrich: Chats, Gruppenanrufe, Bubbles, untere Leiste → 0.1.42
**Erledigt:**
- **UI-Audit** (87b472a, `docs/UI_AUDIT.md`): Navigationskarte, Sackgassen
S1–S6 behoben (Anruf endet → zurück zum Chat statt englischer Leerseite,
Fehler sichtbar, Threads-Knopf weg, Space öffnet ersten Kanal am PC, keine
doppelte Anrufsteuerung), englische Reste, Annehmen-Fenster mit Avatar.
Screenshot-Tour `integration_test/ui_audit_tour_test.dart` (Handy + PC).
- **Bernds Entscheidungen (2026-10-09) umgesetzt** (de24053, 391ebf0):
- „Alles in Chats“: DMs + Gruppen ohne Server in einer Liste nach
Aktivität, Filter-Chips, Leerzustände mit Handlung; virtuelles „Räume“
entfällt, Server-Kanäle nur im Server (`core/room_kinds.dart`).
- Gruppenanruf (`features/group_call/group_call.dart`): Anruf-/Video-Knopf
im Gruppenkopf, LiveKit + Anwesenheit (`io.pyramid.voice.presence`),
Start-Nachricht als m.text (Push; m.notice wäre stumm), Karte
„Gruppenanruf läuft · Beitreten“, Hinweis-Karte im Verlauf. Berechtigung
für die Anwesenheit wird bei Bedarf gesenkt (nur wer Rechte ändern darf;
neue Gruppen beim Erstellen).
- Bubble-Anrufansicht (`call_ui/bubble_physics.dart` + `view/bubble_stage.dart`):
Federphysik, Abflachung an der Potenzgerade, Sprecher wächst + leuchtet,
Kacheln umschaltbar (gemerkt), Bildschirm automatisch groß,
Gesprächsdauer, Matrix-Namen/-Avatare statt LiveKit-IDs. Unit-Test
`test/bubble_physics_test.dart`.
- Untere Leiste als Option (`layout/mobile_tabs.dart`, Einstellung
Erscheinungsbild → Navigation; Standard bleibt Seitenmenü): Chats ·
Server (Übersicht als Karten) · Du (Profil + Einstellungen).
- Gruppen mit rundem Avatar + Mitgliederzahl, Sprachkanal-Kacheln mit
„wer ist drin“, Suchfeld-Lupe, „#“ nur bei Kanälen.
- Vergleichsbilder für Bernd: `build/ui_vergleich/` (aus der Tour).
- Tests: `scripts/test.ps1 -Ui` grün (Analyse, 75 Unit, Handy-UI, Live
Lesebestätigung/Einladung, Live-Anruf); Tour grün inkl. echtem zweitem
LiveKit-Teilnehmer im Gruppenanruf.
- Gitea-Vorabversion **v0.1.42** (Signatur = v0.1.36), PC-Testinstanz in
`pyramid-testinstanzpp-neu` bereitgelegt.
**Offen/Nächster Schritt:** Bernds Urteil Seitenmenü vs. untere Leiste;
Gruppenanruf auf echten Geräten (Handy ↔ PC-Testinstanz); Rest aus
UI_AUDIT §4 (Schnackbar-Stil, kompaktere Anruf-Pille).
**Stolperfallen:**
- `pumpFor` zeichnet im Integrationstest nur alle 100 ms → Physik/Animationen
kommen nicht zur Ruhe; Tour pumpt für Screenshots mit 16 ms.
- Zustand der Anrufansicht geht beim Wechsel Handy↔PC-Layout verloren →
Ansichtswahl statisch gemerkt.
- `addTearDown` darf nach dem Test keinen Provider mehr lesen (Container
schon entsorgt).
- Bash-Python-Patches: Ausgabe von Emojis scheitert an cp1252 (Datei wird
trotzdem geschrieben).
---
## 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):**
+6
View File
@@ -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):**
+121
View File
@@ -0,0 +1,121 @@
# 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. Bernds Entscheidungen (2026-10-09) und Umsetzung (0.1.42)
| Frage | Entscheidung | Umsetzung |
|---|---|---|
| Navigation Handy | Seitenmenü; untere Leiste als Mockup zum Vergleich | Einstellung „Navigation“ (Standard Seitenmenü), `layout/mobile_tabs.dart`, Vergleichsbild `build/ui_vergleich/1_navigation_vergleich.png` |
| Chatliste | Alles in „Chats“ | DMs + Gruppen ohne Server zusammen, Filter-Chips; Server-Kanäle nur im Server |
| Gruppenanruf | Anruf + Beitreten-Karte | Knopf im Gruppenkopf, Start-Nachricht (Push), Karte „Gruppenanruf läuft · Beitreten“ |
| Anrufansicht | animierte, aneinandergedrückte Bubbles → (2026-10-10) „aufgeblasene“ Formen bis an die Fensterränder, erst nur Kontur | `call_ui/inflated_layout.dart` + `view/bubble_stage.dart`, Kacheln umschaltbar, Vorschau `integration_test/call_bubbles_demo_test.dart` |
Noch offen aus §4: einheitliche Hinweis-Meldungen (Snackbar-Stil), kompaktere
Anruf-Pille, Abschnitts-Überschriften Text/Sprache in Server-Listen.
@@ -0,0 +1,111 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/call_ui/voice_channel.dart' show CallBubblesDemo;
/// Vorschaubilder der Anrufansicht mit erfundenen Teilnehmern (ohne Login):
/// Standbilder für 1–7 Personen (Handy + PC) und eine Bildfolge der
/// Animation (Sprecherwechsel, jemand kommt dazu, jemand geht) für ein GIF.
/// Bilder: build/call_demo/.
///
/// flutter test integration_test/call_bubbles_demo_test.dart -d windows
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('Anruf-Formen: Vorschaubilder', (tester) async {
final count = ValueNotifier(5);
final speaking = ValueNotifier(2);
void phone() => tester.view
..physicalSize = const Size(1080, 2340)
..devicePixelRatio = 2.625;
void desktop() => tester.view
..physicalSize = const Size(1600, 1000)
..devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
Future<void> smooth(Duration d) async {
final end = DateTime.now().add(d);
while (DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 16));
}
}
phone();
await tester.pumpWidget(PyramidTheme(
child: MaterialApp(
debugShowCheckedModeBanner: false,
theme: buildPyramidThemeData(ThemeVariant.soft),
home: ValueListenableBuilder<int>(
valueListenable: count,
builder: (_, n, _) => ValueListenableBuilder<int>(
valueListenable: speaking,
builder: (_, s, _) => CallBubblesDemo(count: n, speaking: s),
),
),
),
));
// ── Standbilder ──
for (final n in [1, 2, 3, 5, 7]) {
count.value = n;
speaking.value = n >= 3 ? 2 : -1;
phone();
await smooth(const Duration(milliseconds: 2500));
await _shot(tester, 'p_${n}_personen');
desktop();
await smooth(const Duration(milliseconds: 2500));
await _shot(tester, 'd_${n}_personen');
}
// ── Animation (Handy) ──
phone();
count.value = 4;
speaking.value = -1;
await smooth(const Duration(seconds: 3));
var frame = 0;
Future<void> record(Duration d) async {
final end = DateTime.now().add(d);
while (DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 16));
await _shot(tester, 'anim/f${(frame++).toString().padLeft(4, '0')}',
scale: 0.4, log: false);
}
}
await record(const Duration(milliseconds: 600));
speaking.value = 1; // Anna spricht
await record(const Duration(milliseconds: 1600));
speaking.value = 3; // Clara spricht
await record(const Duration(milliseconds: 1600));
speaking.value = -1;
count.value = 5; // David kommt dazu
await record(const Duration(milliseconds: 1800));
count.value = 4; // David geht wieder
await record(const Duration(milliseconds: 1800));
// ignore: avoid_print
print('Animation: $frame Bilder');
}, timeout: const Timeout(Duration(minutes: 6)));
}
Future<void> _shot(WidgetTester tester, String name,
{double scale = 1, bool log = true}) async {
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, pixelRatio: scale),
);
final bytes = await tester.binding.runAsync(
() => image!.toByteData(format: ui.ImageByteFormat.png),
);
final file = File('build/call_demo/$name.png');
file.parent.createSync(recursive: true);
file.writeAsBytesSync(bytes!.buffer.asUint8List());
// ignore: avoid_print
if (log) print('Bild $name');
}
+54 -285
View File
@@ -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');
}
+40 -33
View File
@@ -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),
); );
+28 -8
View File
@@ -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),
+256
View File
@@ -0,0 +1,256 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'package:matrix/matrix.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:webrtc_interface/webrtc_interface.dart';
// Gemeinsame Hilfen für Live-Tests mit zwei Testkonten (Anruftest,
// UI-Tour): zweiter Matrix-Client im selben Prozess, Zugangsdaten,
// Warte-Helfer. Kein eigenständiger Test.
/// Zweiter Teilnehmer: eigener Matrix-Client + VoIP im selben Prozess.
class TestPartner {
TestPartner(this.client, this.delegate, this.voip, this.zone) {
_sub = client.onCallEvents.stream.listen((events) {
for (final e in events) {
if (e.type != EventTypes.CallCandidates || e.senderId == client.userID) {
continue;
}
final list = e.content.tryGetList<Map<String, dynamic>>('candidates');
if (list != null) _buffer.addAll(list);
}
});
_timer = Timer.periodic(const Duration(milliseconds: 500), (_) async {
final pc = delegate.call?.pc;
if (!fixCandidates || pc == null) return;
try {
if (await pc.getRemoteDescription() == null) return;
for (final c in List.of(_buffer)) {
final cand = c['candidate'] as String?;
if (cand == null || !_applied.add(cand)) continue;
await pc.addCandidate(RTCIceCandidate(
cand,
c['sdpMid']?.toString() ?? '',
(c['sdpMLineIndex'] as num?)?.round() ?? 0,
));
}
} catch (_) {}
});
}
final Client client;
final TestPartnerDelegate delegate;
final VoIP voip;
final Zone zone;
/// Aktion der Gegenseite in ihrer Zone ausführen (Fehler über Completer).
Future<T> run<T>(Future<T> Function() f) {
final done = Completer<T>();
zone.run(() async {
try {
done.complete(await f());
} catch (e, s) {
done.completeError(e, s);
}
});
return done.future;
}
/// Gegenseite reicht Kandidaten nach wie die gepatchte App.
bool fixCandidates = false;
final _buffer = <Map<String, dynamic>>[];
final _applied = <String>{};
StreamSubscription? _sub;
Timer? _timer;
static Future<TestPartner> start(String user, String pass) async {
final db = await databaseFactoryFfi.openDatabase(inMemoryDatabasePath);
final client = Client(
'pyramid-call-partner',
database: await MatrixSdkDatabase.init('pyramid-call-partner', database: db),
);
await client.init(waitForFirstSync: false, waitUntilLoadCompletedLoaded: false);
await client.checkHomeserver(Uri.parse('https://steggi-matrix.work'));
await client.login(
LoginType.mLoginPassword,
identifier: AuthenticationUserIdentifier(user: user),
password: pass,
initialDeviceDisplayName: 'Pyramid-Anruftest (kurzlebig)',
);
final delegate = TestPartnerDelegate();
// Eigene Zone: Fehler der Gegenseite sichtbar protokollieren statt sie
// im Testrahmen zu verlieren.
final zone = Zone.current.fork(
specification: ZoneSpecification(
handleUncaughtError: (self, parent, z, e, s) =>
logStep('Gegenseite (abgefangen): ${e.toString().split('\n').first}'),
),
);
final voip = zone.run(() => VoIP(client, delegate));
return TestPartner(client, delegate, voip, zone);
}
Future<void> dispose(String? roomId) async {
_timer?.cancel();
await _sub?.cancel();
try {
await delegate.call?.hangup(reason: CallErrorCode.userHangup);
} catch (_) {}
if (roomId != null) {
try {
await client.leaveRoom(roomId);
await client.forgetRoom(roomId);
} catch (_) {}
}
try {
client.backgroundSync = false;
await client.logout();
} catch (_) {}
}
}
class TestPartnerDelegate implements WebRTCDelegate {
CallSession? call;
@override
MediaDevices get mediaDevices => TestPartnerMedia(rtc.navigator.mediaDevices);
@override
Future<RTCPeerConnection> createPeerConnection(
Map<String, dynamic> configuration, [
Map<String, dynamic> constraints = const {},
]) =>
rtc.createPeerConnection(configuration, constraints);
@override
Future<void> handleNewCall(CallSession session) async {
call = session;
if (session.direction == CallDirection.kIncoming) {
// Wie ein Mensch nach kurzem Klingeln annehmen. Sofortiges Annehmen
// trifft eine Lücke im SDK: Kandidaten, die WÄHREND answer()
// eintreffen, landen in einer Warteschlange, die nie mehr abgearbeitet
// wird (CallSession.onCandidatesReceived vs. answer()).
unawaited(Future.delayed(const Duration(seconds: 2), () async {
try {
await session.answer();
} catch (e) {
logStep('Gegenseite: Annehmen fehlgeschlagen: $e');
}
}));
}
}
@override
Future<void> handleCallEnded(CallSession session) async {}
@override
Future<void> handleMissedCall(CallSession session) async {}
@override
Future<void> handleNewGroupCall(GroupCallSession groupCall) async {}
@override
Future<void> handleGroupCallEnded(GroupCallSession groupCall) async {}
@override
Future<void> playRingtone() async {}
@override
Future<void> stopRingtone() async {}
@override
Future<void> registerListeners(CallSession session) async {}
@override
bool get isWeb => false;
@override
bool get canHandleNewCall => true;
@override
EncryptionKeyProvider? get keyProvider => null;
}
/// Wie die App am PC: Bildschirm über eine Desktop-Quelle (erster Monitor).
class TestPartnerMedia extends MediaDevices {
TestPartnerMedia(this._d);
final MediaDevices _d;
@override
Future<MediaStream> getUserMedia(Map<String, dynamic> c) => _d.getUserMedia(c);
@override
Future<MediaStream> getDisplayMedia(Map<String, dynamic> c) async {
final sources = await rtc.desktopCapturer
.getSources(types: [rtc.SourceType.Screen]);
return _d.getDisplayMedia({
'video': {
'mandatory': {
'chromeMediaSource': 'desktop',
'chromeMediaSourceId': sources.first.id,
},
'optional': [],
},
'audio': false,
});
}
@override
Future<List<MediaDeviceInfo>> enumerateDevices() => _d.enumerateDevices();
@override
MediaTrackSupportedConstraints getSupportedConstraints() =>
_d.getSupportedConstraints();
@override
set ondevicechange(Function(dynamic event)? callback) =>
_d.ondevicechange = callback;
@override
Function(dynamic event)? get ondevicechange => _d.ondevicechange;
@override
// ignore: deprecated_member_use
Future<List<dynamic>> getSources() => _d.getSources();
@override
Future<MediaDeviceInfo> selectAudioOutput([AudioOutputOptions? options]) =>
_d.selectAudioOutput(options);
}
Map<String, String>? loadTestAccounts() {
final home = Platform.environment['USERPROFILE'] ?? '';
final file = File('$home\\.pyramid-autopilot\\test-accounts.txt');
if (!file.existsSync()) return null;
final map = <String, String>{};
for (final line in file.readAsLinesSync()) {
final t = line.trim();
if (t.isEmpty || t.startsWith('#')) continue;
final i = t.indexOf('=');
if (i > 0) map[t.substring(0, i)] = t.substring(i + 1);
}
return map;
}
Future<void> pumpFor(WidgetTester tester, Duration d) async {
final end = DateTime.now().add(d);
while (DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 100));
}
}
void logStep(String m) {
// ignore: avoid_print
print('SCHRITT ${DateTime.now().toIso8601String().substring(11, 19)} $m');
}
Future<void> pumpUntil(
WidgetTester tester,
bool Function() condition, {
Duration timeout = const Duration(seconds: 60),
String what = '',
void Function()? onTimeout,
void Function()? every5s,
}) async {
final end = DateTime.now().add(timeout);
var next = DateTime.now().add(const Duration(seconds: 5));
while (DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 200));
if (condition()) return;
if (every5s != null && DateTime.now().isAfter(next)) {
every5s();
next = DateTime.now().add(const Duration(seconds: 5));
}
}
logStep('ZEITÜBERSCHREITUNG: $what');
onTimeout?.call();
fail('Zeitüberschreitung: $what');
}
+413
View File
@@ -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');
}
+4
View File
@@ -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)
+36
View File
@@ -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);
+7
View File
@@ -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 =
+344
View File
@@ -0,0 +1,344 @@
import 'dart:math' as math;
import 'dart:ui';
/// Layout der Anrufansicht als „aufgeblasene“ Formen (Bernd 2026-10-10,
/// Referenz: aufgepumpte Buchstaben in einer Kiste): Jede Person bekommt eine
/// Zelle, alle Zellen zusammen füllen den ganzen Anrufbereich bis an die
/// Ränder. Wo zwei Zellen aneinanderstoßen, sind sie plattgedrückt, außen
/// liegen sie am Fensterrand an, die Ecken sind weich gerundet ([outline]).
///
/// Technik: gewichtetes Voronoi-Diagramm (Potenzdiagramm) im Rechteck. Jede
/// Zelle hat einen Anteil an der Fläche ([InflatedCell.share], wer spricht
/// bekommt mehr). In jedem Schritt wandert der Mittelpunkt einer Zelle zu
/// ihrem Schwerpunkt (gleichmäßige, ruhige Formen) und ihr Gewicht wird so
/// nachgeführt, dass die Fläche dem Anteil entspricht. Weil das schrittweise
/// geschieht, sieht man es als federndes Auf- und Abpumpen. Je mehr Personen,
/// desto kleiner die Zellen.
class InflatedCell {
InflatedCell(this.id, this.site, this.weight, {this.share = 1});
final String id;
/// Mittelpunkt (Erzeuger) der Zelle.
Offset site;
/// Platz im Raster, zu dem die Zelle sanft gezogen wird (aufgeräumte
/// Anordnung); null = keiner (z. B. beim Gehen).
Offset? slot;
/// Gewicht im Potenzdiagramm (Einheit: Fläche, wie Radius²).
double weight;
/// Aktueller (animierter) und gewünschter Flächenanteil.
double share;
double targetShare = 1;
bool get leaving => targetShare == 0;
/// Ergebnis der letzten Berechnung.
List<Offset> polygon = const [];
double area = 0;
Offset centroid = Offset.zero;
}
class InflatedLayout {
InflatedLayout({this.gap = 10, this.margin = 12});
/// Wie viel mehr Fläche bekommt, wer gerade spricht.
static const speakingShare = 1.7;
/// Abstand zwischen zwei Zellen bzw. zum Fensterrand.
final double gap;
final double margin;
final Map<String, InflatedCell> cells = {};
Size _size = Size.zero;
Size get size => _size;
Rect get bounds => Rect.fromLTWH(
margin,
margin,
math.max(0, _size.width - 2 * margin),
math.max(0, _size.height - 2 * margin),
);
/// Gleicht die Zellen mit den Teilnehmern ab ([speaking] = wer spricht).
void sync(List<String> ids, Set<String> speaking, Size size) {
final old = _size;
_size = size;
if (size != old && old.width > 0 && old.height > 0 && cells.isNotEmpty) {
final flipped = (old.width >= old.height) != (size.width >= size.height);
if (flipped) {
// Hochkant ↔ quer: neu anordnen, sonst bleiben z. B. zwei Zellen
// übereinander, obwohl nebeneinander viel besser passt.
_placeOnGrid(cells.values.toList());
} else {
final sx = size.width / old.width, sy = size.height / old.height;
for (final c in cells.values) {
c.site = Offset(c.site.dx * sx, c.site.dy * sy);
c.weight *= sx * sy;
}
}
}
final present = ids.toSet();
for (final c in cells.values) {
if (!present.contains(c.id)) c.targetShare = 0;
}
final fresh = ids.where((id) => !cells.containsKey(id)).toList();
if (cells.isEmpty) {
// Erster Aufbau: gleich voll da, im Raster angeordnet.
final created = [for (final id in fresh) InflatedCell(id, Offset.zero, 0)];
_placeOnGrid(created);
for (final c in created) {
cells[c.id] = c;
}
} else {
for (final id in fresh) {
// Neu dazu: entsteht in der größten Zelle und pumpt sich dort auf.
final host = cells.values.reduce((a, b) => a.area >= b.area ? a : b);
final h = id.hashCode;
final dir = Offset(math.cos(h % 360.0), math.sin(h % 360.0));
final spread = math.sqrt(math.max(host.area, 1)) * 0.2;
final site = _inside(host.centroid + dir * spread);
// Gewicht knapp über der Grenze, ab der die Zelle leer wäre
// (w ≤ w_host − d²): sie beginnt winzig und pumpt sich dann auf.
final d2 = (site - host.site).distanceSquared;
cells[id] = InflatedCell(
id,
site,
host.weight - d2 * 0.9,
share: 0.15,
);
}
}
for (final id in ids) {
cells[id]!.targetShare = speaking.contains(id) ? speakingShare : 1.0;
}
final slots = _gridPoints(ids.length);
for (var i = 0; i < ids.length; i++) {
cells[ids[i]]!.slot = i < slots.length ? slots[i] : null;
}
for (final c in cells.values) {
if (c.leaving) c.slot = null;
}
_compute();
}
void _placeOnGrid(List<InflatedCell> list) {
final points = _gridPoints(list.length);
for (var i = 0; i < list.length && i < points.length; i++) {
list[i]
..site = points[i]
..weight = 0;
}
}
/// Mittelpunkte eines Rasters für [n] Zellen: möglichst quadratische
/// Zellen, wenig Lücken; die letzte Reihe wird mittig verteilt.
List<Offset> _gridPoints(int n) {
final b = bounds;
if (n == 0 || b.isEmpty) return const [];
var cols = 1;
var best = double.infinity;
for (var c = 1; c <= n; c++) {
final r = (n / c).ceil();
final aspect = (b.width / c) / (b.height / r);
final score = math.log(aspect).abs() + 0.35 * (c * r - n);
if (score < best) {
best = score;
cols = c;
}
}
final rows = (n / cols).ceil();
return [
for (var i = 0; i < n; i++)
() {
final row = i ~/ cols;
final inRow = row == rows - 1 ? n - row * cols : cols;
final col = i % cols;
// Letzte (unvollständige) Reihe: gleichmäßig über die Breite.
final x = row == rows - 1 && inRow < cols
? b.left + b.width * (col + 0.5) / inRow
: b.left + b.width * (col + 0.5) / cols;
return Offset(x, b.top + b.height * (row + 0.5) / rows);
}(),
];
}
Offset _inside(Offset p) {
final b = bounds.deflate(1);
return Offset(p.dx.clamp(b.left, b.right), p.dy.clamp(b.top, b.bottom));
}
/// Ein Zeitschritt (Sekunden). `true`, solange sich noch etwas bewegt.
bool step(double dt) {
if (_size.isEmpty || cells.isEmpty) return false;
dt = dt.clamp(0.0, 1 / 20);
var moving = false;
final shareRate = 1 - math.exp(-dt * 6);
for (final c in cells.values) {
final d = c.targetShare - c.share;
if (d.abs() > 0.01) moving = true;
c.share += d * shareRate;
}
cells.removeWhere((_, c) => c.leaving && c.share < 0.03);
if (cells.isEmpty) return false;
_compute();
final total = cells.values.fold<double>(0, (s, c) => s + c.area);
final shareSum = cells.values.fold<double>(0, (s, c) => s + c.share);
final f = math.min(1.0, dt * 10);
final move = 1 - math.exp(-dt * 8);
for (final c in cells.values) {
// Fläche zum Anteil passend machen: Gewicht direkt um den Fehler
// verschieben (Gewicht und Fläche haben dieselbe Einheit; ein Plus an
// Gewicht schiebt alle Grenzen der Zelle nach außen). Gedämpft, damit
// es federnd statt ruckartig wirkt.
final target = total * c.share / shareSum;
final error = target - c.area;
if (error.abs() > target * 0.02) moving = true;
c.weight += 0.4 * f * error;
// … und den Mittelpunkt zu Schwerpunkt bzw. Rasterplatz ziehen.
if (c.area > 1) {
final goal = c.slot == null
? c.centroid
: Offset.lerp(c.centroid, c.slot!, 0.5)!;
final d = goal - c.site;
// Ruhe, sobald sich nichts mehr sichtbar bewegt (< ~3 px/s).
if (d.distance * move > 0.05) moving = true;
c.site = _inside(c.site + d * move);
}
}
// Nur Unterschiede zählen – Mittelwert bei 0 halten.
final mean =
cells.values.fold<double>(0, (s, c) => s + c.weight) / cells.length;
for (final c in cells.values) {
c.weight -= mean;
}
_clampWeights();
_compute();
return moving;
}
/// Ein Erzeuger muss in seiner eigenen Zelle liegen: w_j − w_i ≤ d².
void _clampWeights() {
final list = cells.values.toList();
for (var i = 0; i < list.length; i++) {
for (var j = 0; j < list.length; j++) {
if (i == j) continue;
final d2 = (list[i].site - list[j].site).distanceSquared;
if (list[j].weight - list[i].weight > d2) {
list[j].weight = list[i].weight + d2;
}
}
}
}
void _compute() {
final b = bounds;
final list = cells.values.toList();
for (final c in list) {
var poly = <Offset>[b.topLeft, b.topRight, b.bottomRight, b.bottomLeft];
for (final o in list) {
if (identical(o, c) || poly.isEmpty) continue;
final n = (o.site - c.site) * 2;
final len = n.distance;
if (len < 1e-6) continue;
// Grenze des Potenzdiagramms, um die halbe Lücke zu c hin versetzt.
final limit = o.site.distanceSquared -
c.site.distanceSquared +
c.weight -
o.weight -
gap / 2 * len;
poly = _clip(poly, n, limit);
}
c.polygon = poly;
_measure(c);
}
}
/// Polygon auf die Halbebene n·x ≤ limit beschneiden.
static List<Offset> _clip(List<Offset> poly, Offset n, double limit) {
final out = <Offset>[];
for (var i = 0; i < poly.length; i++) {
final a = poly[i], b = poly[(i + 1) % poly.length];
final da = n.dx * a.dx + n.dy * a.dy - limit;
final db = n.dx * b.dx + n.dy * b.dy - limit;
if (da <= 0) out.add(a);
if ((da <= 0) != (db <= 0)) {
out.add(a + (b - a) * (da / (da - db)));
}
}
return out;
}
static void _measure(InflatedCell c) {
final p = c.polygon;
if (p.length < 3) {
c.area = 0;
return;
}
var a2 = 0.0, cx = 0.0, cy = 0.0;
for (var i = 0; i < p.length; i++) {
final q = p[i], r = p[(i + 1) % p.length];
final cross = q.dx * r.dy - r.dx * q.dy;
a2 += cross;
cx += (q.dx + r.dx) * cross;
cy += (q.dy + r.dy) * cross;
}
if (a2.abs() < 1e-6) {
c.area = 0;
return;
}
c.area = a2.abs() / 2;
c.centroid = Offset(cx / (3 * a2), cy / (3 * a2));
}
/// Umriss von [id]: die Zelle mit weich gerundeten Ecken („aufgeblasen“).
Path outline(String id) {
final c = cells[id];
final path = Path();
if (c == null) return path;
final p = <Offset>[];
for (final v in c.polygon) {
if (p.isEmpty || (v - p.last).distance > 0.5) p.add(v);
}
if (p.length > 2 && (p.first - p.last).distance <= 0.5) p.removeLast();
if (p.length < 3) return path;
final rcMax = math.min(72.0, math.sqrt(c.area) * 0.34);
for (var i = 0; i < p.length; i++) {
final v = p[i];
final prev = p[(i - 1 + p.length) % p.length];
final next = p[(i + 1) % p.length];
final lp = (prev - v).distance, ln = (next - v).distance;
final r = math.min(rcMax, math.min(lp, ln) * 0.5);
final a = v + (prev - v) / lp * r;
final b = v + (next - v) / ln * r;
if (i == 0) {
path.moveTo(a.dx, a.dy);
} else {
path.lineTo(a.dx, a.dy);
}
// Kubische Ecke (etwas voller als ein Viertelkreis – wirkt prall).
final c1 = a + (v - a) * 0.62, c2 = b + (v - b) * 0.62;
path.cubicTo(c1.dx, c1.dy, c2.dx, c2.dy, b.dx, b.dy);
}
path.close();
return path;
}
/// Rechteck um die Zelle (für Inhalte wie Video).
Rect cellBounds(String id) {
final p = cells[id]?.polygon ?? const [];
if (p.isEmpty) return Rect.zero;
var l = p.first.dx, t = p.first.dy, r = l, b = t;
for (final v in p) {
l = math.min(l, v.dx);
t = math.min(t, v.dy);
r = math.max(r, v.dx);
b = math.max(b, v.dy);
}
return Rect.fromLTRB(l, t, r, b);
}
}
+2 -2
View File
@@ -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 -10
View File
@@ -14,7 +14,7 @@ class _BigAvatar extends StatelessWidget {
gradient: LinearGradient(colors: [data.avatarColor, data.avatarColor.withAlpha(150)], begin: Alignment.topLeft, end: Alignment.bottomRight), 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'),
),
], ],
), ),
), ),
+436
View File
@@ -0,0 +1,436 @@
part of '../voice_channel.dart';
/// Anrufansicht als „aufgeblasene“ Formen (Layout: inflated_layout.dart,
/// Bernd 2026-10-10): Jede Person füllt ihre Zelle bis an die Nachbarn und
/// den Fensterrand, dargestellt als Kontur mit weichem Innenleuchten; in der
/// Mitte Profilbild und Name. Wer spricht, pumpt sich auf und leuchtet grün,
/// die anderen geben federnd nach. Kamera-Bild füllt die Form.
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 _layout = InflatedLayout();
// 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 = _layout.step(dt);
_data.removeWhere((id, _) => !_layout.cells.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;
}
_layout.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 != _layout.size) {
_needsSync = false;
_sync(size);
}
final glow = 0.5 + 0.5 * math.sin(_time * 5);
final shapes = <_CellShape>[];
for (final c in _layout.cells.values) {
final d = _data[c.id];
if (d == null || c.area < 16) continue;
shapes.add(_CellShape(
data: d,
path: _layout.outline(c.id),
bounds: _layout.cellBounds(c.id),
center: c.centroid,
opacity: c.share.clamp(0.0, 1.0),
));
}
return ColoredBox(
color: pt.bg1,
child: Stack(
clipBehavior: Clip.hardEdge,
children: [
// Kamera-Bild füllt die Form.
for (final s in shapes)
if (s.data.hasVideo && s.data.coverVideoWidget != null)
Positioned.fill(
key: ValueKey('video:${s.data.id}'),
child: ClipPath(
clipper: _PathClipper(s.path),
child: Stack(children: [
Positioned.fromRect(
rect: s.bounds,
child: _MirrorWrapper(
isLocal: s.data.isLocal,
isScreenShare: s.data.isScreenShare,
child: s.data.coverVideoWidget!,
),
),
]),
),
),
// Konturen (alle in einem Zeichenvorgang).
Positioned.fill(
child: IgnorePointer(
child: CustomPaint(
painter: _ContourPainter(
shapes: shapes,
glow: glow,
speakingColor: pt.online,
),
),
),
),
// Profilbild + Name in der Mitte jeder Form.
for (final s in shapes)
Positioned.fromRect(
key: ValueKey('content:${s.data.id}'),
rect: Rect.fromCenter(
center: s.data.hasVideo
? Offset(s.center.dx, s.bounds.bottom - 28)
: s.center,
width: s.bounds.width * 0.86,
height: s.data.hasVideo ? 40 : s.bounds.height * 0.82,
),
child: IgnorePointer(
child: Opacity(
opacity: s.opacity,
child: _CellContent(
data: s.data,
client: widget.client,
pt: pt,
cellSize: s.bounds.size,
),
),
),
),
// Antippen: Form mit Kamera-Bild groß zeigen.
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTapUp: (details) {
for (final s in shapes) {
if (s.path.contains(details.localPosition)) {
widget.onSelect(s.data);
return;
}
}
},
),
),
],
),
);
});
}
}
class _CellShape {
final _ParticipantData data;
final Path path;
final Rect bounds;
final Offset center;
final double opacity;
const _CellShape({
required this.data,
required this.path,
required this.bounds,
required this.center,
required this.opacity,
});
}
/// Profilbild (rund, mit Kontur) und Name – skaliert sich in jede Zellgröße.
class _CellContent extends StatelessWidget {
final _ParticipantData data;
final Client? client;
final PyramidTheme pt;
final Size cellSize;
const _CellContent({
required this.data,
required this.client,
required this.pt,
required this.cellSize,
});
@override
Widget build(BuildContext context) {
final avatar = (math.min(cellSize.width, cellSize.height) * 0.36)
.clamp(28.0, 132.0)
.toDouble();
final fontSize = (avatar * 0.15).clamp(11.0, 16.0).toDouble();
final nameTag = Container(
padding: EdgeInsets.symmetric(
horizontal: fontSize * 0.7, vertical: fontSize * 0.25),
decoration: BoxDecoration(
color: data.hasVideo
? Colors.black.withValues(alpha: 0.5)
: Colors.transparent,
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),
],
Text(
data.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: data.hasVideo ? Colors.white : pt.fg,
fontSize: fontSize,
fontWeight: FontWeight.w600,
),
),
],
),
);
if (data.hasVideo) {
return FittedBox(fit: BoxFit.scaleDown, child: nameTag);
}
return FittedBox(
fit: BoxFit.scaleDown,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_OutlinedAvatar(
data: data,
client: client,
size: avatar,
ring: data.isSpeaking ? pt.online : data.avatarColor,
),
SizedBox(height: fontSize * 0.6),
nameTag,
],
),
);
}
}
/// Rundes Profilbild mit Kontur; ohne Bild nur der Anfangsbuchstabe in der
/// Konturfarbe (passt zum reinen Linien-Look).
class _OutlinedAvatar extends StatelessWidget {
final _ParticipantData data;
final Client? client;
final double size;
final Color ring;
const _OutlinedAvatar({
required this.data,
required this.client,
required this.size,
required this.ring,
});
@override
Widget build(BuildContext context) {
Widget letter(BuildContext _) => Container(
alignment: Alignment.center,
color: ring.withValues(alpha: 0.08),
// Gezeichnet in der 256er-Fläche, danach auf [size] skaliert.
child: Text(data.initial,
style: TextStyle(
color: ring, fontSize: 104, fontWeight: FontWeight.w600)),
);
final c = client;
final inner = (c != null && data.avatarUrl != null)
? MxcAvatar(
mxcUri: data.avatarUrl,
client: c,
size: 256,
borderRadius: BorderRadius.zero,
placeholder: letter,
)
: Builder(builder: letter);
return Container(
width: size,
height: size,
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: ring, width: 2),
),
child: ClipOval(
child: FittedBox(
fit: BoxFit.cover,
child: SizedBox(width: 256, height: 256, child: inner),
),
),
);
}
}
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 _ContourPainter extends CustomPainter {
final List<_CellShape> shapes;
final double glow;
final Color speakingColor;
_ContourPainter({
required this.shapes,
required this.glow,
required this.speakingColor,
});
@override
void paint(Canvas canvas, Size size) {
for (final s in shapes) {
final speaking = s.data.isSpeaking;
final color = speaking ? speakingColor : s.data.avatarColor;
final o = s.opacity;
// Weiches Leuchten innen an der Kontur – wirkt „prall“, bleibt aber
// ein Linien-Look.
canvas.save();
canvas.clipPath(s.path);
canvas.drawPath(
s.path,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = speaking ? 30 : 22
..color = color.withValues(
alpha: o * (speaking ? 0.20 + 0.12 * glow : 0.10))
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 12),
);
canvas.restore();
if (speaking) {
canvas.drawPath(
s.path,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 8
..color = color.withValues(alpha: o * (0.25 + 0.25 * glow))
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8),
);
}
canvas.drawPath(
s.path,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = speaking ? 3 : 2
..strokeJoin = StrokeJoin.round
..color = color.withValues(alpha: o * (speaking ? 1.0 : 0.85)),
);
}
}
@override
bool shouldRepaint(_ContourPainter old) => true;
}
/// Vorführ-Ansicht der Anruf-Formen 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;
/// Wer gerade spricht (Index, -1 = niemand).
final int speaking;
const CallBubblesDemo({super.key, this.count = 5, this.speaking = 2});
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 == speaking,
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,
);
}
}
+148 -36
View File
@@ -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,
_LiveBadge(pt: pt), children: [
const Spacer(), 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),
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,
+9
View File
@@ -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/inflated_layout.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';
+6 -1
View File
@@ -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(
+4
View File
@@ -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(
+1
View File
@@ -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';
+89 -16
View File
@@ -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),
), ),
], ],
+1 -1
View File
@@ -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),
), ),
], ],
+3 -1
View File
@@ -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),
), ),
), ),
+362
View File
@@ -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'),
),
],
],
),
);
}
}
+15 -11
View File
@@ -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,
+30 -20
View File
@@ -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(
+205 -120
View File
@@ -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,
pt: pt, hasInvites: invites.isNotEmpty,
onToggle: () => onToggleSection(sectionId), pt: pt,
onAddTap: isDms )
? () => showDialog( else
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,
+118 -11
View File
@@ -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,
),
],
],
),
),
);
}
}
+76 -48
View File
@@ -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).
final hasVoipCall = voip.currentCall != null &&
voip.currentCall!.state != CallState.kEnded &&
voip.currentCall!.state != CallState.kRinging;
final hasCallDock = call.isActive || hasVoipCall;
final isLandscapeCompact =
MediaQuery.sizeOf(context).height < 500;
final callDock = AnimatedSize(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
alignment: Alignment.bottomCenter,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (call.isActive) MiniCallWidget(call: call),
if (hasVoipCall) MiniCallWidget(call: voip),
],
),
);
if (isLandscapeCompact && hasCallDock) {
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(child: callDock),
const Expanded(child: UserPanel()),
],
);
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [callDock, const UserPanel()],
);
}),
], ],
), ),
); );
} }
} }
/// 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 &&
voip.currentCall!.state != CallState.kEnded &&
voip.currentCall!.state != CallState.kRinging;
// Am PC bei offener Anrufansicht nicht doppelt steuern (UI-Audit S6); am
// Handy liegt die Anrufansicht ohnehin darüber.
final callViewOpen = ref.watch(viewModeProvider) == ViewMode.voice &&
!isMobileLayout(MediaQuery.sizeOf(context));
return AnimatedSize(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
alignment: Alignment.bottomCenter,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (call.isActive && !callViewOpen) MiniCallWidget(call: call),
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) {
return const Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(child: CallDock()),
Expanded(child: UserPanel()),
],
);
}
return const Column(
mainAxisSize: MainAxisSize.min,
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);
+3
View File
@@ -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';
+19 -4
View File
@@ -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;
} }
+25 -107
View File
@@ -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((_) {});
+90 -15
View File
@@ -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,11 +442,13 @@ 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
width: 80, ? null
backgroundColor: pt.bg0, : Drawer(
child: const SafeArea(child: SpacesRail()), width: 80,
), backgroundColor: pt.bg0,
child: const SafeArea(child: SpacesRail()),
),
// SafeArea: Abstand zu Statusleiste/Kamera-Aussparung oben und zur // SafeArea: Abstand zu Statusleiste/Kamera-Aussparung oben und zur
// Gesten-/Navigationsleiste unten (edge-to-edge, siehe main.dart). // Gesten-/Navigationsleiste unten (edge-to-edge, siehe main.dart).
body: SafeArea(child: Stack( body: SafeArea(child: Stack(
@@ -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,
decoration: BoxDecoration( size: 88,
color: pt.accent, placeholder: (_) => Container(
shape: BoxShape.circle, width: 88,
height: 88,
decoration: BoxDecoration(
color: pt.accent,
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Text(
call.room.getLocalizedDisplayname().characters.firstOrNull?.toUpperCase() ?? '?',
style: const TextStyle(color: Colors.white, fontSize: 36, fontWeight: FontWeight.w700),
),
), ),
child: const Icon(Icons.person_rounded, size: 40, color: Colors.white),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
Text( Text(
'Eingehender Anruf', call.type == CallType.kVideo ? 'Eingehender Videoanruf' : 'Eingehender Anruf',
style: TextStyle(color: pt.fgDim, fontSize: 13), style: TextStyle(color: pt.fgDim, fontSize: 13),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
+623
View File
@@ -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),
),
],
],
),
),
),
],
),
),
],
);
}
}
+8 -10
View File
@@ -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',
+15 -1
View File
@@ -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
View File
@@ -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.43+44
environment: environment:
sdk: ">=3.11.1 <4.0.0" sdk: ">=3.11.1 <4.0.0"
+6
View File
@@ -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)"
} }
+107
View File
@@ -0,0 +1,107 @@
import 'dart:ui';
import 'package:flutter_test/flutter_test.dart';
import 'package:pyramid/features/call_ui/inflated_layout.dart';
/// [seconds] lang simulieren (60 Schritte pro Sekunde).
bool _run(InflatedLayout l, double seconds) {
var moving = true;
for (var i = 0; i < (seconds * 60).round(); i++) {
moving = l.step(1 / 60);
}
return moving;
}
List<String> _ids(int n) => [for (var i = 0; i < n; i++) 'p$i'];
void main() {
const phone = Size(390, 620);
const desktop = Size(1200, 700);
test('eine Person füllt den ganzen Bereich bis an die Ränder', () {
final l = InflatedLayout()..sync(['solo'], {}, phone);
_run(l, 1);
final r = l.cellBounds('solo');
expect(r.left, closeTo(l.margin, 0.5));
expect(r.top, closeTo(l.margin, 0.5));
expect(r.right, closeTo(phone.width - l.margin, 0.5));
expect(r.bottom, closeTo(phone.height - l.margin, 0.5));
// Gerundete Ecken: der Umriss enthält die Ecke des Rechtecks nicht mehr.
final path = l.outline('solo');
expect(path.contains(r.topLeft + const Offset(2, 2)), isFalse);
expect(path.contains(r.center), isTrue);
});
test('vier Personen: gleich große Zellen, zusammen fast die ganze Fläche', () {
final l = InflatedLayout()..sync(_ids(4), {}, desktop);
final moving = _run(l, 6);
expect(moving, isFalse, reason: 'kommt nicht zur Ruhe');
final areas = l.cells.values.map((c) => c.area).toList();
final avg = areas.reduce((a, b) => a + b) / areas.length;
for (final a in areas) {
expect(a, closeTo(avg, avg * 0.08));
}
final total = areas.reduce((a, b) => a + b);
expect(total, greaterThan(l.bounds.width * l.bounds.height * 0.9));
});
test('wer spricht, bekommt deutlich mehr Fläche', () {
final l = InflatedLayout()..sync(_ids(3), {}, phone);
_run(l, 4);
l.sync(_ids(3), {'p1'}, phone);
_run(l, 5);
final speaker = l.cells['p1']!.area;
final other = l.cells['p0']!.area;
expect(speaker / other,
closeTo(InflatedLayout.speakingShare, InflatedLayout.speakingShare * 0.15));
});
test('je mehr Personen, desto kleiner die Zellen', () {
double avgArea(int n) {
final l = InflatedLayout()..sync(_ids(n), {}, phone);
_run(l, 5);
return l.cells.values.fold<double>(0, (s, c) => s + c.area) / n;
}
final a2 = avgArea(2), a5 = avgArea(5), a9 = avgArea(9);
expect(a5, lessThan(a2));
expect(a9, lessThan(a5));
});
test('Neue pumpen sich auf, Gehende verschwinden', () {
final l = InflatedLayout()..sync(_ids(2), {}, phone);
_run(l, 3);
l.sync(_ids(3), {}, phone);
expect(l.cells['p2']!.area, lessThan(l.cells['p0']!.area));
_run(l, 5);
expect(l.cells['p2']!.area, closeTo(l.cells['p0']!.area, l.cells['p0']!.area * 0.1));
l.sync(['p0', 'p2'], {}, phone);
_run(l, 3);
expect(l.cells.keys.toSet(), {'p0', 'p2'});
});
test('Zellen überlappen nicht und jeder Mittelpunkt liegt in seiner Zelle', () {
final l = InflatedLayout()..sync(_ids(7), {'p3'}, desktop);
_run(l, 6);
final paths = {for (final id in l.cells.keys) id: l.outline(id)};
for (final c in l.cells.values) {
expect(paths[c.id]!.contains(c.centroid), isTrue, reason: '${c.id} Schwerpunkt');
for (final other in l.cells.values) {
if (other.id == c.id) continue;
expect(paths[other.id]!.contains(c.centroid), isFalse,
reason: '${c.id} liegt in ${other.id}');
}
}
});
test('Drehen (hochkant → quer): zwei Personen nebeneinander', () {
final l = InflatedLayout()..sync(_ids(2), {}, phone);
_run(l, 3);
final a = l.cells['p0']!.centroid, b = l.cells['p1']!.centroid;
expect((a.dy - b.dy).abs(), greaterThan((a.dx - b.dx).abs()), reason: 'hochkant: übereinander');
l.sync(_ids(2), {}, desktop);
_run(l, 3);
final c = l.cells['p0']!.centroid, d = l.cells['p1']!.centroid;
expect((c.dx - d.dx).abs(), greaterThan((c.dy - d.dy).abs()), reason: 'quer: nebeneinander');
});
}