Compare commits

...
26 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
Bernd SteckmeisterandClaude Opus 5.5 5204bb81bb chore: Testversion 0.1.41
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 16:20:49 +02:00
Bernd SteckmeisterandClaude Opus 5.5 f46cc22aae fix(calls): Taub wirkt, Stumm-Knopf zeigt echten Zustand, Mini-Leiste erst nach Annehmen, HDR-Hinweis
Bernds Test 2026-10-08:
- Taub-Knopf setzte bei 1:1-Anrufen nur ein Häkchen. Jetzt wie Discord:
  Ton der Gegenseite aus (Empfangsspuren deaktiviert, auch nach
  Neuverhandlung) und selbst stumm; beim Aufheben Mikro-Zustand von vorher.
- Stumm: schlug nur das Senden der Status-Info fehl, schaltete Pyramid den
  Knopf blind zurück, obwohl das Mikro aus war – danach zeigte der Knopf
  das Gegenteil. Jetzt wird der echte SDK-Zustand übernommen.
- Mini-Anrufleiste erschien schon beim Klingeln (vor dem Annehmen).
- Bildschirm-Auswähler: deutsch, Hinweis zur bekannten WebRTC-Grenze bei
  HDR unter Windows (blasses Bild; Fenster teilen oder Win+Alt+B).
- Live-Anruftest drückt Stumm und Taub über die echten Knöpfe und prüft,
  was bei der Gegenseite ankommt.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 16:20:48 +02:00
Bernd SteckmeisterandClaude Opus 5.5 2bbb45fe19 docs: PROGRESS – Live-Anruftest, Teilen/Verbindung/Sprech-Ring, 0.1.40
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 11:10:20 +02:00
Bernd SteckmeisterandClaude Opus 5.5 064d730f33 chore: Testversion 0.1.40
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 11:05:57 +02:00
Bernd SteckmeisterandClaude Opus 5.5 8074928505 fix(calls): Bildschirm teilen sichtbar, Verbindung trotz SDK-Wettlauf, Sprech-Ring; Trenner in Gruppen
Bernds Test 2026-10-08 (Streamen ging weder am PC noch am Handy, Ton kam
nicht an, Trenner blieb manchmal stehen) – mit neuem Live-Anruftest
(zwei Testkonten im selben Prozess) nachgestellt und behoben:

- Bildschirm teilen: Die Stromzuordnung rief ein SDK-getFeeds() auf, das es
  nicht gibt; der Fehler wurde geschluckt → Übertragungen wurden nie
  angezeigt und beim Sender nie als aktiv erkannt. Jetzt typisierte
  Zugriffe (remote/localScreenSharingStream, 'stopped' beachtet).
- Verbindungsaufbau: matrix-SDK verwirft Kandidaten, die vor der fertig
  verarbeiteten Einladung bzw. während des Annehmens eintreffen; unter
  Windows wirft getRemoteDescription zudem statt null zu liefern. Pyramid
  puffert alle Kandidaten der Gegenseite und reicht sie direkt an die
  Verbindung nach. Test: vorher nie verbunden, jetzt ~0,5 s (angerufen)
  bzw. ~2,7 s (anrufend), Ton beide Richtungen, Teilen beide Richtungen.
- SDK-Aufrufe in eigener Zone: harmlose Plugin-Fehler (Lautsprecher unter
  Windows) werden protokolliert statt durchzuschlagen; Ergebnisse/Fehler
  per Completer über die Zonengrenze (sonst ewiges Warten).
- Sprech-Ring auch bei 1:1-Anrufen (Pegel aus WebRTC-Statistiken) – zeigt,
  ob das eigene Mikrofon Ton liefert und ob die Gegenseite ankommt; großes
  Bild springt nicht mehr bei jedem Sprechwechsel.
- „Neue Nachrichten“ mitten in einer Gruppe (mehrere Nachrichten desselben
  Absenders) blieb bis zum erneuten Öffnen stehen: Inline-Trenner jetzt nur
  mit Ungelesenem, blendet aus und trägt den Sprungschlüssel. Live-Test.
- „Anruf läuft“-Leiste nicht schon beim Klingeln.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 11:05:46 +02:00
Bernd SteckmeisterandClaude Opus 5.5 5485b6d449 docs: PROGRESS – Anruf-/Chat-Test, Testinstanz, 0.1.39
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 00:06:54 +02:00
Bernd SteckmeisterandClaude Opus 5.5 775cf71b77 chore: Testversion 0.1.39
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 00:01:46 +02:00
Bernd SteckmeisterandClaude Opus 5.5 441cc6ef54 fix(calls): Bildschirmübertragung ohne Absturz, Lautsprecher, Mikrofon im Hintergrund, zurück in den Anruf
Bernds Anruftest Handy↔PC (2026-10-07):
- Teilen am Handy: App "verkleinerte sich", Anruf danach weg. Ursache:
  Android 14+ verlangt vor MediaProjection einen Vordergrunddienst vom Typ
  mediaProjection; flutter_webrtc startet keinen, der Manifest-Eintrag
  verwies auf eine nicht mehr existierende Klasse. Neu: CallService
  (microphone|mediaProjection) + CallForeground: Zustimmung → Dienst
  (wartet auf echten startForeground) → Mitschnitt. Emulator-Test (Android
  16): mit Fix 1080x2400@30 läuft; Gegenprobe ohne Dienst wirft genau
  'Media projections require a foreground service ...'.
- Dienst läuft während jedes Anrufs: Mikrofon bleibt im Hintergrund aktiv,
  Benachrichtigung "Anruf läuft" führt zurück. Mikrofon-Typ nur mit
  Berechtigung, sonst schmalere Variante statt Dienst-Abbruch.
- Handy hörte nichts: SDK schaltet Sprachanrufe auf die Hörmuschel. Jetzt
  Lautsprecher als Standard (wie Discord) + Umschalter in der Anrufleiste.
- LiveKit-Teilen am Handy öffnete den PC-Quellen-Auswähler → System-Dialog.
- Im offenen Chat (Handy) Leiste "Anruf läuft · Zurück".
- Einstellungen → Sprache & Video: Mikrofontest mit Pegel (PC-Diagnose:
  das Standardgerät lieferte nur Stille – Voicemeeter/virtuelle Geräte).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 00:01:29 +02:00
Bernd SteckmeisterandClaude Opus 5.5 c817de924a fix: Haken live, Einladung im Chat annehmen, Trenner verschwindet zuverlässig
Bernds Test mit der PC-Testinstanz:
- ✓✓ erschien erst nach erneutem Öffnen: eine Lesebestätigung ohne neue
  Nachricht zeichnete nichts neu (Raum-Update kommt im SDK vor der
  Bestätigung). Jetzt nach jedem Sync Fingerabdruck der Lesepositionen
  prüfen und bei Änderung neu zeichnen.
- Einladung: Chat zeigte leeren Verlauf, Annehmen ging nur in der Liste, und
  danach blieb der Chat leer bis zum App-Neustart. Jetzt Annehmen/Ablehnen
  direkt im Chat; Annehmen wartet auf den Beitritts-Sync, der Verlauf baut
  sich bei invite→join selbst neu auf.
- Trenner blieb stehen, wenn nach dem Öffnen/Zurückkommen nicht gescrollt
  wurde. Jetzt: neueste Nachrichten sichtbar (≤ halbe Bildschirmhöhe
  hochgescrollt) → nach 6 s weich ausblenden, auch ohne Scrollen.
- Live-Test mit beiden Testkonten (Gegenprobe: ohne Fix rot) in
  scripts/test.ps1 -Ui; Testlauf-Sperre erkennt nur noch eigene Testläufe.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 23:33:59 +02:00
Bernd SteckmeisterandClaude Opus 5.5 a6461fe23c feat: Trenner bleibt 6 s stehen, Testprofil im Fenstertitel
- "Neue Nachrichten"-Trenner verschwand beim Öffnen aus der Benachrichtigung
  sofort (Bernd). Jetzt: bei den neuesten Nachrichten angekommen noch 6 s
  stehen, dann weich ausblenden; wer hochscrollt, behält ihn.
- Windows: mit PYRAMID_PROFILE_DIR heißt das Fenster "Pyramid - TESTINSTANZ"
  (Testinstanz mit Testkonto nicht mit der echten Pyramid verwechseln).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 23:06:20 +02:00
Bernd SteckmeisterandClaude Opus 5.5 a30d57ba69 docs: PROGRESS/ROADMAP – Runde 4 (UI, Animationen, Lesebestätigung, LiveKit, Markdown)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 22:15:00 +02:00
Bernd SteckmeisterandClaude Opus 5.5 df377d7c4b chore: Testversion 0.1.38
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 22:13:57 +02:00
Bernd SteckmeisterandClaude Opus 5.5 0ad06e3238 chore: flutter_markdown (eingestellt) -> flutter_markdown_plus
Gleiche API, gepflegter Nachfolger. Rendertest für die Markdown-Vorschau
(test/document_markdown_test.dart).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 22:13:27 +02:00
Bernd SteckmeisterandClaude Opus 5.5 756486d1fe security: LiveKit-Token vom Server statt Secret in der App
Die App signierte LiveKit-Tokens selbst – dafür steckte das SFU-Secret im
App-Code (jeder mit der APK konnte sich Tokens für beliebige Räume und
Identitäten ausstellen). Jetzt holt die App das Token von der bereits
vorhandenen Server-Route /api/livekit-token (whoami-Prüfung, Identität =
geprüfte Matrix-ID). Secret und JWT-Signieren sind aus dem Client entfernt.

- Lesbare Fehlermeldungen (offline, 401, Serverfehler) statt stiller Fehler.
- Gruppenanruf aus dem Chat-Kopf übergibt jetzt den Matrix-Client.
- Tests: Unit-Test mit Mock-HTTP + echter LiveKit-Beitritt (Testkonto).
- Offen: Handy-Anruftest; danach Secret-Rotation durch Bernd (Doku).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 22:12:15 +02:00
Bernd SteckmeisterandClaude Opus 5.5 ae6f651f4c feat: federnde Übergänge und Animationen, Entwürfe je Raum
- Handy: Chat gleitet federnd über die Chatliste (Liste bleibt aufgebaut,
  Scrollposition erhalten), beim Zurück wieder hinaus; Liste schon während
  des Hinausgleitens bedienbar. Mitgliederleiste gleitet herein.
- Neue/gesendete Nachrichten ploppen weich auf (nur live, nicht beim Öffnen
  oder Nachladen), Reaktionen ploppen auf und hüpfen beim Hochzählen,
  Ungelesen-Zähler hüpft, Scroll-nach-unten-Knopf federt (Handy größer),
  Antwort-Leiste klappt federnd auf/zu, Senden-Pfeil ploppt, Vollansicht
  zoomt auf. Alles skaliert mit Einstellung „Animationen“ (0 % = aus).
- Fix: Beim Raumwechsel blieben Antwort-Ziel, Auswahl und getippter Text am
  neuen Raum hängen (Antwort auf Raum A konnte in Raum B rausgehen). Jetzt
  eigenes Eingabefeld je Raum mit Entwurf im Speicher.
- Antwort-Leiste: größeres Schließen-X auf dem Handy.
- Tests: test/spring_motion_test.dart, UI-Durchlauf mit Übergängen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 22:07:17 +02:00
Bernd SteckmeisterandClaude Opus 5.5 1d0ccfde8b fix: Lesebestätigung nur im Vordergrund, ohne Dauerfeuer, privat statt gar nicht
- Offener Chat meldete bei jedem Neuaufbau erneut "gelesen" – auch mit
  App im Hintergrund; die gerade gezeigte Benachrichtigung verschwand dann
  sofort wieder. Jetzt nur bei Fokus, je Event nur einmal.
- Zurück in der App: "Neue Nachrichten"-Trenner hinter dem zuletzt
  gelesenen Stand, danach gelesen melden.
- "Lesebestätigungen senden" aus: nur noch private Bestätigung
  (m.read.private) statt gar keiner – Ungelesen-Zähler stimmen wieder.
  Gilt auch für "Gelesen" aus der Benachrichtigung.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 21:32:36 +02:00
Bernd SteckmeisterandClaude Opus 5.5 223c9448f6 feat: schwebende Eingabeleiste - Chat laeuft darunter weiter (Milchglas), Flaeche faengt Beruehrungen ab
Bernds Wunsch 2026-10-07: Die Nachrichtenliste reicht jetzt bis ganz unten;
Tipp-Anzeige und Eingabefeld schweben darueber. Um das Feld herum ist die
Flaeche klar (Chat sichtbar), aber beruehrungsdicht - dort reagiert nur die
Eingabe. Hinter dem Feld selbst Milchglas (Unschaerfe + 78 % Flaeche), Rahmen
und Fokus-Ring aussen. Die Liste bekommt unten genau die gemessene Hoehe der
Leiste als Abstand (MeasureSize; waechst mit Antwort-Leiste/mehrzeiligem
Text), damit die neueste Nachricht ganz ueber die Leiste scrollt; der
Nach-unten-Knopf sitzt darueber.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 21:26:33 +02:00
Bernd SteckmeisterandClaude Opus 5.5 7063ebfceb feat: Antwort-Zitat antippen springt zur zitierten Nachricht (inkl. Aufleuchten)
_ReplyPreview nutzt den Sprung-Mechanismus der Suche (pendingJumpEventProvider).
Der Sprung scrollt jetzt schrittweise Richtung aeltere Nachrichten, wenn das
Ziel zwar geladen, aber in der lazy ListView noch nicht gezeichnet ist (vorher
gab er dann nach 40 Versuchen auf); dasselbe Zitat darf erneut angetippt
werden. UI-Durchlauf prueft den Sprung per Screenshot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 18:17:35 +02:00
Bernd SteckmeisterandClaude Opus 5.5 382747cdb1 feat: Handy - groessere Symbole, Suche/Mitglieder im Menue, Kontakt-Banner repariert, Schrift 110 %, federnde Knoepfe
Bernds Feedback 2026-10-07:
- Schrift-Standard Handy 110 % (statt 115 %).
- PyrIconBtn: im Handy-Layout Symbol 22 px (vorher fest 16), Flaeche 40,
  Tippflaeche 44; Farben aus dem Farbmodus statt fest verdrahtet.
- Eingabeleiste: Anhaengen/GIF/Emoji/Senden auf dem Handy 22-23 px statt
  12-13 px.
- Chat-Kopf (Handy): Anrufen + Videoanruf sichtbar, Suchen + Mitglieder im
  Drei-Punkte-Menue.
- Aufklappbarer DM-Kopf als Ebenen: Zurueck + Symbole bleiben oben,
  Profilbereich gleitet darunter und nutzt die volle Breite (vorher Name zu
  "p..." gekuerzt, Symbole rutschten in die Mitte); Kuerzel im Avatar
  mittig, skaliert mit.
- SpringPress (widgets/spring_press.dart): federnde Druck-Rueckmeldung,
  aus bei "Animationen 0 %".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 18:13:32 +02:00
84 changed files with 7804 additions and 913 deletions
+228
View File
@@ -13,6 +13,234 @@ 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
**Erledigt (8074928, Version 064d730, Gitea-Vorabversion v0.1.40):**
- **Neuer Live-Anruftest** `integration_test/live_call_test.dart`: App
(pyramidtest1) + zweiter Matrix-Client (pyramidtest2, kurzlebiges Gerät)
im selben Prozess, eigener unverschlüsselter Raum. Teil 1: Gegenseite ruft
an, App nimmt über den echten Knopf an; Ton beide Richtungen (Bytes),
Bildschirm der Gegenseite erscheint in der App, App teilt über den echten
Quellen-Auswähler. Teil 2: App ruft an. In `scripts/test.ps1 -Ui`.
- **Bildschirm teilen** wurde nie angezeigt: `_updateRenderers` rief ein
SDK-`getFeeds()` auf, das es nicht gibt (Fehler geschluckt). Jetzt
typisierte Zugriffe, `stopped` beachtet.
- **Verbindungsaufbau:** matrix-SDK 6.2 verwirft Kandidaten, die vor der
fertig verarbeiteten Einladung (eigener Listener je Sync, nicht
serialisiert) bzw. während `answer()` eintreffen; Windows-
`getRemoteDescription` wirft statt null. Pyramid puffert Kandidaten der
Gegenseite (`_bufferRemoteCandidates`) und reicht sie direkt an die
PeerConnection nach. Vorher im Test NIE verbunden, jetzt 0,5 s / 2,7 s.
- **SDK-Zone** in `voip_manager.dart`: unbehandelte SDK/Plugin-Fehler
werden protokolliert; `_inSdkZone` reicht Ergebnis/Fehler per Completer
über die Zonengrenze (sonst würde der Aufrufer bei Fehlern ewig warten).
- **Sprech-Ring** bei 1:1-Anrufen (audioLevel aus getStats, Hysterese);
großes Bild springt nicht mehr beim Sprechen.
- **Trenner** mitten in einer Gruppe (Inline-Variante) blieb stehen → nur mit
Ungelesenem, blendet aus, trägt den Sprungschlüssel; Live-Test Teil 3 in
`live_receipts_invite_test.dart`.
**Offen/Nächster Schritt:** Bernd testet 0.1.40 + Testinstanz (übernimmt
`app-neu` beim nächsten Start). PC→Handy-Ton: Transport ist belegt (Bytes
fließen), offen ist, ob das PC-Mikrofon Ton liefert (Messung: alle Geräte
still; Voicemeeter) → Sprech-Ring / Mikrofontest prüfen.
**Stolperfallen:** (1) `what:`-Texte in Warte-Helfern werden beim Aufruf
ausgewertet, nicht beim Timeout – Zustände per Callback ausgeben.
(2) SDK-Mitglieder-Cache: `getParticipants()`/`getState(RoomMember)` sind
ohne Nachladen leer – nicht darauf warten. (3) Dart: Fehler überqueren
Error-Zone-Grenzen nicht – Completer verwenden. (4) Analyzer-Warnungen
beginnen ohne Einrückung („warning - …“) – beim grep beachten.
---
## 2026-10-08 – Bernds Anruf-/Chat-Test mit PC-Testinstanz → Test-Version 0.1.39
**Erledigt (je ein Commit, alles gepusht):**
- **PC-Testinstanz** (a6461fe + außerhalb des Repos): `C:\Users\nordm\pyramid-testinstanz\`
(Release-Build, `Pyramid-Testinstanz.cmd`, Desktop-Verknüpfung), Profil
`profile2` = pyramidtest2, Fenstertitel „Pyramid - TESTINSTANZ“,
Doppelstart-Schutz, übernimmt bereitgelegte Updates (`app-neu`) beim
nächsten Start. DM pyramidtest2 ↔ Bernd angelegt.
- **Haken live / Einladungen / Trenner** (c817de9): ✓✓ zeichnet nach jedem
Sync neu, wenn sich Lesepositionen ändern (Raum-Update kommt im SDK VOR der
Bestätigung); Einladung im Chat annehmen, Verlauf baut sich bei
invite→join neu auf; Trenner blendet 6 s nach Erreichen der neuesten
Nachrichten aus, auch ohne Scrollen. Live-Test mit beiden Testkonten
(`integration_test/live_receipts_invite_test.dart`, Gegenprobe rot ohne
Fix), in `scripts/test.ps1 -Ui`.
- **Anrufe** (441cc6e): Bildschirm teilen auf Android 14+ stürzte ab
(kein mediaProjection-Vordergrunddienst; Manifest verwies auf nicht
existierende flutter_webrtc-Klasse) → `CallService.kt` +
`lib/core/call_foreground.dart` (Zustimmung → Dienst, wartet auf echten
startForeground → Mitschnitt). Emulator-Test Android 16
(`integration_test/android_screen_capture_test.dart`): mit Fix läuft der
Mitschnitt, Gegenprobe wirft die erwartete SecurityException. Dienst hält
während jedes Anrufs das Mikrofon im Hintergrund aktiv + „Anruf läuft“-
Benachrichtigung. Handy: Lautsprecher statt Hörmuschel (SDK schaltet
Sprachanrufe auf Hörmuschel) + Umschalter. „Anruf läuft · Zurück“-Leiste im
Chat. LiveKit-Teilen am Handy ohne PC-Quellen-Auswähler. Mikrofontest mit
Pegel in den Einstellungen.
- **Version 0.1.39+40** (775cf71) als Gitea-Vorabversion.
**Befund PC-Mikrofon:** Lokale Pegelmessung über WebRTC lieferte auf ALLEN
10 Aufnahmegeräten (Voicemeeter-Ausgänge, Steam, Razer Barracuda X) nur
digitale Stille (audioLevel 1/32767). Entweder war das Headset aus, oder das
Mikrofon läuft über Voicemeeter und Pyramid nutzt das falsche Gerät – das
erklärt „Handy hört den PC nicht“. Bernd prüft mit dem neuen Mikrofontest.
**Offen/Nächster Schritt:** Bernd testet 0.1.39 + Testinstanz: Teilen am
Handy (System-Dialog „Gesamten Bildschirm teilen“), Ton beide Richtungen
(PC-Mikrofon im Mikrofontest wählen), Teilen vom PC, Verbindungsdauer
außerhalb des Heimnetzes (ICE/TURN ist ok: TURN-Daten werden alle 4 h
erneuert; Verzögerung vermutlich SDK-Kandidaten-Bündelung 2 s + verschlüsselte
Signalisierung – erst messen, dann ändern).
**Stolperfallen:** (1) Git-Bash schreibt `/sdcard/...` in Windows-Pfade um →
`MSYS_NO_PATHCONV=1` für adb. (2) Android 16 schlägt beim Teilen „eine App“
vor und wechselt in diese App – das sah wie „App verkleinert sich“ aus.
(3) Ein Dienst mit Typ microphone ohne RECORD_AUDIO-Berechtigung wird KOMPLETT
verweigert – Typen nur mit Berechtigung anfordern. (4) Python-urllib wird von
Cloudflare ohne User-Agent abgewiesen (Aufräumskript). (5) Testkonten:
Schlüssel in test-accounts.txt heißen user1/pass1/user2/pass2.
---
## 2026-10-07 (Teil 4) – Handy-UI-Feinschliff, Animationen, Lesebestätigung, LiveKit ohne Secret → Test-Version 0.1.38
**Erledigt (je ein Commit, alles gepusht):**
- **Handy-UI** (382747c): größere Symbole (Anruf, Video, Anhang, GIF, Emoji),
Suche/Mitglieder im ⋮-Menü, aufgeklapptes Kontakt-Banner (Kürzel mittig,
Symbole bleiben an ihrem Platz), Schrift-Standard 110 %, federnde Knöpfe.
- **Antwort-Zitat antippen** springt zur Nachricht (7063ebf).
- **Schwebende Eingabeleiste** (223c944): Chat läuft darunter weiter,
Milchglas nur im Eingabefeld, Fläche fängt Berührungen ab.
- **Lesebestätigung** (1d0ccfd): nur bei Fokus/Vordergrund, je Event einmal
(vorher bei jedem Neuaufbau, auch im Hintergrund – die gerade gezeigte
Benachrichtigung verschwand sofort); „Neue Nachrichten“-Trenner nach
Rückkehr in die App; „Lesebestätigungen senden“ aus = private Bestätigung
statt gar keiner (Ungelesen-Zähler stimmen). Haken-Logik geprüft: Uhr =
wird gesendet, ✓ = beim Server, goldenes ✓✓ = gelesen. Matrix kennt kein
„auf dem Gerät angekommen“ – daher keine zweistufigen grauen Haken.
- **Animationen** (ae6f651): Chat gleitet auf dem Handy federnd über die
Liste (Liste bleibt aufgebaut), neue Nachrichten/Reaktionen ploppen auf,
Zähler hüpfen, Scroll-Knopf/Antwort-Leiste/Senden-Pfeil federn, Vollansicht
zoomt auf; alles mit Einstellung „Animationen“ skaliert. Fix dabei: beim
Raumwechsel blieben Antwort-Ziel und getippter Text am neuen Raum hängen –
jetzt Entwurf je Raum.
- **LiveKit-Secret aus der App** (756486d): Token kommt von
`/api/livekit-token`; echter Beitritt mit Testkonto grün.
- **flutter_markdown_plus** statt eingestelltem flutter_markdown (0ad06e3).
- **Version 0.1.38+39** (df377d7).
**Offen/Nächster Schritt:** Bernd testet 0.1.38 auf dem Handy (Push, neue
Animationen, schwebende Leiste, Anruf/Sprachkanal wegen LiveKit-Umbau).
Danach: LiveKit-Secret rotieren (Bernd, wenn alle Geräte aktualisiert),
SQLCipher-Nachfolger (sqlite3 3.x), matrix-SDK-Sprung.
**Stolperfallen:** (1) Wischen-zum-Antworten gibt es nur auf echten Handys
(Platform-Check) – im Windows-UI-Test über Lange-Drücken → Antworten, und
NEBEN die Sprechblase drücken (im Text greift am PC die Textauswahl und
der Test bricht undurchsichtig ab). (2) `Matrix4.getMaxScaleOnAxis` liefert
bei 2D-Skalierung < 1 immer 1 (z-Achse) – `storage[0]` lesen.
(3) LiveKit protokolliert auf dem Pi nur Warnungen – Beitritte sind dort
nicht sichtbar; Nachweis über die Identität aus der Beitrittsantwort.
---
## 2026-10-07 (Teil 3) – Beta-Feedback umgesetzt: Push-Verlauf, Handy-UI, neues Aussehen → Test-Version 0.1.37
**Erledigt (je ein Commit, alles gepusht):**
+20 -8
View File
@@ -129,9 +129,11 @@ Punkte abhaken (`[x]`), wenn erledigt UND in `PROGRESS.md` protokolliert.
PROGRESS.md 2026-07-04). `POST /api/livekit-token` läuft live in `server.py`
(whoami-Auth, stdlib-HS256-Minting aus `livekit.yaml`), headless geprüft
(401/400/413/200, JWT-Signatur unabhängig gegen `livekit.yaml` gültig, Identität =
geprüfte user_id). **Offen:** Client-Umstellung (`livekit_token.dart`/
`livekit_call_manager.dart`) + Secret-Rotation – beides PC/Gerät (heiliger
Call-Pfad), siehe `docs/LIVEKIT_TOKEN_MIGRATION.md`.
geprüfte user_id). **Client umgestellt 2026-10-07 (756486d):** kein Secret
mehr in der App, echter LiveKit-Beitritt mit Testkonto grün
(`integration_test/livekit_token_connect_test.dart`). **Offen:** Anruftest
auf dem Handy, danach Secret-Rotation durch Bernd (erst wenn alle Geräte die
neue Version haben), siehe `docs/LIVEKIT_TOKEN_MIGRATION.md`.
- [ ] **SICHERHEIT: geleakte Secrets rotieren + aus dem Client holen** (Fable-5-Review (k),
PROGRESS.md 2026-07-04). Das Gitea-Repo ist öffentlich (`private:false`), und es lagen
vier aktive Secrets drin – teils schon bereinigt (release.ps1, Admin-Doku), aber:
@@ -140,6 +142,7 @@ Punkte abhaken (`[x]`), wenn erledigt UND in `PROGRESS.md` protokolliert.
`apiSecret`, Giphy-Key. Alte jeweils widerrufen.
- **LiveKit-Token server-seitig minten:** kleiner Token-Endpoint auf dem Pi, damit
`apiSecret` aus `lib/core/livekit_token.dart` verschwindet (App holt nur das JWT).
**Client-Teil ERLEDIGT 2026-10-07** (Secret aus dem Code; Rotation steht noch aus).
**Umsetzungsplan liegt fertig in `docs/LIVEKIT_TOKEN_MIGRATION.md`** (2026-07-04,
gegen Code + Pi-Konfig geschrieben) – braucht echten Call-Test auf einem Gerät.
- **E2EE-Diagnose-Upload: ERLEDIGT (2026-07-04).** Der Dashboard-Server auf dem Pi
@@ -303,14 +306,23 @@ lassen, ohne dass andere Module angefasst werden müssen.
(libwebrtc #142) ist auch in 1.6.2+hotfix.4 NICHT enthalten
(Changelog geprüft 2026-10-07) → Dummy-PeerConnection-Workaround
bleibt nötig.
- Android-Build: Gradle 8.13 → 8.14.3 ERLEDIGT (2026-10-07). Offen: Umstieg auf
„Built-in Kotlin“ (Flutter kündigt Build-Fehler für das Kotlin-Plugin an;
betrifft auch Plugins wie flutter_webrtc/livekit_client).
- `flutter_markdown` ist eingestellt (pub: „replaced by
flutter_markdown_plus“) → umstellen (Markdown-Vorschau im Dokument-Viewer).
- Android-Build: Gradle 8.13 → 8.14.3 ERLEDIGT (2026-10-07). „Built-in Kotlin“
**bewusst noch nicht** (Entscheidung 2026-10-07): AGP bleibt 8.11; die vom
Flutter-Migrator gesetzten Flags `android.builtInKotlin=false` /
`android.newDsl=false` halten den Build stabil. Umstieg erst zusammen mit
AGP 9 und wenn flutter_webrtc, livekit_client, media_kit & Co. ihn
unterstützen – vorher Release-APK + Anruftest.
- ERLEDIGT 2026-10-07 (0ad06e3): `flutter_markdown` → `flutter_markdown_plus`
(Rendertest `test/document_markdown_test.dart`).
## 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,
klarer Zustand wer drin ist, sauberes Verhalten bei Verbindungsabbruch
**Teilfortschritt 2026-07-06 (Nacht, mit 2 echten Test-Accounts durchgeklickt):**
+7 -2
View File
@@ -9,6 +9,7 @@
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
@@ -26,11 +27,15 @@
android:allowBackup="false"
android:fullBackupContent="false"
android:dataExtractionRules="@xml/data_extraction_rules">
<!-- Laufender Anruf: hält das Mikrofon im Hintergrund am Leben und
ist Pflicht für Bildschirmübertragung ab Android 14 (CallService.kt).
Der frühere Eintrag com.cloudwebrtc...FlutterForegroundService
verwies auf eine Klasse, die flutter_webrtc 1.x nicht mehr hat. -->
<service
android:name="com.cloudwebrtc.webrtc.FlutterForegroundService"
android:name=".CallService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="mediaProjection" />
android:foregroundServiceType="microphone|mediaProjection" />
<activity
android:name=".MainActivity"
android:exported="true"
@@ -0,0 +1,143 @@
package chat.pyramid.pyramid
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.Manifest
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
/**
* Vordergrunddienst für laufende Anrufe (1:1 und Sprachkanäle).
*
* Warum: Android schaltet das Mikrofon einer App im Hintergrund stumm, und
* seit Android 14 stürzt jede Bildschirmübertragung ab, wenn vorher kein
* Dienst vom Typ „mediaProjection“ läuft (flutter_webrtc startet keinen).
* Genau das war Bernds Fehlerbild 2026-10-07: Teilen gestartet → App weg →
* beim Öffnen kein Anruf mehr.
*
* Typen: immer „microphone“ (ab Android 11), bei laufender Übertragung
* zusätzlich „mediaProjection“. Der Dienst darf mit mediaProjection erst
* NACH der Zustimmung des Nutzers gestartet werden – das stellt die
* Dart-Seite sicher (lib/core/call_foreground.dart).
*/
class CallService : Service() {
companion object {
private const val TAG = "PYRAMID-CALL"
private const val CHANNEL_ID = "pyramid_call"
private const val NOTIF_ID = 7301
private const val EXTRA_TITLE = "title"
private const val EXTRA_SCREEN = "screen"
// Wer auf den tatsächlichen startForeground wartet (Hauptthread).
// Die Bildschirmübertragung darf erst danach beginnen.
private val waiters = mutableListOf<(Boolean) -> Unit>()
fun whenStarted(callback: (Boolean) -> Unit) {
waiters.add(callback)
}
private fun notifyWaiters(ok: Boolean) {
val list = waiters.toList()
waiters.clear()
list.forEach { it(ok) }
}
fun start(ctx: Context, title: String, screen: Boolean) {
val intent = Intent(ctx, CallService::class.java)
.putExtra(EXTRA_TITLE, title)
.putExtra(EXTRA_SCREEN, screen)
ContextCompat.startForegroundService(ctx, intent)
}
fun stop(ctx: Context) {
ctx.stopService(Intent(ctx, CallService::class.java))
}
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val title = intent?.getStringExtra(EXTRA_TITLE) ?: "Pyramid"
val screen = intent?.getBooleanExtra(EXTRA_SCREEN, false) ?: false
ensureChannel()
val launch = packageManager.getLaunchIntentForPackage(packageName)?.apply {
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
}
val open = launch?.let {
PendingIntent.getActivity(
this, 0, it,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
}
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(if (screen) "Bildschirm wird übertragen" else "Anruf läuft")
.setContentText("$title – tippen, um zurückzukehren")
.setOngoing(true)
.setOnlyAlertOnce(true)
.setSilent(true)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setContentIntent(open)
.build()
// Typ „microphone“ nur mit Mikrofon-Berechtigung (sonst verweigert
// Android den GANZEN Dienst – auch den für die Übertragung).
val micAllowed = ContextCompat.checkSelfPermission(
this, Manifest.permission.RECORD_AUDIO
) == PackageManager.PERMISSION_GRANTED
val candidates = mutableListOf<Int>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val mic = ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
val proj = ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
if (screen) {
if (micAllowed) candidates.add(mic or proj)
candidates.add(proj)
} else if (micAllowed) {
candidates.add(mic)
}
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
candidates.add(if (screen) ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION else 0)
} else {
candidates.add(-1) // ohne Typ
}
var ok = false
for (type in candidates) {
try {
if (type == -1) startForeground(NOTIF_ID, notification)
else startForeground(NOTIF_ID, notification, type)
ok = true
break
} catch (e: Exception) {
// Nächste, schmalere Variante versuchen – lieber ohne Dienst
// weiter als die App abstürzen zu lassen.
Log.e(TAG, "startForeground fehlgeschlagen (screen=$screen, type=$type): ${e.message}")
}
}
if (!ok) stopSelf()
notifyWaiters(ok)
return START_NOT_STICKY
}
private fun ensureChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val nm = getSystemService(NotificationManager::class.java)
if (nm.getNotificationChannel(CHANNEL_ID) != null) return
nm.createNotificationChannel(
NotificationChannel(CHANNEL_ID, "Laufende Anrufe", NotificationManager.IMPORTANCE_LOW).apply {
description = "Zeigt an, dass ein Anruf oder eine Bildschirmübertragung läuft."
setShowBadge(false)
}
)
}
}
@@ -97,6 +97,39 @@ class MainActivity : FlutterActivity() {
super.configureFlutterEngine(flutterEngine)
// Cache so ReplyReceiver can call back into Dart without starting a new engine.
FlutterEngineCache.getInstance().put("main", flutterEngine)
// Anruf-Vordergrunddienst (lib/core/call_foreground.dart ↔ CallService.kt).
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "chat.pyramid.pyramid/call")
.setMethodCallHandler { call, result ->
when (call.method) {
"start" -> {
// Erst antworten, wenn der Dienst wirklich im Vordergrund
// läuft (oder nach 3 s aufgeben) – eine Übertragung vor
// dem startForeground würde die App abstürzen lassen.
val done = java.util.concurrent.atomic.AtomicBoolean(false)
val reply = { ok: Boolean ->
if (done.compareAndSet(false, true)) result.success(ok)
}
try {
CallService.whenStarted(reply)
CallService.start(
this,
call.argument<String>("title") ?: "Pyramid",
call.argument<Boolean>("screen") ?: false,
)
android.os.Handler(mainLooper).postDelayed({ reply(false) }, 3000)
} catch (e: Exception) {
// z. B. Start aus dem Hintergrund verboten (Android 12+).
Log.e("PYRAMID-CALL", "CallService-Start abgelehnt: ${e.message}")
reply(false)
}
}
"stop" -> {
CallService.stop(this)
result.success(true)
}
else -> result.notImplemented()
}
}
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channel)
.setMethodCallHandler { call, result ->
when (call.method) {
+27 -4
View File
@@ -1,9 +1,32 @@
# LiveKit-Token server-seitig minten (apiSecret aus dem Client entfernen)
**Status:** geplant, NICHT umgesetzt (braucht echten Call-Test auf einem Gerät –
Calls sind laut CLAUDE.md „heilig"). Dieser Plan ist gegen den echten Code und die
echte Pi-Konfiguration geschrieben (2026-07-04), damit ein PC-Termin direkt starten
kann. Analog zu `docs/SQLCIPHER_MIGRATION.md`.
**Status (2026-10-07):** Server-Route UND Client umgestellt.
- Server: `POST /api/livekit-token` im Dashboard-Server auf dem Pi
(`/home/steggi/matrix/server.py`) – whoami-Prüfung, Identität = geprüfte
Matrix-ID, Secret aus `livekit.yaml`. Geprüft: ungültiger Token → 401
(öffentlich über `dashboard.steggi-matrix.work`), gültiger Token → JWT mit
`sub` = Matrix-ID und denselben Grants wie früher.
- Client: `lib/core/livekit_token.dart` holt das Token per
`LiveKitTokenService.fetch` (kein Secret, kein JWT-Signieren mehr in der App);
`livekit_call_manager.dart` nutzt es für alle LiveKit-Beitritte. Fehler
(offline, 401, Serverfehler) erscheinen als lesbare Meldung im Anruf.
- Tests: `test/livekit_token_test.dart` (Anfrage, Fehlerpfade, kein Secret im
Code) und `integration_test/livekit_token_connect_test.dart` (ECHTER
LiveKit-Beitritt mit Server-Token, Testkonto pyramidtest1, Wegwerf-Raum) –
grün am 2026-10-07.
**Offen:**
1. Anruf/Sprachkanal auf dem echten Handy prüfen (Ton, Video, Bildschirm
teilen, zweiter Teilnehmer, Verlassen/Wiederbeitreten).
2. Erst wenn ALLE genutzten Geräte (Bernd + Uta) die neue Version haben:
Secret rotieren (siehe „Rotation“ unten) – das macht Bernd, nicht Claude.
Bis dahin funktionieren alte und neue App-Versionen parallel.
3. Neue Abhängigkeit beachten: Anrufe brauchen jetzt zusätzlich den
Dashboard-Server (`matrix-stats.service`). Ist er aus, meldet die App
„Anrufserver nicht erreichbar“.
Die ursprüngliche Planung (2026-07-04) folgt unverändert.
## Problem
+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,52 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:integration_test/integration_test.dart';
import 'package:pyramid/core/call_foreground.dart';
/// Android 14+: Bildschirmübertragung darf die App nicht abstürzen lassen
/// (Bernds Test 2026-10-07: „Teilen“ → App weg → Anruf verloren).
///
/// Ablauf wie im Anruf: Zustimmung → Anruf-Dienst mit mediaProjection →
/// Mitschnitt. Der System-Dialog muss bestätigt werden (auf dem Emulator per
/// adb, siehe docs/TESTS_UND_BENCHMARKS.md).
///
/// flutter test integration_test/android_screen_capture_test.dart -d emulator-5554
///
/// PYRAMID_SCREEN_NEGATIVE=1 (per --dart-define) überspringt den Dienst –
/// Gegenprobe: dann stürzt die App wie früher ab.
const _negative = bool.fromEnvironment('PYRAMID_SCREEN_NEGATIVE');
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets(
'Bildschirmübertragung startet ohne Absturz',
(tester) async {
await tester.pumpWidget(const MaterialApp(home: Scaffold()));
if (_negative) {
expect(await Helper.requestCapturePermission(), isTrue);
} else {
expect(
await CallForeground.prepareScreenCapture('test'),
isTrue,
reason: 'Zustimmung oder Dienststart fehlgeschlagen',
);
}
final stream = await navigator.mediaDevices
.getDisplayMedia({'video': true, 'audio': false});
expect(stream.getVideoTracks(), isNotEmpty);
await Future.delayed(const Duration(seconds: 3));
for (final t in stream.getTracks()) {
await t.stop();
}
await stream.dispose();
await CallForeground.screenCaptureStopped('test');
await CallForeground.callEnded('test');
},
skip: !Platform.isAndroid,
timeout: const Timeout(Duration(minutes: 3)),
);
}
@@ -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');
}
+294
View File
@@ -0,0 +1,294 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'package:integration_test/integration_test.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
import 'package:pyramid/main.dart' as app;
import 'package:webrtc_interface/webrtc_interface.dart';
import 'support/test_partner.dart';
/// Echter 1:1-Anruf zwischen der App (pyramidtest1, Testprofil) und einem
/// zweiten Matrix-Client im selben Prozess (pyramidtest2, kurzlebiges Gerät,
/// wird am Ende abgemeldet). Bernds Fehlerbericht 2026-10-08: Ton kam nicht
/// an, Bildschirm teilen ging weder am PC noch am Handy.
///
/// Geprüft wird: Verbindung steht, Ton fließt in BEIDE Richtungen (Bytes),
/// Bildschirm der Gegenseite wird in der App angezeigt, und die App kann
/// über den echten Quellen-Auswähler ihren Bildschirm teilen, der bei der
/// Gegenseite ankommt. (Ob das Mikrofon hörbaren Ton liefert, kann ein
/// Automat nicht prüfen – dafür gibt es den Mikrofontest und den Sprech-Ring.)
///
/// $env:PYRAMID_PROFILE_DIR="$env:USERPROFILE\.pyramid-autopilot\profile1"
/// flutter test integration_test/live_call_test.dart -d windows
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final profile = Platform.environment['PYRAMID_PROFILE_DIR'];
final accounts = loadTestAccounts();
testWidgets(
'1:1-Anruf: Ton beide Richtungen, Bildschirm teilen beide Richtungen',
(tester) async {
Logs().level = Level.verbose;
// app.main() ersetzt FlutterError.onError – danach den Empfänger des
// Testrahmens zurücksetzen, sonst verschwinden Fehlermeldungen.
final testOnError = FlutterError.onError;
app.main();
await pumpUntil(tester, () => find.byType(MaterialApp).evaluate().isNotEmpty);
FlutterError.onError = testOnError;
final container = ProviderScope.containerOf(
tester.element(find.byType(MaterialApp).first),
);
final me = (await tester.runAsync(
() => container.read(matrixClientProvider.future),
))!;
expect(me.userID, '@pyramidtest1:steggi-matrix.work');
final partner = (await tester.runAsync(
() => TestPartner.start(accounts!['user2']!, accounts['pass2']!),
))!;
String? roomId;
addTearDown(() => tester.runAsync(() async {
await partner.dispose(roomId);
final r = roomId == null ? null : me.getRoomById(roomId);
await r?.leave().catchError((_) {});
await r?.forget().catchError((_) {});
}));
// Eigener, unverschlüsselter Testraum (nur für diesen Lauf).
roomId = await tester.runAsync(() => partner.client.createRoom(
invite: [me.userID!],
preset: CreateRoomPreset.privateChat,
name: 'Anruftest (automatisch)',
));
logStep('Raum angelegt: $roomId');
await pumpUntil(
tester,
() => me.getRoomById(roomId!)?.membership == Membership.invite,
what: 'Einladung kommt bei der App an',
);
await tester.runAsync(() => me.getRoomById(roomId!)!.join());
logStep('App ist beigetreten');
// Die Gegenseite ist Ersteller und damit im Raum; ihr Mitglieder-Cache
// wird vom SDK nur bei Bedarf geladen – kurz warten genügt.
await pumpFor(tester, const Duration(seconds: 3));
final voip = container.read(callSignalingProvider);
// ═══ Teil 1: Gegenseite ruft an, App nimmt über den echten Knopf an ═══
// (prüft die Kandidaten-Pufferung der App mit einem ungepatchten
// Anrufer – genau Bernds Fall „lange/kein Verbindungsaufbau“)
logStep('Teil 1: Gegenseite ruft an');
await tester.runAsync(() => partner.run(() => partner.voip.inviteToCall(
partner.client.getRoomById(roomId!)!,
CallType.kVoice,
)));
final answerBtn = find.byWidgetPredicate(
(w) => w is FloatingActionButton && w.heroTag == 'answer_call',
);
await pumpUntil(tester, () => answerBtn.evaluate().isNotEmpty,
what: 'Annehmen-Knopf erscheint');
await pumpFor(tester, const Duration(seconds: 2)); // klingeln lassen
await tester.tap(answerBtn.first);
await _waitConnected(tester, voip, partner);
await _checkAudio(tester, voip, partner);
// Stumm über den echten Knopf → Gegenseite sieht „stumm“, Spur ist aus.
bool partnerSeesMuted() =>
partner.delegate.call?.remoteUserMediaStream?.audioMuted == true;
await tester.tap(_ctrlBtn(Icons.mic_rounded));
await pumpUntil(tester, partnerSeesMuted,
timeout: const Duration(seconds: 15),
what: 'Gegenseite sieht: App ist stumm');
expect(voip.isMicMuted, isTrue);
expect(
voip.currentCall!.localUserMediaStream!.stream!.getAudioTracks().first.enabled,
isFalse,
reason: 'Mikrofonspur ist trotz Stumm noch an',
);
await tester.tap(_ctrlBtn(Icons.mic_off_rounded));
await pumpUntil(tester, () => !partnerSeesMuted() && !voip.isMicMuted,
timeout: const Duration(seconds: 15),
what: 'Stumm wieder aufgehoben');
logStep('Stumm/Entstummen über den Knopf: ok');
// Taub → Gegenseite nicht mehr hören UND selbst stumm; danach zurück.
await tester.tap(_ctrlBtn(Icons.headphones_rounded));
await pumpUntil(
tester,
() => voip.isDeafened && partnerSeesMuted(),
timeout: const Duration(seconds: 15),
what: 'Taub: selbst stumm',
);
final remoteAudio = voip.currentCall!.getRemoteStreams
.expand((w) => w.stream?.getAudioTracks() ?? const <MediaStreamTrack>[]);
expect(remoteAudio, isNotEmpty);
expect(remoteAudio.every((t) => !t.enabled), isTrue,
reason: 'Ton der Gegenseite ist trotz Taub noch an');
await tester.tap(_ctrlBtn(Icons.headset_off_rounded));
await pumpUntil(
tester,
() => !voip.isDeafened && !partnerSeesMuted() && !voip.isMicMuted,
timeout: const Duration(seconds: 15),
what: 'Taub aufgehoben, Mikro wieder an',
);
expect(
voip.currentCall!.getRemoteStreams
.expand((w) => w.stream?.getAudioTracks() ?? const <MediaStreamTrack>[])
.every((t) => t.enabled),
isTrue,
);
logStep('Taub/Hören über den Knopf: ok');
// Gegenseite teilt Bildschirm → App zeigt ihn
await tester.runAsync(
() => partner.run(() => partner.delegate.call!.setScreensharingEnabled(true)),
);
await pumpUntil(
tester,
() =>
voip.isRemoteScreensharing &&
(voip.remoteRenderer.srcObject?.getVideoTracks().isNotEmpty ?? false),
timeout: const Duration(seconds: 30),
what: 'Bildschirm der Gegenseite erscheint in der App',
);
await pumpFor(tester, const Duration(seconds: 3));
final appVideoIn = (await tester.runAsync(
() => _rtp(voip.currentCall!.pc!, 'video'),
))!;
logStep('BILD bei der App (von der Gegenseite): $appVideoIn');
expect(appVideoIn.received, greaterThan(0),
reason: 'Bildschirm der Gegenseite kommt nicht an');
await tester.runAsync(
() => partner.run(() => partner.delegate.call!.setScreensharingEnabled(false)),
);
await pumpUntil(tester, () => !voip.isRemoteScreensharing,
what: 'Bildschirm der Gegenseite verschwindet wieder');
// App teilt ihren Bildschirm über den echten Auswähler
await pumpUntil(
tester,
() => find.byIcon(Icons.screen_share_rounded).evaluate().isNotEmpty,
what: 'Teilen-Knopf in der Anrufleiste',
);
await tester.tap(find.byIcon(Icons.screen_share_rounded).first);
final tile = find.byWidgetPredicate(
(w) => w.runtimeType.toString() == '_SourceTile',
);
await pumpUntil(tester, () => tile.evaluate().isNotEmpty,
timeout: const Duration(seconds: 20), what: 'Quellen-Auswähler');
await tester.tap(tile.first);
await pumpUntil(
tester,
() {
final s = partner.delegate.call?.remoteScreenSharingStream?.stream;
return voip.isScreensharing &&
s != null &&
s.getVideoTracks().isNotEmpty;
},
timeout: const Duration(seconds: 30),
what: 'Bildschirm der App kommt bei der Gegenseite an',
);
await pumpFor(tester, const Duration(seconds: 3));
final partnerVideoIn = (await tester.runAsync(
() => _rtp(partner.delegate.call!.pc!, 'video'),
))!;
logStep('BILD bei der Gegenseite (von der App): $partnerVideoIn');
expect(partnerVideoIn.received, greaterThan(0),
reason: 'Bildschirm der App kommt nicht an');
await tester.runAsync(() => voip.hangup());
await pumpUntil(tester, () => voip.currentCall == null,
what: 'aufgelegt (Teil 1)');
await pumpFor(tester, const Duration(seconds: 3));
// ═══ Teil 2: App ruft an, Gegenseite nimmt an ═══
logStep('Teil 2: App ruft an');
partner.fixCandidates = true; // Gegenseite wie eine gepatchte Pyramid
await tester.runAsync(() => voip.startCall(roomId!, video: false));
await _waitConnected(tester, voip, partner);
await _checkAudio(tester, voip, partner);
await tester.runAsync(() => voip.hangup());
await pumpUntil(tester, () => voip.currentCall == null,
what: 'aufgelegt (Teil 2)');
},
skip: profile == null || accounts == null,
timeout: const Timeout(Duration(minutes: 5)),
);
}
Future<void> _waitConnected(
WidgetTester tester,
CallSignalingService voip,
TestPartner partner,
) async {
final t0 = DateTime.now();
await pumpUntil(
tester,
() =>
voip.currentCall?.state == CallState.kConnected &&
partner.delegate.call?.state == CallState.kConnected,
timeout: const Duration(seconds: 60),
what: 'Anruf verbunden',
every5s: () {
final a = voip.currentCall;
final b = partner.delegate.call;
logStep('App: ${a?.state} ice=${a?.pc?.iceConnectionState} | '
'Gegenseite: ${b?.state} ice=${b?.pc?.iceConnectionState}');
},
);
logStep('verbunden nach ${DateTime.now().difference(t0).inMilliseconds} ms');
}
Future<void> _checkAudio(
WidgetTester tester,
CallSignalingService voip,
TestPartner partner,
) async {
await pumpFor(tester, const Duration(seconds: 4));
final appAudio = (await tester.runAsync(
() => _rtp(voip.currentCall!.pc!, 'audio'),
))!;
final partnerAudio = (await tester.runAsync(
() => _rtp(partner.delegate.call!.pc!, 'audio'),
))!;
logStep('TON App: $appAudio | Gegenseite: $partnerAudio');
expect(appAudio.sent, greaterThan(0), reason: 'App sendet keinen Ton');
expect(appAudio.received, greaterThan(0), reason: 'App empfängt keinen Ton');
expect(partnerAudio.received, greaterThan(0),
reason: 'Gegenseite empfängt keinen Ton der App');
}
class _Rtp {
_Rtp(this.sent, this.received);
final num sent;
final num received;
@override
String toString() => 'gesendet=$sent B, empfangen=$received B';
}
Future<_Rtp> _rtp(rtc.RTCPeerConnection pc, String kind) async {
num sent = 0;
num received = 0;
for (final r in await pc.getStats()) {
if (r.values['kind'] != kind) continue;
if (r.type == 'outbound-rtp') sent += (r.values['bytesSent'] as num?) ?? 0;
if (r.type == 'inbound-rtp') received += (r.values['bytesReceived'] as num?) ?? 0;
}
return _Rtp(sent, received);
}
/// Knopf der Anrufleiste mit diesem Symbol (nicht die Stumm-ANZEIGE in der
/// Teilnehmer-Kachel, die dasselbe Symbol trägt).
Finder _ctrlBtn(IconData icon) => find.descendant(
of: find.byWidgetPredicate((w) =>
w.runtimeType.toString() == '_VoiceCtrlBtn' &&
(w as dynamic).icon == icon),
matching: find.byIcon(icon),
).first;
@@ -0,0 +1,284 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:integration_test/integration_test.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/main.dart' as app;
/// Live mit beiden Testkonten (Bernds Fehlerbericht 2026-10-07):
/// 1. ✓✓ erscheint LIVE, sobald der Partner liest – ohne den Chat neu zu
/// öffnen (vorher zeichnete eine reine Lesebestätigung nichts neu).
/// 2. Einladung lässt sich direkt im Chat annehmen, danach ist der Verlauf
/// sofort da (vorher leerer Chat bis zum App-Neustart).
///
/// Die App läuft mit dem Testprofil (PYRAMID_PROFILE_DIR, pyramidtest1).
/// pyramidtest2 wird per Client-Server-API mit einem kurzlebigen Gerät
/// gesteuert, das am Ende abgemeldet wird – andere Sitzungen (z. B. Bernds
/// Testinstanz mit profile2) bleiben unberührt.
///
/// $env:PYRAMID_PROFILE_DIR="$env:USERPROFILE\.pyramid-autopilot\profile1"
/// flutter test integration_test/live_receipts_invite_test.dart -d windows
const _hs = 'https://steggi-matrix.work';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final profile = Platform.environment['PYRAMID_PROFILE_DIR'];
final accounts = _loadAccounts();
testWidgets(
'Lesebestätigung live + Einladung im Chat annehmen',
(tester) async {
tester.view
..physicalSize = const Size(1080, 2340)
..devicePixelRatio = 2.625;
addTearDown(tester.view.reset);
final partner = await tester.runAsync(
() => _RestUser.login(accounts!['user2']!, accounts['pass2']!),
);
addTearDown(() => tester.runAsync(() => partner!.logout()));
// app.main() ersetzt FlutterError.onError – danach den Empfänger des
// Testrahmens zurücksetzen, sonst verschwinden Fehlermeldungen.
final testOnError = FlutterError.onError;
app.main();
await _pumpUntil(tester, find.byType(MaterialApp));
FlutterError.onError = testOnError;
final container = ProviderScope.containerOf(
tester.element(find.byType(MaterialApp).first),
);
final client = (await tester.runAsync(
() => container.read(matrixClientProvider.future),
))!;
expect(client.userID, '@pyramidtest1:steggi-matrix.work');
await _pumpFor(tester, const Duration(seconds: 4));
// ── 2. Einladung im Chat annehmen ──
final welcome = 'Willkommen im Einladungstest ${DateTime.now().millisecondsSinceEpoch}';
final inviteRoomId = await tester.runAsync(() async {
final id = await partner!.createRoom('Einladungstest');
await partner.sendText(id, welcome);
await partner.invite(id, client.userID!);
return id;
});
addTearDown(() => tester.runAsync(() => partner!.leave(inviteRoomId!)));
// Einladung kommt per Sync an → Chat öffnen (wie aus der Benachrichtigung).
final end = DateTime.now().add(const Duration(seconds: 30));
while (client.getRoomById(inviteRoomId!)?.membership != Membership.invite &&
DateTime.now().isBefore(end)) {
await _pumpFor(tester, const Duration(milliseconds: 500));
}
container.read(activeRoomIdProvider.notifier).state = inviteRoomId;
await _pumpUntil(tester, find.text('Annehmen'));
await _pumpFor(tester, const Duration(seconds: 1)); // Hereingleiten abwarten
await tester.tap(find.text('Annehmen'));
// Ohne App-Neustart: Verlauf mit der Begrüßung erscheint.
await _pumpUntil(
tester,
find.textContaining(welcome),
timeout: const Duration(seconds: 40),
);
expect(find.text('Annehmen'), findsNothing);
// ── 1. Lesebestätigung live (nach dem Beitritt) ──
// (im Wegwerf-Raum aus Teil 2 – die DM soll nicht mit jedem Lauf
// wachsen)
final dm = client.getRoomById(inviteRoomId)!;
container.read(activeRoomIdProvider.notifier).state = dm.id;
await _pumpFor(tester, const Duration(seconds: 2));
final text = 'Lesetest ${DateTime.now().millisecondsSinceEpoch}';
final eventId = await tester.runAsync(() => dm.sendTextEvent(text));
await _pumpUntil(tester, find.textContaining(text));
await _pumpFor(tester, const Duration(seconds: 3));
expect(
find.byIcon(Icons.done_rounded),
findsOneWidget,
reason: 'eigene neueste Nachricht sollte noch ✓ (ungelesen) zeigen',
);
await tester.runAsync(() => partner!.readReceipt(dm.id, eventId!));
// Ohne den Chat neu zu öffnen: ✓ wird zu ✓✓ direkt unter der Nachricht.
await _pumpUntil(
tester,
find.byIcon(Icons.done_rounded),
gone: true,
timeout: const Duration(seconds: 30),
);
final msgY = tester.getCenter(find.textContaining(text).first).dy;
final readIcons = find.byIcon(Icons.done_all_rounded).evaluate();
expect(
readIcons.any((e) {
final box = e.renderObject as RenderBox;
final y = box.localToGlobal(box.size.center(Offset.zero)).dy;
return y > msgY && y - msgY < 120;
}),
isTrue,
reason: '✓✓ erscheint nicht live unter der gelesenen Nachricht',
);
// ── 3. „Neue Nachrichten“-Trenner MITTEN in einer Gruppe verschwindet ──
// (Bernd 2026-10-08: blieb manchmal stehen – bei mehreren Nachrichten
// desselben Absenders hintereinander, gelesen bis zur ersten.)
container.read(activeRoomIdProvider.notifier).state = null;
await _pumpFor(tester, const Duration(seconds: 1));
final ids = (await tester.runAsync(() async => [
await partner!.sendText(inviteRoomId, 'Gruppe 1'),
await partner.sendText(inviteRoomId, 'Gruppe 2'),
await partner.sendText(inviteRoomId, 'Gruppe 3'),
]))!;
final room = client.getRoomById(inviteRoomId)!;
// Erst warten, bis alle drei da sind, dann bis „Gruppe 1“ gelesen melden.
final tEnd = DateTime.now().add(const Duration(seconds: 30));
while (room.lastEvent?.eventId != ids[2] && DateTime.now().isBefore(tEnd)) {
await _pumpFor(tester, const Duration(milliseconds: 300));
}
await tester.runAsync(() => room.setReadMarker(ids[0], mRead: ids[0]));
final fEnd = DateTime.now().add(const Duration(seconds: 30));
while (room.fullyRead != ids[0] && DateTime.now().isBefore(fEnd)) {
await _pumpFor(tester, const Duration(milliseconds: 300));
}
expect(room.fullyRead, ids[0], reason: 'Lesemarker kam nicht an');
container.read(activeRoomIdProvider.notifier).state = inviteRoomId;
await _pumpUntil(tester, find.text('Neue Nachrichten'),
timeout: const Duration(seconds: 20));
// Nach der Verweildauer (6 s) + Ausblenden muss er weg sein.
await _pumpUntil(
tester,
find.text('Neue Nachrichten'),
gone: true,
timeout: const Duration(seconds: 15),
);
// Aufräumen: Testraum verlassen + vergessen.
await tester.runAsync(() async {
final r = client.getRoomById(inviteRoomId);
await r?.leave();
await r?.forget().catchError((_) {});
});
container.read(activeRoomIdProvider.notifier).state = null;
await _pumpFor(tester, const Duration(seconds: 1));
},
skip: profile == null || accounts == null,
timeout: const Timeout(Duration(minutes: 4)),
);
}
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;
}
/// Minimaler Matrix-Zugang per Client-Server-API (kurzlebiges Gerät).
class _RestUser {
_RestUser(this._token);
final String _token;
int _txn = 0;
static Future<_RestUser> login(String user, String pass) async {
final res = await http.post(
Uri.parse('$_hs/_matrix/client/v3/login'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'type': 'm.login.password',
'identifier': {'type': 'm.id.user', 'user': user},
'password': pass,
'initial_device_display_name': 'Pyramid-Livetest (kurzlebig)',
}),
);
if (res.statusCode != 200) throw StateError('Login ${res.statusCode}');
return _RestUser(jsonDecode(res.body)['access_token'] as String);
}
Future<Map<String, dynamic>> _call(String method, String path,
[Object? body]) async {
final req = http.Request(method, Uri.parse('$_hs/_matrix/client/v3$path'))
..headers['Authorization'] = 'Bearer $_token'
..headers['Content-Type'] = 'application/json'
..body = jsonEncode(body ?? {});
final res = await http.Response.fromStream(await req.send());
if (res.statusCode != 200) {
throw StateError('$method $path → ${res.statusCode}');
}
return jsonDecode(res.body) as Map<String, dynamic>;
}
String _enc(String s) => Uri.encodeComponent(s);
Future<void> readReceipt(String roomId, String eventId) => _call(
'POST',
'/rooms/${_enc(roomId)}/read_markers',
{'m.fully_read': eventId, 'm.read': eventId},
);
Future<String> createRoom(String name) async =>
(await _call('POST', '/createRoom', {
'name': name,
'preset': 'private_chat',
}))['room_id'] as String;
Future<String> sendText(String roomId, String text) async =>
(await _call(
'PUT',
'/rooms/${_enc(roomId)}/send/m.room.message/t${DateTime.now().microsecondsSinceEpoch}${_txn++}',
{'msgtype': 'm.text', 'body': text},
))['event_id'] as String;
Future<void> invite(String roomId, String userId) =>
_call('POST', '/rooms/${_enc(roomId)}/invite', {'user_id': userId});
Future<void> leave(String roomId) async {
try {
await _call('POST', '/rooms/${_enc(roomId)}/leave');
await _call('POST', '/rooms/${_enc(roomId)}/forget');
} catch (_) {}
}
Future<void> logout() async {
try {
await _call('POST', '/logout');
} catch (_) {}
}
}
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));
}
}
Future<void> _pumpUntil(
WidgetTester tester,
Finder finder, {
bool gone = false,
Duration timeout = const Duration(seconds: 60),
}) async {
final end = DateTime.now().add(timeout);
while (DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 200));
final found = finder.evaluate().isNotEmpty;
if (found != gone) return;
}
fail('Zeitüberschreitung: $finder ${gone ? "verschwindet nicht" : "nicht gefunden"}');
}
@@ -0,0 +1,71 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
import 'package:pyramid/main.dart' as app;
/// Echter Beitritt zu LiveKit mit einem vom SERVER ausgestellten Token
/// (docs/LIVEKIT_TOKEN_MIGRATION.md) – über denselben Weg wie die App
/// (voiceChannelProvider.startCall), nur mit dem TEST-Konto.
///
/// Läuft nur mit gesetztem PYRAMID_PROFILE_DIR (eingeloggtes Testprofil,
/// z. B. pyramidtest1) – nie gegen Bernds echtes Profil:
///
/// $env:PYRAMID_PROFILE_DIR="$env:USERPROFILE\.pyramid-autopilot\profile1"
/// flutter test integration_test/livekit_token_connect_test.dart -d windows
///
/// Betritt einen eigenen Wegwerf-Raum („pyramid-tokentest“), nur Ton, und
/// legt sofort wieder auf. Ersetzt NICHT den Anruftest auf dem Handy.
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final profile = Platform.environment['PYRAMID_PROFILE_DIR'];
testWidgets(
'LiveKit-Beitritt mit Server-Token: Identität = eigene Matrix-ID',
(tester) async {
// app.main() ersetzt FlutterError.onError – danach den Empfänger des
// Testrahmens zurücksetzen, sonst verschwinden Fehlermeldungen.
final testOnError = FlutterError.onError;
app.main();
final end = DateTime.now().add(const Duration(seconds: 90));
while (find.byType(MaterialApp).evaluate().isEmpty &&
DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 200));
}
FlutterError.onError = testOnError;
final container = ProviderScope.containerOf(
tester.element(find.byType(MaterialApp).first),
);
final client = await tester.runAsync(
() => container.read(matrixClientProvider.future),
);
expect(client!.isLogged(), isTrue, reason: 'Testprofil nicht angemeldet');
final call = container.read(voiceChannelProvider);
await tester.runAsync(() => call.startCall(
roomName: 'pyramid-tokentest',
roomDisplayName: 'Token-Test',
identity: 'wird-vom-server-ignoriert',
audioOnly: true,
matrixClient: client,
));
await tester.pump(const Duration(milliseconds: 500));
expect(call.error, isNull, reason: 'Beitritt fehlgeschlagen');
expect(call.room, isNotNull, reason: 'keine LiveKit-Verbindung');
// Der Server bestimmt die Identität aus dem geprüften Matrix-Login –
// der vom Client übergebene Wert spielt keine Rolle mehr.
expect(call.room!.localParticipant?.identity, client.userID);
await tester.runAsync(() => call.hangUp());
await tester.pump(const Duration(milliseconds: 500));
expect(call.room, isNull);
},
skip: profile == null,
timeout: const Timeout(Duration(minutes: 3)),
);
}
+83 -1
View File
@@ -10,6 +10,7 @@ import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/main.dart' as app;
import 'package:pyramid/widgets/spring_press.dart';
/// UI-Durchlauf im HANDY-Format mit der echten App (Windows-Build) und einem
/// TEST-Profil – erzeugt Screenshots unter build/ui_screens/.
@@ -46,6 +47,12 @@ void main() {
addTearDown(tester.view.reset);
final size = tester.view.physicalSize / tester.view.devicePixelRatio;
// app.main() ersetzt FlutterError.onError – danach den Empfänger des
// Testrahmens zurücksetzen, sonst verschwinden Fehlermeldungen.
final testOnError = FlutterError.onError;
app.main();
await _pumpUntil(
tester,
@@ -55,14 +62,81 @@ void main() {
final container = ProviderScope.containerOf(
tester.element(find.byType(MaterialApp).first),
);
FlutterError.onError = testOnError;
await _shot(tester, '01_chatliste');
// ── Direktnachricht: Sprechblasen (Handy-Standard) und Liste ──
await tester.tap(find.text(_otherUser).first);
// Chat gleitet federnd über die Liste.
await tester.pump(const Duration(milliseconds: 90));
await _shot(tester, '01b_chat_gleitet_herein');
await _pumpFor(tester, const Duration(seconds: 6));
await _scrollToNewest(tester);
await _shot(tester, '02_chat');
// Kontakt-Banner aufklappen (Tipp auf den Namen im Kopf) und wieder zu.
await tester.tap(find.text(_otherUser).first);
await _pumpFor(tester, const Duration(seconds: 1));
await _shot(tester, '02c_dm_kopf_aufgeklappt');
await tester.tap(find.text(_otherUser).first);
await _pumpFor(tester, const Duration(seconds: 1));
// Antwort-Zitat antippen → springt zur zitierten Nachricht.
final quote = find.byWidgetPredicate(
(w) => w is SpringPress && w.pressedScale == 0.97,
);
// Nach oben blättern, bis ein Zitat gebaut ist (die Liste baut nur
// sichtbare Einträge; .first auf eine leere Trefferliste wirft).
for (var i = 0; i < 60 && quote.evaluate().isEmpty; i++) {
await tester.drag(find.byType(Scrollable).first, const Offset(0, 300),
warnIfMissed: false);
await tester.pump(const Duration(milliseconds: 120));
}
expect(quote, findsWidgets, reason: 'kein Antwort-Zitat im Test-Chat');
await tester.ensureVisible(quote.first);
await _pumpFor(tester, const Duration(milliseconds: 600));
await _shot(tester, '02e_zitat_vor_dem_tippen');
await tester.tap(quote.first, warnIfMissed: false);
await _pumpFor(tester, const Duration(milliseconds: 900));
await _shot(tester, '02f_sprung_zum_zitat');
await _scrollToNewest(tester);
// ⋮-Menü mit Suchen/Mitglieder.
await tester.tap(find.byIcon(Icons.more_vert_rounded).first);
await _pumpFor(tester, const Duration(seconds: 1));
expect(find.text('Mitglieder'), findsWidgets);
await _shot(tester, '02d_menue');
await tester.tapAt(Offset(size.width / 2, size.height / 2));
await _pumpFor(tester, const Duration(seconds: 1));
// Antworten → Antwort-Leiste federt auf. (Wischen zum Antworten gibt
// es nur auf echten Handys; hier über Lange-Drücken → Antworten.)
// Neben die eigene Sprechblase drücken (nicht in den Text – dort
// greift am PC die Textauswahl).
final ownText = find.text('Danke, sieht gut aus!');
for (var i = 0; i < 60 && ownText.evaluate().isEmpty; i++) {
await tester.drag(find.byType(Scrollable).first, const Offset(0, 300),
warnIfMissed: false);
await tester.pump(const Duration(milliseconds: 120));
}
// Mittig holen – unten läge sie unter der schwebenden Eingabeleiste.
await Scrollable.ensureVisible(tester.element(ownText.first), alignment: 0.5);
await _pumpFor(tester, const Duration(milliseconds: 600));
final ownMsg = tester.getCenter(ownText.first);
await tester.longPressAt(Offset(size.width * 0.15, ownMsg.dy));
await _pumpFor(tester, const Duration(milliseconds: 500));
await tester.tap(find.byIcon(Icons.reply_rounded).first);
await tester.pump(const Duration(milliseconds: 60));
await _shot(tester, '02g_antwort_leiste_federt');
await _pumpFor(tester, const Duration(milliseconds: 800));
await _shot(tester, '02h_antwort_leiste');
final closeReply = find.byIcon(Icons.close_rounded);
if (closeReply.evaluate().isNotEmpty) {
await tester.tap(closeReply.first);
await _pumpFor(tester, const Duration(milliseconds: 600));
}
container.read(messageStyleProvider.notifier).state = MessageStyle.list;
await _pumpFor(tester, const Duration(seconds: 2));
await _shot(tester, '02b_chat_liste');
@@ -151,7 +225,7 @@ void main() {
// ── Gruppenraum (Namen + Avatare in den Sprechblasen) ──
container.read(activeRoomIdProvider.notifier).state = null;
container.read(activeSpaceIdProvider.notifier).state = 'rooms';
container.read(activeSpaceIdProvider.notifier).state = 'dms';
await _pumpUntil(
tester,
find.text(_groupName),
@@ -177,6 +251,14 @@ void main() {
await _shot(tester, '10_einstellungen_text_chat');
container.read(activeModalProvider.notifier).state = ModalKind.none;
await _pumpFor(tester, const Duration(seconds: 1));
// ── Zurück zur Chatliste: Chat gleitet hinaus, Liste bleibt erhalten ──
container.read(activeRoomIdProvider.notifier).state = null;
await tester.pump(const Duration(milliseconds: 90));
await _shot(tester, '11_zurueck_gleitet_hinaus');
await _pumpFor(tester, const Duration(seconds: 1));
expect(find.text(_groupName), findsWidgets);
await _shot(tester, '12_liste_nach_zurueck');
},
skip: profile == null,
timeout: const Timeout(Duration(minutes: 6)),
+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);
/// 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 ───────────────────────────────────────────────────────────────
// Die Call-Provider leben seit dem M2-Call-Pilot in ihren Modulen:
// `callSignalingProvider` (features/call_signaling/call_signaling_service.dart)
+3 -2
View File
@@ -10,8 +10,9 @@ bool isMobileLayout(Size size) => size.width < 900 || size.height < 500;
// ── Schriftgröße ───────────────────────────────────────────────────────────
/// Standard-Schriftfaktor, solange der Nutzer nichts eingestellt hat: auf dem
/// Handy größer (Bernds Wunsch 2026-10-07: ≈ 16 statt 14 px), am PC wie bisher.
double defaultTextScale({required bool mobile}) => mobile ? 1.15 : 1.0;
/// Handy größer (Bernd 2026-10-07: 115 % ausprobiert, 110 % als Standard
/// gewünscht), am PC wie bisher.
double defaultTextScale({required bool mobile}) => mobile ? 1.10 : 1.0;
const minTextScale = 0.85;
const maxTextScale = 1.5;
+6 -3
View File
@@ -9,6 +9,7 @@ import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod;
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/app_database.dart';
import 'package:pyramid/core/notification_content.dart';
import 'package:pyramid/core/read_markers.dart';
import 'package:pyramid/core/soft_logout_guard.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -167,9 +168,11 @@ Future<bool> _bgMarkRead(String? roomId, String? eventId) async {
if (client == null) return false;
try {
final room = client.getRoomById(roomId) ?? Room(id: roomId, client: client);
await room
.setReadMarker(eventId, mRead: eventId)
.timeout(const Duration(seconds: 15));
await markRoomReadUpTo(
room,
eventId,
publicReceipt: await loadReadReceiptsPublic(),
).timeout(const Duration(seconds: 15));
debugPrint('[NOTIF-BGENGINE] read marker set for $roomId');
return true;
} catch (e) {
+84
View File
@@ -0,0 +1,84 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
/// Anruf-Vordergrunddienst auf Android (android/.../CallService.kt).
///
/// * Solange ein Anruf läuft, hält der Dienst das Mikrofon auch im
/// Hintergrund aktiv (Android schaltet es sonst stumm) und zeigt eine
/// „Anruf läuft“-Benachrichtigung, über die man zurück in die App kommt.
/// * Für eine Bildschirmübertragung MUSS ab Android 14 vorher ein Dienst vom
/// Typ mediaProjection laufen – und der darf erst NACH der Zustimmung des
/// Nutzers starten. [prepareScreenCapture] erledigt beides in der richtigen
/// Reihenfolge; flutter_webrtc benutzt danach die gespeicherte Zustimmung.
///
/// Beide Anrufwege (1:1 über Matrix, Sprachkanäle über LiveKit) melden sich
/// mit eigenem Namen an; der Dienst endet erst, wenn keiner mehr läuft.
/// Auf anderen Plattformen passiert nichts.
class CallForeground {
CallForeground._();
static const _channel = MethodChannel('chat.pyramid.pyramid/call');
static final _owners = <String>{};
static final _screenOwners = <String>{};
static String _title = 'Pyramid';
static bool get _android => !kIsWeb && Platform.isAndroid;
/// true = Dienst läuft (bzw. wurde beendet) wie gewünscht.
static Future<bool> _apply() async {
if (!_android) return true;
try {
if (_owners.isEmpty) {
await _channel.invokeMethod('stop');
return true;
}
final ok = await _channel.invokeMethod<bool>('start', {
'title': _title,
'screen': _screenOwners.isNotEmpty,
});
return ok ?? false;
} catch (e) {
debugPrint('[CallForeground] $e');
return false;
}
}
/// Anruf von [owner] läuft (z. B. 'voip', 'livekit').
static Future<void> callStarted(String owner, {required String title}) async {
_owners.add(owner);
_title = title;
await _apply();
}
/// Anruf von [owner] ist beendet.
static Future<void> callEnded(String owner) async {
final changed = _owners.remove(owner) | _screenOwners.remove(owner);
if (changed) await _apply();
}
/// Vor einer Bildschirmübertragung auf Android: Zustimmung einholen, dann
/// den Dienst um „mediaProjection“ erweitern. false = Nutzer hat abgelehnt.
static Future<bool> prepareScreenCapture(String owner) async {
if (!_android) return true;
final granted = await rtc.Helper.requestCapturePermission();
if (!granted) return false;
_owners.add(owner);
_screenOwners.add(owner);
if (!await _apply()) {
// Ohne laufenden mediaProjection-Dienst würde der Mitschnitt die App
// abstürzen lassen – dann lieber gar nicht übertragen.
_screenOwners.remove(owner);
await _apply();
return false;
}
return true;
}
/// Übertragung von [owner] beendet – Dienst wieder ohne mediaProjection.
static Future<void> screenCaptureStopped(String owner) async {
if (_screenOwners.remove(owner)) await _apply();
}
}
+84 -32
View File
@@ -1,37 +1,89 @@
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
import 'dart:async';
import 'dart:convert';
import 'dart:io';
class LiveKitTokenGenerator {
// Your LiveKit Credentials
static const String apiKey = 'LKMatrixPi';
static const String apiSecret = 'rYUT2PRaKLedp5VLQCQE83eZG7fjuBWtFPXGiveBmIE';
import 'package:http/http.dart' as http;
static String generate({
required String roomName,
required String identity,
String? displayName,
int ttlSeconds = 3600, // Valid for 1 hour by default
}) {
final jwt = JWT(
{
'video': {
'roomJoin': true,
'room': roomName,
'canPublish': true,
'canSubscribe': true,
'canPublishData': true,
},
'metadata': displayName ?? identity,
},
issuer: apiKey,
subject: identity,
);
/// LiveKit-Beitritts-Token vom Pyramid-Server holen.
///
/// Früher hat die App die Tokens selbst signiert – dafür steckte das
/// LiveKit-Secret im App-Code, und wer die App hatte, konnte sich für jeden
/// Raum und jede Identität ein Token ausstellen. Jetzt signiert nur noch der
/// Server (`/api/livekit-token` im Dashboard-Server auf dem Pi): Er prüft den
/// Matrix-Login per whoami und setzt die LiveKit-Identität selbst auf die
/// geprüfte Matrix-ID. Siehe docs/LIVEKIT_TOKEN_MIGRATION.md.
class LiveKitTokenService {
static const endpoint =
'https://dashboard.steggi-matrix.work/api/livekit-token';
static const fallbackUrl = 'wss://livekit.steggi-matrix.work';
static const _timeout = Duration(seconds: 15);
// Sign the token with your secret
final token = jwt.sign(
SecretKey(apiSecret),
expiresIn: Duration(seconds: ttlSeconds),
);
return token;
/// Liefert Token und LiveKit-Adresse für [room]. Wirft
/// [LiveKitTokenException] mit einer verständlichen Meldung.
static Future<({String token, String url})> fetch({
required String room,
required String matrixToken,
http.Client? httpClient,
}) async {
final client = httpClient ?? http.Client();
try {
final res = await client
.post(
Uri.parse(endpoint),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'room': room, 'matrix_token': matrixToken}),
)
.timeout(_timeout);
if (res.statusCode == 401) {
throw const LiveKitTokenException(
'Anmeldung vom Anrufserver abgelehnt – bitte neu anmelden.',
);
}
if (res.statusCode != 200) {
throw LiveKitTokenException(
'Anrufserver antwortet nicht richtig (HTTP ${res.statusCode}).',
);
}
final data = jsonDecode(res.body);
final token = data is Map ? data['token'] : null;
if (token is! String || token.isEmpty) {
throw const LiveKitTokenException(
'Anrufserver hat kein Zugangstoken geliefert.',
);
}
final url = (data as Map)['url'];
return (
token: token,
url: url is String && url.startsWith('wss://') ? url : fallbackUrl,
);
} on LiveKitTokenException {
rethrow;
} on TimeoutException {
throw const LiveKitTokenException(
'Anrufserver nicht erreichbar (Zeitüberschreitung).',
);
} on SocketException {
throw const LiveKitTokenException(
'Anrufserver nicht erreichbar – Internetverbindung prüfen.',
);
} on http.ClientException {
throw const LiveKitTokenException(
'Anrufserver nicht erreichbar – Internetverbindung prüfen.',
);
} on FormatException {
throw const LiveKitTokenException(
'Anrufserver hat eine unlesbare Antwort geschickt.',
);
} finally {
if (httpClient == null) client.close();
}
}
}
class LiveKitTokenException implements Exception {
final String message;
const LiveKitTokenException(this.message);
@override
String toString() => message;
}
+9 -3
View File
@@ -13,6 +13,7 @@ import 'package:pyramid/core/background_push.dart';
import 'package:pyramid/core/fcm_push_service.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/notification_content.dart';
import 'package:pyramid/core/read_markers.dart';
import 'package:pyramid/core/settings_prefs.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:windows_taskbar/windows_taskbar.dart';
@@ -205,9 +206,14 @@ Future<void> _initAndroid(WidgetRef ref) async {
if (roomId != null && eventId != null) {
try {
final client = await ref.read(matrixClientProvider.future);
await client
.getRoomById(roomId)
?.setReadMarker(eventId, mRead: eventId);
final room = client.getRoomById(roomId);
if (room != null) {
await markRoomReadUpTo(
room,
eventId,
publicReceipt: ref.read(privacyReadReceiptsProvider),
);
}
debugPrint('[NOTIF-FG] markReadFromNotification: $roomId bis $eventId');
} catch (e) {
debugPrint('[NOTIF-FG] markReadFromNotification error: $e');
+25
View File
@@ -0,0 +1,25 @@
import 'package:matrix/matrix.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// SharedPreferences-Schlüssel der Einstellung „Lesebestätigungen senden“
/// (siehe privacyReadReceiptsProvider). Auch der Hintergrund-Isolate der
/// Benachrichtigungen liest ihn, dort gibt es keine Provider.
const readReceiptsPrefKey = 'privacy_read_receipts';
Future<bool> loadReadReceiptsPublic() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(readReceiptsPrefKey) ?? true;
}
/// Setzt Lesemarker und Lesebestätigung bis [eventId].
///
/// Mit [publicReceipt] = false (Einstellung „Lesebestätigungen senden“ aus)
/// geht nur die private Bestätigung (m.read.private) raus: Der Server zählt
/// Ungelesenes trotzdem herunter und andere eigene Geräte kennen den Stand,
/// Chatpartner sehen aber kein „gelesen“. Früher wurde in diesem Fall gar
/// nichts gesendet – Ungelesen-Zähler blieben dann stehen.
Future<void> markRoomReadUpTo(
Room room,
String eventId, {
required bool publicReceipt,
}) => room.setReadMarker(eventId, mRead: eventId, public: publicReceipt);
+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);
+9 -1
View File
@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/read_markers.dart';
import 'package:pyramid/core/theme.dart';
// ── Generic SharedPreferences-backed bool notifier ──────────────────────────
@@ -67,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 ─────────────────────────────────────────────────
final notifDesktopProvider =
@@ -87,7 +95,7 @@ final privacyPresenceProvider =
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('privacy_presence', true));
final privacyReadReceiptsProvider =
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('privacy_read_receipts', true));
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref(readReceiptsPrefKey, true));
final privacyTypingProvider =
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('privacy_typing', true));
@@ -49,6 +49,16 @@ abstract class CallSignalingService extends ChangeNotifier {
Future<void> changeQuality(String key);
/// Spricht gerade jemand? (Pegel aus den WebRTC-Statistiken, für den
/// grünen Sprech-Ring.)
bool get isLocalSpeaking => false;
bool get isRemoteSpeaking => false;
/// Handy: Ton über den Lautsprecher (true) oder die Hörmuschel (false).
/// Auf dem PC ohne Bedeutung.
bool get isSpeakerOn => true;
Future<void> toggleSpeaker() async {}
/// Wendet die in den Einstellungen gewählte Ausgabelautstärke auf den
/// Remote-Stream an. Ohne [value] wird der Pref-Wert gelesen.
Future<void> applyOutputVolume([double? value]);
+319 -88
View File
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'package:matrix/matrix.dart';
import 'package:webrtc_interface/webrtc_interface.dart';
import 'package:pyramid/core/call_foreground.dart';
import 'package:pyramid/core/ice_servers.dart';
import 'package:pyramid/core/settings_prefs.dart';
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
@@ -19,6 +20,7 @@ class PyramidVoipManager extends CallSignalingService
final Client client;
late final VoIP voip;
late final Zone _sdkZone;
@override
CallSession? currentCall;
@@ -35,6 +37,34 @@ class PyramidVoipManager extends CallSignalingService
bool _isDeafened = false;
bool _isUpdating = false;
String? _selectedSourceId;
// Handy: Lautsprecher statt Hörmuschel (wie Discord). Das SDK schaltet bei
// Sprachanrufen auf die Hörmuschel – hält man das Handy vor sich, hört man
// dann praktisch nichts (Bernds Test 2026-10-07).
bool _speakerOn = true;
bool get _isMobile =>
!kIsWeb &&
(defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS);
@override
bool get isSpeakerOn => _speakerOn;
@override
Future<void> toggleSpeaker() async {
_speakerOn = !_speakerOn;
notifyListeners();
await _applySpeaker();
}
Future<void> _applySpeaker() async {
if (!_isMobile) return;
try {
await rtc.Helper.setSpeakerphoneOn(_speakerOn);
} catch (e) {
debugPrint('[VOIP] Lautsprecher: $e');
}
}
String _currentQualityKey = 'hd';
@override
@@ -52,7 +82,21 @@ class PyramidVoipManager extends CallSignalingService
PyramidVoipManager(this.client) {
_instance = this;
voip = VoIP(client, this);
// VOR dem SDK anmelden: Kandidaten der Gegenseite puffern (siehe
// _bufferRemoteCandidates).
_callEventSub = client.onCallEvents.stream.listen(_bufferRemoteCandidates);
// Alles, was das Anruf-SDK anstößt, läuft in einer eigenen Zone: Fehler,
// die SDK/WebRTC-Plugin ins Leere werfen (z. B. Lautsprecher-Umschalten
// unter Windows, getRemoteDescription wirft statt null), werden
// protokolliert statt als „unbehandelt“ durchzuschlagen.
_sdkZone = Zone.current.fork(
specification: ZoneSpecification(
handleUncaughtError: (self, parent, zone, error, stack) {
debugPrint('[VOIP] SDK-Fehler (abgefangen): $error');
},
),
);
voip = _sdkZone.run(() => VoIP(client, this));
// Suppress speakerphone call — not supported on Windows/Desktop
try {
(voip as dynamic).onConfigSpeakerphone = (bool _) async {};
@@ -60,6 +104,29 @@ class PyramidVoipManager extends CallSignalingService
_ensureRenderers();
}
/// Führt [f] in der SDK-Zone aus. Ergebnis UND Fehler kommen über einen
/// Completer von außen zurück – Fehler können Zonengrenzen sonst nicht
/// überqueren, und der Aufrufer würde ewig warten.
Future<T> _inSdkZone<T>(Future<T> Function() f) {
final done = Completer<T>();
_sdkZone.run(() async {
try {
done.complete(await f());
} catch (e, s) {
done.completeError(e, s);
}
});
return done.future;
}
@override
void dispose() {
_callEventSub?.cancel();
_levelTimer?.cancel();
_renderRefreshTimer?.cancel();
super.dispose();
}
Future<void> _ensureRenderers() async {
if (_renderersInitialized) return;
try {
@@ -104,13 +171,88 @@ class PyramidVoipManager extends CallSignalingService
await _ensureRenderers();
currentCall = call;
_isCameraMuted = call.type == CallType.kVoice;
_speakerOn = true;
_isDeafened = false;
_isMicMuted = false;
_setupCallListeners(call);
_updateInternalStates();
notifyListeners();
unawaited(_applyBufferedCandidates(call));
}
// ── Netzwerk-Kandidaten der Gegenseite puffern ───────────────────────────
// Lücke im matrix-SDK (6.2): Kommen die Kandidaten des Anrufers an, bevor
// die Einladung fertig verarbeitet ist, verwirft das SDK sie endgültig
// („we do not have the call“); Kandidaten, die WÄHREND des Annehmens
// eintreffen, landen in einer Warteschlange, die nie mehr abgearbeitet
// wird. Dann kennt man keinen Weg zur Gegenseite – im Heimnetz rettet das
// oft noch ein direkter Prüfversuch der anderen Seite, über Mobilfunk/NAT
// dauert der Aufbau ewig oder scheitert (Bernd 2026-10-08, im Anruftest
// nachgestellt). Deshalb: alle Kandidaten mitschreiben und direkt an die
// Verbindung geben, sobald sie die Gegenseite kennt. Doppelte ignoriert
// WebRTC.
StreamSubscription<List<BasicEventWithSender>>? _callEventSub;
final _candidateBuffer = <String, List<Map<String, dynamic>>>{};
final _appliedCandidates = <String>{};
void _bufferRemoteCandidates(List<BasicEventWithSender> events) {
for (final e in events) {
if (e.type != EventTypes.CallCandidates || e.senderId == client.userID) {
continue;
}
final callId = e.content.tryGet<String>('call_id');
final list = e.content.tryGetList<Map<String, dynamic>>('candidates');
if (callId == null || list == null) continue;
final party = e.content.tryGet<String>('party_id');
(_candidateBuffer[callId] ??= []).addAll(
list.map((c) => {...c, '_party': party}),
);
// Nur die letzten Anrufe behalten.
while (_candidateBuffer.length > 8) {
_candidateBuffer.remove(_candidateBuffer.keys.first);
}
final call = currentCall;
if (call != null && call.callId == callId) {
unawaited(_applyBufferedCandidates(call));
}
}
}
Future<void> _applyBufferedCandidates(CallSession call) async {
final pc = call.pc;
final list = _candidateBuffer[call.callId];
if (pc == null || list == null || list.isEmpty) return;
try {
if (await pc.getRemoteDescription() == null) return;
} catch (_) {
return;
}
for (final c in List.of(list)) {
final cand = c['candidate'];
if (cand is! String || cand.isEmpty) continue;
final party = c['_party'];
final remoteParty = call.remotePartyId;
if (remoteParty != null && party != null && party != remoteParty) continue;
if (!_appliedCandidates.add('${call.callId}|$cand')) continue;
try {
await pc.addCandidate(rtc.RTCIceCandidate(
cand,
c['sdpMid']?.toString() ?? '',
(c['sdpMLineIndex'] as num?)?.round() ?? 0,
));
} catch (e) {
debugPrint('[VOIP] Kandidat nachreichen: $e');
}
}
}
Timer? _renderRefreshTimer;
String? _listenedCallId;
void _setupCallListeners(CallSession call) {
// Ausgehende Anrufe meldet das SDK zusätzlich über handleNewCall –
// Listener nur einmal je Anruf anhängen.
if (_listenedCallId == call.callId) return;
_listenedCallId = call.callId;
call.onCallStateChanged.stream.listen((state) {
debugPrint('[VOIP] State: $state');
_updateInternalStates();
@@ -120,14 +262,36 @@ class PyramidVoipManager extends CallSignalingService
debugPrint('[VOIP] Ensuring camera is unmuted on connect');
call.setLocalVideoMuted(false);
}
_applySpeaker();
_startLevelPolling();
// Android: Mikrofon im Hintergrund + „Anruf läuft“-Benachrichtigung.
CallForeground.callStarted('voip',
title: call.room.getLocalizedDisplayname());
}
if (state == CallState.kEnded) {
_stopLevelPolling();
CallForeground.callEnded('voip');
}
_updateRenderers();
});
// Das SDK setzt bei jedem neuen eigenen Medienstrom die Hörmuschel –
// danach unsere Wahl erneut anwenden.
call.onCallEventChanged.stream.listen((event) {
if (event == CallStateChange.kFeedsChanged) _applySpeaker();
});
_renderRefreshTimer?.cancel();
_renderRefreshTimer = Timer.periodic(
const Duration(milliseconds: 1000), // Faster polling to catch feeds
(_) => _updateRenderers(),
(_) {
_updateRenderers();
// Gepufferte Kandidaten nachreichen, sobald die Verbindung die
// Gegenseite kennt (nach Annehmen bzw. Antwort).
final c = currentCall;
if (c != null && c.state != CallState.kConnected) {
_applyBufferedCandidates(c);
}
},
);
_updateRenderers();
@@ -144,99 +308,68 @@ class PyramidVoipManager extends CallSignalingService
}
Future<void> _updateRenderers() async {
if (currentCall == null || _isUpdating) return;
final call = currentCall;
if (call == null || _isUpdating) return;
_isUpdating = true;
try {
await _ensureRenderers();
final dynamic c = currentCall;
rtc.MediaStream? remote;
rtc.MediaStream? local;
rtc.MediaStream? extract(dynamic obj) {
if (obj == null) return null;
if (obj is rtc.MediaStream) return obj;
try { return obj.stream as rtc.MediaStream?; } catch (_) {}
return null;
}
final List<dynamic> feeds = [];
try {
final dynamic sdkFeeds = c.getFeeds();
if (sdkFeeds is Iterable) feeds.addAll(sdkFeeds.toList());
} catch (_) {}
bool remoteScreenFound = false;
bool localScreenFound = false;
for (final dynamic f in feeds) {
final s = extract(f);
if (s == null) continue;
bool isLocal = false;
try { isLocal = f.isLocal == true || f.userId == client.userID; } catch (_) {}
String purpose = 'm.usermedia';
try { purpose = f.purpose ?? 'm.usermedia'; } catch (_) {}
if (isLocal) {
if (local == null || purpose == 'm.screenshare') {
local = s;
if (purpose == 'm.screenshare') localScreenFound = true;
}
} else {
if (remote == null || purpose == 'm.screenshare') {
remote = s;
if (purpose == 'm.screenshare') remoteScreenFound = true;
}
// Typisierte SDK-Zugriffe. Früher lief das über ein dynamisches
// `getFeeds()` – das gibt es im SDK gar nicht; der Fehler wurde
// geschluckt, und Bildschirmübertragungen wurden weder beim Empfänger
// angezeigt noch beim Sender als aktiv erkannt (Bernd 2026-10-08).
rtc.MediaStream? video(WrappedMediaStream? w, {bool respectMute = false}) {
final s = w?.stream;
// stopped: Gegenseite hat die Übertragung beendet (das SDK lässt den
// Strom stehen und markiert ihn nur).
if (w == null || w.stopped || s == null || s.getVideoTracks().isEmpty) {
return null;
}
if (respectMute && w.videoMuted) return null;
return s;
}
_isScreensharing = localScreenFound;
_isRemoteScreensharing = remoteScreenFound;
final remoteScreen = video(call.remoteScreenSharingStream);
final localScreen = video(call.localScreenSharingStream);
final remoteUser = call.remoteUserMediaStream?.stream;
if (remote == null) {
try { remote = extract(c.remoteUserMediaStream); } catch (_) {}
}
if (remote == null) {
try { remote = extract(c.remoteStream); } catch (_) {}
}
if (local == null) {
try { local = extract(c.localUserMediaStream); } catch (_) {}
var changed = false;
final remoteSharing = remoteScreen != null;
final localSharing = call.localScreenSharingStream != null;
if (remoteSharing != _isRemoteScreensharing ||
localSharing != _isScreensharing) {
changed = true;
}
_isRemoteScreensharing = remoteSharing;
_isScreensharing = localSharing;
// Ausgabelautstärke (Settings-Slider) auf den Remote-Stream anwenden —
if (_isDeafened) _applyDeafen(call);
// Ausgabelautstärke (Settings-Slider) auf den Ton der Gegenseite –
// nur einmal pro Stream, _updateRenderers läuft auch per Timer.
if (remote != null && remote.id != _volumeAppliedStreamId) {
_volumeAppliedStreamId = remote.id;
_remoteAudioStream = remote;
if (remoteUser != null && remoteUser.id != _volumeAppliedStreamId) {
_volumeAppliedStreamId = remoteUser.id;
_remoteAudioStream = remoteUser;
applyOutputVolume();
}
// Final check for tracks before assigning
bool changed = false;
if (remote != null && remote.getVideoTracks().isNotEmpty) {
if (remoteRenderer.srcObject?.id != remote.id) {
debugPrint('[VOIP] Assigning Remote Renderer: ${remote.id} (${remote.getVideoTracks().length} video tracks)');
remoteRenderer.srcObject = remote;
changed = true;
}
} else if (remoteRenderer.srcObject != null) {
remoteRenderer.srcObject = null;
// Bildschirm hat Vorrang vor der Kamera.
final remoteVideo =
remoteScreen ?? video(call.remoteUserMediaStream, respectMute: true);
final localVideo =
localScreen ?? video(call.localUserMediaStream, respectMute: true);
if (remoteRenderer.srcObject?.id != remoteVideo?.id) {
debugPrint('[VOIP] Remote-Video: ${remoteVideo?.id} (Bildschirm: $remoteSharing)');
remoteRenderer.srcObject = remoteVideo;
changed = true;
}
if (localRenderer.srcObject?.id != localVideo?.id) {
debugPrint('[VOIP] Lokales Video: ${localVideo?.id} (Bildschirm: $localSharing)');
localRenderer.srcObject = localVideo;
changed = true;
}
if (local != null && local.getVideoTracks().isNotEmpty) {
if (localRenderer.srcObject?.id != local.id) {
debugPrint('[VOIP] Assigning Local Renderer: ${local.id} (${local.getVideoTracks().length} video tracks)');
localRenderer.srcObject = local;
changed = true;
}
} else if (localRenderer.srcObject != null) {
localRenderer.srcObject = null;
changed = true;
}
if (changed) notifyListeners();
} catch (e) {
debugPrint('[VOIP] Error in _updateRenderers: $e');
@@ -245,11 +378,64 @@ class PyramidVoipManager extends CallSignalingService
}
}
// ── Wer spricht gerade? (Pegel aus den WebRTC-Statistiken) ───────────────
// Zeigt den grünen Sprech-Ring wie bei Discord – und macht sichtbar, wo
// Ton verloren geht: leuchtet „Du“ beim Sprechen nicht, kommt vom eigenen
// Mikrofon nichts an; leuchtet die Gegenseite nicht, kommt nichts an.
Timer? _levelTimer;
bool _localSpeaking = false;
bool _remoteSpeaking = false;
@override
bool get isLocalSpeaking => _localSpeaking;
@override
bool get isRemoteSpeaking => _remoteSpeaking;
void _startLevelPolling() {
_levelTimer?.cancel();
_levelTimer = Timer.periodic(
const Duration(milliseconds: 300),
(_) => _pollLevels(),
);
}
void _stopLevelPolling() {
_levelTimer?.cancel();
_levelTimer = null;
_localSpeaking = false;
_remoteSpeaking = false;
}
Future<void> _pollLevels() async {
final pc = currentCall?.pc;
if (pc == null) return;
try {
double local = 0;
double remote = 0;
for (final r in await pc.getStats()) {
if (r.values['kind'] != 'audio') continue;
final level = (r.values['audioLevel'] as num?)?.toDouble() ?? 0;
if (r.type == 'media-source' && level > local) local = level;
if (r.type == 'inbound-rtp' && level > remote) remote = level;
}
// Hysterese: an ab 0,03, aus unter 0,01 – kein Flackern.
bool speaking(bool was, double level) => was ? level > 0.01 : level > 0.03;
final ls = speaking(_localSpeaking, _isMicMuted ? 0 : local);
final rs = speaking(_remoteSpeaking, remote);
if (ls != _localSpeaking || rs != _remoteSpeaking) {
_localSpeaking = ls;
_remoteSpeaking = rs;
notifyListeners();
}
} catch (_) {}
}
@override
Future<void> handleCallEnded(CallSession session) async {
if (currentCall?.callId == session.callId) {
_renderRefreshTimer?.cancel();
_renderRefreshTimer = null;
_stopLevelPolling();
localRenderer.srcObject = null;
remoteRenderer.srcObject = null;
_remoteAudioStream = null;
@@ -259,6 +445,7 @@ class PyramidVoipManager extends CallSignalingService
_isRemoteScreensharing = false;
notifyListeners();
}
CallForeground.callEnded('voip');
}
// ── Ausgabelautstärke (Settings-Slider "voice_output_volume") ─────────────
@@ -292,6 +479,7 @@ class PyramidVoipManager extends CallSignalingService
currentCall = null;
notifyListeners();
}
CallForeground.callEnded('voip');
}
@override
@@ -312,7 +500,7 @@ class PyramidVoipManager extends CallSignalingService
@override
Future<void> safeAction(Future<dynamic> Function() action) async {
try {
await action();
await _inSdkZone(action);
await Future.delayed(const Duration(milliseconds: 1000));
} catch (e) {
debugPrint('[VOIP] Action error: $e');
@@ -322,16 +510,24 @@ class PyramidVoipManager extends CallSignalingService
}
@override
Future<void> toggleMic() async {
if (currentCall == null) return;
_isMicMuted = !_isMicMuted;
Future<void> toggleMic() => _setMicMuted(!_isMicMuted);
Future<void> _setMicMuted(bool muted) async {
final c = currentCall;
if (c == null) return;
_isMicMuted = muted;
notifyListeners();
try {
await currentCall!.setMicrophoneMuted(_isMicMuted);
await _inSdkZone(() => c.setMicrophoneMuted(muted));
} catch (e) {
_isMicMuted = !_isMicMuted;
notifyListeners();
// Das SDK schaltet die Spur VOR dem Senden der Status-Info um. Schlägt
// nur das Senden fehl, ist das Mikro trotzdem (ent)stummt – früher
// wurde hier blind zurückgeschaltet, und der Knopf zeigte danach das
// Gegenteil des echten Zustands (Bernd 2026-10-08).
debugPrint('[VOIP] Stummschalten: $e');
}
_isMicMuted = c.isMicrophoneMuted;
notifyListeners();
}
@override
@@ -340,7 +536,8 @@ class PyramidVoipManager extends CallSignalingService
_isCameraMuted = !_isCameraMuted;
notifyListeners();
try {
await currentCall!.setLocalVideoMuted(_isCameraMuted);
final c = currentCall!;
await _inSdkZone(() => c.setLocalVideoMuted(_isCameraMuted));
for (var i = 0; i < 5; i++) {
await Future.delayed(Duration(milliseconds: 300 + (i * 200)));
await _updateRenderers();
@@ -361,6 +558,7 @@ class PyramidVoipManager extends CallSignalingService
if (_isScreensharing) {
await safeAction(() => call.setScreensharingEnabled(false));
await CallForeground.screenCaptureStopped('voip');
return;
}
@@ -379,8 +577,31 @@ class PyramidVoipManager extends CallSignalingService
@override
Future<void> toggleDeafen() async {
// Wie Discord: taub = Gegenseite nicht mehr hören UND selbst stumm;
// beim Aufheben den Mikro-Zustand von vorher wiederherstellen. (Früher
// setzte der Knopf nur ein Häkchen ohne Wirkung.)
_isDeafened = !_isDeafened;
notifyListeners();
final c = currentCall;
if (c == null) return;
_applyDeafen(c);
if (_isDeafened) {
_micMutedBeforeDeafen = _isMicMuted;
if (!_isMicMuted) await _setMicMuted(true);
} else if (!_micMutedBeforeDeafen && _isMicMuted) {
await _setMicMuted(false);
}
}
bool _micMutedBeforeDeafen = false;
/// Ton der Gegenseite an/aus (deaktivierte Empfangsspur = stumm).
void _applyDeafen(CallSession call) {
for (final w in call.getRemoteStreams) {
for (final t in w.stream?.getAudioTracks() ?? const <MediaStreamTrack>[]) {
if (t.enabled == _isDeafened) t.enabled = !_isDeafened;
}
}
}
@override
@@ -390,6 +611,7 @@ class PyramidVoipManager extends CallSignalingService
await currentCall!.hangup(reason: CallErrorCode.userHangup);
currentCall = null;
});
await CallForeground.callEnded('voip');
}
@override
@@ -399,15 +621,17 @@ class PyramidVoipManager extends CallSignalingService
await _ensureRenderers();
_isCameraMuted = !video;
_speakerOn = true;
debugPrint('[VOIP] Starting call in room $roomId (video=$video)');
final call = await voip.inviteToCall(room, video ? CallType.kVideo : CallType.kVoice);
final call = await _inSdkZone(
() => voip.inviteToCall(room, video ? CallType.kVideo : CallType.kVoice));
currentCall = call;
_setupCallListeners(call);
if (video) {
await Future.delayed(const Duration(milliseconds: 800));
await call.setLocalVideoMuted(false);
await _inSdkZone(() => call.setLocalVideoMuted(false));
await _updateRenderers();
}
@@ -525,6 +749,13 @@ class MediaDevicesWrapper extends MediaDevices {
@override
Future<rtc.MediaStream> getDisplayMedia(Map<String, dynamic> constraints) async {
// Android 14+: ohne laufenden mediaProjection-Dienst stürzt die App beim
// Mitschnitt ab – erst Zustimmung, dann Dienst, dann Mitschnitt.
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
if (!await CallForeground.prepareScreenCapture('voip')) {
throw Exception('Bildschirmübertragung wurde nicht erlaubt');
}
}
final String? sourceId = _manager._selectedSourceId;
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.windows && sourceId != null) {
final desktopConstraints = {
+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 String displayName = isVoip
? (call.currentCall?.room.getLocalizedDisplayname() ?? 'Unknown')
? (call.currentCall?.room.getLocalizedDisplayname() ?? 'Unbekannt')
: call.displayName;
final bool isConnecting = isVoip ? false : call.isConnecting;
@@ -160,7 +160,7 @@ class _MiniCallWidgetState extends ConsumerState<MiniCallWidget> {
const _LiveDot(),
const SizedBox(width: 6),
Text(
isConnecting ? 'CONNECTING...' : 'VOICE CONNECTED',
isConnecting ? 'CONNECTING...' : (isVoip ? 'ANRUF LÄUFT' : (call.isVoiceChannel ? 'SPRACHKANAL' : 'GRUPPENANRUF')),
style: TextStyle(
color: pt.online,
fontSize: 10,
+31 -11
View File
@@ -14,7 +14,7 @@ class _BigAvatar extends StatelessWidget {
gradient: LinearGradient(colors: [data.avatarColor, data.avatarColor.withAlpha(150)], begin: Alignment.topLeft, end: Alignment.bottomRight),
shape: BoxShape.circle,
),
child: Center(child: Text(data.name.isNotEmpty ? data.name[0].toUpperCase() : '?', style: const TextStyle(color: Colors.white, fontSize: 40, fontWeight: FontWeight.bold))),
child: Center(child: Text(data.initial, style: const TextStyle(color: Colors.white, fontSize: 40, fontWeight: FontWeight.bold))),
),
);
}
@@ -34,7 +34,7 @@ class _SmallAvatar extends StatelessWidget {
gradient: LinearGradient(colors: [data.avatarColor, data.avatarColor.withAlpha(150)], begin: Alignment.topLeft, end: Alignment.bottomRight),
shape: BoxShape.circle,
),
child: Center(child: Text(data.name.isNotEmpty ? data.name[0].toUpperCase() : '?', style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold))),
child: Center(child: Text(data.initial, style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold))),
),
);
}
@@ -51,6 +51,9 @@ class _CallControls extends StatelessWidget {
final VoidCallback onToggleScreen;
final VoidCallback onHangUp;
final PyramidTheme pt;
// Handy: Lautsprecher/Hörmuschel statt „Ton aus“ (Platz in der Leiste).
final bool isSpeakerOn;
final VoidCallback? onToggleSpeaker;
const _CallControls({
required this.isMuted,
@@ -63,10 +66,15 @@ class _CallControls extends StatelessWidget {
required this.onToggleScreen,
required this.onHangUp,
required this.pt,
this.isSpeakerOn = true,
this.onToggleSpeaker,
});
@override
Widget build(BuildContext context) {
final platform = Theme.of(context).platform;
final showSpeaker = onToggleSpeaker != null &&
(platform == TargetPlatform.android || platform == TargetPlatform.iOS);
return SafeArea(
top: false,
child: Container(
@@ -77,7 +85,15 @@ class _CallControls extends StatelessWidget {
children: [
_VoiceCtrlBtn(icon: isMuted ? Icons.mic_off_rounded : Icons.mic_rounded, danger: isMuted, pt: pt, onTap: onToggleMic),
const SizedBox(width: 16),
_VoiceCtrlBtn(icon: isDeafened ? Icons.headset_off_rounded : Icons.headphones_rounded, danger: isDeafened, pt: pt, onTap: onToggleDeafen),
if (showSpeaker)
_VoiceCtrlBtn(
icon: isSpeakerOn ? Icons.volume_up_rounded : Icons.phone_in_talk_rounded,
active: isSpeakerOn,
pt: pt,
onTap: onToggleSpeaker!,
)
else
_VoiceCtrlBtn(icon: isDeafened ? Icons.headset_off_rounded : Icons.headphones_rounded, danger: isDeafened, pt: pt, onTap: onToggleDeafen),
const SizedBox(width: 16),
_VoiceCtrlBtn(icon: isCameraOff ? Icons.videocam_off_rounded : Icons.videocam_rounded, active: !isCameraOff, pt: pt, onTap: onToggleCam),
const SizedBox(width: 16),
@@ -111,14 +127,12 @@ class _NoCall extends StatelessWidget {
child: Row(
children: [
PyrIconBtn(
icon: const Icon(Icons.menu_rounded),
tooltip: 'Toggle sidebar',
onPressed: () => ref.read(railCollapsedProvider.notifier).update((s) => !s),
icon: const Icon(Icons.arrow_back_rounded),
tooltip: 'Zurück',
onPressed: () => ref.read(viewModeProvider.notifier).state = ViewMode.chat,
),
const SizedBox(width: 8),
Icon(Icons.headphones_rounded, size: 16, color: pt.fgMuted),
const SizedBox(width: 8),
Text('No active call', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)),
Text('Kein Anruf', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)),
],
),
),
@@ -127,9 +141,15 @@ class _NoCall extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('𐃏', style: TextStyle(fontSize: 48, color: pt.fgDim.withAlpha(100))),
Icon(Icons.call_end_rounded, size: 48, color: pt.fgDim),
const SizedBox(height: 12),
Text('Join a voice room to start a call', style: TextStyle(color: pt.fgMuted, fontSize: 14)),
Text('Gerade läuft kein Anruf.', style: TextStyle(color: pt.fgMuted, fontSize: 14)),
const SizedBox(height: 16),
FilledButton(
onPressed: () => ref.read(viewModeProvider.notifier).state = ViewMode.chat,
style: FilledButton.styleFrom(backgroundColor: pt.accent, foregroundColor: pt.accentFg),
child: const Text('Zurück zum Chat'),
),
],
),
),
+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,
);
}
}
+155 -30
View File
@@ -1,7 +1,13 @@
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 {
final List<_ParticipantData> participants;
final Client? client;
final DateTime startedAt;
final bool isLiveKit;
final String displayName;
final bool isConnecting;
@@ -17,9 +23,13 @@ class _UnifiedCallView extends StatefulWidget {
final bool isScreenSharing;
final Function(Offset)? onQualityMenu;
final PyramidTheme pt;
final bool isSpeakerOn;
final VoidCallback? onToggleSpeaker;
const _UnifiedCallView({
required this.participants,
required this.client,
required this.startedAt,
required this.isLiveKit,
required this.displayName,
required this.isConnecting,
@@ -35,6 +45,8 @@ class _UnifiedCallView extends StatefulWidget {
required this.isScreenSharing,
this.onQualityMenu,
required this.pt,
this.isSpeakerOn = true,
this.onToggleSpeaker,
});
@override
@@ -42,31 +54,52 @@ class _UnifiedCallView extends StatefulWidget {
}
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;
bool _isGridView = false;
bool _stripCollapsed = false;
Set<String> _knownScreens = {};
@override
void initState() {
super.initState();
_autoSelectHero();
_followScreenShares();
}
@override
void didUpdateWidget(_UnifiedCallView oldWidget) {
super.didUpdateWidget(oldWidget);
_autoSelectHero();
_followScreenShares();
}
void _autoSelectHero() {
if (_isGridView) return;
final screenshare = widget.participants.firstWhereOrNull((p) => p.isScreenShare);
if (screenshare != null && (_heroId == null || !widget.participants.any((p) => p.id == _heroId && p.hasVideo))) {
_heroId = screenshare.id;
} else if (_heroId == null && widget.participants.isNotEmpty) {
final firstVideo = widget.participants.firstWhereOrNull((p) => p.hasVideo);
if (firstVideo != null) _heroId = firstVideo.id;
/// Neue Bildschirmübertragung → groß zeigen. Verschwindet das große Bild
/// (Person weg, Kamera aus), zurück zu den Bubbles.
void _followScreenShares() {
final screens = widget.participants
.where((p) => p.isScreenShare)
.map((p) => p.id)
.toSet();
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) {
@@ -83,8 +116,10 @@ class _UnifiedCallViewState extends State<_UnifiedCallView> {
Widget build(BuildContext context) {
final pt = widget.pt;
final all = _sorted(widget.participants);
final hero = all.firstWhereOrNull((p) => p.id == _heroId) ?? (all.isNotEmpty ? all.first : null);
final hero = _mode == _CallLayout.hero
? widget.participants.firstWhereOrNull((p) => p.id == _heroId)
: null;
if (widget.isConnecting) {
return Scaffold(
backgroundColor: pt.bg1,
@@ -115,23 +150,45 @@ class _UnifiedCallViewState extends State<_UnifiedCallView> {
children: [
_CallHeader(
displayName: widget.displayName,
isGridView: _isGridView,
onToggleView: () => setState(() => _isGridView = !_isGridView),
startedAt: widget.startedAt,
count: widget.participants.length,
layout: _mode,
onToggleView: () => setState(() {
_mode = _mode == _CallLayout.bubbles
? _CallLayout.grid
: _CallLayout.bubbles;
_preferred = _mode;
_heroId = null;
}),
onMinimize: widget.onMinimize,
pt: pt,
),
Expanded(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: _isGridView || hero == null
? _ParticipantGrid(participants: all, pt: pt, onSelect: (p) => setState(() { _heroId = p.id; _isGridView = false; }))
duration: motionDuration(context, 300),
child: hero == null
? (_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(
key: const ValueKey('hero'),
hero: hero,
others: all.where((p) => p.id != hero.id).toList(),
stripCollapsed: _stripCollapsed,
onToggleStrip: () => setState(() => _stripCollapsed = !_stripCollapsed),
onSetStripCollapsed: (v) => setState(() => _stripCollapsed = v),
onSelectOther: (p) => setState(() => _heroId = p.id),
onSelectOther: (p) => p.hasVideo
? setState(() => _heroId = p.id)
: null,
onQualityMenu: widget.onQualityMenu,
pt: pt,
),
@@ -148,6 +205,8 @@ class _UnifiedCallViewState extends State<_UnifiedCallView> {
onToggleScreen: widget.onToggleScreen,
onHangUp: widget.onHangUp,
pt: pt,
isSpeakerOn: widget.isSpeakerOn,
onToggleSpeaker: widget.onToggleSpeaker,
),
],
),
@@ -157,30 +216,55 @@ class _UnifiedCallViewState extends State<_UnifiedCallView> {
class _CallHeader extends StatelessWidget {
final String displayName;
final bool isGridView;
final DateTime startedAt;
final int count;
final _CallLayout layout;
final VoidCallback onToggleView;
final VoidCallback onMinimize;
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
Widget build(BuildContext context) {
final toBubbles = layout != _CallLayout.bubbles;
return Container(
height: 52,
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: pt.border))),
child: Row(
children: [
Icon(Icons.headphones_rounded, size: 16, color: pt.fgMuted),
const SizedBox(width: 8),
Text(displayName, style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)),
const SizedBox(width: 8),
_LiveBadge(pt: pt),
const Spacer(),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)),
const SizedBox(height: 2),
Row(children: [
_LiveBadge(pt: pt),
const SizedBox(width: 8),
_CallDuration(startedAt: startedAt, pt: pt),
Text(count == 1 ? ' · nur du' : ' · $count Personen',
style: TextStyle(color: pt.fgMuted, fontSize: 12)),
]),
],
),
),
PyrIconBtn(
icon: Icon(isGridView ? Icons.grid_view_rounded : Icons.featured_video_rounded, color: isGridView ? pt.accent : pt.fg),
tooltip: isGridView ? 'Hero mode' : 'Grid mode',
icon: Icon(toBubbles ? Icons.bubble_chart_rounded : Icons.grid_view_rounded, color: pt.fg),
tooltip: toBubbles ? 'Bubble-Ansicht' : 'Kachelansicht',
onPressed: onToggleView,
),
const SizedBox(width: 4),
@@ -195,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 {
final PyramidTheme pt;
const _LiveBadge({required this.pt});
@@ -5,7 +5,7 @@ class _ParticipantGrid extends StatelessWidget {
final PyramidTheme pt;
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
Widget build(BuildContext context) {
@@ -41,6 +41,7 @@ class _HeroLayout extends StatelessWidget {
final PyramidTheme pt;
const _HeroLayout({
super.key,
required this.hero,
required this.others,
required this.stripCollapsed,
@@ -3,6 +3,11 @@ part of '../voice_channel.dart';
void _handleLiveKitScreenShare(BuildContext context, VoiceChannelService call) async {
if (call.isScreenSharing) {
await call.stopScreenShare();
} else if (Theme.of(context).platform == TargetPlatform.android ||
Theme.of(context).platform == TargetPlatform.iOS) {
// Handy: ganzer Bildschirm über die System-Zustimmung (kein
// Fenster-Auswähler wie am PC).
await call.toggleScreenShare();
} else {
final source = await ScreenSharePicker.show(context);
if (source != null) await call.startScreenShare(source.id);
@@ -12,6 +12,19 @@ class _ParticipantData {
final Color avatarColor;
final dynamic originalParticipant;
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({
required this.id,
@@ -24,9 +37,22 @@ class _ParticipantData {
this.avatarColor = Colors.grey,
this.originalParticipant,
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 {
const VoiceChannelView({super.key});
@@ -35,10 +61,13 @@ class VoiceChannelView extends ConsumerWidget {
final pt = PyramidTheme.of(context);
final call = ref.watch(voiceChannelProvider);
final voip = ref.watch(callSignalingProvider);
final client = ref.watch(matrixClientProvider).valueOrNull;
if (voip.currentCall != null && voip.currentCall!.state != CallState.kEnded) {
return _UnifiedCallView(
participants: _getVoipParticipants(voip),
client: client,
startedAt: _callStartFor('voip:${voip.currentCall!.callId}'),
isLiveKit: false,
displayName: voip.currentCall!.room.getLocalizedDisplayname(),
isConnecting: false,
@@ -58,6 +87,8 @@ class VoiceChannelView extends ConsumerWidget {
isScreenSharing: voip.isScreensharing,
onQualityMenu: (pos) => _showVoipQualityMenu(context, voip, pos),
pt: pt,
isSpeakerOn: voip.isSpeakerOn,
onToggleSpeaker: voip.toggleSpeaker,
);
}
@@ -66,7 +97,9 @@ class VoiceChannelView extends ConsumerWidget {
}
return _UnifiedCallView(
participants: _getLiveKitParticipants(call),
participants: _getLiveKitParticipants(call, client),
client: client,
startedAt: _callStartFor('lk:${call.currentRoomId ?? call.displayName}'),
isLiveKit: true,
displayName: call.displayName,
isConnecting: call.isConnecting,
@@ -86,12 +119,31 @@ class VoiceChannelView extends ConsumerWidget {
isScreenSharing: call.isScreenSharing,
onQualityMenu: (pos) => _showLiveKitQualityMenu(context, call, pos),
pt: pt,
isSpeakerOn: call.isSpeakerOn,
onToggleSpeaker: call.toggleSpeaker,
);
}
List<_ParticipantData> _getLiveKitParticipants(VoiceChannelService call) {
List<_ParticipantData> _getLiveKitParticipants(
VoiceChannelService call, Client? client) {
final room = call.room;
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>[
if (room.localParticipant != null) room.localParticipant!,
...room.remoteParticipants.values,
@@ -115,10 +167,20 @@ class VoiceChannelView extends ConsumerWidget {
} 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(
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,
coverVideoWidget: videoTrack != null ? VideoTrackRenderer(videoTrack, fit: VideoViewFit.cover) : null,
isSpeaking: p.isSpeaking,
isMuted: !p.isMicrophoneEnabled(),
hasVideo: videoTrack != null,
@@ -139,14 +201,23 @@ class VoiceChannelView extends ConsumerWidget {
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
final isRemoteScreen = voip.isRemoteScreensharing;
final hasRemoteVideo = voip.remoteRenderer.srcObject != null && voip.remoteRenderer.srcObject!.getVideoTracks().isNotEmpty;
participants.add(_ParticipantData(
id: 'remote',
name: voip.currentCall?.room.getLocalizedDisplayname() ?? 'Remote',
name: voip.currentCall?.room.getLocalizedDisplayname() ?? 'Gegenseite',
videoWidget: hasRemoteVideo ? rtc.RTCVideoView(voip.remoteRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null,
coverVideoWidget: hasRemoteVideo ? rtc.RTCVideoView(voip.remoteRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitCover) : null,
avatarUrl: partnerId == null
? mroom?.avatar
: mroom?.unsafeGetUserFromMemoryOrFallback(partnerId).avatarUrl,
isMuted: false,
isSpeaking: voip.isRemoteSpeaking,
hasVideo: hasRemoteVideo,
isScreenShare: isRemoteScreen,
avatarColor: _avatarColors[1],
@@ -159,8 +230,15 @@ class VoiceChannelView extends ConsumerWidget {
participants.add(_ParticipantData(
id: 'local',
name: 'Du',
initialFrom: voip.currentCall?.room.client.userID?.split(':').first,
videoWidget: hasLocalVideo ? rtc.RTCVideoView(voip.localRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null,
coverVideoWidget: hasLocalVideo ? rtc.RTCVideoView(voip.localRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitCover) : null,
avatarUrl: myId == null
? null
: mroom?.unsafeGetUserFromMemoryOrFallback(myId).avatarUrl,
isLocal: true,
isMuted: voip.isMicMuted,
isSpeaking: voip.isLocalSpeaking,
hasVideo: hasLocalVideo,
isScreenShare: isLocalScreen,
avatarColor: _avatarColors[0],
+9
View File
@@ -1,9 +1,17 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart' show Ticker;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:collection/collection.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/core/theme.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/avatars_controls.dart';
part 'view/quality_menu.dart';
part 'view/bubble_stage.dart';
+104 -24
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -7,7 +8,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import 'package:path_provider/path_provider.dart';
import 'package:pyramid/core/settings_prefs.dart';
import 'package:pyramid/core/appearance.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/features/chat/attachment_dialog.dart';
import 'package:pyramid/features/chat/emoji_picker.dart';
import 'package:pyramid/features/chat/gif_sticker_picker.dart';
@@ -32,6 +37,10 @@ class ChatComposer extends ConsumerStatefulWidget {
}
class _ChatComposerState extends ConsumerState<ChatComposer> {
/// Angefangene Nachrichten je Raum (nur im Speicher): Beim Raumwechsel
/// bleibt der Text beim richtigen Raum, statt im nächsten Chat zu landen.
static final _drafts = <String, String>{};
final _ctrl = TextEditingController();
late final FocusNode _focus;
bool _canSend = false;
@@ -44,6 +53,12 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
void initState() {
super.initState();
_focus = FocusNode(onKeyEvent: _handleKeyEvent);
// Entwurf vor dem Listener setzen – sonst ginge sofort „tippt…“ raus.
final draft = _drafts.remove(widget.room.id);
if (draft != null) {
_ctrl.text = draft;
_canSend = draft.trim().isNotEmpty;
}
_ctrl.addListener(_onTextChanged);
}
@@ -118,6 +133,8 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
@override
void dispose() {
final text = _ctrl.text;
if (text.trim().isNotEmpty) _drafts[widget.room.id] = text;
_typingTimer?.cancel();
if (_isTyping) widget.room.setTyping(false).catchError((_) {});
_gifOverlay?.remove();
@@ -300,22 +317,43 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
final pt = PyramidTheme.of(context);
final size = MediaQuery.sizeOf(context);
final isSmall = size.width < 400;
// Handy: größere Symbole (vorher 12–13 px, Bernds Feedback 2026-10-07).
final mobile = isMobileLayout(size);
final roomName = widget.room.getLocalizedDisplayname();
// „#“ nur bei Server-Kanälen; Gruppen heißen wie die Gruppe.
final placeholder = widget.room.isDirectChat
? 'Nachricht…'
: 'Nachricht an #$roomName';
: ref.watch(spaceChannelIdsProvider).contains(widget.room.id)
? 'Nachricht an #$roomName'
: 'Nachricht an $roomName';
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.replyTo != null)
_ReplyBar(
event: widget.replyTo!,
onClear: widget.onClearReply ?? () {},
pt: pt,
// Antwort-Leiste federt auf und klappt beim Schließen weich zu.
AnimatedSwitcher(
duration: motionDuration(context, 300),
reverseDuration: motionDuration(context, 160),
switchInCurve: Curves.easeOutBack,
switchOutCurve: Curves.easeIn,
transitionBuilder: (child, anim) => SizeTransition(
sizeFactor: anim,
alignment: Alignment.bottomCenter,
child: FadeTransition(opacity: anim, child: child),
),
child: widget.replyTo != null
? KeyedSubtree(
key: ValueKey('reply:${widget.replyTo!.eventId}'),
child: _ReplyBar(
event: widget.replyTo!,
onClear: widget.onClearReply ?? () {},
pt: pt,
),
)
: const SizedBox(key: ValueKey('reply:none'), width: double.infinity),
),
Padding(
// Bottom inset matches the profile block in the rooms list (8px) so
// the composer sits flush with it.
@@ -327,8 +365,10 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
// block in the rooms list (~89px on the user's display). Grows for
// multiline.
constraints: const BoxConstraints(minHeight: 53),
// Rahmen + Fokus-Ring außen (würden vom Zuschnitt sonst
// abgeschnitten), innen Milchglas: der Chat läuft unter dem
// schwebenden Feld durch und scheint unscharf durch.
decoration: BoxDecoration(
color: pt.bg2,
borderRadius: BorderRadius.circular(pt.rBase + 4),
border: Border.all(
color: _focus.hasFocus ? pt.accent : pt.border,
@@ -337,7 +377,13 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
? [BoxShadow(color: pt.accentSoft, blurRadius: 0, spreadRadius: 3)]
: null,
),
child: Focus(
child: ClipRRect(
borderRadius: BorderRadius.circular(pt.rBase + 3),
child: BackdropFilter(
filter: ui.ImageFilter.blur(sigmaX: 12, sigmaY: 12),
child: ColoredBox(
color: pt.bg2.withValues(alpha: 0.78),
child: Focus(
onFocusChange: (_) => setState(() {}),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
@@ -347,7 +393,7 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
icon: Icons.add_rounded,
tooltip: 'Datei anhängen',
pt: pt,
size: size.width < 600 ? 24 : 30,
size: mobile ? 42 : 30,
onTap: _pickAttachment,
),
// Text input
@@ -397,14 +443,14 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
icon: Icons.gif_box_outlined,
tooltip: 'GIF',
pt: pt,
size: size.width < 600 ? 22 : 26,
size: mobile ? 40 : 26,
onTap: _toggleGifPicker,
),
_ComposerIconBtn(
icon: Icons.tag_faces_rounded,
tooltip: 'Emoji',
pt: pt,
size: size.width < 600 ? 22 : 26,
size: mobile ? 40 : 26,
onTap: _toggleEmojiPicker,
),
if (size.width > 600)
@@ -424,10 +470,14 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
canSend: _canSend,
pt: pt,
onTap: _send,
size: mobile ? 42 : 36,
),
),
],
),
),
),
),
),
),
),
@@ -461,9 +511,7 @@ class _ComposerIconBtnState extends State<_ComposerIconBtn> {
@override
Widget build(BuildContext context) {
final platform = Theme.of(context).platform;
final isMobile =
platform == TargetPlatform.android || platform == TargetPlatform.iOS;
final isMobile = isMobileLayout(MediaQuery.sizeOf(context));
final touchSize = isMobile ? 44.0 : widget.size;
return MouseRegion(
@@ -475,7 +523,9 @@ class _ComposerIconBtnState extends State<_ComposerIconBtn> {
child: Tooltip(
message: widget.tooltip,
waitDuration: const Duration(milliseconds: 500),
child: SizedBox(
child: SpringPress(
enabled: widget.onTap != null,
child: SizedBox(
width: touchSize,
height: touchSize,
child: Center(
@@ -497,6 +547,7 @@ class _ComposerIconBtnState extends State<_ComposerIconBtn> {
),
),
),
),
),
),
);
@@ -507,8 +558,14 @@ class _SendButton extends StatefulWidget {
final bool canSend;
final PyramidTheme pt;
final VoidCallback onTap;
final double size;
const _SendButton({required this.canSend, required this.pt, required this.onTap});
const _SendButton({
required this.canSend,
required this.pt,
required this.onTap,
this.size = 36,
});
@override
State<_SendButton> createState() => _SendButtonState();
@@ -528,11 +585,14 @@ class _SendButtonState extends State<_SendButton> {
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: active ? widget.onTap : null,
child: AnimatedContainer(
child: SpringPress(
enabled: active,
pressedScale: 0.82,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutBack,
width: 36,
height: 36,
width: widget.size,
height: widget.size,
decoration: BoxDecoration(
color: active ? pt.accent : pt.bg3,
borderRadius: BorderRadius.circular(pt.rSm),
@@ -547,13 +607,22 @@ class _SendButtonState extends State<_SendButton> {
: Matrix4.identity(),
transformAlignment: Alignment.center,
child: Center(
child: Icon(
Icons.send_rounded,
size: 16,
color: active ? pt.accentFg : pt.fgDim,
// Sobald gesendet werden kann, ploppt der Pfeil federnd auf.
child: AnimatedScale(
scale: active ? 1 : 0.8,
duration: active
? motionDuration(context, 520)
: const Duration(milliseconds: 140),
curve: active ? Curves.elasticOut : Curves.easeOut,
child: Icon(
Icons.send_rounded,
size: widget.size * 0.45,
color: active ? pt.accentFg : pt.fgDim,
),
),
),
),
),
),
);
}
@@ -599,9 +668,20 @@ class _ReplyBar extends StatelessWidget {
],
),
),
// Handy: größeres X mit fingergerechter Trefferfläche.
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onClear,
child: Icon(Icons.close_rounded, size: 16, color: pt.fgDim),
child: SpringPress(
child: Padding(
padding: const EdgeInsets.all(6),
child: Icon(
Icons.close_rounded,
size: isMobileLayout(MediaQuery.sizeOf(context)) ? 22 : 16,
color: pt.fgDim,
),
),
),
),
],
),
+56
View File
@@ -10,7 +10,9 @@ import 'package:pyramid/core/matrix_client.dart';
class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
Timeline? _timeline;
StreamSubscription<String>? _roomUpdateSub;
StreamSubscription<SyncUpdate>? _syncSub;
Timer? _redecryptTimer;
String _receiptSig = '';
@override
Future<Timeline> build(String arg) async {
@@ -21,6 +23,7 @@ class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
_timeline?.cancelSubscriptions();
_roomUpdateSub?.cancel();
_syncSub?.cancel();
_redecryptTimer?.cancel();
final diag = ref.read(e2eeDiagnosticsProvider.notifier);
@@ -49,6 +52,28 @@ class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
});
});
// Nach jedem Sync (erst dann sind Lesebestätigungen verarbeitet – das
// Raum-Update kommt im SDK VORHER):
// • Einladung angenommen (invite → join): Verlauf neu aufbauen; der beim
// Einladen gebaute blieb sonst leer, bis man die App neu startete.
// • Lesebestätigung eines anderen hat sich geändert: neu zeichnen, damit
// ✓✓ live erscheint. Neue Nachrichten allein lösen _onUpdate schon aus,
// eine Bestätigung ohne neue Nachricht bisher nicht.
final membershipAtBuild = room.membership;
_receiptSig = _receiptSignature(room);
_syncSub = client.onSync.stream.listen((_) {
if (membershipAtBuild != Membership.join &&
room.membership == Membership.join) {
ref.invalidateSelf();
return;
}
final sig = _receiptSignature(room);
if (sig != _receiptSig) {
_receiptSig = sig;
_onUpdate();
}
});
// Ensure device keys are downloaded for all room members so outbound
// Megolm session key sharing works on the first send.
if (room.encrypted && client.encryptionEnabled) {
@@ -57,6 +82,7 @@ class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
ref.onDispose(() {
_roomUpdateSub?.cancel();
_syncSub?.cancel();
_redecryptTimer?.cancel();
_timeline?.cancelSubscriptions();
_timeline = null;
@@ -70,6 +96,36 @@ class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
}
}
/// Kurzer Fingerabdruck der Lesepositionen der anderen Mitglieder.
String _receiptSignature(Room room) {
final b = StringBuffer();
for (final users in [
room.receiptState.global.otherUsers,
room.receiptState.mainThread?.otherUsers ?? const {},
]) {
users.forEach((userId, data) => b
..write(userId)
..write('=')
..write(data.eventId)
..write(';'));
}
return b.toString();
}
/// Einladung annehmen und warten, bis der Raum als beigetreten
/// synchronisiert ist – erst dann hat der Verlauf Inhalt.
Future<void> acceptInvite(WidgetRef ref, Room room) async {
final client = room.client;
// Vor dem Beitreten anfangen zu warten, sonst kann der Sync durchrutschen.
final synced = client
.waitForRoomInSync(room.id, join: true)
.timeout(const Duration(seconds: 20))
.then<void>((_) {}, onError: (_) {});
await room.join();
await synced;
ref.invalidate(timelineProvider(room.id));
}
final timelineProvider =
AsyncNotifierProvider.family<TimelineNotifier, Timeline, String>(
TimelineNotifier.new,
+8
View File
@@ -10,6 +10,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/appearance.dart' show isMobileLayout;
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/settings_prefs.dart';
import 'package:pyramid/core/theme.dart';
@@ -18,7 +19,14 @@ import 'package:pyramid/features/chat/chat_composer.dart';
import 'package:pyramid/features/chat/chat_provider.dart';
import 'package:pyramid/features/chat/message_group.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/measure_size.dart';
import 'package:pyramid/widgets/spring_press.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/core/notification_service.dart';
+1 -1
View File
@@ -6,7 +6,7 @@ import 'package:archive/archive.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:path_provider/path_provider.dart';
import 'package:pdfx/pdfx.dart';
+13 -2
View File
@@ -6,6 +6,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/chat/media_player.dart';
import 'package:pyramid/utils/gif_favorite_service.dart';
import 'package:pyramid/widgets/spring_press.dart' show motionDuration;
/// Overlay viewer for images, GIFs and videos.
///
@@ -50,9 +51,19 @@ class MediaViewer extends StatefulWidget {
// Den Hintergrund zeichnet der Viewer selbst (damit er beim Wischen
// ausblenden kann).
barrierColor: Colors.transparent,
transitionDuration: const Duration(milliseconds: 180),
transitionDuration: motionDuration(context, 300),
transitionBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
FadeTransition(
opacity: animation,
child: ScaleTransition(
scale: Tween(begin: 0.9, end: 1.0).animate(CurvedAnimation(
parent: animation,
curve: Curves.easeOutBack,
reverseCurve: Curves.easeIn,
)),
child: child,
),
),
pageBuilder: (context, animation, secondaryAnimation) => MediaViewer(
senderName: senderName,
filename: filename,
@@ -103,7 +103,7 @@ extension _BubbleBuilder on _MessageGroupState {
rows.add(_ReadMarker(pt: pt));
}
if (widget.fullyReadId == event.eventId && !isLast) {
rows.add(_InlineUnreadDivider(pt: pt));
rows.add(_InlineUnreadDivider(key: widget.unreadDividerKey, pt: pt, fading: widget.unreadFading));
}
}
@@ -1,5 +1,15 @@
part of '../message_group.dart';
/// Bereits federnd eingeblendete Nachrichten (begrenzt, älteste fallen raus).
final _poppedMessageIds = <String>{};
void _rememberPopped(String id) {
_poppedMessageIds.add(id);
if (_poppedMessageIds.length > 300) {
_poppedMessageIds.remove(_poppedMessageIds.first);
}
}
String _formatMessageTime(DateTime t) {
final h = t.hour.toString().padLeft(2, '0');
final m = t.minute.toString().padLeft(2, '0');
@@ -22,12 +32,19 @@ class MessageGroup extends StatefulWidget {
/// When non-null and pointing to a non-last event in this group, an inline
/// "Neue Nachrichten" divider is rendered after that event's row.
final String? fullyReadId;
/// Ausblenden des Inline-Trenners + Schlüssel für den Sprung zu ihm.
final bool unreadFading;
final GlobalKey? unreadDividerKey;
/// Jump-to-message (from search): the row matching [jumpEventId] gets
/// [jumpKey] attached (for ensureVisible) and [highlightEventId] is tinted.
final String? jumpEventId;
final GlobalKey? jumpKey;
final String? highlightEventId;
final bool highlightOn;
/// Nachrichten, die nach diesem Zeitpunkt eintreffen (oder gerade
/// gesendet werden), ploppen beim ersten Erscheinen federnd auf. null = aus
/// (z. B. solange der Chat gerade erst geöffnet wird).
final DateTime? popInAfter;
const MessageGroup({
super.key,
@@ -41,10 +58,13 @@ class MessageGroup extends StatefulWidget {
this.showStatusIndicator = false,
this.readEventIds = const {},
this.fullyReadId,
this.unreadFading = false,
this.unreadDividerKey,
this.jumpEventId,
this.jumpKey,
this.highlightEventId,
this.highlightOn = false,
this.popInAfter,
});
@override
@@ -140,7 +160,7 @@ class _MessageGroupState extends State<MessageGroup> {
// Inline unread divider after the first event when it's the fullyRead
// marker and there are more events in this group.
if (widget.fullyReadId == firstEvent.eventId && widget.events.length > 1) {
rows.add(_InlineUnreadDivider(pt: pt));
rows.add(_InlineUnreadDivider(key: widget.unreadDividerKey, pt: pt, fading: widget.unreadFading));
}
// ── Continuation event rows (siblings, same indentation) ──
@@ -159,7 +179,7 @@ class _MessageGroupState extends State<MessageGroup> {
if (widget.readEventIds.contains(event.eventId)) rows.add(_ReadMarker(pt: pt));
// Inline divider after this event if it's the fullyRead and not the last.
if (widget.fullyReadId == event.eventId && j < widget.events.length - 1) {
rows.add(_InlineUnreadDivider(pt: pt));
rows.add(_InlineUnreadDivider(key: widget.unreadDividerKey, pt: pt, fading: widget.unreadFading));
}
}
@@ -301,15 +321,39 @@ class _MessageGroupState extends State<MessageGroup> {
),
);
if (_isMobile) return row;
if (_isMobile) return _popIn(event, row);
// Desktop: per-row hover highlight + actions.
return MouseRegion(
return _popIn(event, MouseRegion(
onEnter: (_) => setState(() => _hoveredEventId = event.eventId),
onExit: (_) => setState(() {
if (_hoveredEventId == event.eventId) _hoveredEventId = null;
}),
child: row,
));
}
// Neu angekommene oder gerade gesendete Nachricht: federnd einblenden.
// Der Schlüssel (Transaktions-ID bleibt beim Senden gleich) hält den
// Zustand der Zeile, die Merkliste verhindert ein zweites Abspielen, falls
// die Gruppe neu aufgebaut wird (z. B. lokale Kopie → Server-Event).
Widget _popIn(Event event, Widget row) {
final id = event.transactionId ?? event.eventId;
final after = widget.popInAfter;
final fresh = after != null &&
!_poppedMessageIds.contains(id) &&
(event.status.isSending ||
(event.originServerTs.isAfter(after) &&
DateTime.now().difference(event.originServerTs) <
const Duration(minutes: 2)));
if (fresh) _rememberPopped(id);
return PopIn(
key: ValueKey('row:$id'),
play: fresh,
alignment: event.senderId == widget.currentUserId
? Alignment.bottomRight
: Alignment.bottomLeft,
child: row,
);
}
@@ -159,7 +159,18 @@ class _ReplyPreview extends StatelessWidget {
// matching WhatsApp/Discord. Falls back to stripped text for m.text.
final (IconData? icon, String body) = _previewFor(replyEvent);
return Padding(
// Antippen springt zur zitierten Nachricht und lässt sie kurz aufleuchten
// (gleicher Mechanismus wie der Sprung aus der Suche).
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => ProviderScope.containerOf(context, listen: false)
.read(pendingJumpEventProvider.notifier)
.state = replyEvent.eventId,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: SpringPress(
pressedScale: 0.97,
child: Padding(
padding: const EdgeInsets.only(bottom: 4),
child: IntrinsicHeight(
child: ClipRRect(
@@ -204,6 +215,9 @@ class _ReplyPreview extends StatelessWidget {
),
),
),
),
),
),
),
);
}
@@ -22,11 +22,15 @@ class _ReactionsRow extends StatelessWidget {
// Group by emoji key → list of senders
final Map<String, List<String>> grouped = {};
final newest = <String, DateTime>{};
for (final r in reactionEvents) {
final key = r.content.tryGetMap<String, dynamic>('m.relates_to')?['key'] as String?;
if (key == null || key.isEmpty) continue;
(grouped[key] ??= []).add(r.senderId);
final ts = r.originServerTs;
if (newest[key] == null || ts.isAfter(newest[key]!)) newest[key] = ts;
}
final now = DateTime.now();
if (grouped.isEmpty) return const SizedBox.shrink();
return Padding(
@@ -37,6 +41,9 @@ class _ReactionsRow extends StatelessWidget {
children: grouped.entries.map((e) {
final myReaction = e.value.contains(currentUserId);
return _ReactionChip(
key: ValueKey(e.key),
// Gerade erst vergeben → federnd aufploppen.
fresh: now.difference(newest[e.key]!) < const Duration(seconds: 4),
emoji: e.key,
count: e.value.length,
mine: myReaction,
@@ -69,8 +76,11 @@ class _ReactionChip extends StatefulWidget {
final bool mine;
final PyramidTheme pt;
final VoidCallback onTap;
final bool fresh;
const _ReactionChip({
super.key,
this.fresh = false,
required this.emoji,
required this.count,
required this.mine,
@@ -88,12 +98,22 @@ class _ReactionChipState extends State<_ReactionChip> {
@override
Widget build(BuildContext context) {
final pt = widget.pt;
return MouseRegion(
return PopIn(
play: widget.fresh,
fromScale: 0.3,
offsetY: 0,
alignment: Alignment.center,
milliseconds: 520,
child: SpringBump(
value: widget.count,
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: SpringPress(
pressedScale: 0.85,
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
@@ -122,6 +142,9 @@ class _ReactionChipState extends State<_ReactionChip> {
],
),
),
),
),
),
),
);
}
+11 -2
View File
@@ -93,11 +93,19 @@ class _InitialBox extends StatelessWidget {
// fullyRead event is in the middle of the group (same sender, <5 min window).
class _InlineUnreadDivider extends StatelessWidget {
final PyramidTheme pt;
const _InlineUnreadDivider({required this.pt});
final bool fading;
const _InlineUnreadDivider({super.key, required this.pt, this.fading = false});
@override
Widget build(BuildContext context) {
return Padding(
// Blendet aus wie der Gruppen-Trenner (vorher blieb dieser „Neue
// Nachrichten“-Trenner mitten in einer Gruppe bis zum erneuten Öffnen
// stehen – Bernd 2026-10-08).
return AnimatedOpacity(
opacity: fading ? 0.0 : 1.0,
duration: const Duration(milliseconds: 700),
curve: Curves.easeOut,
child: Padding(
padding: const EdgeInsets.fromLTRB(0, 8, 0, 4),
child: Row(
children: [
@@ -112,6 +120,7 @@ class _InlineUnreadDivider extends StatelessWidget {
Expanded(child: Divider(color: pt.accent.withAlpha(160), height: 1)),
],
),
),
);
}
}
@@ -44,6 +44,10 @@ class _MessageContent extends StatelessWidget {
final displayEvent = _displayEvent;
final isEdited = displayEvent.eventId != event.eventId;
if (GroupCallNotice.matches(displayEvent)) {
return GroupCallNotice(event: displayEvent);
}
// Still encrypted (decryption pending or failed)
if (displayEvent.type == EventTypes.Encrypted) {
return Padding(
+4 -1
View File
@@ -9,7 +9,8 @@ import 'package:pyramid/features/chat/media_player.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/app_state.dart' show messageStyleProvider;
import 'package:pyramid/core/app_state.dart'
show messageStyleProvider, pendingJumpEventProvider;
import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/media_cache.dart';
@@ -19,8 +20,10 @@ import 'package:flutter_svg/flutter_svg.dart';
import 'package:pyramid/features/chat/document_viewer.dart';
import 'package:pyramid/features/chat/media_viewer.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/widgets/mxc_image.dart';
import 'package:pyramid/widgets/spring_press.dart';
import 'package:url_launcher/url_launcher.dart';
part 'message/message_group_core.dart';
+396 -217
View File
@@ -26,10 +26,19 @@ class _ChatHeader extends ConsumerWidget {
final name = room?.getLocalizedDisplayname() ?? roomId;
final isDm = room?.isDirectChat ?? 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 size = MediaQuery.sizeOf(context);
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(
height: 52,
@@ -42,15 +51,19 @@ class _ChatHeader extends ConsumerWidget {
children: [
// Back button on mobile
if (isMobile) ...[
GestureDetector(
onTap: () => ref.read(activeRoomIdProvider.notifier).state = null,
child: Icon(Icons.arrow_back_rounded, size: 22, color: pt.fg),
PyrIconBtn(
icon: Icon(Icons.arrow_back_rounded, color: pt.fg),
tooltip: 'Zurück',
onPressed: () =>
ref.read(activeRoomIdProvider.notifier).state = null,
),
const SizedBox(width: 12),
const SizedBox(width: 4),
],
// Room icon / avatar
if (isDm && client != null) ...[
_DmHeaderAvatar(room: room, name: name, client: client),
] else if (isGroup && client != null) ...[
_GroupHeaderAvatar(room: room!, client: client, pt: pt),
] else if (!isDm) ...[
Icon(
isEncrypted ? Icons.lock_outline_rounded : Icons.tag_rounded,
@@ -83,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
Text(
name,
@@ -90,7 +125,9 @@ class _ChatHeader extends ConsumerWidget {
color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600),
),
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),
const SizedBox(width: 12),
Expanded(
@@ -105,18 +142,25 @@ class _ChatHeader extends ConsumerWidget {
// Right actions
Row(
children: [
if (isDm) ...[
if (isDm || isGroup) ...[
PyrIconBtn(
size: 28,
icon: const Icon(Icons.phone_rounded),
tooltip: 'Voice call',
onPressed: () => _startCall(ref, room, cam: false),
icon: Icon(Icons.phone_rounded,
color: groupCallRunning ? pt.online : null),
tooltip: groupCallRunning
? 'Gruppenanruf beitreten'
: (isGroup ? 'Gruppenanruf' : 'Anrufen'),
onPressed: () => isGroup
? startOrJoinGroupCall(context, ref, room!)
: _startCall(ref, room, cam: false),
),
PyrIconBtn(
size: 28,
icon: const Icon(Icons.videocam_rounded),
tooltip: 'Video call',
onPressed: () => _startCall(ref, room, cam: true),
tooltip: 'Videoanruf',
onPressed: () => isGroup
? startOrJoinGroupCall(context, ref, room!, video: true)
: _startCall(ref, room, cam: true),
),
if (size.width > 700)
Container(
@@ -127,12 +171,6 @@ class _ChatHeader extends ConsumerWidget {
),
],
if (size.width > 800) ...[
PyrIconBtn(
size: 28,
icon: const Icon(Icons.inbox_rounded),
tooltip: 'Threads',
onPressed: () {},
),
PyrIconBtn(
size: 28,
icon: const Icon(Icons.push_pin_rounded),
@@ -141,19 +179,27 @@ class _ChatHeader extends ConsumerWidget {
onPressed: onTogglePinned,
),
],
PyrIconBtn(
size: 28,
icon: const Icon(Icons.search_rounded),
tooltip: 'Suchen',
onPressed: onOpenSearch,
),
PyrIconBtn(
size: 28,
icon: const Icon(Icons.group_rounded),
tooltip: 'Mitglieder',
active: membersOpen,
onPressed: onToggleMembers,
),
if (isMobile)
_ChatOverflowMenu(
pt: pt,
onSearch: onOpenSearch,
onMembers: onToggleMembers,
)
else ...[
PyrIconBtn(
size: 28,
icon: const Icon(Icons.search_rounded),
tooltip: 'Suchen',
onPressed: onOpenSearch,
),
PyrIconBtn(
size: 28,
icon: const Icon(Icons.group_rounded),
tooltip: 'Mitglieder',
active: membersOpen,
onPressed: onToggleMembers,
),
],
],
),
],
@@ -176,8 +222,10 @@ class _ChatHeader extends ConsumerWidget {
await ref.read(voiceChannelProvider).startCall(
roomName: roomId,
roomDisplayName: room.getLocalizedDisplayname(),
identity: 'user_${DateTime.now().millisecondsSinceEpoch}',
identity: room.client.userID ?? '',
audioOnly: !cam,
// Für das Beitritts-Token (Server prüft den Matrix-Login).
matrixClient: room.client,
);
}
}
@@ -222,6 +270,20 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
Profile? _profile;
String? _bannerMxcUri;
// Breite der Symbolleiste (gemessen nach dem Zeichnen) – eingeklappt reicht
// der Name genau bis davor.
final _actionsKey = GlobalKey();
double _actionsWidth = 140;
void _measureActions() {
final box = _actionsKey.currentContext?.findRenderObject() as RenderBox?;
if (box == null || !box.hasSize) return;
final w = box.size.width;
if ((w - _actionsWidth).abs() > 0.5 && mounted) {
setState(() => _actionsWidth = w);
}
}
String? _statusMsg;
bool _online = false;
@@ -291,6 +353,7 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
@override
Widget build(BuildContext context) {
WidgetsBinding.instance.addPostFrameCallback((_) => _measureActions());
final pt = widget.pt;
final expanded = widget.expanded;
final size = MediaQuery.sizeOf(context);
@@ -361,198 +424,222 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
if (isMobile) ...[
GestureDetector(
onTap: () =>
ref.read(activeRoomIdProvider.notifier).state = null,
child:
Icon(Icons.arrow_back_rounded, size: 22, color: pt.fg),
),
const SizedBox(width: 12),
],
// Klickbarer Profilbereich: Avatar + Name + Details
Expanded(
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.onToggle,
child: Row(
children: [
// Avatar — skaliert mit (rund, wie im Mockup)
AnimatedContainer(
duration: _animDuration,
curve: _animCurve,
width: avatarSize,
height: avatarSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: pt.bg3,
),
clipBehavior: Clip.antiAlias,
child: avatarUrl != null && client != null
? MxcImage(
mxcUri: avatarUrl.toString(),
client: client,
fit: BoxFit.cover,
placeholder: _initialBox(initial, pt),
)
: _initialBox(initial, pt),
),
AnimatedContainer(
duration: _animDuration,
curve: _animCurve,
width: expanded ? 20 : 10,
),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
// Profilbereich: gleitet beim Aufklappen unter die oberste Zeile und
// nutzt dann die volle Breite (vorher teilte er sie mit den Symbolen –
// aufgeklappt wurde der Name zu „p…“ gekürzt).
AnimatedPositioned(
duration: _animDuration,
curve: _animCurve,
left: expanded ? 16 : (isMobile ? 54 : 16),
right: expanded ? 16 : _actionsWidth + (isMobile ? 14 : 24),
top: expanded ? 44 : 0,
bottom: 0,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.onToggle,
child: Row(
children: [
// Avatar — skaliert mit (rund, wie im Mockup)
AnimatedContainer(
duration: _animDuration,
curve: _animCurve,
width: avatarSize,
height: avatarSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: pt.bg3,
),
clipBehavior: Clip.antiAlias,
child: avatarUrl != null && client != null
? MxcImage(
mxcUri: avatarUrl.toString(),
client: client,
fit: BoxFit.cover,
placeholder:
_initialBox(initial, pt, avatarSize),
)
: _initialBox(initial, pt, avatarSize),
),
AnimatedContainer(
duration: _animDuration,
curve: _animCurve,
width: expanded ? 20 : 10,
),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Name skaliert mit; Status blendet daneben ein
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Name skaliert mit; Status blendet daneben ein
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
child: AnimatedDefaultTextStyle(
duration: _animDuration,
curve: _animCurve,
style: TextStyle(
color: pt.fg,
fontSize: expanded ? 24 : 15,
fontWeight: expanded
? FontWeight.w700
: FontWeight.w600,
),
child: Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Flexible(
child: AnimatedDefaultTextStyle(
duration: _animDuration,
curve: _animCurve,
style: TextStyle(
color: pt.fg,
fontSize: expanded ? 24 : 15,
fontWeight: expanded
? FontWeight.w700
: FontWeight.w600,
),
AnimatedOpacity(
opacity: expanded ? 1 : 0,
duration: _animDuration,
child: Padding(
padding:
const EdgeInsets.only(left: 8),
child: Text(
_online ? 'online' : 'offline',
style: TextStyle(
color: _online
? pt.online
: pt.fgMuted,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
child: Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
// Eingeklappt: "zuletzt online vor…",
// aufgeklappt: Handle + Biographie.
AnimatedCrossFade(
AnimatedOpacity(
opacity: expanded ? 1 : 0,
duration: _animDuration,
sizeCurve: _animCurve,
crossFadeState: expanded
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
firstChild: client != null
? _PresenceLine(
userId: _partnerId,
client: client,
pt: pt,
)
: const SizedBox.shrink(),
secondChild: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text(
userId,
style: TextStyle(
color: pt.fgMuted, fontSize: 13),
maxLines: 1,
overflow: TextOverflow.ellipsis,
child: Padding(
padding:
const EdgeInsets.only(left: 8),
child: Text(
_online ? 'online' : 'offline',
style: TextStyle(
color: _online
? pt.online
: pt.fgMuted,
fontSize: 13,
fontWeight: FontWeight.w500,
),
if (_statusMsg != null &&
_statusMsg!.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
_statusMsg!,
style: TextStyle(
color: pt.fgMuted,
fontSize: 13,
height: 1.35),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
),
],
),
),
],
// Eingeklappt: "zuletzt online vor…",
// aufgeklappt: Handle + Biographie.
AnimatedCrossFade(
duration: _animDuration,
sizeCurve: _animCurve,
crossFadeState: expanded
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
firstChild: client != null
? _PresenceLine(
userId: _partnerId,
client: client,
pt: pt,
)
: const SizedBox.shrink(),
secondChild: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text(
userId,
style: TextStyle(
color: pt.fgMuted, fontSize: 13),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (_statusMsg != null &&
_statusMsg!.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
_statusMsg!,
style: TextStyle(
color: pt.fgMuted,
fontSize: 13,
height: 1.35),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
],
),
),
),
],
),
),
),
),
// Zurück-Pfeil und Aktionen: fest in der obersten 52-px-Zeile –
// sie rutschen beim Aufklappen nicht mehr in die Mitte.
if (isMobile)
Positioned(
left: 6,
top: 0,
height: 52,
child: Center(
child: PyrIconBtn(
icon: Icon(Icons.arrow_back_rounded, color: pt.fg),
tooltip: 'Zurück',
onPressed: () =>
ref.read(activeRoomIdProvider.notifier).state = null,
),
const SizedBox(width: 12),
// Aktionen — bleiben immer sichtbar
Row(
),
),
Positioned(
right: isMobile ? 6 : 16,
top: 0,
height: 52,
child: KeyedSubtree(
key: _actionsKey,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
PyrIconBtn(
size: 28,
icon: const Icon(Icons.phone_rounded),
tooltip: 'Voice call',
onPressed: () => _startCall(cam: false),
),
PyrIconBtn(
size: 28,
icon: const Icon(Icons.videocam_rounded),
tooltip: 'Video call',
onPressed: () => _startCall(cam: true),
),
if (size.width > 700)
Container(
width: 1,
height: 20,
margin: const EdgeInsets.symmetric(horizontal: 4),
color: pt.border,
),
if (size.width > 800)
PyrIconBtn(
size: 28,
icon: const Icon(Icons.push_pin_rounded),
tooltip: 'Angepinnte Nachrichten',
active: widget.pinnedOpen,
onPressed: widget.onTogglePinned,
),
PyrIconBtn(
size: 28,
icon: const Icon(Icons.search_rounded),
tooltip: 'Suchen',
onPressed: widget.onOpenSearch,
),
PyrIconBtn(
size: 28,
icon: const Icon(Icons.group_rounded),
tooltip: 'Mitglieder',
active: widget.membersOpen,
onPressed: widget.onToggleMembers,
),
],
PyrIconBtn(
size: 28,
icon: const Icon(Icons.phone_rounded),
tooltip: 'Anrufen',
onPressed: () => _startCall(cam: false),
),
PyrIconBtn(
size: 28,
icon: const Icon(Icons.videocam_rounded),
tooltip: 'Videoanruf',
onPressed: () => _startCall(cam: true),
),
if (size.width > 700)
Container(
width: 1,
height: 20,
margin: const EdgeInsets.symmetric(horizontal: 4),
color: pt.border,
),
if (size.width > 800)
PyrIconBtn(
size: 28,
icon: const Icon(Icons.push_pin_rounded),
tooltip: 'Angepinnte Nachrichten',
active: widget.pinnedOpen,
onPressed: widget.onTogglePinned,
),
if (isMobile)
_ChatOverflowMenu(
pt: pt,
onSearch: widget.onOpenSearch,
onMembers: widget.onToggleMembers,
)
else ...[
PyrIconBtn(
size: 28,
icon: const Icon(Icons.search_rounded),
tooltip: 'Suchen',
onPressed: widget.onOpenSearch,
),
PyrIconBtn(
size: 28,
icon: const Icon(Icons.group_rounded),
tooltip: 'Mitglieder',
active: widget.membersOpen,
onPressed: widget.onToggleMembers,
),
],
],
),
),
),
],
@@ -560,15 +647,25 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
);
}
Widget _initialBox(String initial, PyramidTheme pt) => Center(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Padding(
padding: const EdgeInsets.all(6),
child: Text(
initial,
style: TextStyle(
color: pt.fg, fontSize: 28, fontWeight: FontWeight.w700),
// Kürzel exakt mittig: Schrift wächst mit dem Avatar mit (vorher feste
// 28 px in FittedBox mit Innenabstand – wirkte aufgeklappt verschoben).
Widget _initialBox(String initial, PyramidTheme pt, double avatarSize) =>
Center(
child: AnimatedDefaultTextStyle(
duration: _animDuration,
curve: _animCurve,
textAlign: TextAlign.center,
style: TextStyle(
color: pt.fg,
fontSize: avatarSize * 0.42,
fontWeight: FontWeight.w700,
height: 1.0,
),
child: Text(
initial,
textHeightBehavior: const TextHeightBehavior(
applyHeightToFirstAscent: false,
applyHeightToLastDescent: false,
),
),
),
@@ -580,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 {
final Room? room;
final String name;
@@ -733,3 +867,48 @@ class _PresenceLineState extends State<_PresenceLine> {
return 'vor ${(diff.inDays / 365).floor()}j';
}
}
/// Handy: Suche und Mitglieder im ⋮-Menü statt als eigene Symbole
/// (Bernds Feedback 2026-10-07 – mehr Platz, größere Hauptsymbole).
class _ChatOverflowMenu extends StatelessWidget {
final PyramidTheme pt;
final VoidCallback onSearch;
final VoidCallback onMembers;
const _ChatOverflowMenu({
required this.pt,
required this.onSearch,
required this.onMembers,
});
@override
Widget build(BuildContext context) {
PopupMenuItem<VoidCallback> item(
IconData icon, String label, VoidCallback action) =>
PopupMenuItem(
value: action,
child: Row(
children: [
Icon(icon, size: 20, color: pt.fgMuted),
const SizedBox(width: 12),
Text(label, style: TextStyle(color: pt.fg, fontSize: 14)),
],
),
);
return PopupMenuButton<VoidCallback>(
tooltip: 'Mehr',
icon: Icon(Icons.more_vert_rounded, size: 22, color: pt.fgMuted),
color: pt.bg2,
elevation: 6,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rBase),
side: BorderSide(color: pt.border),
),
onSelected: (action) => action(),
itemBuilder: (_) => [
item(Icons.search_rounded, 'Suchen', onSearch),
item(Icons.group_rounded, 'Mitglieder', onMembers),
],
);
}
}
+20 -6
View File
@@ -19,6 +19,13 @@ class _MessageList extends StatelessWidget {
final String? highlightEventId;
final bool highlightOn;
/// Höhe der darüber schwebenden Eingabeleiste: so viel Platz bleibt unten
/// frei, damit die neueste Nachricht ganz über die Leiste scrollen kann.
final double bottomInset;
/// Siehe MessageGroup.popInAfter.
final DateTime? popInAfter;
const _MessageList({
required this.timeline,
required this.scrollCtrl,
@@ -35,6 +42,8 @@ class _MessageList extends StatelessWidget {
this.jumpKey,
this.highlightEventId,
this.highlightOn = false,
this.bottomInset = 0,
this.popInAfter,
});
@override
@@ -56,7 +65,7 @@ class _MessageList extends StatelessWidget {
Icon(Icons.forum_outlined, size: 48, color: pt.fgDim),
const SizedBox(height: 16),
Text(
'No messages yet',
'Noch keine Nachrichten – schreib die erste!',
style: TextStyle(color: pt.fgMuted, fontSize: 14),
),
],
@@ -207,10 +216,11 @@ class _MessageList extends StatelessWidget {
// Pass fullyReadId to the group only when the event is mid-group
// (not the last event) so MessageGroup can render an inline divider.
// groupContainsFullyRead already guarantees fullyReadId != null.
final passedFullyReadId =
(groupContainsFullyRead && group.last.eventId != fullyReadId)
? fullyReadId
: null;
final passedFullyReadId = (hasUnread &&
groupContainsFullyRead &&
group.last.eventId != fullyReadId)
? fullyReadId
: null;
itemFullyReadIds.add(passedFullyReadId);
if (isLastOfDay) {
@@ -241,7 +251,7 @@ class _MessageList extends StatelessWidget {
controller: scrollCtrl,
reverse: true,
physics: const ClampingScrollPhysics(),
padding: const EdgeInsets.only(bottom: 8),
padding: EdgeInsets.only(bottom: 8 + bottomInset),
scrollCacheExtent: const ScrollCacheExtent.pixels(2500),
itemCount: itemKeys.length,
findChildIndexCallback: (Key key) {
@@ -283,10 +293,14 @@ class _MessageList extends StatelessWidget {
showStatusIndicator: showStatus,
readEventIds: readEventIds,
fullyReadId: itemFullyReadIds[i],
unreadFading: unreadFading,
unreadDividerKey:
itemFullyReadIds[i] != null ? unreadDividerKey : null,
jumpEventId: jumpEventId,
jumpKey: jumpKey,
highlightEventId: highlightEventId,
highlightOn: highlightOn,
popInAfter: popInAfter,
);
},
);
+230 -1
View File
@@ -120,7 +120,7 @@ class NoChatSelected extends StatelessWidget {
),
const SizedBox(height: 8),
Text(
'Choose from the sidebar on the left',
'Links findest du alle Chats und Räume.',
style: TextStyle(color: pt.fgDim, fontSize: 13),
),
],
@@ -129,3 +129,232 @@ class NoChatSelected extends StatelessWidget {
);
}
}
/// Fläche der schwebenden Eingabeleiste: um das Eingabefeld herum völlig
/// klar – der Chat ist dort normal zu sehen –, fängt aber alle Berührungen
/// ab (ColoredBox ist auch transparent „opak“ für Treffer), damit dort nur
/// die Eingabe reagiert und nicht der Chat dahinter. Die Unschärfe sitzt nur
/// hinter dem Eingabefeld selbst (chat_composer.dart).
class _FloatingComposerArea extends StatelessWidget {
final PyramidTheme pt;
final Widget child;
const _FloatingComposerArea({required this.pt, required this.child});
@override
Widget build(BuildContext context) {
return ColoredBox(
color: Colors.transparent,
child: Padding(
padding: const EdgeInsets.only(top: 6),
child: child,
),
);
}
}
/// Einladung in diesen Chat (z. B. aus einer Benachrichtigung geöffnet):
/// Annehmen/Ablehnen direkt hier statt eines leeren Verlaufs – vorher ging
/// das nur in der Chatliste (Bernd 2026-10-07).
class _InvitePanel extends ConsumerStatefulWidget {
final Room room;
final PyramidTheme pt;
const _InvitePanel({required this.room, required this.pt});
@override
ConsumerState<_InvitePanel> createState() => _InvitePanelState();
}
class _InvitePanelState extends ConsumerState<_InvitePanel> {
bool _busy = false;
String? _error;
String get _inviter {
final me = widget.room.client.userID;
final sender = me == null
? null
: widget.room.getState(EventTypes.RoomMember, me)?.senderId;
if (sender == null) return 'Jemand';
return widget.room.unsafeGetUserFromMemoryOrFallback(sender).calcDisplayname();
}
Future<void> _accept() async {
setState(() {
_busy = true;
_error = null;
});
try {
await acceptInvite(ref, widget.room);
} catch (_) {
if (mounted) {
setState(() {
_busy = false;
_error = 'Annehmen hat nicht geklappt – bitte noch einmal versuchen.';
});
}
}
}
Future<void> _decline() async {
setState(() {
_busy = true;
_error = null;
});
try {
await widget.room.leave();
if (mounted) ref.read(activeRoomIdProvider.notifier).state = null;
} catch (_) {
if (mounted) {
setState(() {
_busy = false;
_error = 'Ablehnen hat nicht geklappt – bitte noch einmal versuchen.';
});
}
}
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final isDm = widget.room.isDirectChat;
return Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(28),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isDm ? Icons.person_add_alt_1_rounded : Icons.group_add_rounded,
size: 48,
color: pt.accent,
),
const SizedBox(height: 16),
Text(
isDm
? '$_inviter möchte mit dir chatten'
: '$_inviter lädt dich in „${widget.room.getLocalizedDisplayname()}“ ein',
textAlign: TextAlign.center,
style: TextStyle(
color: pt.fg,
fontSize: 17,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
'Nach dem Annehmen siehst du die Nachrichten und kannst antworten.',
textAlign: TextAlign.center,
style: TextStyle(color: pt.fgMuted, fontSize: 13.5),
),
const SizedBox(height: 24),
if (_busy)
SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(strokeWidth: 2.5, color: pt.accent),
)
else
Wrap(
spacing: 12,
runSpacing: 12,
alignment: WrapAlignment.center,
children: [
OutlinedButton(
onPressed: _decline,
style: OutlinedButton.styleFrom(
foregroundColor: pt.fgMuted,
side: BorderSide(color: pt.border),
padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 14),
),
child: const Text('Ablehnen'),
),
FilledButton(
onPressed: _accept,
style: FilledButton.styleFrom(
backgroundColor: pt.accent,
foregroundColor: pt.accentFg,
padding: const EdgeInsets.symmetric(horizontal: 26, vertical: 14),
),
child: const Text('Annehmen'),
),
],
),
if (_error != null) ...[
const SizedBox(height: 14),
Text(_error!,
textAlign: TextAlign.center,
style: TextStyle(color: pt.danger, fontSize: 13)),
],
],
),
),
);
}
}
/// Handy: Läuft ein Anruf, während ein Chat offen ist, zeigt diese Leiste den
/// Weg zurück (am PC übernimmt das die Anrufleiste in der Seitenleiste).
/// Vorher war im Chat nirgends zu sehen, dass/wie man zurück in den Anruf
/// kommt (Bernd 2026-10-07).
class _ActiveCallBar extends ConsumerWidget {
const _ActiveCallBar();
@override
Widget build(BuildContext context, WidgetRef ref) {
if (!isMobileLayout(MediaQuery.sizeOf(context))) return const SizedBox.shrink();
final voip = ref.watch(callSignalingProvider);
final lk = ref.watch(voiceChannelProvider);
final voipCall = voip.currentCall;
final String? name;
// Klingeln zählt nicht als „läuft“ – dafür gibt es das Annehmen-Fenster.
if (voipCall != null &&
voipCall.state != CallState.kEnded &&
voipCall.state != CallState.kRinging) {
name = voipCall.room.getLocalizedDisplayname();
} else if (lk.isActive || lk.isConnecting) {
name = lk.displayName;
} else {
name = null;
}
final pt = PyramidTheme.of(context);
return AnimatedSize(
duration: motionDuration(context, 220),
curve: Curves.easeOutCubic,
child: name == null
? const SizedBox(width: double.infinity)
: Material(
color: pt.online.withValues(alpha: 0.16),
child: InkWell(
onTap: () =>
ref.read(viewModeProvider.notifier).state = ViewMode.voice,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
children: [
Icon(Icons.call_rounded, size: 18, color: pt.online),
const SizedBox(width: 10),
Expanded(
child: Text(
'Anruf läuft · $name',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: pt.fg,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
Text('Zurück',
style: TextStyle(
color: pt.online,
fontSize: 13.5,
fontWeight: FontWeight.w700)),
Icon(Icons.chevron_right_rounded, size: 20, color: pt.online),
],
),
),
),
),
);
}
}
+217 -43
View File
@@ -28,9 +28,25 @@ class _ChatViewState extends ConsumerState<ChatView> {
// Fade-out of the "Neue Nachrichten" divider once the user reaches the bottom.
bool _unreadFading = false;
Timer? _unreadFadeTimer;
// So lange bleibt der Trenner stehen, nachdem man bei den neuesten
// Nachrichten angekommen ist (Bernd 2026-10-07: verschwand beim Öffnen aus
// der Benachrichtigung sofort).
static const _unreadDwell = Duration(seconds: 6);
Timer? _unreadDwellTimer;
// Zuletzt von hier als gelesen gemeldetes Event. Verhindert, dass jeder
// Neuaufbau erneut eine Lesebestätigung schickt, und markiert nach dem
// Zurückkehren in die App, ab wo „Neue Nachrichten“ beginnen.
String? _lastMarkedEventId;
// Was danach eintrifft, ploppt federnd auf (beim Öffnen gesetzt, damit die
// schon vorhandenen Nachrichten ruhig erscheinen).
DateTime? _popInAfter;
// Jump-to-message (from search): scroll to + briefly highlight a target event.
final _jumpKey = GlobalKey();
// Höhe der schwebenden Eingabeleiste (gemessen, wächst mit Antwort-Leiste
// oder mehrzeiligem Text).
double _composerHeight = 72;
String? _jumpEventId;
String? _highlightEventId;
bool _highlightOn = false;
@@ -77,11 +93,25 @@ class _ChatViewState extends ConsumerState<ChatView> {
setState(() { _jumpInProgress = false; _highlightEventId = null; _jumpEventId = null; _highlightOn = false; });
return;
}
// Not built yet — load older history toward the target every few tries.
// Not built yet. Die Liste baut nur sichtbare Einträge (lazy, reverse):
// Ist das Ziel schon geladen, Richtung ältere Nachrichten scrollen, bis
// es gezeichnet ist (Antwort-Zitate zeigen fast immer nach oben); sonst
// zusätzlich ältere Historie nachladen.
final timeline = ref.read(timelineProvider(widget.roomId)).valueOrNull;
if (timeline != null && timeline.canRequestHistory && attempt % 3 == 0) {
final loaded =
timeline?.events.any((e) => e.eventId == _jumpEventId) ?? false;
if (!loaded &&
timeline != null &&
timeline.canRequestHistory &&
attempt % 3 == 0) {
timeline.requestHistory(historyCount: 100).catchError((_) {});
}
if (_scrollCtrl.hasClients && (loaded || attempt % 3 == 1)) {
final pos = _scrollCtrl.position;
final step = pos.viewportDimension * 0.8;
final target = (pos.pixels + step).clamp(0.0, pos.maxScrollExtent);
if (target > pos.pixels) _scrollCtrl.jumpTo(target);
}
WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 120), () => _attemptJump(attempt + 1));
});
@@ -93,6 +123,9 @@ class _ChatViewState extends ConsumerState<ChatView> {
bool get _inSelectionMode => _selectedIds.isNotEmpty;
double get _scrollBtnSize =>
isMobileLayout(MediaQuery.sizeOf(context)) ? 44 : 36;
void _toggleSelect(String eventId) {
setState(() {
if (_selectedIds.contains(eventId)) {
@@ -109,12 +142,22 @@ class _ChatViewState extends ConsumerState<ChatView> {
void initState() {
super.initState();
_scrollCtrl.addListener(_onScroll);
// App/Fenster wieder im Vordergrund: was inzwischen kam, jetzt als
// gelesen melden – mit „Neue Nachrichten“-Trenner davor.
ref.listenManual<bool>(windowFocusedProvider, (prev, focused) {
if (focused && prev == false) _onFocusRegained();
});
}
@override
void didUpdateWidget(ChatView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.roomId != widget.roomId) {
// Antwort/Auswahl gehören zum alten Raum – sonst ginge eine Antwort
// auf eine Nachricht aus Raum A in Raum B raus.
_replyTo = null;
_selectedIds.clear();
_showScrollDown = false;
_profileExpanded = false;
_sessionFullyReadId = null;
_sessionHasUnread = false;
@@ -124,6 +167,9 @@ class _ChatViewState extends ConsumerState<ChatView> {
_historyExhausted = false;
_unreadFading = false;
_unreadFadeTimer?.cancel();
_unreadDwellTimer?.cancel();
_lastMarkedEventId = null;
_popInAfter = null;
_jumpEventId = null;
_highlightEventId = null;
_highlightOn = false;
@@ -137,6 +183,7 @@ class _ChatViewState extends ConsumerState<ChatView> {
@override
void dispose() {
_unreadFadeTimer?.cancel();
_unreadDwellTimer?.cancel();
_highlightTimer?.cancel();
_scrollCtrl.dispose();
super.dispose();
@@ -174,6 +221,7 @@ class _ChatViewState extends ConsumerState<ChatView> {
setState(() {
_sessionFullyReadId = newFullyReadId;
_sessionHasUnread = newHasUnread;
_popInAfter = events.firstOrNull?.originServerTs ?? DateTime.now();
});
if (newHasUnread) {
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToUnread(timeline));
@@ -206,10 +254,12 @@ class _ChatViewState extends ConsumerState<ChatView> {
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
);
_scheduleUnreadFadeIfAtNewest();
return;
}
if (attempt >= 24) {
_didScrollToUnread = true; // give up — stay at the newest messages
_scheduleUnreadFadeIfAtNewest();
return;
}
if (attempt > 0 && attempt % 4 == 0 && _scrollCtrl.hasClients) {
@@ -224,21 +274,100 @@ class _ChatViewState extends ConsumerState<ChatView> {
}
void _markRead([Timeline? tl]) {
// Nur als gelesen melden, was wirklich zu sehen ist: Läuft die App im
// Hintergrund (oder hat das Fenster keinen Fokus), bleibt alles ungelesen
// und die Benachrichtigung stehen – vorher wurde sie beim nächsten
// Neuaufbau sofort wieder entfernt und die Nachricht als gelesen gemeldet.
if (!ref.read(windowFocusedProvider)) return;
// Cancel any pending notification for this room (user is now reading it).
cancelNotificationForRoom(widget.roomId);
final sendReceipts = ref.read(privacyReadReceiptsProvider);
if (!sendReceipts) return;
final room = ref.read(roomProvider(widget.roomId));
if (room == null) return;
if (room == null || room.membership != Membership.join) return;
if (room.markedUnread) room.markUnread(false).catchError((_) {});
final timeline = tl ?? ref.read(timelineProvider(widget.roomId)).valueOrNull;
final latest = timeline?.events.firstOrNull;
if (latest != null && latest.status.isSynced) {
room.setReadMarker(latest.eventId, mRead: latest.eventId)
.catchError((_) {});
} else {
room.markUnread(false).catchError((_) {});
final latest = timeline?.events
.where((e) => e.status.isSynced)
.firstOrNull;
if (latest == null || latest.eventId == _lastMarkedEventId) return;
_lastMarkedEventId = latest.eventId;
markRoomReadUpTo(
room,
latest.eventId,
publicReceipt: ref.read(privacyReadReceiptsProvider),
).catchError((_) {
// Beim nächsten Anlass erneut versuchen.
if (_lastMarkedEventId == latest.eventId) _lastMarkedEventId = null;
});
}
/// Zurück in der App mit diesem Chat offen: Kamen inzwischen Nachrichten
/// von anderen, erscheint der „Neue Nachrichten“-Trenner hinter dem zuletzt
/// gelesenen Stand; danach wird bis zur neuesten Nachricht gelesen gemeldet.
void _onFocusRegained() {
if (!mounted || !_sessionCaptured) return;
final timeline = ref.read(timelineProvider(widget.roomId)).valueOrNull;
if (timeline == null) return;
final since = _lastMarkedEventId;
if (since != null && !_sessionHasUnread) {
final me = timeline.room.client.userID;
final events = timeline.events; // newest-first
final idx = events.indexWhere((e) => e.eventId == since);
final hasNew = idx > 0 &&
events
.take(idx)
.any((e) => _MessageList._isVisible(e) && e.senderId != me);
if (hasNew) {
_unreadFadeTimer?.cancel();
_unreadDwellTimer?.cancel();
setState(() {
_sessionFullyReadId = since;
_sessionHasUnread = true;
_unreadFading = false;
});
}
}
if (_sessionHasUnread) _scheduleUnreadFadeIfAtNewest();
_markRead(timeline);
}
// Neueste Nachrichten sind zu sehen: höchstens eine halbe Bildschirmhöhe
// hochgescrollt. (Nach dem Sprung zum Trenner steht die Liste meist ein
// Stück oberhalb des Endes – mit der strengen 60-px-Grenze blieb der
// Trenner dann für immer stehen.)
bool get _atNewest {
if (!_scrollCtrl.hasClients) return true;
final pos = _scrollCtrl.position;
return pos.pixels < 60 || pos.pixels < pos.viewportDimension * 0.5;
}
/// „Neue Nachrichten“-Trenner nach [_unreadDwell] weich ausblenden – nur
/// wenn man dann noch bei den neuesten Nachrichten ist. Wer zwischendurch
/// hochscrollt, behält ihn; unten angekommen beginnt die Wartezeit neu.
void _scheduleUnreadFade() {
if (!_sessionHasUnread || _unreadFading) return;
if (_unreadDwellTimer?.isActive ?? false) return;
_unreadDwellTimer = Timer(_unreadDwell, () {
if (!mounted || !_sessionHasUnread) return;
// App im Hintergrund oder weggescrollt: stehen lassen, beim nächsten
// Scrollen/Zurückkommen beginnt die Wartezeit neu.
if (!_atNewest || !ref.read(windowFocusedProvider)) return;
setState(() => _unreadFading = true);
_unreadFadeTimer?.cancel();
_unreadFadeTimer = Timer(const Duration(milliseconds: 900), () {
if (mounted) setState(() { _sessionHasUnread = false; _unreadFading = false; });
});
});
}
// Nach dem Sprung zum Trenner (Animation ~250 ms) prüfen: Passt alles
// Ungelesene auf den Bildschirm, scrollt niemand mehr – dann läuft die
// Verweildauer trotzdem an.
void _scheduleUnreadFadeIfAtNewest() {
Future.delayed(const Duration(milliseconds: 400), () {
if (mounted && _atNewest) _scheduleUnreadFade();
});
}
Future<void> _onScroll() async {
@@ -246,18 +375,10 @@ class _ChatViewState extends ConsumerState<ChatView> {
final pixels = _scrollCtrl.position.pixels;
final maxExtent = _scrollCtrl.position.maxScrollExtent;
if (pixels < 60) {
_markRead(null);
// Reached the newest messages → the "Neue Nachrichten" divider is no
// longer useful. Fade it out, then remove it.
if (_sessionHasUnread && !_unreadFading) {
setState(() => _unreadFading = true);
_unreadFadeTimer?.cancel();
_unreadFadeTimer = Timer(const Duration(milliseconds: 900), () {
if (mounted) setState(() { _sessionHasUnread = false; _unreadFading = false; });
});
}
}
if (pixels < 60) _markRead(null);
// Bei den neuesten Nachrichten angekommen → Trenner nach einer
// Verweildauer ausblenden.
if (_atNewest) _scheduleUnreadFade();
final shouldShow = pixels > 400;
if (shouldShow != _showScrollDown) {
setState(() => _showScrollDown = shouldShow);
@@ -313,7 +434,9 @@ class _ChatViewState extends ConsumerState<ChatView> {
// listen) so it also fires when this ChatView is freshly mounted for a
// DIFFERENT room — listen would miss a value set before registration.
final pendingJump = ref.watch(pendingJumpEventProvider);
if (pendingJump != null && !_jumpInProgress && pendingJump != _jumpEventId) {
// (Kein Vergleich mit _jumpEventId mehr: dasselbe Zitat darf zweimal
// hintereinander angetippt werden – _startJump verbraucht die Anfrage.)
if (pendingJump != null && !_jumpInProgress) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted &&
!_jumpInProgress &&
@@ -324,6 +447,9 @@ class _ChatViewState extends ConsumerState<ChatView> {
}
final isDesktop = Platform.isWindows || Platform.isMacOS || Platform.isLinux;
// Noch nicht beigetreten (Einladung): statt eines leeren Verlaufs gleich
// hier Annehmen/Ablehnen anbieten.
final isInvite = room?.membership == Membership.invite;
final body = Stack(
fit: StackFit.expand,
children: [
@@ -394,8 +520,13 @@ class _ChatViewState extends ConsumerState<ChatView> {
setState(() => _pinnedOpen = !_pinnedOpen),
),
const _ConnectionBanner(),
const _ActiveCallBar(),
if (room != null && !isInvite)
GroupCallBanner(room: room),
Expanded(
child: Stack(
child: isInvite
? _InvitePanel(room: room!, pt: pt)
: Stack(
children: [
timelineAsync.when(
loading: () => Center(
@@ -406,7 +537,7 @@ class _ChatViewState extends ConsumerState<ChatView> {
),
error: (e, _) => Center(
child: Text(
'Could not load messages',
'Nachrichten konnten nicht geladen werden.',
style: TextStyle(color: pt.fgDim, fontSize: 14),
),
),
@@ -429,28 +560,36 @@ class _ChatViewState extends ConsumerState<ChatView> {
jumpKey: _jumpKey,
highlightEventId: _highlightEventId,
highlightOn: _highlightOn,
bottomInset: room != null ? _composerHeight : 0,
popInAfter: _popInAfter,
);
},
),
if (_showScrollDown)
Positioned(
Positioned(
right: 16,
bottom: 12,
child: AnimatedOpacity(
opacity: _showScrollDown ? 1 : 0,
duration: const Duration(milliseconds: 150),
bottom: (room != null ? _composerHeight : 0) + 12,
child: IgnorePointer(
ignoring: !_showScrollDown,
child: AnimatedScale(
scale: _showScrollDown ? 1 : 0,
duration: motionDuration(
context, _showScrollDown ? 520 : 180),
curve: _showScrollDown
? Curves.elasticOut
: Curves.easeInBack,
child: GestureDetector(
onTap: () => _scrollCtrl.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
),
child: SpringPress(
child: Container(
width: 36,
height: 36,
width: _scrollBtnSize,
height: _scrollBtnSize,
decoration: BoxDecoration(
color: pt.bg0,
borderRadius: BorderRadius.circular(18),
borderRadius: BorderRadius.circular(_scrollBtnSize / 2),
border: Border.all(color: pt.border),
boxShadow: [
BoxShadow(
@@ -460,7 +599,49 @@ class _ChatViewState extends ConsumerState<ChatView> {
),
],
),
child: Icon(Icons.keyboard_arrow_down_rounded, size: 20, color: pt.fgMuted),
child: Icon(Icons.keyboard_arrow_down_rounded,
size: _scrollBtnSize * 0.56, color: pt.fgMuted),
),
),
),
),
),
),
// Schwebende Eingabeleiste (Bernds Wunsch 2026-10-07):
// der Chat läuft optisch darunter weiter
// (durchscheinend + Unschärfe), die ganze Fläche
// fängt aber Berührungen ab – dort reagiert nur
// die Eingabe, nicht der Chat dahinter.
if (room != null)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: MeasureSize(
onChange: (size) {
if (mounted &&
(size.height - _composerHeight).abs() >
0.5) {
setState(
() => _composerHeight = size.height);
}
},
child: _FloatingComposerArea(
pt: pt,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_TypingIndicator(room: room, pt: pt),
ChatComposer(
// Je Raum ein eigenes Eingabefeld
// (Text, „tippt…“, Entwurf).
key: ValueKey('composer:${room.id}'),
room: room,
replyTo: _replyTo,
onClearReply: () =>
setState(() => _replyTo = null),
),
],
),
),
),
@@ -468,13 +649,6 @@ class _ChatViewState extends ConsumerState<ChatView> {
],
),
),
if (room != null) _TypingIndicator(room: room, pt: pt),
if (room != null)
ChatComposer(
room: room,
replyTo: _replyTo,
onClearReply: () => setState(() => _replyTo = null),
),
],
),
),
+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:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/widgets/mxc_image.dart';
import 'package:pyramid/widgets/pyramid_logo.dart';
class MembersPanel extends ConsumerWidget {
@@ -94,7 +95,7 @@ class MembersPanel extends ConsumerWidget {
child: Padding(
padding: const EdgeInsets.only(top: 24),
child: Text(
'No members loaded',
'Keine Mitglieder geladen',
style: TextStyle(color: pt.fgDim, fontSize: 12),
),
),
@@ -177,19 +178,22 @@ class _MemberItemState extends State<_MemberItem> {
opacity: widget.isOffline ? 0.5 : 1,
child: Stack(
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(pt.rSm),
),
child: Center(
// Rund wie überall sonst, mit echtem Profilbild.
MxcAvatar(
mxcUri: member.avatarUrl,
client: member.room.client,
size: 32,
borderRadius: BorderRadius.circular(16),
placeholder: (_) => Container(
width: 32,
height: 32,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
alignment: Alignment.center,
child: Text(
initial,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
@@ -214,7 +218,7 @@ class _MemberItemState extends State<_MemberItem> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
member.id == member.room.client.userID ? '$name (Du)' : name,
style: TextStyle(
color: widget.isOffline ? pt.fgMuted : pt.fg,
fontSize: 13,
+30 -20
View File
@@ -15,6 +15,7 @@ class _Header extends ConsumerWidget {
final isRealSpace = spaceId != null && spaceId != 'dms' && spaceId != 'rooms';
final size = MediaQuery.sizeOf(context);
final isMobile = size.width < 900 || size.height < 500;
final bottomNav = ref.watch(bottomNavProvider);
final spaces = ref.watch(spacesProvider).valueOrNull ?? [];
Room? activeSpace;
@@ -29,7 +30,7 @@ class _Header extends ConsumerWidget {
final name = isRealSpace
? (activeSpace?.getLocalizedDisplayname() ?? 'Space')
: (spaceId == 'dms' ? 'Chats' : 'Räume');
: 'Chats';
void handleMenu(_SpaceMenuItem item) {
switch (item) {
@@ -101,7 +102,7 @@ class _Header extends ConsumerWidget {
).then((confirmed) {
if (confirmed == true) {
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() {
if (spaceId == 'dms' || spaceId == null) {
if (!isRealSpace) {
return [
PopupMenuItem(
value: _SpaceMenuItem.newDm,
@@ -119,15 +120,10 @@ class _Header extends ConsumerWidget {
PopupMenuItem(
value: _SpaceMenuItem.createGroup,
child: _MenuRow(Icons.group_add_rounded, 'Gruppe erstellen', pt)),
];
} else if (spaceId == 'rooms') {
return [
PopupMenuItem(
value: _SpaceMenuItem.createRoom,
child: _MenuRow(Icons.add_rounded, 'Raum erstellen', pt)),
const PopupMenuDivider(),
PopupMenuItem(
value: _SpaceMenuItem.joinRoom,
child: _MenuRow(Icons.login_rounded, 'Beitreten', pt)),
child: _MenuRow(Icons.login_rounded, 'Raum beitreten', pt)),
PopupMenuItem(
value: _SpaceMenuItem.discoverRooms,
child: _MenuRow(Icons.explore_rounded, 'Entdecken', pt)),
@@ -167,7 +163,18 @@ class _Header extends ConsumerWidget {
Widget controlsRow = Row(
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(
icon: const Icon(Icons.menu_rounded),
tooltip: 'Spaces',
@@ -176,7 +183,7 @@ class _Header extends ConsumerWidget {
else ...[
PyrIconBtn(
icon: const Icon(Icons.menu_rounded),
tooltip: 'Toggle sidebar',
tooltip: 'Seitenleiste ein/aus',
onPressed: () =>
ref.read(railCollapsedProvider.notifier).update((s) => !s),
),
@@ -316,7 +323,7 @@ class _Header extends ConsumerWidget {
height: isRealSpace ? 56 : 52,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: isRealSpace ? pt.bg0 : pt.bg1,
color: isRealSpace && !isMobile ? pt.bg0 : pt.bg1,
border: Border(bottom: BorderSide(color: pt.border)),
),
child: controlsRow,
@@ -458,9 +465,13 @@ class _NotificationsButton extends ConsumerWidget {
class _FilterInput extends StatelessWidget {
final TextEditingController controller;
final PyramidTheme pt;
final String hint;
final ValueChanged<String> onChanged;
const _FilterInput(
{required this.controller, required this.pt, required this.onChanged});
{required this.controller,
required this.pt,
required this.hint,
required this.onChanged});
@override
Widget build(BuildContext context) {
@@ -469,18 +480,17 @@ class _FilterInput extends StatelessWidget {
child: Stack(
alignment: Alignment.centerLeft,
children: [
Padding(
padding: const EdgeInsets.only(left: 8),
child: Icon(Icons.search_rounded, size: 14, color: pt.fgDim),
),
TextField(
controller: controller,
onChanged: onChanged,
style: TextStyle(color: pt.fg, fontSize: 13),
decoration: InputDecoration(
hintText: 'Chats durchsuchen',
hintText: hint,
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,
fillColor: pt.bg2,
border: OutlineInputBorder(
@@ -16,7 +16,7 @@ class _InviteItemState extends ConsumerState<_InviteItem> {
Future<void> _accept() async {
setState(() => _accepting = true);
try {
await widget.room.join();
await acceptInvite(ref, widget.room);
if (mounted) {
ref.read(activeRoomIdProvider.notifier).state = widget.room.id;
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
+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 {
final List<Room> rooms;
final String filter;
final String? spaceId;
final Map<String, bool> collapsed;
final PyramidTheme pt;
final ValueChanged<String> onToggleSection;
const _RoomsList({
required this.rooms,
required this.filter,
required this.spaceId,
required this.collapsed,
required this.pt,
required this.onToggleSection,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
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
.where((r) =>
r.membership == Membership.invite &&
isDms == r.isDirectChat)
.toList();
final invites =
rooms.where((r) => r.membership == Membership.invite).toList();
final filtered = rooms.where((r) {
if (r.membership == Membership.invite) return false;
if (isDms != r.isDirectChat) return false;
if (filter.isEmpty) return true;
return r.getLocalizedDisplayname()
.toLowerCase()
.contains(filter.toLowerCase());
}).toList();
if (filtered.isEmpty && invites.isEmpty) {
return Center(
child:
Text(isDms ? 'Noch keine Chats' : 'Keine Räume', style: TextStyle(color: pt.fgDim, fontSize: 13)));
bool matches(Room r) {
switch (chatFilter) {
case _ChatFilter.all:
break;
case _ChatFilter.unread:
if (!r.isUnread) return false;
case _ChatFilter.people:
if (!r.isDirectChat) return false;
case _ChatFilter.groups:
if (r.isDirectChat) return false;
}
if (query.isEmpty) return true;
return r.getLocalizedDisplayname().toLowerCase().contains(query);
}
final sectionTitle = isDms ? 'Direktnachrichten' : 'Räume';
final sectionId = isDms ? 'dms' : 'rooms';
final isCollapsed = collapsed[sectionId] ?? false;
final chats = rooms
.where((r) =>
r.membership == Membership.join && isChatListRoom(r, channels))
.toList();
final filtered = chats.where(matches).toList();
final unreadCount = chats.where((r) => r.isUnread).length;
return ListView(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 12),
padding: const EdgeInsets.fromLTRB(8, 0, 8, 12),
children: [
_ChatFilterChips(
value: chatFilter,
unreadCount: unreadCount,
pt: pt,
onChanged: (f) => ref.read(_chatFilterProvider.notifier).state = f,
),
if (invites.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 8, 2),
@@ -124,22 +134,18 @@ class _RoomsList extends ConsumerWidget {
child: _InviteItem(room: r, pt: pt))),
const SizedBox(height: 4),
],
_SectionHeader(
title: sectionTitle,
count: filtered.length,
isCollapsed: isCollapsed,
pt: pt,
onToggle: () => onToggleSection(sectionId),
onAddTap: isDms
? () => showDialog(
context: context, builder: (_) => const NewDmDialog())
: () => showDialog(
context: context, builder: (_) => const CreateJoinDialog()),
),
if (!isCollapsed)
if (filtered.isEmpty)
_ChatListEmpty(
filter: chatFilter,
searching: query.isNotEmpty,
hasInvites: invites.isNotEmpty,
pt: pt,
)
else
...filtered.map((room) {
final isVoice = _isVoiceRoom(room);
return _RoomItem(
key: ValueKey('chat:${room.id}'),
room: room,
isActive: isVoice ? false : activeRoomId == room.id,
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
/// Verhalten von `joinVoiceChannel` in rooms_space_list.dart (Toggle beim
/// 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 {
final Room room;
final bool isActive;
@@ -513,6 +580,19 @@ class _RoomItemState extends ConsumerState<_RoomItem> {
final voiceParticipants = isVoice ? ref.watch(voiceParticipantsProvider(room.id)) : <Map<String, dynamic>>[];
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).
if (!isVoice && isMobileLayout(MediaQuery.sizeOf(context))) {
return _MobileRoomTile(
@@ -574,6 +654,11 @@ class _RoomItemState extends ConsumerState<_RoomItem> {
children: [
if (isDm) ...[
_DmAvatar(room: room, pt: pt),
] else if (!isVoice &&
!ref
.watch(spaceChannelIdsProvider)
.contains(room.id)) ...[
_RoomTileAvatar(room: room, pt: pt, size: 22),
] else ...[
SizedBox(
width: 16,
+130 -15
View File
@@ -95,10 +95,18 @@ class _MobileRoomTile extends StatelessWidget {
),
),
),
if (unread > 0) ...[
const SizedBox(width: 8),
_UnreadBadge(count: unread, isMention: mention, pt: pt),
],
// Immer im Baum, damit der Zähler beim Erscheinen und
// Hochzählen federnd aufploppen kann.
SpringBump(
value: unread,
child: unread > 0
? Padding(
padding: const EdgeInsets.only(left: 8),
child: _UnreadBadge(
count: unread, isMention: mention, pt: pt),
)
: const SizedBox.shrink(),
),
],
),
],
@@ -111,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 {
final Room room;
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
Widget build(BuildContext context, WidgetRef ref) {
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(
width: _kTileAvatar,
height: _kTileAvatar,
width: size,
height: size,
decoration: BoxDecoration(
color: pt.bg3,
borderRadius: BorderRadius.circular(14),
color: isChannel ? pt.bg3 : pt.accentSoft,
borderRadius: radius,
),
child: Icon(
room.encrypted ? Icons.lock_outline_rounded : Icons.tag_rounded,
size: 22,
color: pt.fgMuted,
isChannel
? (room.encrypted ? Icons.lock_outline_rounded : Icons.tag_rounded)
: Icons.groups_rounded,
size: size * 0.5,
color: isChannel ? pt.fgMuted : pt.accent,
),
);
if (client == null || room.avatar == null) return fallback;
return MxcAvatar(
mxcUri: room.avatar,
client: client,
size: _kTileAvatar,
borderRadius: BorderRadius.circular(14),
size: size,
borderRadius: radius,
placeholder: (_) => fallback,
);
}
@@ -157,3 +172,103 @@ String? _roomPreview(Room room, String? myUserId) {
if (room.isDirectChat) return 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 -45
View File
@@ -64,8 +64,8 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
});
final roomsAsync = ref.watch(roomListProvider);
final activeSpaceId = ref.watch(activeSpaceIdProvider);
final call = ref.watch(voiceChannelProvider);
final voip = ref.watch(callSignalingProvider);
final bottomNav = ref.watch(bottomNavProvider) &&
isMobileLayout(MediaQuery.sizeOf(context));
final isRealSpace = activeSpaceId != null &&
activeSpaceId != 'dms' &&
@@ -87,6 +87,7 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
_FilterInput(
controller: _filterCtrl,
pt: pt,
hint: isRealSpace ? 'Kanäle durchsuchen' : 'Chats durchsuchen',
onChanged: (v) => setState(() => _filter = v),
),
Expanded(
@@ -112,60 +113,90 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
),
),
error: (e, _) => Center(
child: Text('Error',
child: Text('Fehler',
style: TextStyle(color: pt.fgDim, fontSize: 12)),
),
data: (rooms) => _RoomsList(
rooms: rooms,
filter: _filter,
spaceId: activeSpaceId,
collapsed: _collapsed,
pt: pt,
onToggleSection: (id) => setState(() {
_collapsed[id] = !(_collapsed[id] ?? false);
}),
),
),
),
// Call-Dock: immer ÜBER dem Konto-Balken, animiert ein-/ausblenden.
// Im Mobil-Querformat (Höhe knapp) liegen Call-Balken und
// Konto-Balken nebeneinander statt übereinander.
Builder(builder: (context) {
final hasVoipCall = voip.currentCall != null &&
voip.currentCall!.state != CallState.kEnded;
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()],
);
}),
// Call-Dock + Profilbalken. Mit der unteren Leiste (Handy) liegen
// beide dort (layout/mobile_tabs.dart).
if (!bottomNav) const _PanelFooter(),
],
),
);
}
}
/// 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 {
await widget.space.leave();
if (mounted) {
ref.read(activeSpaceIdProvider.notifier).state = 'rooms';
ref.read(activeSpaceIdProvider.notifier).state = 'dms';
}
} catch (_) {
if (mounted) setState(() => _declining = false);
+5
View File
@@ -10,10 +10,14 @@ import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/matrix_client.dart';
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/features/rooms/rooms_provider.dart';
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_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/rooms/user_panel.dart';
import 'package:pyramid/features/spaces/space_admin_dialog.dart';
@@ -22,6 +26,7 @@ import 'package:pyramid/widgets/create_join_dialog.dart';
import 'package:pyramid/widgets/hover_region.dart';
import 'package:pyramid/widgets/mxc_image.dart';
import 'package:pyramid/widgets/new_dm_dialog.dart';
import 'package:pyramid/widgets/spring_press.dart' show SpringBump;
// ─── Helpers ──────────────────────────────────────────────────────────────────
+19 -4
View File
@@ -1,6 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/room_kinds.dart';
final roomListProvider = StreamProvider<List<Room>>((ref) async* {
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 client = await ref.watch(matrixClientProvider.future);
yield _countDmUnread(client);
yield _countChatUnread(client);
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
.where((r) => !r.isSpace && r.isDirectChat && r.notificationCount > 0)
.where((r) =>
r.membership == Membership.join &&
isChatListRoom(r, channels) &&
r.notificationCount > 0)
.length;
}
+25 -107
View File
@@ -2,7 +2,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/appearance.dart' show isMobileLayout;
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/room_kinds.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/rooms/rooms_provider.dart';
import 'package:pyramid/widgets/create_join_dialog.dart';
@@ -32,8 +34,25 @@ class SpacesRail extends ConsumerWidget {
}
// Switch space and restore last visited room for the new space
ref.read(activeSpaceIdProvider.notifier).state = id;
ref.read(activeRoomIdProvider.notifier).state =
ref.read(lastRoomPerSpaceProvider)[id];
var target = ref.read(lastRoomPerSpaceProvider)[id];
// Am PC nicht auf „Wähle einen Chat aus“ landen: ersten Textkanal des
// Spaces öffnen (UI-Audit S5). Am Handy bleibt die Kanalliste sichtbar.
if (target == null &&
id != null &&
id != 'dms' &&
id != 'rooms' &&
!isMobileLayout(MediaQuery.sizeOf(context))) {
final client = ref.read(matrixClientProvider).valueOrNull;
final space = client?.getRoomById(id);
for (final child in space?.spaceChildren ?? []) {
final r = child.roomId == null ? null : client!.getRoomById(child.roomId!);
if (r != null && r.membership == Membership.join && !isVoiceChannelRoom(r)) {
target = r.id;
break;
}
}
}
ref.read(activeRoomIdProvider.notifier).state = target;
final scaffold = Scaffold.maybeOf(context);
if (scaffold != null && scaffold.isDrawerOpen) {
scaffold.closeDrawer();
@@ -48,7 +67,9 @@ class SpacesRail extends ConsumerWidget {
const SizedBox(height: 12),
_HomeButton(
pt: pt,
active: activeSpaceId == 'dms' || activeSpaceId == null,
active: activeSpaceId == 'dms' ||
activeSpaceId == 'rooms' ||
activeSpaceId == null,
unreadCount: dmUnread,
onTap: () => selectSpace('dms'),
),
@@ -67,18 +88,8 @@ class SpacesRail extends ConsumerWidget {
child: ListView(
padding: EdgeInsets.zero,
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(
padding: const EdgeInsets.only(top: 6),
padding: const EdgeInsets.only(bottom: 6),
child: _SpaceItem(
space: space,
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 {
final Room space;
final bool active;
@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'package:livekit_client/livekit_client.dart';
import 'package:matrix/matrix.dart' hide Room;
import 'package:pyramid/core/call_foreground.dart';
import 'package:pyramid/core/ice_servers.dart';
import 'package:pyramid/core/livekit_token.dart';
import 'package:pyramid/core/settings_prefs.dart';
@@ -98,8 +99,35 @@ class LiveKitCallManager extends VoiceChannelService {
void _onRoomChanged() => notifyListeners();
// Handy: Lautsprecher statt Hörmuschel (wie Discord).
bool _speakerOn = true;
bool get _isMobile =>
!kIsWeb &&
(defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS);
@override
bool get isSpeakerOn => _speakerOn;
@override
Future<void> toggleSpeaker() async {
_speakerOn = !_speakerOn;
notifyListeners();
await _applySpeaker();
}
Future<void> _applySpeaker() async {
if (!_isMobile) return;
try {
await rtc.Helper.setSpeakerphoneOn(_speakerOn);
} catch (e) {
debugPrint('[LiveKit] Lautsprecher: $e');
}
}
void _cleanUpAfterDisconnect() {
_callGeneration++;
CallForeground.callEnded('livekit');
_stopStatsLogging();
_presenceHeartbeat?.cancel();
_presenceHeartbeat = null;
@@ -173,8 +201,6 @@ class LiveKitCallManager extends VoiceChannelService {
if (isActive) return;
final gen = ++_callGeneration;
final url = 'wss://livekit.steggi-matrix.work';
isConnecting = true;
isVoiceChannel = voiceChannel;
error = null;
@@ -191,12 +217,21 @@ class LiveKitCallManager extends VoiceChannelService {
notifyListeners();
try {
final token = LiveKitTokenGenerator.generate(
roomName: roomName,
identity: identity,
displayName: identity,
ttlSeconds: 21600,
// Token holt der Server (prüft den Matrix-Login, Identität = eigene
// Matrix-ID); das LiveKit-Secret liegt nicht mehr in der App.
// [identity] wird dafür nicht mehr gebraucht.
final accessToken = matrixClient?.accessToken;
if (accessToken == null || accessToken.isEmpty) {
throw const LiveKitTokenException(
'Nicht angemeldet – Beitritt nicht möglich.');
}
final lk = await LiveKitTokenService.fetch(
room: roomName,
matrixToken: accessToken,
);
if (gen != _callGeneration) return; // inzwischen aufgelegt/gewechselt
final token = lk.token;
final url = lk.url;
final room = Room(
roomOptions: const RoomOptions(
@@ -247,8 +282,14 @@ class LiveKitCallManager extends VoiceChannelService {
_room = room;
isConnecting = false;
notifyListeners();
_speakerOn = true;
unawaited(_applySpeaker());
// Android: Mikrofon im Hintergrund + „Anruf läuft“-Benachrichtigung.
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);
_presenceHeartbeat = Timer.periodic(const Duration(seconds: 30), (_) {
_writePresence(true).catchError((_) {});
@@ -262,6 +303,7 @@ class LiveKitCallManager extends VoiceChannelService {
isScreenSharing = false;
_currentScreenShareSourceId = null;
_screenShareTrack = null;
CallForeground.screenCaptureStopped('livekit');
}
notifyListeners();
})
@@ -480,6 +522,11 @@ class LiveKitCallManager extends VoiceChannelService {
}
}
// Android 14+: erst Zustimmung + mediaProjection-Dienst, sonst
// stürzt die App beim Mitschnitt ab.
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
if (!await CallForeground.prepareScreenCapture('livekit')) return;
}
await _room?.localParticipant?.setScreenShareEnabled(true);
isScreenSharing = true;
notifyListeners();
@@ -580,6 +627,7 @@ class LiveKitCallManager extends VoiceChannelService {
}
}
try { await _room?.localParticipant?.setScreenShareEnabled(false); } catch (_) {}
await CallForeground.screenCaptureStopped('livekit');
isScreenSharing = false;
_currentScreenShareSourceId = null;
_screenShareTrack = null;
@@ -590,6 +638,7 @@ class LiveKitCallManager extends VoiceChannelService {
Future<void> hangUp() async {
// Entwertet einen evtl. noch laufenden Verbindungsaufbau (s. _callGeneration).
_callGeneration++;
unawaited(CallForeground.callEnded('livekit'));
_stopStatsLogging();
_presenceHeartbeat?.cancel();
_presenceHeartbeat = null;
@@ -53,6 +53,11 @@ abstract class VoiceChannelService extends ChangeNotifier {
Future<void> hangUp();
Future<void> toggleMute();
/// Handy: Ton über den Lautsprecher (true) oder die Hörmuschel (false).
/// Auf dem PC ohne Bedeutung.
bool get isSpeakerOn => true;
Future<void> toggleSpeaker() async {}
Future<void> toggleDeafen();
Future<void> toggleCamera();
Future<void> toggleScreenShare();
+123 -27
View File
@@ -17,6 +17,11 @@ import 'package:pyramid/features/chat/chat_view.dart';
import 'package:pyramid/features/members/members_panel.dart';
import 'package:pyramid/features/rooms/rooms_panel.dart';
import 'package:pyramid/features/spaces/spaces_rail.dart';
import 'package:pyramid/layout/mobile_page_stack.dart';
import 'package:pyramid/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/mxc_image.dart';
import 'package:pyramid/widgets/search_modal.dart';
import 'package:pyramid/widgets/settings_modal.dart';
import 'package:pyramid/widgets/share_target_dialog.dart';
@@ -149,6 +154,44 @@ class _AppShellState extends ConsumerState<AppShell>
ref.read(activeModalProvider.notifier).state = ModalKind.none;
}
bool _hadVoipCall = false;
bool _hadLiveKit = false;
/// Anruf ist vorbei: Anrufansicht verlassen (sofern kein anderer Anruf
/// mehr läuft) und kurz Bescheid geben.
void _leaveCallView(String message) {
WidgetsBinding.instance.addPostFrameCallback((_) {
try {
_leaveCallViewNow(message);
} catch (e, s) {
debugPrint('[AppShell] Anrufansicht verlassen: $e $s');
}
});
}
void _leaveCallViewNow(String message) {
{
if (!mounted) return;
final voip = ref.read(callSignalingProvider);
final lk = ref.read(voiceChannelProvider);
final stillInCall = (voip.currentCall != null &&
voip.currentCall!.state != CallState.kEnded) ||
lk.isActive ||
lk.isConnecting;
if (stillInCall) return;
if (ref.read(viewModeProvider) == ViewMode.voice) {
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
}
ScaffoldMessenger.maybeOf(context)
?..hideCurrentSnackBar()
..showSnackBar(SnackBar(
content: Text(message),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 3),
));
}
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
@@ -162,6 +205,12 @@ class _AppShellState extends ConsumerState<AppShell>
// würde der Nutzer permanent zurück in die Call-UI gezwungen und könnte
// sie nie minimieren.
ref.listen(callSignalingProvider, (prev, next) {
// Anruf vorbei (aufgelegt, abgelehnt, gescheitert) → nicht auf der
// leeren Anrufansicht stehen lassen (UI-Audit S1, 2026-10-08).
final hasCall = next.currentCall != null &&
next.currentCall!.state != CallState.kEnded;
if (_hadVoipCall && !hasCall) _leaveCallView('Anruf beendet');
_hadVoipCall = hasCall;
final wasConnected = _wasVoipConnected;
final isConnected = next.currentCall != null &&
next.currentCall!.state == CallState.kConnected;
@@ -174,6 +223,12 @@ class _AppShellState extends ConsumerState<AppShell>
});
ref.listen(voiceChannelProvider, (prev, next) {
final lkActive = next.isActive || next.isConnecting;
if (_hadLiveKit && !lkActive) {
// Fehler (z. B. Verbindung verloren) sichtbar machen statt still.
_leaveCallView(next.error ?? (next.isVoiceChannel ? 'Sprachkanal verlassen' : 'Anruf beendet'));
}
_hadLiveKit = lkActive;
// Only auto-switch to voice view for video/audio calls, NOT voice channels
// (voice channels should stay in background with a mini bar)
final wasActive = _wasLiveKitActive;
@@ -335,14 +390,24 @@ class _MobileShell extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final voip = ref.watch(callSignalingProvider);
Widget body;
if (viewMode == ViewMode.voice) {
body = const VoiceChannelView();
} else if (activeRoomId != null) {
body = ChatView(roomId: activeRoomId!);
} else {
body = const RoomsPanel();
}
// Die Chatliste bleibt aufgebaut; Chat bzw. Sprachkanal gleiten federnd
// darüber (Bernds Wunsch 2026-10-07: mehr Übergänge/Elastizität).
final Widget? front = viewMode == ViewMode.voice
? const VoiceChannelView()
: activeRoomId != null
? ChatView(roomId: activeRoomId!)
: null;
final bottomNav = ref.watch(bottomNavProvider);
final tab = ref.watch(mobileTabProvider);
final body = MobilePageStack(
base: bottomNav ? const MobileTabsBase() : const RoomsPanel(),
front: front,
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).
final overlayStyle = (pt.isDark
@@ -356,7 +421,10 @@ class _MobileShell extends ConsumerWidget {
return AnnotatedRegion<SystemUiOverlayStyle>(
value: overlayStyle,
child: PopScope(
canPop: activeRoomId == null && viewMode == ViewMode.chat && !membersOpen,
canPop: activeRoomId == null &&
viewMode == ViewMode.chat &&
!membersOpen &&
tabsAtRoot,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) {
if (membersOpen) {
@@ -365,6 +433,8 @@ class _MobileShell extends ConsumerWidget {
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
} else if (activeRoomId != null) {
ref.read(activeRoomIdProvider.notifier).state = null;
} else if (bottomNav) {
handleMobileTabBack(ref);
}
}
},
@@ -372,11 +442,13 @@ class _MobileShell extends ConsumerWidget {
// bg1 = Farbe der Kopfzeilen → der Streifen hinter der Statusleiste
// geht nahtlos in sie über.
backgroundColor: pt.bg1,
drawer: Drawer(
width: 80,
backgroundColor: pt.bg0,
child: const SafeArea(child: SpacesRail()),
),
drawer: bottomNav
? null
: Drawer(
width: 80,
backgroundColor: pt.bg0,
child: const SafeArea(child: SpacesRail()),
),
// SafeArea: Abstand zu Statusleiste/Kamera-Aussparung oben und zur
// Gesten-/Navigationsleiste unten (edge-to-edge, siehe main.dart).
body: SafeArea(child: Stack(
@@ -387,7 +459,12 @@ class _MobileShell extends ConsumerWidget {
Positioned.fill(
child: GestureDetector(
onTap: () => ref.read(membersPanelOpenProvider.notifier).state = false,
child: Container(color: Colors.black.withAlpha(110)),
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: 1),
duration: motionDuration(context, 220),
builder: (_, v, _) =>
ColoredBox(color: Colors.black.withAlpha((110 * v).round())),
),
),
),
Positioned(
@@ -396,10 +473,20 @@ class _MobileShell extends ConsumerWidget {
bottom: 0,
child: Builder(builder: (ctx) {
final w = MediaQuery.sizeOf(ctx).width;
return MembersPanel(
roomId: activeRoomId!,
width: (w * 0.82).clamp(240.0, 320.0),
onClose: () => ref.read(membersPanelOpenProvider.notifier).state = false,
// Gleitet von rechts herein.
return TweenAnimationBuilder<double>(
tween: Tween(begin: 1, end: 0),
duration: motionDuration(ctx, 340),
curve: Curves.easeOutCubic,
builder: (_, v, child) => FractionalTranslation(
translation: Offset(v, 0),
child: child,
),
child: MembersPanel(
roomId: activeRoomId!,
width: (w * 0.82).clamp(240.0, 320.0),
onClose: () => ref.read(membersPanelOpenProvider.notifier).state = false,
),
);
}),
),
@@ -498,18 +585,27 @@ class _IncomingCallOverlay extends ConsumerWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: pt.accent,
shape: BoxShape.circle,
MxcAvatar(
mxcUri: call.room.avatar,
client: call.room.client,
size: 88,
placeholder: (_) => Container(
width: 88,
height: 88,
decoration: BoxDecoration(
color: pt.accent,
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Text(
call.room.getLocalizedDisplayname().characters.firstOrNull?.toUpperCase() ?? '?',
style: const TextStyle(color: Colors.white, fontSize: 36, fontWeight: FontWeight.w700),
),
),
child: const Icon(Icons.person_rounded, size: 40, color: Colors.white),
),
const SizedBox(height: 24),
Text(
'Eingehender Anruf',
call.type == CallType.kVideo ? 'Eingehender Videoanruf' : 'Eingehender Anruf',
style: TextStyle(color: pt.fgDim, fontSize: 13),
),
const SizedBox(height: 8),
+166
View File
@@ -0,0 +1,166 @@
import 'package:flutter/physics.dart';
import 'package:flutter/widgets.dart';
import 'package:pyramid/core/theme.dart';
/// Handy-Navigation zwischen Chatliste und geöffnetem Chat.
///
/// Die Chatliste ([base]) bleibt immer aufgebaut – Scrollposition und Zustand
/// bleiben beim Zurückkehren erhalten. Der Vordergrund ([front], z. B. der
/// Chat) gleitet federnd von rechts darüber, die Liste weicht dabei leicht
/// nach links aus und dunkelt ab; beim Zurück läuft es umgekehrt. Wechselt
/// der Vordergrund direkt (anderer Chat), wird ohne Schieben getauscht.
/// Mit „Animationen: 0 %“ wird sofort umgeschaltet.
class MobilePageStack extends StatefulWidget {
const MobilePageStack({
super.key,
required this.base,
required this.front,
required this.background,
});
final Widget base;
final Widget? front;
/// Hintergrund hinter dem Vordergrund, damit die Liste beim Schieben nicht
/// durch durchsichtige Stellen scheint.
final Color background;
@override
State<MobilePageStack> createState() => _MobilePageStackState();
}
class _MobilePageStackState extends State<MobilePageStack>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl = AnimationController.unbounded(
vsync: this,
value: widget.front != null ? 1.0 : 0.0,
);
/// Angezeigter Vordergrund – bleibt beim Hinausgleiten noch stehen.
late Widget? _front = widget.front;
@override
void didUpdateWidget(MobilePageStack oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.front != null) {
_front = widget.front;
if (oldWidget.front == null) _animateTo(1);
} else if (oldWidget.front != null) {
_animateTo(0);
}
}
void _animateTo(double target) {
final motion = PyramidTheme.of(context).motion;
if (motion < 0.05) {
_ctrl.value = target;
_settle();
return;
}
// Feder knapp unter kritischer Dämpfung: zügig, weich auslaufend.
// Kleinere „Animationen“-Werte = steifere Feder = schneller.
final spring = SpringDescription.withDampingRatio(
mass: 1,
stiffness: 520 / (motion * motion),
ratio: 0.86,
);
_ctrl
.animateWith(
SpringSimulation(spring, _ctrl.value, target, _ctrl.velocity),
)
.then((_) {
_ctrl.value = target;
_settle();
});
}
void _settle() {
if (mounted && widget.front == null && _front != null) {
setState(() => _front = null);
}
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
return AnimatedBuilder(
animation: _ctrl,
builder: (context, _) {
final front = _front;
final shown = _ctrl.value.clamp(0.0, 1.0);
// Vollständig verdeckt: Liste nicht mehr zeichnen/animieren
// (bleibt aber aufgebaut).
final covered = front != null && shown >= 0.999;
return Stack(
fit: StackFit.expand,
children: [
KeyedSubtree(
key: const ValueKey('base'),
child: IgnorePointer(
// Nur gesperrt, solange der Vordergrund offen ist bzw.
// hereinkommt – beim Hinausgleiten ist die Liste schon
// wieder bedienbar (schneller Tipp auf den nächsten Chat).
ignoring: widget.front != null,
child: TickerMode(
enabled: !covered,
child: Offstage(
offstage: covered,
child: Transform.translate(
offset: Offset(-0.25 * width * shown, 0),
child: widget.base,
),
),
),
),
),
if (front != null && !covered && shown > 0)
Positioned.fill(
key: const ValueKey('scrim'),
child: IgnorePointer(
child: ColoredBox(
color: const Color(0xFF000000)
.withValues(alpha: 0.30 * shown),
),
),
),
if (front != null)
KeyedSubtree(
key: const ValueKey('front'),
child: Transform.translate(
offset: Offset(width * (1 - shown), 0),
child: DecoratedBox(
decoration: BoxDecoration(
color: widget.background,
boxShadow: covered
? null
: const [
BoxShadow(
color: Color(0x40000000),
blurRadius: 18,
),
],
),
// Beim Hinausgleiten nicht mehr bedienbar.
child: IgnorePointer(
ignoring: widget.front == null,
child: front,
),
),
),
),
],
);
},
);
},
);
}
}
+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/matrix_client.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';
enum CreateJoinTab { create, join, discover }
@@ -100,15 +102,7 @@ class _CreateJoinDialogState extends ConsumerState<CreateJoinDialog> {
// weil Continuwuity power_level_content_override beim createRoom
// ignoriert.
try {
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? ?? {});
events['io.pyramid.voice.presence'] = 0;
pl['events'] = events;
await client.setRoomStateWithKey(
roomId, EventTypes.RoomPowerLevels, '', pl);
await allowVoicePresenceForAll(client, roomId);
} catch (_) {
// 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)]
: null,
);
// Gruppenanrufe: alle Mitglieder dürfen ihre Anwesenheit zeigen.
try {
await allowVoicePresenceForAll(client, roomId);
} catch (_) {}
}
if (mounted) {
ref.read(activeRoomIdProvider.notifier).state = roomId;
@@ -581,7 +579,7 @@ class _SpaceEditDialogState extends ConsumerState<SpaceEditDialog> {
try {
await widget.space.leave();
if (mounted) {
ref.read(activeSpaceIdProvider.notifier).state = 'rooms';
ref.read(activeSpaceIdProvider.notifier).state = 'dms';
Navigator.of(context).pop(true);
}
} catch (e) {
+40 -29
View File
@@ -1,4 +1,9 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/widgets/spring_press.dart';
// Simple hover-aware widget for desktop hover effects
class HoverRegion extends StatefulWidget {
@@ -29,7 +34,10 @@ class _HoverRegionState extends State<HoverRegion> {
}
}
// Icon button styled to match Pyramid's .icon-btn
// Icon button styled to match Pyramid's .icon-btn.
// Handy-Layout (Bernds Feedback 2026-10-07: Symbole zu klein): Symbol 22 px
// statt 16, Fläche mind. 40 px, Tippfläche mind. 44 px. Farben folgen dem
// gewählten Farbmodus, Druck federt (SpringPress).
class PyrIconBtn extends StatefulWidget {
final Widget icon;
final VoidCallback? onPressed;
@@ -39,6 +47,9 @@ class PyrIconBtn extends StatefulWidget {
final Color? dangerColor;
final double size;
/// Symbolgröße; ohne Angabe 16 (PC) bzw. 22 (Handy-Layout).
final double? iconSize;
const PyrIconBtn({
super.key,
required this.icon,
@@ -48,6 +59,7 @@ class PyrIconBtn extends StatefulWidget {
this.activeColor,
this.dangerColor,
this.size = 32,
this.iconSize,
});
@override
@@ -59,20 +71,16 @@ class _PyrIconBtnState extends State<PyrIconBtn> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final fgMuted = isDark ? const Color(0xFFA4A4B0) : const Color(0xFF54545E);
final fg = isDark ? const Color(0xFFECECF0) : const Color(0xFF1A1A1F);
final bgHover = isDark ? const Color(0xFF232330) : const Color(0xFFE9E7E1);
final pt = PyramidTheme.of(context);
final mobile = isMobileLayout(MediaQuery.sizeOf(context));
final color = widget.active
? (widget.activeColor ?? const Color(0xFFF5A614))
: _hovered ? fg : fgMuted;
? (widget.activeColor ?? pt.accent)
: _hovered ? pt.fg : pt.fgMuted;
final platform = Theme.of(context).platform;
final isMobile = platform == TargetPlatform.android || platform == TargetPlatform.iOS;
// On mobile, use a slightly larger touch target, but respect the requested size
final touchSize = isMobile ? (widget.size + 8).clamp(32.0, 48.0) : widget.size;
final boxSize = mobile ? math.max(widget.size, 40.0) : widget.size;
final touchSize = mobile ? math.max(boxSize, 44.0) : boxSize;
final iconSize = widget.iconSize ?? (mobile ? 22.0 : 16.0);
final btn = MouseRegion(
cursor: SystemMouseCursors.click,
@@ -80,23 +88,26 @@ class _PyrIconBtnState extends State<PyrIconBtn> {
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onPressed,
child: SizedBox(
width: touchSize,
height: touchSize,
child: Center(
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
curve: Curves.easeOutCubic,
width: widget.size,
height: widget.size,
decoration: BoxDecoration(
color: _hovered ? bgHover : Colors.transparent,
borderRadius: BorderRadius.circular(7),
),
child: Center(
child: IconTheme(
data: IconThemeData(color: color, size: 16),
child: widget.icon,
child: SpringPress(
enabled: widget.onPressed != null,
child: SizedBox(
width: touchSize,
height: touchSize,
child: Center(
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
curve: Curves.easeOutCubic,
width: boxSize,
height: boxSize,
decoration: BoxDecoration(
color: _hovered ? pt.bgHover : Colors.transparent,
borderRadius: BorderRadius.circular(mobile ? boxSize / 2 : 7),
),
child: Center(
child: IconTheme(
data: IconThemeData(color: color, size: iconSize),
child: widget.icon,
),
),
),
),
+37
View File
@@ -0,0 +1,37 @@
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
/// Meldet die gezeichnete Größe des Kindes, sobald sie sich ändert (z. B.
/// die Höhe der schwebenden Eingabeleiste, die mit Antwort-Leiste oder
/// mehrzeiligem Text wächst). Die Meldung kommt nach dem Layout, damit der
/// Empfänger gefahrlos setState aufrufen kann.
class MeasureSize extends SingleChildRenderObjectWidget {
const MeasureSize({super.key, required this.onChange, super.child});
final ValueChanged<Size> onChange;
@override
RenderObject createRenderObject(BuildContext context) =>
_MeasureSizeRender(onChange);
@override
void updateRenderObject(BuildContext context, RenderObject renderObject) {
(renderObject as _MeasureSizeRender).onChange = onChange;
}
}
class _MeasureSizeRender extends RenderProxyBox {
_MeasureSizeRender(this.onChange);
ValueChanged<Size> onChange;
Size? _last;
@override
void performLayout() {
super.performLayout();
final size = this.size;
if (size == _last) return;
_last = size;
WidgetsBinding.instance.addPostFrameCallback((_) => onChange(size));
}
}
+214
View File
@@ -0,0 +1,214 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:pyramid/core/settings_prefs.dart';
import 'package:pyramid/core/theme.dart';
/// Mikrofontest mit Pegelanzeige (Einstellungen → Sprache & Video).
///
/// Nimmt über DENSELBEN Weg wie ein Anruf auf (gewähltes Gerät aus den
/// Einstellungen, WebRTC) und zeigt den Pegel live. Anlass: Bernds Test
/// 2026-10-07 – das Handy hörte nichts vom PC, weil (mit Voicemeeter und
/// vielen virtuellen Geräten) das Standardgerät nur Stille lieferte. Ohne
/// Pegelanzeige war das nicht zu erkennen.
///
/// Technik: lokale Verbindung zu sich selbst (zwei PeerConnections im selben
/// Prozess) – erst dann startet libwebrtc die Aufnahme und liefert den Pegel
/// in den Statistiken. Es wird nichts verschickt oder gespeichert. Nach
/// 30 Sekunden endet der Test von selbst.
class MicLevelTest extends StatefulWidget {
const MicLevelTest({super.key, required this.pt, this.enabled = true});
final PyramidTheme pt;
/// false z. B. während eines Anrufs (zwei Aufnahmen gleichzeitig vermeiden).
final bool enabled;
@override
State<MicLevelTest> createState() => _MicLevelTestState();
}
class _MicLevelTestState extends State<MicLevelTest> {
MediaStream? _stream;
RTCPeerConnection? _a;
RTCPeerConnection? _b;
Timer? _poll;
Timer? _autoStop;
bool _running = false;
bool _starting = false;
double _level = 0; // 0..1 (geglättet)
double _peak = 0;
String? _error;
@override
void dispose() {
_stop(notify: false);
super.dispose();
}
Future<void> _start() async {
setState(() {
_starting = true;
_error = null;
_peak = 0;
_level = 0;
});
try {
final mic = (await VoicePrefs.loadDevices()).mic;
final audio = mic == null
? true
: {
'deviceId': mic,
'optional': [
{'sourceId': mic},
],
};
final stream = await navigator.mediaDevices
.getUserMedia({'audio': audio, 'video': false});
final a = await createPeerConnection({});
final b = await createPeerConnection({});
a.onIceCandidate = (c) => b.addCandidate(c);
b.onIceCandidate = (c) => a.addCandidate(c);
await a.addTrack(stream.getAudioTracks().first, stream);
final offer = await a.createOffer();
await a.setLocalDescription(offer);
await b.setRemoteDescription(offer);
final answer = await b.createAnswer();
await b.setLocalDescription(answer);
await a.setRemoteDescription(answer);
if (!mounted) {
await _release(stream, a, b);
return;
}
_stream = stream;
_a = a;
_b = b;
_poll = Timer.periodic(const Duration(milliseconds: 120), (_) => _read());
_autoStop = Timer(const Duration(seconds: 30), _stop);
setState(() {
_running = true;
_starting = false;
});
} catch (e) {
if (mounted) {
setState(() {
_starting = false;
_error = 'Mikrofon ließ sich nicht öffnen: ${e.toString().split('\n').first}';
});
}
}
}
Future<void> _read() async {
final a = _a;
if (a == null) return;
try {
for (final r in await a.getStats()) {
if (r.type != 'media-source') continue;
final raw = (r.values['audioLevel'] as num?)?.toDouble() ?? 0;
// Wurzel-Skala: leise Sprache soll sichtbar ausschlagen.
final shown = math.sqrt(raw.clamp(0.0, 1.0));
if (!mounted) return;
setState(() {
_level = _level * 0.4 + shown * 0.6;
_peak = math.max(_peak, shown);
});
}
} catch (_) {}
}
Future<void> _release(MediaStream? s, RTCPeerConnection? a, RTCPeerConnection? b) async {
for (final t in s?.getTracks() ?? <MediaStreamTrack>[]) {
try {
await t.stop();
} catch (_) {}
}
try {
await s?.dispose();
} catch (_) {}
try {
await a?.close();
} catch (_) {}
try {
await b?.close();
} catch (_) {}
}
void _stop({bool notify = true}) {
_poll?.cancel();
_autoStop?.cancel();
_poll = null;
_autoStop = null;
final s = _stream, a = _a, b = _b;
_stream = null;
_a = null;
_b = null;
_release(s, a, b);
if (notify && mounted) {
setState(() {
_running = false;
_level = 0;
});
} else {
_running = false;
}
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final silent = _running && _peak < 0.02;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.mic_rounded, size: 15, color: pt.fgMuted),
const SizedBox(width: 8),
Text('Mikrofontest',
style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500)),
const Spacer(),
TextButton(
onPressed: !widget.enabled || _starting
? null
: (_running ? _stop : _start),
child: Text(_running ? 'Beenden' : 'Testen'),
),
],
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Container(
height: 8,
color: pt.bg3,
alignment: Alignment.centerLeft,
child: AnimatedFractionallySizedBox(
duration: const Duration(milliseconds: 110),
widthFactor: _level.clamp(0.0, 1.0),
heightFactor: 1,
child: Container(color: _level > 0.85 ? pt.danger : pt.accent),
),
),
),
const SizedBox(height: 6),
Text(
!widget.enabled
? 'Während eines Anrufs nicht möglich.'
: _error ??
(_running
? (silent
? 'Kein Ton zu hören – sprich etwas. Bleibt der Balken leer, oben ein anderes Mikrofon wählen.'
: 'Sprich etwas – der Balken zeigt, was die anderen hören würden.')
: 'Prüft das oben gewählte Mikrofon. Es wird nichts gesendet oder gespeichert.'),
style: TextStyle(
color: _error != null || silent ? pt.away : pt.fgDim,
fontSize: 11,
),
),
],
);
}
}
+29 -6
View File
@@ -78,7 +78,7 @@ class _ScreenSharePickerState extends State<ScreenSharePicker> with SingleTicker
padding: const EdgeInsets.fromLTRB(24, 20, 16, 8),
child: Row(
children: [
Text('Share your screen',
Text('Bildschirm teilen',
style: TextStyle(color: pt.fg, fontSize: 20, fontWeight: FontWeight.w700)),
const Spacer(),
IconButton(
@@ -97,8 +97,8 @@ class _ScreenSharePickerState extends State<ScreenSharePicker> with SingleTicker
unselectedLabelColor: pt.fgMuted,
dividerColor: pt.border,
tabs: const [
Tab(text: 'Screens'),
Tab(text: 'Windows'),
Tab(text: 'Bildschirme'),
Tab(text: 'Fenster'),
],
),
@@ -124,11 +124,34 @@ class _ScreenSharePickerState extends State<ScreenSharePicker> with SingleTicker
border: Border(top: BorderSide(color: pt.border)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// Bekannte WebRTC-Grenze unter Windows: Bei eingeschaltetem
// HDR liefert die Bildschirmaufnahme (DXGI) ein blasses,
// zu helles Bild – Zoom/Discord haben dasselbe. Abhilfe:
// Fenster teilen oder HDR kurz umschalten (Bernd 2026-10-08).
if (Theme.of(context).platform == TargetPlatform.windows)
Expanded(
child: Row(
children: [
Icon(Icons.hdr_on_rounded, size: 18, color: pt.fgDim),
const SizedBox(width: 8),
Expanded(
child: Text(
'HDR an? Dann wirkt ein geteilter Bildschirm blass. '
'Besser ein Fenster teilen oder HDR kurz umschalten '
'(Win + Alt + B).',
style: TextStyle(color: pt.fgDim, fontSize: 11.5),
),
),
],
),
)
else
const Spacer(),
const SizedBox(width: 12),
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Cancel', style: TextStyle(color: pt.fgMuted)),
child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)),
),
],
),
@@ -149,7 +172,7 @@ class _SourceGrid extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (sources.isEmpty) {
return Center(child: Text('No sources found', style: TextStyle(color: pt.fgDim)));
return Center(child: Text('Keine Quellen gefunden', style: TextStyle(color: pt.fgDim)));
}
return GridView.builder(
@@ -169,6 +169,26 @@ class _AppearanceSection extends StatelessWidget {
),
]),
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: [
_SettingsRow(
title: 'Eckenrundung',
+16
View File
@@ -270,6 +270,22 @@ class _VoiceSectionState extends ConsumerState<_VoiceSection> {
]),
const SizedBox(height: 16),
_SettingsGroup(title: 'Pegel', pt: pt, children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Builder(builder: (context) {
final voip = ref.watch(callSignalingProvider);
final lk = ref.watch(voiceChannelProvider);
final inCall = voip.currentCall != null || lk.isActive;
// Schlüssel je gewähltem Mikrofon: Gerätewechsel beendet
// einen laufenden Test (sonst misst er das alte Gerät).
return MicLevelTest(
key: ValueKey('mictest:$_selectedMic'),
pt: pt,
enabled: !inCall,
);
}),
),
_Divider(pt: pt),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Column(
+16 -1
View File
@@ -36,6 +36,7 @@ import 'package:pyramid/widgets/update_download_dialog.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:pyramid/widgets/banner_crop_dialog.dart';
import 'package:pyramid/widgets/hover_region.dart';
import 'package:pyramid/widgets/mic_level_test.dart';
import 'package:pyramid/widgets/mxc_image.dart';
import 'package:pyramid/widgets/pyramid_loader.dart';
@@ -81,6 +82,20 @@ class _SettingsModalState extends ConsumerState<SettingsModal> {
('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) =>
_categories.firstWhere((c) => c.$1 == id, orElse: () => (id, id, Icons.settings)).$2;
@@ -213,7 +228,7 @@ class _SettingsModalState extends ConsumerState<SettingsModal> {
child: ListView(
padding: const EdgeInsets.symmetric(vertical: 8),
children: [
for (final c in _categories)
for (final c in _categories.where((c) => c.$1 != 'keys'))
_MobileCategoryTile(
label: c.$2,
icon: c.$3,
+206
View File
@@ -0,0 +1,206 @@
import 'package:flutter/widgets.dart';
import 'package:pyramid/core/theme.dart';
/// Federnde Druck-Rückmeldung: beim Drücken leicht kleiner, beim Loslassen
/// elastisch zurück (Bernds Wunsch 2026-10-07: mehr „Springiness“).
///
/// Hört nur passiv mit (Listener) – Tippen, Halten und Scrollen bleiben beim
/// Kind bzw. beim umgebenden Scrollbereich. Bewegt sich der Finger (Scrollen),
/// wird die Rückmeldung sofort abgebrochen. Mit „Animationen: 0 %“ in den
/// Einstellungen (PyramidTheme.motion) ist sie aus.
class SpringPress extends StatefulWidget {
const SpringPress({
super.key,
required this.child,
this.pressedScale = 0.88,
this.enabled = true,
});
final Widget child;
final double pressedScale;
final bool enabled;
@override
State<SpringPress> createState() => _SpringPressState();
}
class _SpringPressState extends State<SpringPress> {
static const _moveSlop = 8.0;
bool _pressed = false;
Offset? _down;
void _set(bool pressed) {
if (_pressed != pressed && mounted) setState(() => _pressed = pressed);
}
@override
Widget build(BuildContext context) {
final motion = PyramidTheme.of(context).motion;
if (!widget.enabled || motion < 0.05) return widget.child;
return Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: (e) {
_down = e.position;
_set(true);
},
onPointerMove: (e) {
final d = _down;
if (d != null && (e.position - d).distance > _moveSlop) _set(false);
},
onPointerUp: (_) => _set(false),
onPointerCancel: (_) => _set(false),
child: AnimatedScale(
scale: _pressed ? widget.pressedScale : 1.0,
duration: _pressed
? const Duration(milliseconds: 90)
: Duration(milliseconds: (420 * motion).round().clamp(120, 420)),
curve: _pressed ? Curves.easeOut : Curves.elasticOut,
child: widget.child,
),
);
}
}
/// Gemeinsame „federnde“ Kurve für Ein-/Ausblendungen (leichtes Überschwingen).
const springyCurve = Cubic(0.18, 0.89, 0.32, 1.12);
/// Dauer, skaliert mit der Einstellung „Animationen“ (0 = sofort).
Duration motionDuration(BuildContext context, int milliseconds) {
final motion = PyramidTheme.of(context).motion;
return Duration(milliseconds: (milliseconds * motion).round());
}
/// Einmaliges federndes Einblenden (z. B. eine gerade angekommene Nachricht):
/// kommt etwas kleiner und tiefer herein und schwingt leicht über.
///
/// Ob gespielt wird, entscheidet sich nur beim ersten Aufbau ([play]); danach
/// bleibt das Kind unverändert, auch wenn [play] sich ändert. Mit
/// „Animationen: 0 %“ wird nie gespielt.
class PopIn extends StatefulWidget {
const PopIn({
super.key,
required this.child,
this.play = true,
this.alignment = Alignment.bottomCenter,
this.offsetY = 18,
this.fromScale = 0.86,
this.milliseconds = 460,
});
final Widget child;
final bool play;
final Alignment alignment;
final double offsetY;
final double fromScale;
final int milliseconds;
@override
State<PopIn> createState() => _PopInState();
}
class _PopInState extends State<PopIn> with SingleTickerProviderStateMixin {
AnimationController? _ctrl;
bool _decided = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_decided) return;
_decided = true;
final motion = PyramidTheme.of(context).motion;
if (!widget.play || motion < 0.05) return;
_ctrl = AnimationController(
vsync: this,
duration: Duration(milliseconds: (widget.milliseconds * motion).round()),
)..forward();
}
@override
void dispose() {
_ctrl?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final ctrl = _ctrl;
if (ctrl == null) return widget.child;
return AnimatedBuilder(
animation: ctrl,
child: widget.child,
builder: (context, child) {
final t = ctrl.value;
final move = Curves.easeOutBack.transform(t);
final fade = Curves.easeOut.transform((t / 0.4).clamp(0.0, 1.0));
return Opacity(
opacity: fade,
child: Transform.translate(
offset: Offset(0, (1 - move) * widget.offsetY),
child: Transform.scale(
scale: widget.fromScale + (1 - widget.fromScale) * move,
alignment: widget.alignment,
child: child,
),
),
);
},
);
}
}
/// Federndes „Aufploppen“, sobald [value] steigt (z. B. Ungelesen-Zähler,
/// Anzahl einer Reaktion). Beim ersten Aufbau passiert nichts – so ploppt
/// beim Scrollen durch eine Liste nichts grundlos auf.
class SpringBump extends StatefulWidget {
const SpringBump({
super.key,
required this.value,
required this.child,
this.alignment = Alignment.center,
});
final num value;
final Widget child;
final Alignment alignment;
@override
State<SpringBump> createState() => _SpringBumpState();
}
class _SpringBumpState extends State<SpringBump>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl =
AnimationController(vsync: this, value: 1);
double _from = 1;
@override
void didUpdateWidget(SpringBump oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.value <= oldWidget.value) return;
final motion = PyramidTheme.of(context).motion;
if (motion < 0.05) return;
// Aus dem Nichts (0 → 1) größer aufploppen als beim Hochzählen.
_from = oldWidget.value <= 0 ? 0.3 : 0.7;
_ctrl.duration = Duration(milliseconds: (650 * motion).round());
_ctrl.forward(from: 0);
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _ctrl,
child: widget.child,
builder: (context, child) => Transform.scale(
scale: _from + (1 - _from) * Curves.elasticOut.transform(_ctrl.value),
alignment: widget.alignment,
child: child,
),
);
}
}
+5 -5
View File
@@ -202,7 +202,7 @@ packages:
source: hosted
version: "1.0.2"
dart_jsonwebtoken:
dependency: "direct main"
dependency: transitive
description:
name: dart_jsonwebtoken
sha256: ad84e60181696513d04d5f2078e0bbc20365b911f46f647797317414bdc88fbe
@@ -400,14 +400,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_markdown:
flutter_markdown_plus:
dependency: "direct main"
description:
name: flutter_markdown
sha256: "08fb8315236099ff8e90cb87bb2b935e0a724a3af1623000a9cec930468e0f27"
name: flutter_markdown_plus
sha256: fce641d6c2106cc495de1cd603f97a4f9d615a97a64011928ded380dbdade935
url: "https://pub.dev"
source: hosted
version: "0.7.7+1"
version: "1.0.12"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
+2 -3
View File
@@ -1,7 +1,7 @@
name: pyramid
description: A flexible Matrix client with resizable panel layout.
publish_to: none
version: 0.1.37+38
version: 0.1.43+44
environment:
sdk: ">=3.11.1 <4.0.0"
@@ -26,7 +26,6 @@ dependencies:
livekit_client: ^2.4.0
flutter_webrtc: ^1.4.1
webrtc_interface: ^1.5.1
dart_jsonwebtoken: ^3.3.1
# State management & routing
flutter_riverpod: ^2.6.1
@@ -68,7 +67,7 @@ dependencies:
# Document viewer
pdfx: ^2.7.0
archive: ^4.0.0
flutter_markdown: ^0.7.7
flutter_markdown_plus: ^1.0.3
# Clipboard image paste (Windows FFI) & image decoding
ffi: ^2.1.0
+24 -3
View File
@@ -109,17 +109,38 @@ if ($Ui) {
$uiProfile = Join-Path $env:USERPROFILE ".pyramid-autopilot\profile1"
if (-not (Test-Path (Join-Path $uiProfile "pyramid.sqlite"))) {
Add-Skipped "UI-Durchlauf (Handy-Format)" "Testprofil fehlt: $uiProfile"
} elseif (Get-Process pyramid -ErrorAction SilentlyContinue) {
# Nie zwei Matrix-Clients auf derselben DB – und Bernds Fenster nicht stören.
Add-Skipped "UI-Durchlauf (Handy-Format)" "Pyramid läuft gerade – erst beenden"
} elseif (Get-Process pyramid -ErrorAction SilentlyContinue |
Where-Object { $_.Path -like "*\build\windows\*" }) {
# Nie zwei Matrix-Clients auf derselben DB: ein noch laufender Testlauf
# (Build-Ordner) nutzt profile1. Bernds echte Pyramid und seine
# Testinstanz (profile2, C:\Users\nordm\pyramid-testinstanz) stören nicht.
Add-Skipped "UI-Durchlauf (Handy-Format)" "Ein Testlauf läuft noch – erst beenden"
} else {
$env:PYRAMID_PROFILE_DIR = $uiProfile
try {
Invoke-Step "UI-Durchlauf (Handy-Format, Testprofil)" {
flutter test integration_test/mobile_ui_screens_test.dart -d windows
}
$accounts = Join-Path $env:USERPROFILE ".pyramid-autopilot\test-accounts.txt"
if (Test-Path $accounts) {
Invoke-Step "Live: Lesebestätigung + Einladung (2 Testkonten)" {
flutter test integration_test/live_receipts_invite_test.dart -d windows
}
# Nutzt kurz Mikrofon + Bildschirm (nur lokal zwischen zwei Testkonten).
Invoke-Step "Live: 1:1-Anruf, Ton + Bildschirm teilen (2 Testkonten)" {
flutter test integration_test/live_call_test.dart -d windows
}
} else {
Add-Skipped "Live: Lesebestätigung + Einladung" "test-accounts.txt fehlt"
}
} finally {
Remove-Item Env:PYRAMID_PROFILE_DIR -ErrorAction SilentlyContinue
# Integrationstests lassen die Test-App manchmal weiterlaufen – die
# hielte die Testprofil-DB offen (zwei Clients auf einer DB beim
# nächsten Lauf). Nur Prozesse aus DIESEM Build-Ordner beenden.
Get-Process pyramid -ErrorAction SilentlyContinue |
Where-Object { $_.Path -like "$PWDuild\windows\*" } |
Stop-Process -Force -Confirm:$false -ErrorAction SilentlyContinue
}
Write-Host "Screenshots: build/ui_screens/ (Testinhalte ggf. mit test/live_ui_fixture_test.dart anlegen)"
}
+42
View File
@@ -0,0 +1,42 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/chat/document_viewer.dart';
/// Markdown-Vorschau (seit 2026-10-07 mit flutter_markdown_plus statt dem
/// eingestellten flutter_markdown): Überschrift, Liste, Code und Link werden
/// gezeichnet.
void main() {
testWidgets('Markdown-Datei wird in der Vorschau dargestellt', (
tester,
) async {
const md = '# Einkaufsliste\n\n'
'- **Milch**\n- _Brot_\n\n'
'`code` und [ein Link](https://example.org)\n\n'
'> Zitat\n';
await tester.pumpWidget(
PyramidTheme(
child: MaterialApp(
home: DocumentViewer(
filename: 'notizen.md',
bytes: Uint8List.fromList(utf8.encode(md)),
senderName: 'Test',
),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
// Auswählbarer Text (SelectableText → EditableText) oder RichText.
bool shows(String text) =>
find.textContaining(text, findRichText: true).evaluate().isNotEmpty;
expect(shows('Einkaufsliste'), isTrue);
expect(shows('Milch'), isTrue);
expect(shows('ein Link'), isTrue);
expect(shows('Zitat'), isTrue);
});
}
+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');
});
}
+87
View File
@@ -0,0 +1,87 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:pyramid/core/livekit_token.dart';
/// Beitritts-Token kommt vom Server; das LiveKit-Secret steckt nicht mehr
/// in der App (docs/LIVEKIT_TOKEN_MIGRATION.md).
void main() {
test('schickt Raum + Matrix-Token und liefert Token und Adresse', () async {
late Map<String, dynamic> sent;
final client = MockClient((req) async {
expect(req.url.toString(), LiveKitTokenService.endpoint);
sent = jsonDecode(req.body) as Map<String, dynamic>;
return http.Response(
jsonEncode({'token': 'abc.def.ghi', 'url': 'wss://lk.example'}),
200,
);
});
final r = await LiveKitTokenService.fetch(
room: '!raum:server',
matrixToken: 'mt',
httpClient: client,
);
expect(sent, {'room': '!raum:server', 'matrix_token': 'mt'});
expect(r.token, 'abc.def.ghi');
expect(r.url, 'wss://lk.example');
});
test('ohne brauchbare Adresse: Standard-LiveKit', () async {
final client = MockClient(
(_) async => http.Response(jsonEncode({'token': 't'}), 200),
);
final r = await LiveKitTokenService.fetch(
room: 'r',
matrixToken: 'm',
httpClient: client,
);
expect(r.url, LiveKitTokenService.fallbackUrl);
});
Future<String> failureFor(MockClient client) async {
try {
await LiveKitTokenService.fetch(
room: 'r',
matrixToken: 'm',
httpClient: client,
);
} on LiveKitTokenException catch (e) {
return e.message;
}
fail('LiveKitTokenException erwartet');
}
test('Fehler werden verständlich gemeldet statt still geschluckt', () async {
expect(
await failureFor(MockClient((_) async => http.Response('{}', 401))),
contains('neu anmelden'),
);
expect(
await failureFor(MockClient((_) async => http.Response('kaputt', 500))),
contains('HTTP 500'),
);
expect(
await failureFor(MockClient((_) async => http.Response('{}', 200))),
contains('kein Zugangstoken'),
);
expect(
await failureFor(MockClient((_) async => http.Response('<html>', 200))),
contains('unlesbare'),
);
expect(
await failureFor(
MockClient((_) async => throw const SocketException('offline')),
),
contains('nicht erreichbar'),
);
});
test('App-Code enthält kein LiveKit-Secret und signiert nicht selbst', () {
final src = File('lib/core/livekit_token.dart').readAsStringSync();
expect(src, isNot(contains('apiSecret')));
expect(src, isNot(contains('jsonwebtoken')));
});
}
+150
View File
@@ -0,0 +1,150 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/layout/mobile_page_stack.dart';
import 'package:pyramid/widgets/spring_press.dart';
/// Handy-Übergang Chatliste ↔ Chat und die federnden Einblendungen.
void main() {
Widget host({required Widget? front, double motion = 1}) {
return PyramidTheme(
motion: motion,
child: Directionality(
textDirection: TextDirection.ltr,
child: MobilePageStack(
base: const _Counter(key: ValueKey('liste')),
front: front,
background: const Color(0xFF101010),
),
),
);
}
testWidgets('Chatliste behält ihren Zustand, Chat gleitet herein und hinaus',
(tester) async {
await tester.pumpWidget(host(front: null));
await tester.tap(find.byKey(const ValueKey('liste')));
await tester.pump();
expect(find.text('Zähler 1'), findsOneWidget);
// Chat öffnen: gleitet von rechts herein.
await tester.pumpWidget(host(front: const Text('Chat')));
await tester.pump(const Duration(milliseconds: 40));
final midX = tester.getTopLeft(find.text('Chat')).dx;
expect(midX, greaterThan(0), reason: 'Chat sollte noch hereingleiten');
await tester.pumpAndSettle();
expect(tester.getTopLeft(find.text('Chat')).dx, 0);
// Verdeckte Liste ist nicht mehr bedienbar, aber noch aufgebaut.
expect(find.text('Zähler 1', skipOffstage: false), findsOneWidget);
// Zurück: Chat gleitet hinaus und wird danach entfernt.
await tester.pumpWidget(host(front: null));
await tester.pump(const Duration(milliseconds: 40));
expect(find.text('Chat'), findsOneWidget, reason: 'noch beim Hinausgleiten');
// Schon während des Hinausgleitens ist die Liste wieder bedienbar.
await tester.tapAt(const Offset(20, 300));
await tester.pumpAndSettle();
expect(find.text('Zähler 2'), findsOneWidget);
await tester.pumpAndSettle();
expect(find.text('Chat'), findsNothing);
// Zustand der Liste ist erhalten geblieben.
await tester.tap(find.byKey(const ValueKey('liste')));
await tester.pump();
expect(find.text('Zähler 3'), findsOneWidget);
});
testWidgets('Animationen 0 %: sofortiger Wechsel ohne Gleiten', (
tester,
) async {
await tester.pumpWidget(host(front: null, motion: 0));
await tester.pumpWidget(host(front: const Text('Chat'), motion: 0));
await tester.pump();
expect(tester.getTopLeft(find.text('Chat')).dx, 0);
await tester.pumpWidget(host(front: null, motion: 0));
await tester.pump();
expect(find.text('Chat'), findsNothing);
});
testWidgets('PopIn spielt nur, wenn verlangt, und endet unverändert', (
tester,
) async {
Widget pop(bool play) => PyramidTheme(
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: PopIn(play: play, child: const Text('Neu')),
),
),
);
await tester.pumpWidget(pop(true));
await tester.pump(const Duration(milliseconds: 30));
final opacity = tester.widget<Opacity>(
find.ancestor(of: find.text('Neu'), matching: find.byType(Opacity)),
);
expect(opacity.opacity, lessThan(1));
await tester.pumpAndSettle();
expect(
tester
.widget<Opacity>(
find.ancestor(of: find.text('Neu'), matching: find.byType(Opacity)),
)
.opacity,
1,
);
await tester.pumpWidget(const SizedBox());
await tester.pumpWidget(pop(false));
expect(
find.ancestor(of: find.text('Neu'), matching: find.byType(Opacity)),
findsNothing,
);
});
testWidgets('SpringBump hüpft nur beim Hochzählen', (tester) async {
Widget bump(int v) => PyramidTheme(
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(child: SpringBump(value: v, child: Text('$v'))),
),
);
double scale() => tester
.widget<Transform>(
find.ancestor(
of: find.byType(Text),
matching: find.byType(Transform),
),
)
.transform
.storage[0]; // x-Skalierung (z bleibt bei Transform.scale immer 1)
await tester.pumpWidget(bump(1));
expect(scale(), 1);
await tester.pumpWidget(bump(2));
await tester.pump(const Duration(milliseconds: 16));
expect(scale(), lessThan(1), reason: 'Hochzählen soll hüpfen');
await tester.pumpAndSettle();
expect(scale(), closeTo(1, 0.001));
await tester.pumpWidget(bump(0));
await tester.pump(const Duration(milliseconds: 16));
expect(scale(), closeTo(1, 0.001), reason: 'Herunterzählen hüpft nicht');
});
}
class _Counter extends StatefulWidget {
const _Counter({super.key});
@override
State<_Counter> createState() => _CounterState();
}
class _CounterState extends State<_Counter> {
int _n = 0;
@override
Widget build(BuildContext context) => GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => setState(() => _n++),
child: Center(child: Text('Zähler $_n')),
);
}
+8 -1
View File
@@ -27,7 +27,14 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
FlutterWindow window(project);
Win32Window::Point origin(10, 10);
Win32Window::Size size(1280, 720);
if (!window.Create(L"Pyramid", origin, size)) {
// Testprofil (PYRAMID_PROFILE_DIR, z. B. die Testinstanz mit einem
// Testkonto) im Fenstertitel kenntlich machen, damit sie nicht mit der
// echten Pyramid verwechselt wird. Ohne die Variable bleibt alles wie immer.
wchar_t profile_probe[2] = {0};
const bool test_profile =
::GetEnvironmentVariableW(L"PYRAMID_PROFILE_DIR", profile_probe, 2) > 0;
if (!window.Create(test_profile ? L"Pyramid - TESTINSTANZ" : L"Pyramid",
origin, size)) {
return EXIT_FAILURE;
}
window.SetQuitOnClose(true);