Compare commits

...
19 Commits
Author SHA1 Message Date
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
61 changed files with 4104 additions and 504 deletions
+135
View File
@@ -13,6 +13,141 @@ Schritt (und beim Abbruch mitten im Schritt den Zwischenstand). Format:
---
## 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):**
+14 -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,11 +306,14 @@ 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)
+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
@@ -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)),
);
}
+525
View File
@@ -0,0 +1,525 @@
import 'dart:async';
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:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:webrtc_interface/webrtc_interface.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 = _loadAccounts();
testWidgets(
'1:1-Anruf: Ton beide Richtungen, Bildschirm teilen beide Richtungen',
(tester) async {
Logs().level = Level.verbose;
app.main();
await _pumpUntil(tester, () => find.byType(MaterialApp).evaluate().isNotEmpty);
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(
() => _Partner.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)',
));
_log('Raum angelegt: $roomId');
await _pumpUntil(
tester,
() => me.getRoomById(roomId!)?.membership == Membership.invite,
what: 'Einladung kommt bei der App an',
);
await tester.runAsync(() => me.getRoomById(roomId!)!.join());
_log('App ist beigetreten');
// 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“)
_log('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(find.byIcon(Icons.mic_rounded).first);
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(find.byIcon(Icons.mic_off_rounded).first);
await _pumpUntil(tester, () => !partnerSeesMuted() && !voip.isMicMuted,
timeout: const Duration(seconds: 15),
what: 'Stumm wieder aufgehoben');
_log('Stumm/Entstummen über den Knopf: ok');
// Taub → Gegenseite nicht mehr hören UND selbst stumm; danach zurück.
await tester.tap(find.byIcon(Icons.headphones_rounded).first);
await _pumpUntil(
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(find.byIcon(Icons.headset_off_rounded).first);
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,
);
_log('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'),
))!;
_log('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'),
))!;
_log('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 ═══
_log('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,
_Partner 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;
_log('App: ${a?.state} ice=${a?.pc?.iceConnectionState} | '
'Gegenseite: ${b?.state} ice=${b?.pc?.iceConnectionState}');
},
);
_log('verbunden nach ${DateTime.now().difference(t0).inMilliseconds} ms');
}
Future<void> _checkAudio(
WidgetTester tester,
CallSignalingService voip,
_Partner 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'),
))!;
_log('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);
}
/// Zweiter Teilnehmer: eigener Matrix-Client + VoIP im selben Prozess.
class _Partner {
_Partner(this.client, this.delegate, this.voip, this.zone) {
_sub = client.onCallEvents.stream.listen((events) {
for (final e in events) {
if (e.type != EventTypes.CallCandidates || e.senderId == client.userID) {
continue;
}
final list = e.content.tryGetList<Map<String, dynamic>>('candidates');
if (list != null) _buffer.addAll(list);
}
});
_timer = Timer.periodic(const Duration(milliseconds: 500), (_) async {
final pc = delegate.call?.pc;
if (!fixCandidates || pc == null) return;
try {
if (await pc.getRemoteDescription() == null) return;
for (final c in List.of(_buffer)) {
final cand = c['candidate'] as String?;
if (cand == null || !_applied.add(cand)) continue;
await pc.addCandidate(RTCIceCandidate(
cand,
c['sdpMid']?.toString() ?? '',
(c['sdpMLineIndex'] as num?)?.round() ?? 0,
));
}
} catch (_) {}
});
}
final Client client;
final _PartnerDelegate delegate;
final VoIP voip;
final Zone zone;
/// Aktion der Gegenseite in ihrer Zone ausführen (Fehler über Completer).
Future<T> run<T>(Future<T> Function() f) {
final done = Completer<T>();
zone.run(() async {
try {
done.complete(await f());
} catch (e, s) {
done.completeError(e, s);
}
});
return done.future;
}
/// Gegenseite reicht Kandidaten nach wie die gepatchte App.
bool fixCandidates = false;
final _buffer = <Map<String, dynamic>>[];
final _applied = <String>{};
StreamSubscription? _sub;
Timer? _timer;
static Future<_Partner> start(String user, String pass) async {
final db = await databaseFactoryFfi.openDatabase(inMemoryDatabasePath);
final client = Client(
'pyramid-call-partner',
database: await MatrixSdkDatabase.init('pyramid-call-partner', database: db),
);
await client.init(waitForFirstSync: false, waitUntilLoadCompletedLoaded: false);
await client.checkHomeserver(Uri.parse('https://steggi-matrix.work'));
await client.login(
LoginType.mLoginPassword,
identifier: AuthenticationUserIdentifier(user: user),
password: pass,
initialDeviceDisplayName: 'Pyramid-Anruftest (kurzlebig)',
);
final delegate = _PartnerDelegate();
// Eigene Zone: Fehler der Gegenseite sichtbar protokollieren statt sie
// im Testrahmen zu verlieren.
final zone = Zone.current.fork(
specification: ZoneSpecification(
handleUncaughtError: (self, parent, z, e, s) =>
_log('Gegenseite (abgefangen): ${e.toString().split('\n').first}'),
),
);
final voip = zone.run(() => VoIP(client, delegate));
return _Partner(client, delegate, voip, zone);
}
Future<void> dispose(String? roomId) async {
_timer?.cancel();
await _sub?.cancel();
try {
await delegate.call?.hangup(reason: CallErrorCode.userHangup);
} catch (_) {}
if (roomId != null) {
try {
await client.leaveRoom(roomId);
await client.forgetRoom(roomId);
} catch (_) {}
}
try {
client.backgroundSync = false;
await client.logout();
} catch (_) {}
}
}
class _PartnerDelegate implements WebRTCDelegate {
CallSession? call;
@override
MediaDevices get mediaDevices => _PartnerMedia(rtc.navigator.mediaDevices);
@override
Future<RTCPeerConnection> createPeerConnection(
Map<String, dynamic> configuration, [
Map<String, dynamic> constraints = const {},
]) =>
rtc.createPeerConnection(configuration, constraints);
@override
Future<void> handleNewCall(CallSession session) async {
call = session;
if (session.direction == CallDirection.kIncoming) {
// Wie ein Mensch nach kurzem Klingeln annehmen. Sofortiges Annehmen
// trifft eine Lücke im SDK: Kandidaten, die WÄHREND answer()
// eintreffen, landen in einer Warteschlange, die nie mehr abgearbeitet
// wird (CallSession.onCandidatesReceived vs. answer()).
unawaited(Future.delayed(const Duration(seconds: 2), () async {
try {
await session.answer();
} catch (e) {
_log('Gegenseite: Annehmen fehlgeschlagen: $e');
}
}));
}
}
@override
Future<void> handleCallEnded(CallSession session) async {}
@override
Future<void> handleMissedCall(CallSession session) async {}
@override
Future<void> handleNewGroupCall(GroupCallSession groupCall) async {}
@override
Future<void> handleGroupCallEnded(GroupCallSession groupCall) async {}
@override
Future<void> playRingtone() async {}
@override
Future<void> stopRingtone() async {}
@override
Future<void> registerListeners(CallSession session) async {}
@override
bool get isWeb => false;
@override
bool get canHandleNewCall => true;
@override
EncryptionKeyProvider? get keyProvider => null;
}
/// Wie die App am PC: Bildschirm über eine Desktop-Quelle (erster Monitor).
class _PartnerMedia extends MediaDevices {
_PartnerMedia(this._d);
final MediaDevices _d;
@override
Future<MediaStream> getUserMedia(Map<String, dynamic> c) => _d.getUserMedia(c);
@override
Future<MediaStream> getDisplayMedia(Map<String, dynamic> c) async {
final sources = await rtc.desktopCapturer
.getSources(types: [rtc.SourceType.Screen]);
return _d.getDisplayMedia({
'video': {
'mandatory': {
'chromeMediaSource': 'desktop',
'chromeMediaSourceId': sources.first.id,
},
'optional': [],
},
'audio': false,
});
}
@override
Future<List<MediaDeviceInfo>> enumerateDevices() => _d.enumerateDevices();
@override
MediaTrackSupportedConstraints getSupportedConstraints() =>
_d.getSupportedConstraints();
@override
set ondevicechange(Function(dynamic event)? callback) =>
_d.ondevicechange = callback;
@override
Function(dynamic event)? get ondevicechange => _d.ondevicechange;
@override
// ignore: deprecated_member_use
Future<List<dynamic>> getSources() => _d.getSources();
@override
Future<MediaDeviceInfo> selectAudioOutput([AudioOutputOptions? options]) =>
_d.selectAudioOutput(options);
}
Map<String, String>? _loadAccounts() {
final home = Platform.environment['USERPROFILE'] ?? '';
final file = File('$home\\.pyramid-autopilot\\test-accounts.txt');
if (!file.existsSync()) return null;
final map = <String, String>{};
for (final line in file.readAsLinesSync()) {
final t = line.trim();
if (t.isEmpty || t.startsWith('#')) continue;
final i = t.indexOf('=');
if (i > 0) map[t.substring(0, i)] = t.substring(i + 1);
}
return map;
}
Future<void> _pumpFor(WidgetTester tester, Duration d) async {
final end = DateTime.now().add(d);
while (DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 100));
}
}
void _log(String m) {
// ignore: avoid_print
print('SCHRITT ${DateTime.now().toIso8601String().substring(11, 19)} $m');
}
Future<void> _pumpUntil(
WidgetTester tester,
bool Function() condition, {
Duration timeout = const Duration(seconds: 60),
String what = '',
void Function()? onTimeout,
void Function()? every5s,
}) async {
final end = DateTime.now().add(timeout);
var next = DateTime.now().add(const Duration(seconds: 5));
while (DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 200));
if (condition()) return;
if (every5s != null && DateTime.now().isAfter(next)) {
every5s();
next = DateTime.now().add(const Duration(seconds: 5));
}
}
_log('ZEITÜBERSCHREITUNG: $what');
onTimeout?.call();
fail('Zeitüberschreitung: $what');
}
@@ -0,0 +1,277 @@
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();
await _pumpUntil(tester, find.byType(MaterialApp));
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));
// ── 1. Lesebestätigung live ──
final dm = client.rooms.firstWhere(
(r) =>
r.membership == Membership.join &&
r.directChatMatrixID == '@pyramidtest2:steggi-matrix.work',
);
container.read(activeRoomIdProvider.notifier).state = dm.id;
await _pumpFor(tester, const Duration(seconds: 4));
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',
);
// ── 2. Einladung im Chat annehmen ──
final welcome = 'Willkommen im Einladungstest ${DateTime.now().millisecondsSinceEpoch}';
final inviteRoomId = await tester.runAsync(() async {
final id = await partner!.createRoom('Einladungstest');
await partner.sendText(id, welcome);
await partner.invite(id, client.userID!);
return id;
});
addTearDown(() => tester.runAsync(() => partner!.leave(inviteRoomId!)));
// Einladung kommt per Sync an → Chat öffnen (wie aus der Benachrichtigung).
final end = DateTime.now().add(const Duration(seconds: 30));
while (client.getRoomById(inviteRoomId!)?.membership != Membership.invite &&
DateTime.now().isBefore(end)) {
await _pumpFor(tester, const Duration(milliseconds: 500));
}
container.read(activeRoomIdProvider.notifier).state = inviteRoomId;
await _pumpUntil(tester, find.text('Annehmen'));
await tester.tap(find.text('Annehmen'));
// Ohne App-Neustart: Verlauf mit der Begrüßung erscheint.
await _pumpUntil(
tester,
find.textContaining(welcome),
timeout: const Duration(seconds: 40),
);
expect(find.text('Annehmen'), findsNothing);
// ── 3. „Neue Nachrichten“-Trenner MITTEN in einer Gruppe verschwindet ──
// (Bernd 2026-10-08: blieb manchmal stehen – bei mehreren Nachrichten
// desselben Absenders hintereinander, gelesen bis zur ersten.)
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,67 @@
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();
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));
}
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)),
);
}
@@ -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/.
@@ -59,10 +60,63 @@ void main() {
// ── 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,
);
await tester.scrollUntilVisible(
quote.first,
300,
scrollable: find.byType(Scrollable).first,
maxScrolls: 40,
);
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 ownMsg = tester.getCenter(find.text('Danke, sieht gut aus!').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');
@@ -177,6 +231,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)),
+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();
}
}
+83 -31
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);
/// 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.',
);
// Sign the token with your secret
final token = jwt.sign(
SecretKey(apiSecret),
expiresIn: Duration(seconds: ttlSeconds),
}
if (res.statusCode != 200) {
throw LiveKitTokenException(
'Anrufserver antwortet nicht richtig (HTTP ${res.statusCode}).',
);
return token;
}
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);
+2 -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 ──────────────────────────
@@ -87,7 +88,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]);
+315 -84
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,96 +308,65 @@ 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 (_) {}
// 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;
}
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;
}
}
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;
changed = true;
}
// Bildschirm hat Vorrang vor der Kamera.
final remoteVideo =
remoteScreen ?? video(call.remoteUserMediaStream, respectMute: true);
final localVideo =
localScreen ?? video(call.localUserMediaStream, respectMute: 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;
if (remoteRenderer.srcObject?.id != remoteVideo?.id) {
debugPrint('[VOIP] Remote-Video: ${remoteVideo?.id} (Bildschirm: $remoteSharing)');
remoteRenderer.srcObject = remoteVideo;
changed = true;
}
} else if (localRenderer.srcObject != null) {
localRenderer.srcObject = null;
if (localRenderer.srcObject?.id != localVideo?.id) {
debugPrint('[VOIP] Lokales Video: ${localVideo?.id} (Bildschirm: $localSharing)');
localRenderer.srcObject = localVideo;
changed = true;
}
@@ -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 = {
@@ -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,6 +85,14 @@ 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),
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),
+14 -1
View File
@@ -17,6 +17,8 @@ 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,
@@ -35,6 +37,8 @@ class _UnifiedCallView extends StatefulWidget {
required this.isScreenSharing,
this.onQualityMenu,
required this.pt,
this.isSpeakerOn = true,
this.onToggleSpeaker,
});
@override
@@ -83,7 +87,14 @@ 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);
// Ohne gewählten Hero nicht nach „spricht gerade“ wählen – sonst springt
// das große Bild bei jedem Sprechwechsel hin und her.
final stable = [...widget.participants]..sort((a, b) {
int r(_ParticipantData p) => p.isScreenShare ? 0 : (p.hasVideo ? 1 : 2);
return r(a).compareTo(r(b));
});
final hero = all.firstWhereOrNull((p) => p.id == _heroId) ??
(stable.isNotEmpty ? stable.first : null);
if (widget.isConnecting) {
return Scaffold(
@@ -148,6 +159,8 @@ class _UnifiedCallViewState extends State<_UnifiedCallView> {
onToggleScreen: widget.onToggleScreen,
onHangUp: widget.onHangUp,
pt: pt,
isSpeakerOn: widget.isSpeakerOn,
onToggleSpeaker: widget.onToggleSpeaker,
),
],
),
@@ -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);
@@ -58,6 +58,8 @@ class VoiceChannelView extends ConsumerWidget {
isScreenSharing: voip.isScreensharing,
onQualityMenu: (pos) => _showVoipQualityMenu(context, voip, pos),
pt: pt,
isSpeakerOn: voip.isSpeakerOn,
onToggleSpeaker: voip.toggleSpeaker,
);
}
@@ -86,6 +88,8 @@ class VoiceChannelView extends ConsumerWidget {
isScreenSharing: call.isScreenSharing,
onQualityMenu: (pos) => _showLiveKitQualityMenu(context, call, pos),
pt: pt,
isSpeakerOn: call.isSpeakerOn,
onToggleSpeaker: call.toggleSpeaker,
);
}
@@ -147,6 +151,7 @@ class VoiceChannelView extends ConsumerWidget {
name: voip.currentCall?.room.getLocalizedDisplayname() ?? 'Remote',
videoWidget: hasRemoteVideo ? rtc.RTCVideoView(voip.remoteRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null,
isMuted: false,
isSpeaking: voip.isRemoteSpeaking,
hasVideo: hasRemoteVideo,
isScreenShare: isRemoteScreen,
avatarColor: _avatarColors[1],
@@ -161,6 +166,7 @@ class VoiceChannelView extends ConsumerWidget {
name: 'Du',
videoWidget: hasLocalVideo ? rtc.RTCVideoView(voip.localRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null,
isMuted: voip.isMicMuted,
isSpeaking: voip.isLocalSpeaking,
hasVideo: hasLocalVideo,
isScreenShare: isLocalScreen,
avatarColor: _avatarColors[0],
+89 -14
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,9 @@ 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/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 +35,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 +51,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 +131,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,6 +315,8 @@ 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();
final placeholder = widget.room.isDirectChat
@@ -310,12 +327,28 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.replyTo != null)
_ReplyBar(
// 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 +360,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,6 +372,12 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
? [BoxShadow(color: pt.accentSoft, blurRadius: 0, spreadRadius: 3)]
: null,
),
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(
@@ -347,7 +388,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 +438,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,6 +465,7 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
canSend: _canSend,
pt: pt,
onTap: _send,
size: mobile ? 42 : 36,
),
),
],
@@ -431,6 +473,9 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
),
),
),
),
),
),
],
),
);
@@ -461,9 +506,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,6 +518,8 @@ class _ComposerIconBtnState extends State<_ComposerIconBtn> {
child: Tooltip(
message: widget.tooltip,
waitDuration: const Duration(milliseconds: 500),
child: SpringPress(
enabled: widget.onTap != null,
child: SizedBox(
width: touchSize,
height: touchSize,
@@ -499,6 +544,7 @@ class _ComposerIconBtnState extends State<_ComposerIconBtn> {
),
),
),
),
);
}
}
@@ -507,8 +553,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 +580,14 @@ class _SendButtonState extends State<_SendButton> {
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: active ? widget.onTap : null,
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,14 +602,23 @@ class _SendButtonState extends State<_SendButton> {
: Matrix4.identity(),
transformAlignment: Alignment.center,
child: Center(
// 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: 16,
size: widget.size * 0.45,
color: active ? pt.accentFg : pt.fgDim,
),
),
),
),
),
),
);
}
}
@@ -599,9 +663,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,
+4
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';
@@ -19,6 +20,9 @@ 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/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/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),
@@ -123,6 +143,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)),
],
),
),
);
}
}
+3 -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';
@@ -21,6 +22,7 @@ import 'package:pyramid/features/chat/media_viewer.dart';
import 'package:pyramid/features/chat/reaction_service.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';
+139 -33
View File
@@ -42,11 +42,13 @@ 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) ...[
@@ -141,6 +143,13 @@ class _ChatHeader extends ConsumerWidget {
onPressed: onTogglePinned,
),
],
if (isMobile)
_ChatOverflowMenu(
pt: pt,
onSearch: onOpenSearch,
onMembers: onToggleMembers,
)
else ...[
PyrIconBtn(
size: 28,
icon: const Icon(Icons.search_rounded),
@@ -155,6 +164,7 @@ class _ChatHeader extends ConsumerWidget {
onPressed: onToggleMembers,
),
],
],
),
],
),
@@ -176,8 +186,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 +234,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 +317,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,21 +388,16 @@ 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(
// 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(
@@ -399,9 +421,10 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
mxcUri: avatarUrl.toString(),
client: client,
fit: BoxFit.cover,
placeholder: _initialBox(initial, pt),
placeholder:
_initialBox(initial, pt, avatarSize),
)
: _initialBox(initial, pt),
: _initialBox(initial, pt, avatarSize),
),
AnimatedContainer(
duration: _animDuration,
@@ -506,9 +529,30 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
),
),
),
const SizedBox(width: 12),
// Aktionen — bleiben immer sichtbar
Row(
// 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,
),
),
),
Positioned(
right: isMobile ? 6 : 16,
top: 0,
height: 52,
child: KeyedSubtree(
key: _actionsKey,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
PyrIconBtn(
size: 28,
@@ -537,6 +581,13 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
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),
@@ -551,24 +602,34 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
onPressed: widget.onToggleMembers,
),
],
),
],
),
),
),
],
),
);
}
Widget _initialBox(String initial, PyramidTheme pt) => Center(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Padding(
padding: const EdgeInsets.all(6),
// 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,
style: TextStyle(
color: pt.fg, fontSize: 28, fontWeight: FontWeight.w700),
textHeightBehavior: const TextHeightBehavior(
applyHeightToFirstAscent: false,
applyHeightToLastDescent: false,
),
),
),
@@ -733,3 +794,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),
],
);
}
}
+17 -3
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
@@ -207,8 +216,9 @@ 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)
final passedFullyReadId = (hasUnread &&
groupContainsFullyRead &&
group.last.eventId != fullyReadId)
? fullyReadId
: null;
itemFullyReadIds.add(passedFullyReadId);
@@ -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,
);
},
);
+229
View File
@@ -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),
],
),
),
),
),
);
}
}
+213 -41
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,11 @@ class _ChatViewState extends ConsumerState<ChatView> {
setState(() => _pinnedOpen = !_pinnedOpen),
),
const _ConnectionBanner(),
const _ActiveCallBar(),
Expanded(
child: Stack(
child: isInvite
? _InvitePanel(room: room!, pt: pt)
: Stack(
children: [
timelineAsync.when(
loading: () => Center(
@@ -429,28 +558,36 @@ class _ChatViewState extends ConsumerState<ChatView> {
jumpKey: _jumpKey,
highlightEventId: _highlightEventId,
highlightOn: _highlightOn,
bottomInset: room != null ? _composerHeight : 0,
popInAfter: _popInAfter,
);
},
),
if (_showScrollDown)
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 +597,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 +647,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),
),
],
),
),
@@ -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;
@@ -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(),
),
],
),
],
@@ -131,8 +131,11 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
// Im Mobil-Querformat (Höhe knapp) liegen Call-Balken und
// Konto-Balken nebeneinander statt übereinander.
Builder(builder: (context) {
// Klingeln (noch nicht angenommen) zählt nicht – dafür gibt es
// das Annehmen-Fenster (Bernd 2026-10-08).
final hasVoipCall = voip.currentCall != null &&
voip.currentCall!.state != CallState.kEnded;
voip.currentCall!.state != CallState.kEnded &&
voip.currentCall!.state != CallState.kRinging;
final hasCallDock = call.isActive || hasVoipCall;
final isLandscapeCompact =
MediaQuery.sizeOf(context).height < 500;
+2
View File
@@ -12,6 +12,7 @@ import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/notification_content.dart' show notificationBody;
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/voice_channel/voice_channel_service.dart';
@@ -22,6 +23,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 ──────────────────────────────────────────────────────────────────
@@ -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,6 +282,10 @@ 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) {
await _writePresence(true);
@@ -262,6 +301,7 @@ class LiveKitCallManager extends VoiceChannelService {
isScreenSharing = false;
_currentScreenShareSourceId = null;
_screenShareTrack = null;
CallForeground.screenCaptureStopped('livekit');
}
notifyListeners();
})
@@ -480,6 +520,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 +625,7 @@ class LiveKitCallManager extends VoiceChannelService {
}
}
try { await _room?.localParticipant?.setScreenShareEnabled(false); } catch (_) {}
await CallForeground.screenCaptureStopped('livekit');
isScreenSharing = false;
_currentScreenShareSourceId = null;
_screenShareTrack = null;
@@ -590,6 +636,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();
+31 -10
View File
@@ -17,6 +17,8 @@ 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/widgets/spring_press.dart' show motionDuration;
import 'package:pyramid/widgets/search_modal.dart';
import 'package:pyramid/widgets/settings_modal.dart';
import 'package:pyramid/widgets/share_target_dialog.dart';
@@ -335,14 +337,18 @@ 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 body = MobilePageStack(
base: const RoomsPanel(),
front: front,
background: pt.bg1,
);
// Symbole der Statusleiste passend zum Theme (hell auf dunkel und umgekehrt).
final overlayStyle = (pt.isDark
@@ -387,7 +393,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 +407,20 @@ class _MobileShell extends ConsumerWidget {
bottom: 0,
child: Builder(builder: (ctx) {
final w = MediaQuery.sizeOf(ctx).width;
return MembersPanel(
// 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,
),
);
}),
),
+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,
),
),
),
),
],
);
},
);
},
);
}
}
+28 -17
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,6 +88,8 @@ class _PyrIconBtnState extends State<PyrIconBtn> {
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onPressed,
child: SpringPress(
enabled: widget.onPressed != null,
child: SizedBox(
width: touchSize,
height: touchSize,
@@ -87,15 +97,15 @@ class _PyrIconBtnState extends State<PyrIconBtn> {
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
curve: Curves.easeOutCubic,
width: widget.size,
height: widget.size,
width: boxSize,
height: boxSize,
decoration: BoxDecoration(
color: _hovered ? bgHover : Colors.transparent,
borderRadius: BorderRadius.circular(7),
color: _hovered ? pt.bgHover : Colors.transparent,
borderRadius: BorderRadius.circular(mobile ? boxSize / 2 : 7),
),
child: Center(
child: IconTheme(
data: IconThemeData(color: color, size: 16),
data: IconThemeData(color: color, size: iconSize),
child: widget.icon,
),
),
@@ -103,6 +113,7 @@ class _PyrIconBtnState extends State<PyrIconBtn> {
),
),
),
),
);
if (widget.tooltip != null) {
+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(
+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(
+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';
+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.41+42
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
+18 -3
View File
@@ -109,15 +109,30 @@ 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
}
+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);
});
}
+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);