Compare commits

...
28 Commits
Author SHA1 Message Date
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
Bernd SteckmeisterandClaude Opus 5.5 36875ef9cd fix: Android-Backup und Geraeteumzug fuer App-Daten abschalten (Aussperr-Schutz) + Gradle 8.14.3
Ohne allowBackup=false/dataExtractionRules zog die SQLCipher-Datenbank beim
Handywechsel ohne ihren Keystore-Schluessel mit - AppDatabase bricht dann
bewusst ab, die App waere unbenutzbar. Ausserdem lag der Push-Zugangsschluessel
(SharedPreferences) im Cloud-Backup. Neues Geraet = neu anmelden + Schluessel
per Wiederherstellungsschluessel (Matrix-Weg). Gradle-Wrapper auf 8.14.3
(Flutter kuendigte Ende der 8.13-Unterstuetzung an).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 17:44:58 +02:00
Bernd SteckmeisterandClaude Opus 5.5 e90b954348 fix: Abmelden ohne verspaetete Sync-Antwort (SDK-Wettlauf) + Live-Regressionstest
logoutWithoutStraySync (lib/core/session_logout.dart): Sync-Schleife
anhalten, laufenden Long-Poll per Account-Daten wecken und auslaufen lassen,
erst dann abmelden - es ist keine Anfrage mit altem Token mehr unterwegs,
die per 401 ein zweites clear() ausloesen koennte. Genutzt an allen drei
Abmelde-Stellen (Einstellungen, Ueber, Konto geloescht) und im E2E-Live-Test.
Live gemessen: die verspaetete 401 tritt real auf, kam aber stets vor dem
Abschluss einer Neuanmeldung an - Risiko war klein, ist jetzt ausgeschlossen.
test/live_logout_race_test.dart in scripts/test.ps1 -Live.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 17:39:37 +02:00
Bernd SteckmeisterandClaude Opus 5.5 f09e4875c6 chore: Version 0.1.37+38 (Test-Build Beta-Feedback) + PROGRESS/ROADMAP nachgezogen
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 17:25:23 +02:00
Bernd SteckmeisterandClaude Opus 5.5 c38e9c18b0 feat: Handy-Chatliste als Messenger-Kachel (Vorschau, Uhrzeit, Ungelesen) + deutsche Beschriftungen
- rooms_mobile_tile.dart: auf dem Handy grosser Avatar, Name + Uhrzeit der
  letzten Nachricht, Vorschau (gleiche Texte wie Push: Medien-Labels,
  Antwort-Zitat entfernt; "Du:" bzw. Absender in Gruppen), Ungelesen-Zaehler.
  PC behaelt die kompakte Liste, Voice-Kanaele ihre Darstellung.
- utils/date_labels.dart: chatListTime (heute Uhrzeit, Gestern, Wochentag,
  Datum; sicher an Zeitumstellungs-Tagen) + 5 Tests.
- Sichtbare englische Texte eingedeutscht (Chats durchsuchen,
  Direktnachrichten, Raeume, Nachricht..., Mitglieder, Suchen, ...).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 17:21:33 +02:00
Bernd SteckmeisterandClaude Opus 5.5 c5f10901e6 feat: UI-Umbau - weiches Dunkel, Schriftgroessen-Regler, Sprechblasen umschaltbar, Einstellungen als Vollbild
Bernds Entscheidungen 2026-10-07:
- theme.dart: ThemeVariant (Dunkel = neues weiches Dunkelgrau als Standard,
  Schwarz = bisheriges Fast-Schwarz, Hell) mit PyramidPalette; Nebentexte
  kontrastreicher. Alte Einstellung theme_dark wird uebernommen
  (dunkel -> weiches Dunkel). Fest verdrahtete Dunkelfarben im Profil-Popup
  und GIF-Picker folgen jetzt dem Farbmodus.
- appearance.dart: Schriftgroesse = Pyramid-Regler x Systemeinstellung
  (Handy-Standard 115 %, PC 100 %), MessageStyle Automatisch/Sprechblasen/
  Liste, gemeinsame isMobileLayout-Regel.
- message_bubbles.dart: Sprechblasen-Darstellung (eigene rechts in
  Akzentfarbe, Gruppen mit Name + Avatar, Bilder ohne Blase in voller Groesse);
  Listen-Darstellung unveraendert, Weiche in MessageGroup.
- Formatierte Nachrichten (u. a. Antworten) nutzten RichText ohne
  Schriftskalierung -> erschienen kleiner; jetzt Text.rich.
- Einstellungen auf dem Handy als ganze Seite, Schalter unter dem Text.
- UI-Durchlauf um Liste/Hell/Gruppe/Einstellungen erweitert; Tests fuer
  Theme-Varianten, Uebernahme alter Werte, Schriftfaktor, Darstellung.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 17:16:07 +02:00
Bernd SteckmeisterandClaude Opus 5.5 a1d64ec813 feat: Handy - Platz fuer Statusleiste, randlose Bild-Vollansicht mit Gesten, groessere Medien im Chat
Bernds Beta-Feedback:
- main.dart: edgeToEdge statt immersiveSticky (Statusleiste schob sich ueber
  den Inhalt); MobileShell mit SafeArea + Statusleisten-Symbolen passend
  zum Theme, Hintergrund bg1 = Kopfzeilenfarbe.
- MediaViewer: Handy randlos auf Schwarz (vorher 24-px-Rand, halbtransparent);
  Tippen neben das Bild schliesst, aufs Bild blendet Leiste ein/aus; Wischen
  hoch/runter schliesst (nur ohne Zoom, ueber InteractiveViewer-Callbacks,
  kein Gestenkonflikt mit Zwei-Finger-Zoom); Doppeltipp zoomt an der Stelle.
- Chat: Bilder auf dem Handy bis halbe Bildschirmhoehe statt fest 320 px;
  Videos im echten Seitenverhaeltnis statt fest 16:9.
- UI-Durchlauf integration_test/mobile_ui_screens_test.dart (Handy-Format,
  Testprofil, Screenshots nach build/ui_screens/) + Testinhalte-Helfer
  test/live_ui_fixture_test.dart; scripts/test.ps1 -Ui.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 16:52:10 +02:00
Bernd SteckmeisterandClaude Opus 5.5 ef1d881967 feat: Push als Chat-Verlauf - mehrere Nachrichten, voller Text, Bildvorschau, echtes "Gelesen"
Bernds Beta-Feedback: pro Raum wurde jede Nachricht durch die naechste ersetzt
und auf 100 Zeichen gekuerzt, ohne Bild. Jetzt:
- NotificationHelper: MessagingStyle mit den letzten 8 Nachrichten pro Raum
  (Verlauf in SharedPreferences, nur solange die Benachrichtigung sichtbar ist),
  gleiche eventId ersetzt (Platzhalter -> Klartext), nur Neues klingelt.
- notification_content.dart: Text bis 1000 Zeichen, Antwort-Zitat entfernt,
  Medien-Labels, Vorschau-Einstellung auch bei gekillter App; Bild (E2EE
  entschluesselt) + Avatar per FileProvider an die System-UI.
- "Gelesen" setzt eine echte Lesebestaetigung (Haupt-Engine bzw. BgEngine).
- Laufende App holt genau das gepushte Event statt room.lastEvent.
- Debug-only DebugNotifyReceiver zum Testen per adb ohne Login.
Emulator (API 36): Verlauf, Ersetzen, Bild, Avatar, Gelesen geprueft;
9 neue Tests. Echtes Geraet (Bernd) steht aus.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 16:27:56 +02:00
Bernd SteckmeisterandClaude Opus 5.5 460477204b docs: Bernds Beta-Feedback analysiert - Ursachen (Push, Statusleiste, Vollansicht) + Plan + UI-Entscheidungen
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 12:32:36 +02:00
Bernd SteckmeisterandClaude Opus 5.5 dbc0340eeb docs: Wiederaufnahme 2026-10-07 - Tests/Benchmarks dokumentiert, SDK-Wettlauf + Abhaengigkeiten in ROADMAP
Neue Punkte: SDK-Sync-Wettlauf (Logout-Pfad pruefen), Abhaengigkeits-Updates
(sqlcipher_flutter_libs EOL - nie auf 0.7.0 heben, matrix 6.2 -> 13,
flutter_markdown eingestellt; WebRTC-Audio-Fix weiterhin nicht upstream).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 11:55:32 +02:00
Bernd SteckmeisterandClaude Opus 5.5 df1b392ed8 feat: Benchmarks (DB Klartext vs. SQLCipher, Migration) + scripts/test.ps1
benchmark/ misst Sync-Schreiben, App-Start, Chat oeffnen, lokale Suche und die
Migrationsdauer (A-B-B-A, Aufwaermlauf, Median), schreibt build/benchmarks/*.json
und vergleicht mit benchmark/baseline/. scripts/test.ps1 fuehrt Analyse,
Unit-Tests, optional Live-Test (-Live) und Benchmarks (-Bench) mit einem Befehl aus.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 11:55:30 +02:00
Bernd SteckmeisterandClaude Opus 5.5 f4d334a60b test: Live-E2E-Test gegen SDK-Sync-Wettlauf abgesichert (sporadisch rot)
matrix 6.2 wirft den Fehler einer veralteten Sync-Antwort, bevor es die
Request-ID prueft: die 401 eines alten Long-Polls nach dem Logout loeste ein
zweites clear() aus (nach dispose -> "databaseFactory not initialized").
_logoutWithoutStraySync haelt die Sync-Schleife an und laesst den Long-Poll
auslaufen. Danach 3/3 Laeufe gruen, 0 verspaetete Logout-Meldungen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 11:55:29 +02:00
Bernd SteckmeisterandClaude Opus 5.5 d5ff7982a0 refactor: lokale Nachrichtensuche als eigenes Modul (lib/features/search) + 5 Vertragstests
Verhaltensgleich aus search_modal.dart herausgeloest (gleiche Deckel 1500/60/500,
gleiche Schleife; Medien-Filter jetzt Parameter). Tests laufen ueber die echte
SDK-DB-Schicht mit synthetischen Daten (test/support/synthetic_matrix_data.dart).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 11:55:28 +02:00
107 changed files with 8948 additions and 857 deletions
+266
View File
@@ -13,6 +13,272 @@ Schritt (und beim Abbruch mitten im Schritt den Zwischenstand). Format:
--- ---
## 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):**
- **Push als Chat-Verlauf** (ef1d881): MessagingStyle mit den letzten 8
Nachrichten pro Raum, voller Text (Deckel 1000 statt 100), Antwort-Zitat
entfernt, Medien-Labels, Bildvorschau (E2EE entschlüsselt, FileProvider),
Avatare, „Gelesen“ setzt echte Lesebestätigung. Emulator: Verlauf,
Platzhalter-Ersetzen, Bild, Avatar, Gelesen geprüft (DebugNotifyReceiver,
nur Debug-Build).
- **Handy** (a1d64ec): edge-to-edge statt immersiveSticky + SafeArea
(Statusleiste überdeckt nichts mehr); Bild-Vollansicht randlos, Tippen
daneben/Wischen schließt, Doppeltipp zoomt; Bilder im Chat bis halbe
Bildschirmhöhe, Videos im echten Seitenverhältnis.
- **UI-Umbau** (c5f1090): Farbmodus Dunkel (neues weiches Dunkelgrau,
Standard) / Schwarz (alt) / Hell; Schriftgrößen-Regler (Handy-Standard
115 % × Systemgröße); Nachrichten Automatisch/Sprechblasen/Liste;
Einstellungen auf dem Handy als Vollbild; formatierte Nachrichten
(Antworten!) ignorierten die Schriftgröße (RichText → Text.rich).
- **Chatliste** (c38e9c1): Handy-Kachel mit Avatar, Uhrzeit, Vorschau der
letzten Nachricht, Ungelesen-Zähler; sichtbare englische Texte eingedeutscht.
- **Werkzeuge:** `integration_test/mobile_ui_screens_test.dart` (echte App im
Handy-Format mit Testprofil, prüft Vollansicht-Gesten, Screenshots nach
`build/ui_screens/`), `test/live_ui_fixture_test.dart` (Testinhalte),
`scripts/test.ps1 -Ui`. 60 Unit-Tests grün.
- **Version 0.1.37+38**, release-signierte APKs lokal gebaut
(`build/app/outputs/flutter-apk/`), Signatur-Zertifikat identisch mit
v0.1.36 (SHA-256 c3de66be…), Release-APK startet auf dem Emulator ohne
Fehler. NICHT auf Gitea veröffentlicht (Bernd testet zuerst).
- **SDK-Logout-Wettlauf** geschlossen: `lib/core/session_logout.dart`
(`logoutWithoutStraySync`) an allen drei Abmelde-Stellen; Live-Test
`test/live_logout_race_test.dart` (Leerlauf + direkt nach Login, je 45 s
beobachtet). Ehrliche Einordnung: verspätete 401 tritt real auf, kam aber
stets vor Abschluss der Neuanmeldung – Risiko war klein. E2E-Live-Test nutzt
jetzt denselben App-Baustein.
- **Android-Backup aus** (allowBackup=false, fullBackupContent=false,
dataExtractionRules schließt Cloud-Backup UND Geräteumzug aus): verhindert
den Aussperr-Fall „verschlüsselte DB ohne Schlüssel auf neuem Handy“ und
den Push-Token im Cloud-Backup. Gradle 8.13 → 8.14.3.
**Offen/Nächster Schritt:** Bernd testet 0.1.37 auf dem Handy (Push mit
mehreren Nachrichten + Bild, „Gelesen“, Statusleiste, Vollansicht-Gesten,
Sprechblasen, Schriftgröße). Danach ROADMAP: SDK-Logout-Wettlauf im
App-Pfad, Abhängigkeiten (sqlcipher_flutter_libs EOL, Gradle 8.13→8.14,
Kotlin-Plugin-Migration laut Flutter-Warnung).
**Stolperfallen:** (1) Simulierte Fensterklicks (PostMessage) erreichen
Flutter unter Windows nicht → UI-Prüfung über integration_test. (2) PowerShell
ohne DPI-Awareness bekommt bei 125 % Skalierung verkleinerte Fenstermaße →
abgeschnittene Screenshots (SetProcessDPIAware). (3) SharedPreferences sind
unter Windows NICHT pro PYRAMID_PROFILE_DIR getrennt – Testläufe teilen Bernds
Einstellungen (nur lesen, im UI-Test nichts speichern). (4) RichText
skaliert nicht mit MediaQuery.textScaler – immer Text.rich verwenden.
(5) apksigner braucht JAVA_HOME = Android-Studio-JBR (aus `flutter doctor -v`).
(6) Kein `shrinkImageMaxDimension: 0` an sendFileEvent (SDK verkleinert auf 0).
---
## 2026-10-07 (Teil 2) – Bernds Beta-Feedback analysiert, Plan steht (NOCH KEIN CODE)
**Bernds Feedback (Beta v0.1.36, Android):** Push kommt an, aber (a) beim
Aufklappen viel weniger Text als bei Element/WhatsApp, (b) immer nur EINE
Nachricht statt mehrerer, (c) keine Bildvorschau. Handy-UI gefällt nicht:
dunkle Farbe, Schrift zu klein, oben kein Platz (Statusleiste überlagert
Text), Bild-/Video-Vollansicht unnötig klein (durchsichtiger Rahmen), lässt
sich nicht durch Tippen ins Leere schließen. Wunsch: genereller UI-Overhaul;
danach Liste weiter, Fokus Stabilität + Flexibilität. Härtetest (PC_TESTPLAN 1)
und Call-Tests (3) kann er noch nicht machen.
**Bernds Entscheidungen (AskUserQuestion, 2026-10-07):** Farben = „Weiches
Dunkel“ (Dunkelgrau statt #0A0A0C, Hell bleibt Option); Nachrichten-
Darstellung = UMSCHALTBAR (Sprechblasen / Liste, Einstellung); Schrift =
größerer Standard (≈16 statt 14 px) + Regler unter Erscheinungsbild, System-
Schriftgröße weiter beachten.
**Ursachen (im Code verifiziert):**
- Push (b): `NotificationHelper.show` nutzt EINE notifId pro Raum
(`stableId(roomId)`) und ersetzt die alte Benachrichtigung komplett.
- Push (a): kein Style (nur `setContentText`) UND Dart kürzt hart auf 100
Zeichen (`_buildBody` in background_push.dart, `_watchEvents` +
`_decryptAndUpdateNotification` in notification_service.dart).
- Push (c): kein Bild-Pfad vorhanden.
- Statusleiste: `main.dart` setzt `SystemUiMode.immersiveSticky`, und
`_MobileShell` (app_shell.dart) hat kein SafeArea → Leiste liegt über
Kopfzeile (auch auf April-Screenshots sichtbar).
- Vollansicht: `media_viewer.dart` Inhalt = Bildschirm minus 24 px,
Barriere 70 % transparent; leere Fläche neben dem Bild gehört zum
GestureDetector (`_toggle`) → Tippen blendet nur Knöpfe aus.
- Zusatzbefunde: Knopf „Gelesen“ schließt nur, sendet KEINE Lesebestätigung;
`_decryptAndUpdateNotification` nimmt `room.lastEvent` statt des gepushten
Events; Access-Token liegt für PushService.kt im Klartext in
FlutterSharedPreferences (`notif_access_token`) – Sicherheitsnotiz.
**Plan / nächster konkreter Handgriff (je ein Commit):**
1. Push: Dart-Modul für Benachrichtigungs-Inhalt (Text ohne 100er-Kürzung,
Antwort-Zitat entfernt via `calcUnlocalizedBody(hideReply: true)`,
Medien-Labels, `notif_preview` auch im Hintergrund beachten) + Tests;
native `NotificationHelper` auf `MessagingStyle` (Verlauf pro Raum in
SharedPreferences, nur fortführen solange Benachrichtigung aktiv ist;
Ersetzen per eventId statt Anhängen beim Platzhalter→Klartext; neue
Nachricht = Alarm, Update = still); Bild: Dart lädt Thumbnail
(`downloadAndDecryptAttachment(getThumbnail: true)`) nach
`cacheDir/downloads/notif/`, native per FileProvider (`.fileprovider`,
Pfad `downloads/` existiert) + `grantUriPermission("com.android.systemui")`;
„Gelesen“ → echte Lesebestätigung (Haupt-Engine bzw. BgEngine `markRead`).
Test: Debug-only Receiver zum Auslösen auf dem Emulator (kein Login nötig),
dann Bernd auf echtem Gerät.
2. Handy: `edgeToEdge` statt immersiveSticky + SafeArea im MobileShell;
Vollansicht randlos/deckend schwarz, Tippen außerhalb des Bildes schließt,
Wischen nach unten schließt, Doppeltipp-Zoom.
3. UI-Overhaul nach Bernds Entscheidungen (Palette in theme.dart, globaler
TextScaler × Nutzer-Regler, Sprechblasen-Variante in chat/message/).
4. Danach ROADMAP-Liste (SDK-Logout-Wettlauf, Abhängigkeiten, …).
**Stolperfallen:** Android-Toolchain + Emulator `Medium_Phone_API_36.1`
laufen auf diesem PC (flutter doctor sauber). In die App auf dem Emulator
KEINE Zugangsdaten eintippen (echter Server) – UI im eingeloggten Zustand
per Windows-Build mit `PYRAMID_PROFILE_DIR=…\.pyramid-autopilot\profile1`
und schmalem Fenster (<900 px = Handy-Layout) prüfen.
---
## 2026-10-07 – Wiederaufnahme nach Pause: Bestandsaufnahme, Test-Runner, Benchmarks, Live-Test stabilisiert
**Erledigt:**
- **Bestandsaufnahme:** Repo synchron mit Gitea (0 vor/0 hinter), Arbeitsbaum
war sauber, Toolchain unverändert (Flutter 3.44.4 / Dart 3.12.2).
`flutter analyze` sauber (nur bekannter onUpdate-Hinweis), 36 Unit-Tests
grün. Autopilot (PC + Pi) weiterhin pausiert.
- **Lokale Suche als Modul:** `_localMessageSearch` aus `search_modal.dart`
nach `lib/features/search/local_message_search.dart` verschoben – gleiche
Deckel (1500/Raum, 60 Treffer, 500er-Blöcke), gleiche Schleife, nur der
Medien-Filter kommt jetzt als Parameter. 5 Vertragstests
(`test/local_message_search_test.dart`) auf der echten SDK-DB-Schicht.
- **Benchmarks** (`benchmark/`, `docs/TESTS_UND_BENCHMARKS.md`): Klartext vs.
SQLCipher (Sync-Schreiben, App-Start, Chat öffnen, Suche) + Migrationsdauer;
JSON-Berichte, Baseline in `benchmark/baseline/`, Regressionswarnung >25 %.
Ergebnis: SQLCipher-Aufschlag im Rauschen (±5–10 %), Migration ≈ 42 ms/MB,
Suche (Worst Case) 64 ms für 10 000 Nachrichten.
- **`scripts/test.ps1`:** Analyse + Unit-Tests (+ `-Live`, `-Bench`, `-All`,
`-UpdateBaseline`) mit Zusammenfassung; baut die SQLCipher-DLL bei Bedarf.
- **Live-Test sporadisch rot → Ursache gefunden, Test stabilisiert:** siehe
Befund unten. `live_encryption_e2e_test.dart` meldet sich jetzt über
`_logoutWithoutStraySync` ab (Sync anhalten, laufenden Long-Poll
auslaufen lassen, dann Logout). Danach 3 Läufe in Folge grün (Ergebnis
siehe unten).
**Befund (neu in ROADMAP, M1-Bereich):** matrix 6.2 `_innerSync` wirft den
Fehler einer Sync-Antwort, bevor geprüft wird, ob die Anfrage noch aktuell
ist. Die 401 eines alten Long-Polls nach einem Logout löst ein erneutes
`clear()` aus – nach einem schnellen Re-Login auf demselben Client würde das
die neue Session löschen. Im Test trat es als „databaseFactory not
initialized" nach `dispose()` auf. Upstream `main` unverändert (geprüft).
App-Logout-Pfad noch NICHT angefasst (heilig, braucht Gerätetest).
**Offen/Nächster Schritt:** Bernds Rückmeldungen aus der Beta-Testphase
(v0.1.36) einsammeln, dann Prioritäten setzen: Klick-Tests aus
`docs/PC_TESTPLAN.md`, App-Logout-Pfad gegen den SDK-Wettlauf prüfen,
Abhängigkeits-Updates (ROADMAP M2, Achtung `sqlcipher_flutter_libs` EOL).
**Stolperfallen:** (1) sqflite liefert für denselben Pfad dieselbe
Verbindung zurück – ein zweites Öffnen+Schließen kappt die erste
(Benchmark-„database_closed"). (2) Benchmark-Reihenfolge: der Dart-JIT wird
während des Laufs schneller, der zuerst gemessene Modus verlor ±20 % →
großer Aufwärm-Durchlauf + A-B-B-A. (3) `dart format` NUR auf neue Dateien
anwenden – Bestandsdateien sind nicht formatiert, sonst riesiger Diff.
(4) Vor einer Aussage nachmessen: Schreibkosten hängen NICHT nennenswert von
der Raumgröße ab (250 vs. 4000 Nachrichten/Raum ≈ +15 %).
---
## 2026-07-06 – Nacht-Session (C+B): 60-FPS-Streaming + Call mit ZWEI echten Test-Accounts real durchgeklickt ## 2026-07-06 – Nacht-Session (C+B): 60-FPS-Streaming + Call mit ZWEI echten Test-Accounts real durchgeklickt
**Erledigt:** Fokus (C) Streaming und (B) Calls – beides mit zwei echten **Erledigt:** Fokus (C) Streaming und (B) Calls – beides mit zwei echten
+2
View File
@@ -25,6 +25,7 @@ für die unterstützte Dart-Version).
flutter pub get flutter pub get
flutter run -d windows # schnellster Praxistest während der Entwicklung flutter run -d windows # schnellster Praxistest während der Entwicklung
flutter analyze # muss sauber bleiben flutter analyze # muss sauber bleiben
pwsh scripts/test.ps1 # Analyse + Tests (-Live, -Bench, -All: siehe docs/TESTS_UND_BENCHMARKS.md)
``` ```
Unterstützte Zielplattformen: Android, Windows, Linux, iOS, macOS (Ordner Unterstützte Zielplattformen: Android, Windows, Linux, iOS, macOS (Ordner
@@ -52,5 +53,6 @@ lib/
- `CLAUDE.md` – Arbeitsregeln und Infrastruktur für die Zusammenarbeit mit Claude - `CLAUDE.md` – Arbeitsregeln und Infrastruktur für die Zusammenarbeit mit Claude
- `ROADMAP.md` / `PROGRESS.md` – aktueller Stand und nächste Schritte - `ROADMAP.md` / `PROGRESS.md` – aktueller Stand und nächste Schritte
- `docs/TESTS_UND_BENCHMARKS.md` – Tests, Live-Tests, Benchmarks (ein Befehl)
- `docs/NOTIFICATIONS.md` – Push-Architektur (native Anzeige vs. Hintergrund-Entschlüsselung) - `docs/NOTIFICATIONS.md` – Push-Architektur (native Anzeige vs. Hintergrund-Entschlüsselung)
- `docs/matrix-sdk/` – Hinweise zum verwendeten Matrix-SDK - `docs/matrix-sdk/` – Hinweise zum verwendeten Matrix-SDK
+70 -3
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` 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 (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 = (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`/ geprüfte user_id). **Client umgestellt 2026-10-07 (756486d):** kein Secret
`livekit_call_manager.dart`) + Secret-Rotation – beides PC/Gerät (heiliger mehr in der App, echter LiveKit-Beitritt mit Testkonto grün
Call-Pfad), siehe `docs/LIVEKIT_TOKEN_MIGRATION.md`. (`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), - [ ] **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 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: 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. `apiSecret`, Giphy-Key. Alte jeweils widerrufen.
- **LiveKit-Token server-seitig minten:** kleiner Token-Endpoint auf dem Pi, damit - **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). `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, **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. 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 - **E2EE-Diagnose-Upload: ERLEDIGT (2026-07-04).** Der Dashboard-Server auf dem Pi
@@ -160,6 +163,13 @@ Punkte abhaken (`[x]`), wenn erledigt UND in `PROGRESS.md` protokolliert.
Gitea-API (`/api/v1/repos/steggi/pyramid/releases`). Repo privat = Utas App Gitea-API (`/api/v1/repos/steggi/pyramid/releases`). Repo privat = Utas App
findet keine Updates mehr (und ein Fix ließe sich nicht mehr per Update findet keine Updates mehr (und ein Fix ließe sich nicht mehr per Update
verteilen – Henne-Ei). Erst Updater-Strategie klären, dann Sichtbarkeit ändern. verteilen – Henne-Ei). Erst Updater-Strategie klären, dann Sichtbarkeit ändern.
- [x] **Android-Backup/Geräteumzug abgeschaltet** (2026-10-07): ohne
`allowBackup=false` + `dataExtractionRules` wäre die SQLCipher-DB beim
Handywechsel ohne ihren Keystore-Schlüssel mitgezogen → App startet
nicht (Aussperr-Fall), und der Push-Zugangsschlüssel aus den
SharedPreferences läge im Cloud-Backup. Neues Gerät = neu anmelden +
Schlüssel per Wiederherstellungsschlüssel. Im APK geprüft (aapt2,
`dumpsys package`: kein ALLOW_BACKUP mehr).
- [ ] Härtetest dokumentieren: Login → Nachrichten → Logout → Login neu → alles noch lesbar - [ ] Härtetest dokumentieren: Login → Nachrichten → Logout → Login neu → alles noch lesbar
(gehört zum ausstehenden PC-Praxistest der beiden Bugfixes oben; (gehört zum ausstehenden PC-Praxistest der beiden Bugfixes oben;
kompletter Ablauf inkl. aller aufgelaufenen UNGETESTET-Punkte steht kompletter Ablauf inkl. aller aufgelaufenen UNGETESTET-Punkte steht
@@ -169,6 +179,28 @@ Punkte abhaken (`[x]`), wenn erledigt UND in `PROGRESS.md` protokolliert.
E2EE-Empfang live belegt, auth_log unauffällig; App läuft seit 12:43 E2EE-Empfang live belegt, auth_log unauffällig; App läuft seit 12:43
für die Langzeit-Beobachtung weiter. **Offen (Klick/Bernd):** für die Langzeit-Beobachtung weiter. **Offen (Klick/Bernd):**
Senden, Logout-Warnung, Re-Login mit Lesbarkeits-Check. Senden, Logout-Warnung, Re-Login mit Lesbarkeits-Check.
- [x] **SDK-Wettlauf: verspätete 401 einer alten Sync-Anfrage löscht ggf. die
NEUE Session** – ERLEDIGT 2026-10-07: alle App-Abmeldungen laufen über
`logoutWithoutStraySync` (lib/core/session_logout.dart: Sync anhalten,
Long-Poll per Account-Daten wecken, auslaufen lassen, dann abmelden).
Live gemessen (test/live_logout_race_test.dart): die verspätete 401 tritt
real auf, kam aber in allen Läufen an, BEVOR die Neuanmeldung fertig
war – reales Risiko in der App klein (Neuanmeldung binnen ~1 s nötig),
jetzt ausgeschlossen; Regressionstest in `scripts/test.ps1 -Live`.
Ursprüngliche Analyse: (gefunden 2026-10-07 über einen sporadisch roten
Live-Test, siehe PROGRESS.md). matrix 6.2 `Client._innerSync` wirft den
Fehler einer Sync-Antwort, BEVOR geprüft wird, ob die Anfrage noch
aktuell ist (`_currentSyncId`). Läuft beim Logout noch ein Long-Poll
(bis 30 s) mit dem alten Token, kommt dessen M_UNKNOWN_TOKEN danach an
und das SDK ruft `clear()` – nach einem schnellen Re-Login auf
demselben Client wäre das ein „Random-Logout" direkt nach dem Login.
Upstream (famedly/matrix-dart-sdk `main`, 2026-10-07) unverändert;
neuere SDKs versuchen bei vorhandenem Refresh-Token aber zuerst einen
Refresh statt `clear()` (entschärft). Im Live-Test per
`_logoutWithoutStraySync` umgangen. **Offen:** App-Logout-Pfad prüfen
(läuft der Re-Login auf demselben Client-Objekt? dann vor
`client.logout()` Sync anhalten + laufenden Long-Poll abwarten) –
heiliger Pfad, nur mit Gerätetest; ggf. Upstream-Issue melden.
## M2 – Das große Refactoring (erst nach M0/M1!) ## M2 – Das große Refactoring (erst nach M0/M1!)
@@ -253,6 +285,35 @@ lassen, ohne dass andere Module angefasst werden müssen.
prüfen), siehe „Fragen an Bernd"/Nächster-Schritt in den jeweiligen prüfen), siehe „Fragen an Bernd"/Nächster-Schritt in den jeweiligen
PROGRESS.md-Einträgen) PROGRESS.md-Einträgen)
- [ ] Nach JEDEM Schritt: App läuft, Kernflows getestet, Commit - [ ] Nach JEDEM Schritt: App läuft, Kernflows getestet, Commit
- [x] Test-/Benchmark-Gerüst: `scripts/test.ps1` (Analyse, Unit-, Live-Tests,
Benchmarks mit einem Befehl), `benchmark/` mit Baseline, lokale Suche
als testbares Modul (`lib/features/search/`). Siehe
`docs/TESTS_UND_BENCHMARKS.md`, PROGRESS.md 2026-10-07.
- [ ] **Abhängigkeiten aktualisieren (3 Monate Stand, 119 Pakete veraltet)** –
einzeln, jeweils mit `scripts/test.ps1 -All` abgesichert:
- **`sqlcipher_flutter_libs` ist EOL:** 0.7.0 ist ein leerer Platzhalter
(pub.dev). Unsere Bindung `^0.6.8` schließt ihn aus – NIEMALS per
`pub upgrade --major-versions` mitziehen, sonst fehlt SQLCipher und
die App kann die verschlüsselte DB nicht mehr öffnen
(`AppDatabase` wirft dann bewusst, kein Datenverlust, aber Aussperren).
Nachfolger: `sqlite3` 3.x mit `hooks: user_defines: sqlite3: source:
sqlcipher` – Kompatibilität mit `sqflite_common_ffi` und Android
vorher klären; Migrations-/DB-Tests + Gerätetest Pflicht.
- `matrix` 6.2 → 13.x (großer Sprung, Breaking Changes; heiliger
Login-/Krypto-Pfad), `flutter_vodozemac` 0.5 → 0.8 gehört dazu.
- `livekit_client` 2.7 → 2.13 (innerhalb der Bindung möglich),
`flutter_webrtc`-Override 1.4.1 → 1.6.2: der Windows-Audiogeräte-Fix
(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). „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) ## M3 – Calls: Logik, UI & Audio (Discord als Referenz)
@@ -316,6 +377,12 @@ lassen, ohne dass andere Module angefasst werden müssen.
## M7 – Design & Feinschliff ## M7 – Design & Feinschliff
- [x] **Beta-Feedback 2026-10-07 umgesetzt (UNGETESTET auf echtem Handy):**
Push als Chat-Verlauf mit Bildvorschau + echtem „Gelesen“, Statusleiste
überdeckt nichts mehr, Bild-Vollansicht mit Gesten, weiches Dunkel,
Schriftgrößen-Regler, Sprechblasen umschaltbar, Messenger-Chatliste,
deutsche Beschriftungen. Test-Version 0.1.37 (siehe PROGRESS.md).
- [ ] Rooms-Icon überarbeiten – passt stilistisch nicht zu den anderen Buttons - [ ] Rooms-Icon überarbeiten – passt stilistisch nicht zu den anderen Buttons
- [ ] Design-Konsistenz-Pass über alle Icons/Buttons - [ ] Design-Konsistenz-Pass über alle Icons/Buttons
- [ ] Onboarding: erster Start erklärt Server-Wahl und Verifikation verständlich - [ ] Onboarding: erster Start erklärt Server-Wahl und Verifikation verständlich
@@ -4,4 +4,10 @@
to allow setting breakpoints, to provide hot reload, etc. to allow setting breakpoints, to provide hot reload, etc.
--> -->
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<application>
<!-- NUR Debug: Benachrichtigungen per adb auslösen (DebugNotifyReceiver.kt). -->
<receiver
android:name=".DebugNotifyReceiver"
android:exported="true" />
</application>
</manifest> </manifest>
@@ -0,0 +1,55 @@
package chat.pyramid.pyramid
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import java.io.File
/**
* NUR IM DEBUG-BUILD (src/debug): löst Nachrichten-Benachrichtigungen per adb
* aus, um NotificationHelper ohne Login/Push auf dem Emulator zu prüfen.
* Ist nicht im Release-APK enthalten.
*
* adb shell am broadcast -n chat.pyramid.pyramid/.DebugNotifyReceiver \
* --es sender Uta --es body "Hallo" [--es roomName Familie] \
* [--es event e1] [--ez image true] [--ez avatar true] [--es room !r:x]
*/
class DebugNotifyReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val roomId = intent.getStringExtra("room") ?: "!debug:pyramid.local"
val sender = intent.getStringExtra("sender") ?: "Testnutzer"
val roomName = intent.getStringExtra("roomName")
val eventId = intent.getStringExtra("event") ?: "\$debug${System.nanoTime()}"
val args = mapOf(
"roomId" to roomId,
"notifId" to NotificationHelper.stableId(roomId),
"eventId" to eventId,
"senderId" to "@${sender.lowercase()}:pyramid.local",
"senderName" to sender,
"roomName" to roomName,
"isGroup" to (roomName != null),
"body" to (intent.getStringExtra("body") ?: NotificationHelper.PLACEHOLDER_BODY),
"timestamp" to System.currentTimeMillis(),
"imagePath" to if (intent.getBooleanExtra("image", false)) testImage(context, "img_debug", 960, 640) else null,
"avatarPath" to if (intent.getBooleanExtra("avatar", false)) testImage(context, "avatar_$sender", 256, 256) else null,
)
NotificationHelper.showFromArgs(context, args)
}
/** Erzeugt ein buntes Testbild unter cache/downloads/notif/ (FileProvider-Pfad). */
private fun testImage(context: Context, name: String, w: Int, h: Int): String {
val dir = File(context.cacheDir, "downloads/notif").apply { mkdirs() }
val file = File(dir, name)
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
val c = Canvas(bmp)
c.drawColor(Color.rgb(0x3B, 0x6D, 0x11))
val p = Paint().apply { color = Color.rgb(0xF5, 0xA6, 0x14); isAntiAlias = true }
c.drawCircle(w * 0.5f, h * 0.5f, minOf(w, h) * 0.3f, p)
file.outputStream().use { bmp.compress(Bitmap.CompressFormat.JPEG, 90, it) }
return file.absolutePath
}
}
+13 -3
View File
@@ -9,6 +9,7 @@
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <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_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.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.VIBRATE"/> <uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
@@ -17,15 +18,24 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/> <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
<!-- Kein Backup/Umzug der App-Daten: siehe res/xml/data_extraction_rules.xml
(verschlüsselte DB ohne Keystore-Schlüssel = App startet nicht). -->
<application <application
android:label="Pyramid" android:label="Pyramid"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher"
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 <service
android:name="com.cloudwebrtc.webrtc.FlutterForegroundService" android:name=".CallService"
android:enabled="true" android:enabled="true"
android:exported="false" android:exported="false"
android:foregroundServiceType="mediaProjection" /> android:foregroundServiceType="microphone|mediaProjection" />
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
@@ -35,14 +35,17 @@ object BgEngine {
private val fallbacks = mutableMapOf<Int, Runnable>() private val fallbacks = mutableMapOf<Int, Runnable>()
/** Schedule a placeholder shown after [delayMs] unless decryption beats it. */ /** Schedule a placeholder shown after [delayMs] unless decryption beats it. */
fun scheduleFallback(context: Context, title: String, roomId: String, notifId: Int, delayMs: Long) { fun scheduleFallback(
context: Context, meta: PushService.PushMeta, roomId: String, notifId: Int,
eventId: String?, delayMs: Long,
) {
val ctx = context.applicationContext val ctx = context.applicationContext
appContext = ctx appContext = ctx
fallbackHandler.post { fallbackHandler.post {
cancelFallback(notifId) cancelFallback(notifId)
val r = Runnable { val r = Runnable {
fallbacks.remove(notifId) fallbacks.remove(notifId)
NotificationHelper.show(ctx, title, "Neue Nachricht", roomId, notifId) meta.showPlaceholder(ctx, roomId, notifId, eventId)
Log.d(TAG, "fallback placeholder shown for $roomId (decrypt too slow)") Log.d(TAG, "fallback placeholder shown for $roomId (decrypt too slow)")
} }
fallbacks[notifId] = r fallbacks[notifId] = r
@@ -114,16 +117,19 @@ object BgEngine {
} }
// Dart finished decrypting — show the real content using the SAME // Dart finished decrypting — show the real content using the SAME
// native notification (keeps the reliable MainActivity reply action). // native notification (keeps the reliable MainActivity reply action).
// May arrive twice per event: first the text, then silently
// the same event with avatar/image (NotificationHelper merges).
"showDecrypted" -> { "showDecrypted" -> {
val title = call.argument<String>("title") ?: "" @Suppress("UNCHECKED_CAST")
val body = call.argument<String>("body") ?: "Neue Nachricht" val args = (call.arguments as? Map<String, Any?>) ?: emptyMap()
val roomId = call.argument<String>("room_id") ?: "" val roomId = (args["roomId"] ?: args["room_id"]) as? String ?: ""
val notifId = call.argument<Int>("notif_id") ?: NotificationHelper.stableId(roomId) val notifId = ((args["notifId"] ?: args["notif_id"]) as? Number)?.toInt()
?: NotificationHelper.stableId(roomId)
val ctx = appContext val ctx = appContext
if (ctx != null && roomId.isNotEmpty()) { if (ctx != null && roomId.isNotEmpty()) {
// Beat the fallback placeholder to it — show decrypted directly. // Beat the fallback placeholder to it — show decrypted directly.
cancelFallback(notifId) cancelFallback(notifId)
NotificationHelper.show(ctx, title, body, roomId, notifId) NotificationHelper.showFromArgs(ctx, args)
Log.d(TAG, "showDecrypted → shown decrypted notification for $roomId") Log.d(TAG, "showDecrypted → shown decrypted notification for $roomId")
} }
result.success(null) result.success(null)
@@ -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) super.configureFlutterEngine(flutterEngine)
// Cache so ReplyReceiver can call back into Dart without starting a new engine. // Cache so ReplyReceiver can call back into Dart without starting a new engine.
FlutterEngineCache.getInstance().put("main", flutterEngine) 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) MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channel)
.setMethodCallHandler { call, result -> .setMethodCallHandler { call, result ->
when (call.method) { when (call.method) {
@@ -206,12 +239,11 @@ class MainActivity : FlutterActivity() {
result.success(null) result.success(null)
} }
"showNativeNotification" -> { "showNativeNotification" -> {
val title = call.argument<String>("title") ?: "" @Suppress("UNCHECKED_CAST")
val body = call.argument<String>("body") ?: "" val args = (call.arguments as? Map<String, Any?>) ?: emptyMap()
val roomId = call.argument<String>("roomId") ?: "" val roomId = args["roomId"] as? String ?: ""
val notifId = call.argument<Int>("notifId") ?: NotificationHelper.stableId(roomId)
if (roomId.isNotEmpty()) { if (roomId.isNotEmpty()) {
NotificationHelper.show(this, title, body, roomId, notifId) NotificationHelper.showFromArgs(this, args)
result.success(null) result.success(null)
} else { } else {
result.error("INVALID_ARG", "roomId missing", null) result.error("INVALID_ARG", "roomId missing", null)
@@ -5,13 +5,79 @@ import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Color import android.graphics.Color
import android.net.Uri
import android.os.Build import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import androidx.core.app.RemoteInput import androidx.core.app.RemoteInput
import androidx.core.content.FileProvider
import androidx.core.graphics.drawable.IconCompat
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
/**
* Native Nachrichten-Benachrichtigungen (alle Pfade: Platzhalter aus
* PushService, Klartext aus der Haupt-Engine oder dem BgEngine-Isolate).
*
* Pro Raum gibt es EINE Benachrichtigung (notifId = stableId(roomId)), die als
* Chat-Verlauf (MessagingStyle) die letzten [MAX_MESSAGES] Nachrichten zeigt –
* wie WhatsApp/Element. Der Verlauf liegt in SharedPreferences, weil bei
* beendeter App jeder Push in einem neuen Prozess ankommt. Er wird nur
* fortgeführt, solange die Benachrichtigung noch sichtbar ist: hat der Nutzer
* sie weggewischt oder den Raum in der App geöffnet (Dart cancelt), beginnt
* der nächste Push mit einem leeren Verlauf.
*
* Gleiche eventId = Aktualisierung (Platzhalter → Klartext, Text → Text+Bild):
* die Nachricht wird ersetzt statt doppelt angehängt, und es klingelt nicht
* erneut. Nur eine wirklich neue Nachricht alarmiert.
*/
object NotificationHelper { object NotificationHelper {
const val CHANNEL_ID = "pyramid_messages" const val CHANNEL_ID = "pyramid_messages"
const val PLACEHOLDER_BODY = "Neue Nachricht"
private const val TAG = "PYRAMID-NOTIF"
private const val STORE = "pyramid_notif_history"
private const val MAX_MESSAGES = 8
private const val SYSTEM_UI = "com.android.systemui"
/** Eine Nachricht im Verlauf einer Raum-Benachrichtigung. */
data class Msg(
val eventId: String?,
val senderKey: String,
val senderName: String,
val body: String,
val timestamp: Long,
val imagePath: String? = null,
val avatarPath: String? = null,
) {
val isPlaceholder get() = body == PLACEHOLDER_BODY
fun toJson(): JSONObject = JSONObject().apply {
put("eventId", eventId ?: JSONObject.NULL)
put("senderKey", senderKey)
put("senderName", senderName)
put("body", body)
put("timestamp", timestamp)
put("imagePath", imagePath ?: JSONObject.NULL)
put("avatarPath", avatarPath ?: JSONObject.NULL)
}
companion object {
fun fromJson(o: JSONObject) = Msg(
eventId = o.optStringOrNull("eventId"),
senderKey = o.optString("senderKey"),
senderName = o.optString("senderName"),
body = o.optString("body"),
timestamp = o.optLong("timestamp"),
imagePath = o.optStringOrNull("imagePath"),
avatarPath = o.optStringOrNull("avatarPath"),
)
}
}
/** Deterministic notification ID — same algorithm as Dart's _stableRoomId. */ /** Deterministic notification ID — same algorithm as Dart's _stableRoomId. */
fun stableId(roomId: String): Int { fun stableId(roomId: String): Int {
@@ -32,15 +98,226 @@ object NotificationHelper {
} }
} }
fun show(context: Context, title: String, body: String, roomId: String, notifId: Int) { /**
* Platzhalter/Alt-Aufrufer mit fertigem Titel „Absender · Raum“ (bzw. nur
* „Absender“ bei Direktnachrichten).
*/
fun show(
context: Context,
title: String,
body: String,
roomId: String,
notifId: Int,
eventId: String? = null,
) {
val parts = title.split(" · ", limit = 2)
val sender = parts[0].ifBlank { "Pyramid" }
val roomName = parts.getOrNull(1)?.takeIf { it.isNotBlank() }
showMessage(
context, roomId, notifId, roomName, isGroup = roomName != null,
msg = Msg(eventId, sender, sender, body, System.currentTimeMillis()),
)
}
/** Strukturierter Aufruf aus Dart (`toNativeArgs` in notification_content.dart). */
fun showFromArgs(context: Context, args: Map<String, Any?>) {
val roomId = (args["roomId"] ?: args["room_id"]) as? String ?: return
if (roomId.isEmpty()) return
val notifId = ((args["notifId"] ?: args["notif_id"]) as? Number)?.toInt()
?: stableId(roomId)
val senderName = args["senderName"] as? String
if (senderName == null) {
// Alter Aufrufer ohne strukturierte Felder.
show(
context, args["title"] as? String ?: "",
args["body"] as? String ?: PLACEHOLDER_BODY,
roomId, notifId, args["eventId"] as? String,
)
return
}
val roomName = (args["roomName"] as? String)?.takeIf { it.isNotBlank() }
showMessage(
context, roomId, notifId, roomName,
isGroup = (args["isGroup"] as? Boolean) ?: (roomName != null),
msg = Msg(
eventId = args["eventId"] as? String,
senderKey = args["senderId"] as? String ?: senderName,
senderName = senderName,
body = args["body"] as? String ?: PLACEHOLDER_BODY,
timestamp = (args["timestamp"] as? Number)?.toLong() ?: System.currentTimeMillis(),
imagePath = args["imagePath"] as? String,
avatarPath = args["avatarPath"] as? String,
),
)
}
/** Fügt [msg] dem Verlauf des Raums hinzu (oder aktualisiert sie) und zeigt ihn. */
fun showMessage(
context: Context,
roomId: String,
notifId: Int,
roomName: String?,
isGroup: Boolean,
msg: Msg,
) {
ensureChannel(context) ensureChannel(context)
val (history, isNew) = mergeIntoHistory(context, roomId, notifId, msg)
val latest = history.last()
val mutFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) val style = NotificationCompat.MessagingStyle(
PendingIntent.FLAG_MUTABLE else 0 Person.Builder().setName("Du").setKey("me").build()
)
if (isGroup && roomName != null) {
style.conversationTitle = roomName
style.isGroupConversation = true
}
for (m in history) {
val person = Person.Builder()
.setName(m.senderName)
.setKey(m.senderKey)
.apply {
contentUri(context, m.avatarPath)?.let { setIcon(IconCompat.createWithContentUri(it)) }
}
.build()
val message = NotificationCompat.MessagingStyle.Message(m.body, m.timestamp, person)
contentUri(context, m.imagePath)?.let { message.setData("image/jpeg", it) }
style.addMessage(message)
}
// Reply action — BroadcastReceiver so replying NEVER opens the app. val builder = NotificationCompat.Builder(context, CHANNEL_ID)
// ReplyReceiver decrypts/sends via the background engine when killed. .setSmallIcon(R.drawable.ic_notification)
// (ReplyReceiver has multiple RemoteInput key fallbacks for OEM quirks.) .setColor(Color.rgb(0x7B, 0x61, 0xFF))
.setStyle(style)
// Fallback-Texte für Geräte/Ansichten ohne MessagingStyle.
.setContentTitle(if (isGroup && roomName != null) roomName else latest.senderName)
.setContentText(latest.body)
.setWhen(latest.timestamp)
.setShowWhen(true)
.setNumber(history.size)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setPriority(NotificationCompat.PRIORITY_HIGH)
// Nur eine wirklich neue Nachricht klingelt; Aktualisierungen
// (Platzhalter → Klartext, Bild nachgeladen) ersetzen still.
.setOnlyAlertOnce(!isNew)
.setContentIntent(tapIntent(context, roomId, notifId))
.setAutoCancel(true)
.addAction(replyAction(context, roomId, notifId))
.addAction(markReadAction(context, roomId, notifId, latest.eventId))
.setGroup(CHANNEL_ID)
if (!isGroup) {
avatarBitmap(latest.avatarPath)?.let { builder.setLargeIcon(it) }
}
try {
nm(context).notify(notifId, builder.build())
} catch (e: Exception) {
// Sicherheitsnetz: lieber ohne Bilder/Avatare als gar nicht.
Log.w(TAG, "notify failed (${e.message}) — retrying as plain text")
builder.setLargeIcon(null as Bitmap?)
.setStyle(NotificationCompat.BigTextStyle().bigText(latest.body))
nm(context).notify(notifId, builder.build())
}
}
/** Vergisst den Verlauf eines Raums (nach Antworten/„Gelesen“). */
@Synchronized
fun clearHistory(context: Context, roomId: String) {
prefs(context).edit().remove(roomId).apply()
}
@Synchronized
private fun mergeIntoHistory(
context: Context, roomId: String, notifId: Int, msg: Msg,
): Pair<List<Msg>, Boolean> {
val prefs = prefs(context)
val list = if (isActive(context, notifId)) load(prefs.getString(roomId, null)) else mutableListOf()
val index = msg.eventId?.let { id -> list.indexOfFirst { it.eventId == id } } ?: -1
val isNew = index < 0
if (isNew) {
list.add(msg)
} else {
val old = list[index]
// Ein später eintreffender Platzhalter überschreibt nie echten Text.
val keepOld = msg.isPlaceholder && !old.isPlaceholder
list[index] = Msg(
eventId = old.eventId,
senderKey = if (keepOld) old.senderKey else msg.senderKey,
senderName = if (keepOld) old.senderName else msg.senderName,
body = if (keepOld) old.body else msg.body,
timestamp = if (old.isPlaceholder) msg.timestamp else old.timestamp,
imagePath = msg.imagePath ?: old.imagePath,
avatarPath = msg.avatarPath ?: old.avatarPath,
)
}
list.sortBy { it.timestamp }
while (list.size > MAX_MESSAGES) list.removeAt(0)
prefs.edit().putString(roomId, JSONArray(list.map { it.toJson() }).toString()).apply()
return list to isNew
}
private fun load(json: String?): MutableList<Msg> {
if (json == null) return mutableListOf()
return try {
val arr = JSONArray(json)
MutableList(arr.length()) { Msg.fromJson(arr.getJSONObject(it)) }
} catch (_: Exception) {
mutableListOf()
}
}
/** Ist die Benachrichtigung noch sichtbar? (Sonst beginnt ein neuer Verlauf.) */
private fun isActive(context: Context, notifId: Int): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
return try {
nm(context).activeNotifications.any { it.id == notifId }
} catch (_: Exception) {
true
}
}
/**
* content://-URI für eine Datei unter cache/downloads/ (FileProvider). Die
* System-UI zeichnet die Benachrichtigung und braucht dafür Leserecht.
*/
private fun contentUri(context: Context, path: String?): Uri? {
if (path.isNullOrEmpty()) return null
val file = File(path)
if (!file.exists()) return null
return try {
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
context.grantUriPermission(SYSTEM_UI, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
uri
} catch (e: Exception) {
Log.w(TAG, "contentUri failed for $path: ${e.message}")
null
}
}
/** Kleines Avatar-Bitmap (≈192 px) für das große Icon bei Direktnachrichten. */
private fun avatarBitmap(path: String?): Bitmap? {
if (path.isNullOrEmpty() || !File(path).exists()) return null
return try {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(path, bounds)
var sample = 1
while (bounds.outWidth / (sample * 2) >= 192 && bounds.outHeight / (sample * 2) >= 192) {
sample *= 2
}
BitmapFactory.decodeFile(path, BitmapFactory.Options().apply { inSampleSize = sample })
} catch (_: Exception) {
null
}
}
// ── Aktionen ──────────────────────────────────────────────────────────────
private fun mutableFlag() =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
// Reply action — BroadcastReceiver so replying NEVER opens the app.
// ReplyReceiver decrypts/sends via the background engine when killed.
// (ReplyReceiver has multiple RemoteInput key fallbacks for OEM quirks.)
private fun replyAction(context: Context, roomId: String, notifId: Int): NotificationCompat.Action {
val replyInput = RemoteInput.Builder(ReplyReceiver.REMOTE_INPUT_KEY) val replyInput = RemoteInput.Builder(ReplyReceiver.REMOTE_INPUT_KEY)
.setLabel("Antworten…").build() .setLabel("Antworten…").build()
val replyIntent = Intent(context, ReplyReceiver::class.java).apply { val replyIntent = Intent(context, ReplyReceiver::class.java).apply {
@@ -49,59 +326,57 @@ object NotificationHelper {
putExtra(ReplyReceiver.EXTRA_NOTIF_ID, notifId) putExtra(ReplyReceiver.EXTRA_NOTIF_ID, notifId)
} }
val replyPi = PendingIntent.getBroadcast( val replyPi = PendingIntent.getBroadcast(
context, notifId, context, notifId, replyIntent,
replyIntent, PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag(),
PendingIntent.FLAG_UPDATE_CURRENT or mutFlag,
) )
val replyAction = NotificationCompat.Action.Builder(0, "Antworten", replyPi) return NotificationCompat.Action.Builder(0, "Antworten", replyPi)
.addRemoteInput(replyInput) .addRemoteInput(replyInput)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY) .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
.setShowsUserInterface(false)
.build() .build()
}
// Dismiss action — also points to ReplyReceiver so notification is cancelled. // „Gelesen“ — schließt die Benachrichtigung UND setzt die Lesebestätigung
val dismissIntent = Intent(context, ReplyReceiver::class.java).apply { // bis zur neuesten Nachricht (ReplyReceiver → Haupt-Engine bzw. BgEngine).
private fun markReadAction(
context: Context, roomId: String, notifId: Int, eventId: String?,
): NotificationCompat.Action {
val intent = Intent(context, ReplyReceiver::class.java).apply {
action = ReplyReceiver.ACTION_DISMISS action = ReplyReceiver.ACTION_DISMISS
putExtra(ReplyReceiver.EXTRA_ROOM_ID, roomId) putExtra(ReplyReceiver.EXTRA_ROOM_ID, roomId)
putExtra(ReplyReceiver.EXTRA_NOTIF_ID, notifId) putExtra(ReplyReceiver.EXTRA_NOTIF_ID, notifId)
putExtra(ReplyReceiver.EXTRA_EVENT_ID, eventId)
} }
val dismissPi = PendingIntent.getBroadcast( val pi = PendingIntent.getBroadcast(
context, notifId + 1_000_000, context, notifId + 1_000_000, intent,
dismissIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
) )
return NotificationCompat.Action.Builder(0, "Gelesen", pi)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ)
.setShowsUserInterface(false)
.build()
}
// Tap action — opens MainActivity to the right room. // Tap action — opens MainActivity to the right room.
private fun tapIntent(context: Context, roomId: String, notifId: Int): PendingIntent {
val tapIntent = Intent(context, MainActivity::class.java).apply { val tapIntent = Intent(context, MainActivity::class.java).apply {
action = Intent.ACTION_MAIN action = Intent.ACTION_MAIN
addCategory(Intent.CATEGORY_LAUNCHER) addCategory(Intent.CATEGORY_LAUNCHER)
putExtra("room_id", roomId) putExtra("room_id", roomId)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
} }
val tapPi = PendingIntent.getActivity( return PendingIntent.getActivity(
context, notifId + 2_000_000, context, notifId + 2_000_000, tapIntent,
tapIntent, PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag(),
PendingIntent.FLAG_UPDATE_CURRENT or mutFlag,
) )
val notif = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setColor(Color.rgb(0x7B, 0x61, 0xFF))
.setContentTitle(title)
.setContentText(body)
.setPriority(NotificationCompat.PRIORITY_HIGH)
// Only buzz/heads-up on the first show — the decrypted update reuses
// the same notifId and replaces the text silently (no second alert).
.setOnlyAlertOnce(true)
.setContentIntent(tapPi)
.setAutoCancel(true)
.addAction(replyAction)
.addAction(NotificationCompat.Action(0, "Gelesen", dismissPi))
.setGroup(CHANNEL_ID)
.build()
nm(context).notify(notifId, notif)
} }
private fun prefs(context: Context) =
context.getSharedPreferences(STORE, Context.MODE_PRIVATE)
private fun nm(context: Context) = private fun nm(context: Context) =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
} }
private fun JSONObject.optStringOrNull(key: String): String? =
if (!has(key) || isNull(key)) null else optString(key).takeIf { it.isNotEmpty() }
@@ -41,23 +41,23 @@ class PushService : FirebaseMessagingService() {
val token = (prefs.getString("flutter.notif_access_token", "") ?: "") val token = (prefs.getString("flutter.notif_access_token", "") ?: "")
// Fetch sender + room name on a background thread (main thread forbids I/O). // Fetch sender + room name on a background thread (main thread forbids I/O).
var title = "Pyramid" var meta = PushMeta()
if (hs.isNotEmpty() && token.isNotEmpty() && eventId != null) { if (hs.isNotEmpty() && token.isNotEmpty() && eventId != null) {
val latch = CountDownLatch(1) val latch = CountDownLatch(1)
Thread { Thread {
try { title = fetchTitle(hs, token, roomId, eventId) ?: "Pyramid" } try { meta = fetchMeta(hs, token, roomId, eventId) ?: PushMeta() }
finally { latch.countDown() } finally { latch.countDown() }
}.start() }.start()
latch.await(12L, TimeUnit.SECONDS) latch.await(12L, TimeUnit.SECONDS)
} }
Log.d(TAG, "handling push: title=$title roomId=$roomId") Log.d(TAG, "handling push: sender=${meta.senderName} room=${meta.roomName} roomId=$roomId")
val engine = FlutterEngineCache.getInstance().get("main") val engine = FlutterEngineCache.getInstance().get("main")
if (engine != null) { if (engine != null) {
// App backgrounded but alive — show placeholder immediately, then the // App backgrounded but alive — show placeholder immediately, then the
// warm main client decrypts and updates it (fast, barely visible). // warm main client decrypts and updates it (fast, barely visible).
NotificationHelper.show(this, title, "Neue Nachricht", roomId, notifId) meta.showPlaceholder(this, roomId, notifId, eventId)
if (eventId != null) { if (eventId != null) {
val capturedRoomId = roomId val capturedRoomId = roomId
val capturedEventId = eventId val capturedEventId = eventId
@@ -82,7 +82,7 @@ class PushService : FirebaseMessagingService() {
// real content directly. The "Neue Nachricht" placeholder is only a // real content directly. The "Neue Nachricht" placeholder is only a
// fallback if decryption takes too long or fails. // fallback if decryption takes too long or fails.
Log.d(TAG, "main engine gone — decrypt-first via background engine for $roomId") Log.d(TAG, "main engine gone — decrypt-first via background engine for $roomId")
BgEngine.scheduleFallback(this, title, roomId, notifId, 6_000L) BgEngine.scheduleFallback(this, meta, roomId, notifId, eventId, 6_000L)
BgEngine.run(this, "decryptAndShow", mapOf( BgEngine.run(this, "decryptAndShow", mapOf(
"room_id" to roomId, "room_id" to roomId,
"event_id" to eventId, "event_id" to eventId,
@@ -90,7 +90,7 @@ class PushService : FirebaseMessagingService() {
)) ))
} else { } else {
// No event id to decrypt — just show the placeholder. // No event id to decrypt — just show the placeholder.
NotificationHelper.show(this, title, "Neue Nachricht", roomId, notifId) meta.showPlaceholder(this, roomId, notifId, null)
} }
} }
@@ -102,11 +102,31 @@ class PushService : FirebaseMessagingService() {
.apply() .apply()
} }
/** Absender + Raumname aus unverschlüsselten Room-States (für den Platzhalter). */
data class PushMeta(
val senderId: String = "",
val senderName: String = "Pyramid",
val roomName: String? = null,
) {
fun showPlaceholder(context: android.content.Context, roomId: String, notifId: Int, eventId: String?) {
NotificationHelper.showMessage(
context, roomId, notifId, roomName, isGroup = roomName != null,
msg = NotificationHelper.Msg(
eventId = eventId,
senderKey = senderId.ifEmpty { senderName },
senderName = senderName,
body = NotificationHelper.PLACEHOLDER_BODY,
timestamp = System.currentTimeMillis(),
),
)
}
}
// ─── HTTP helpers ───────────────────────────────────────────────────────── // ─── HTTP helpers ─────────────────────────────────────────────────────────
private fun fetchTitle( private fun fetchMeta(
hs: String, token: String, roomId: String, eventId: String hs: String, token: String, roomId: String, eventId: String
): String? { ): PushMeta? {
return try { return try {
val encRoom = java.net.URLEncoder.encode(roomId, "UTF-8") val encRoom = java.net.URLEncoder.encode(roomId, "UTF-8")
val encEvent = java.net.URLEncoder.encode(eventId, "UTF-8") val encEvent = java.net.URLEncoder.encode(eventId, "UTF-8")
@@ -128,8 +148,11 @@ class PushService : FirebaseMessagingService() {
val nameState = httpGet("$hs/_matrix/client/v3/rooms/$encRoom/state/m.room.name", token) val nameState = httpGet("$hs/_matrix/client/v3/rooms/$encRoom/state/m.room.name", token)
val roomName = nameState?.optString("name") val roomName = nameState?.optString("name")
return if (!roomName.isNullOrEmpty()) "$senderName · $roomName" return PushMeta(
else senderName.ifEmpty { null } senderId = senderId,
senderName = senderName.ifEmpty { "Pyramid" },
roomName = roomName?.takeIf { it.isNotEmpty() },
)
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "fetchTitle failed: ${e.message}") Log.w(TAG, "fetchTitle failed: ${e.message}")
null null
@@ -16,6 +16,7 @@ class ReplyReceiver : BroadcastReceiver() {
const val ACTION_DISMISS = "chat.pyramid.pyramid.DISMISS" const val ACTION_DISMISS = "chat.pyramid.pyramid.DISMISS"
const val EXTRA_ROOM_ID = "room_id" const val EXTRA_ROOM_ID = "room_id"
const val EXTRA_NOTIF_ID = "notif_id" const val EXTRA_NOTIF_ID = "notif_id"
const val EXTRA_EVENT_ID = "event_id"
const val REMOTE_INPUT_KEY = "reply_text" const val REMOTE_INPUT_KEY = "reply_text"
} }
@@ -23,12 +24,13 @@ class ReplyReceiver : BroadcastReceiver() {
val roomId = intent.getStringExtra(EXTRA_ROOM_ID) ?: return val roomId = intent.getStringExtra(EXTRA_ROOM_ID) ?: return
val notifId = intent.getIntExtra(EXTRA_NOTIF_ID, 0) val notifId = intent.getIntExtra(EXTRA_NOTIF_ID, 0)
// Dismiss the notification immediately. // Dismiss the notification immediately; its message history goes too.
(context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager) (context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
.cancel(notifId) .cancel(notifId)
NotificationHelper.clearHistory(context, roomId)
if (intent.action == ACTION_DISMISS) { if (intent.action == ACTION_DISMISS) {
Log.d("PYRAMID-REPLY", "dismiss action for room $roomId") markRead(context, roomId, intent.getStringExtra(EXTRA_EVENT_ID))
return return
} }
@@ -81,4 +83,30 @@ class ReplyReceiver : BroadcastReceiver() {
try { pending.finish() } catch (_: Exception) {} try { pending.finish() } catch (_: Exception) {}
}, 15_000L) }, 15_000L)
} }
/**
* „Gelesen“: Lesebestätigung bis [eventId] setzen – über die laufende
* Haupt-Engine oder (App beendet) still über die BgEngine. Ohne eventId
* (alter Platzhalter) bleibt es beim Schließen.
*/
private fun markRead(context: Context, roomId: String, eventId: String?) {
Log.d("PYRAMID-REPLY", "mark-read action for room $roomId event=$eventId")
if (eventId.isNullOrEmpty()) return
val args = mapOf("room_id" to roomId, "event_id" to eventId)
val engine = FlutterEngineCache.getInstance().get("main")
if (engine != null) {
try {
MethodChannel(engine.dartExecutor.binaryMessenger, "chat.pyramid.pyramid/install")
.invokeMethod("markReadFromNotification", args)
return
} catch (e: Exception) {
Log.w("PYRAMID-REPLY", "markRead via main engine failed: ${e.message}")
}
}
val pending = goAsync()
BgEngine.run(context, "markRead", args)
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
try { pending.finish() } catch (_: Exception) {}
}, 10_000L)
}
} }
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Kein Cloud-Backup und keine Gerät-zu-Gerät-Übertragung der App-Daten
(Android 12+). Grund: Die lokale Datenbank ist mit SQLCipher verschlüsselt,
ihr Schlüssel liegt im Android-Keystore und zieht NICHT mit um – eine
übertragene Datenbank ließe sich auf dem neuen Gerät nicht öffnen
(AppDatabase bricht dann bewusst ab, statt Daten zu überschreiben).
Außerdem enthalten die SharedPreferences den Zugangsschlüssel für den
Push-Dienst. Matrix-Weg auf neuem Gerät: neu anmelden + Schlüssel mit dem
Wiederherstellungsschlüssel aus dem Server-Backup holen.
-->
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
</cloud-backup>
<device-transfer>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
</device-transfer>
</data-extraction-rules>
+1 -1
View File
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
+151
View File
@@ -0,0 +1,151 @@
{
"suite": "db",
"timestamp": "2026-10-07T11:35:13.322832",
"git": "b2413ef",
"host": {
"os": "\"Windows 11 Home\" 10.0 (Build 26200)",
"cpus": 12
},
"params": {
"rooms": 20,
"eventsPerRoom": 500
},
"metrics": {
"klartext.write_total_ms": {
"label": "[klartext] Sync-Schreiben, 10000 Nachrichten",
"unit": "ms",
"value": 2513.443,
"min": 2469.054,
"runs": 2,
"compare": true
},
"klartext.write_per_event_us": {
"label": "[klartext] Sync-Schreiben je Nachricht",
"unit": "µs",
"value": 251.344,
"runs": 1,
"compare": true
},
"klartext.startup_ms": {
"label": "[klartext] App-Start: DB + Raumliste",
"unit": "ms",
"value": 4.121,
"min": 3.254,
"runs": 6,
"compare": true
},
"klartext.open_chat_ms": {
"label": "[klartext] Chat öffnen (50 Nachr.)",
"unit": "ms",
"value": 0.328,
"min": 0.286,
"runs": 6,
"compare": true
},
"klartext.search_miss_ms": {
"label": "[klartext] Suche ohne Treffer (Worst Case)",
"unit": "ms",
"value": 63.897,
"min": 63.716,
"runs": 4,
"compare": true
},
"klartext.search_hit_ms": {
"label": "[klartext] Suche mit Treffern",
"unit": "ms",
"value": 31.777,
"min": 31.202,
"runs": 4,
"compare": true
},
"klartext.size_mb": {
"label": "[klartext] DB-Größe inkl. WAL",
"unit": "MB",
"value": 4.008,
"runs": 1,
"compare": false
},
"sqlcipher.write_total_ms": {
"label": "[sqlcipher] Sync-Schreiben, 10000 Nachrichten",
"unit": "ms",
"value": 2615.633,
"min": 2456.356,
"runs": 2,
"compare": true
},
"sqlcipher.write_per_event_us": {
"label": "[sqlcipher] Sync-Schreiben je Nachricht",
"unit": "µs",
"value": 261.563,
"runs": 1,
"compare": true
},
"sqlcipher.startup_ms": {
"label": "[sqlcipher] App-Start: DB + Raumliste",
"unit": "ms",
"value": 4.313,
"min": 3.718,
"runs": 6,
"compare": true
},
"sqlcipher.open_chat_ms": {
"label": "[sqlcipher] Chat öffnen (50 Nachr.)",
"unit": "ms",
"value": 0.323,
"min": 0.309,
"runs": 6,
"compare": true
},
"sqlcipher.search_miss_ms": {
"label": "[sqlcipher] Suche ohne Treffer (Worst Case)",
"unit": "ms",
"value": 63.748,
"min": 62.8,
"runs": 4,
"compare": true
},
"sqlcipher.search_hit_ms": {
"label": "[sqlcipher] Suche mit Treffern",
"unit": "ms",
"value": 31.676,
"min": 31.296,
"runs": 4,
"compare": true
},
"sqlcipher.size_mb": {
"label": "[sqlcipher] DB-Größe inkl. WAL",
"unit": "MB",
"value": 4.098,
"runs": 1,
"compare": false
},
"cipher_overhead.write_total_ms": {
"label": "Aufschlag Verschlüsselung: Sync-Schreiben",
"unit": "%",
"value": 4.066,
"runs": 1,
"compare": false
},
"cipher_overhead.startup_ms": {
"label": "Aufschlag Verschlüsselung: App-Start",
"unit": "%",
"value": 4.634,
"runs": 1,
"compare": false
},
"cipher_overhead.open_chat_ms": {
"label": "Aufschlag Verschlüsselung: Chat öffnen",
"unit": "%",
"value": -1.388,
"runs": 1,
"compare": false
},
"cipher_overhead.search_miss_ms": {
"label": "Aufschlag Verschlüsselung: Suche ohne Treffer",
"unit": "%",
"value": -0.233,
"runs": 1,
"compare": false
}
}
}
+37
View File
@@ -0,0 +1,37 @@
{
"suite": "migration",
"timestamp": "2026-10-07T11:35:21.106818",
"git": "b2413ef",
"host": {
"os": "\"Windows 11 Home\" 10.0 (Build 26200)",
"cpus": 12
},
"params": {
"rooms": 20,
"eventsPerRoom": 1000
},
"metrics": {
"db_size_mb": {
"label": "DB-Größe vor Migration",
"unit": "MB",
"value": 7.793,
"runs": 1,
"compare": false
},
"migrate_ms": {
"label": "Migration gesamt",
"unit": "ms",
"value": 325.058,
"min": 322.058,
"runs": 3,
"compare": true
},
"migrate_ms_per_mb": {
"label": "Migration je MB",
"unit": "ms/MB",
"value": 41.712,
"runs": 1,
"compare": true
}
}
}
+257
View File
@@ -0,0 +1,257 @@
// Benchmarks sind Test-Code, liegen aber bewusst außerhalb von test/ (sie
// sollen in einem normalen `flutter test` NICHT mitlaufen) – deshalb kennt
// der Analyzer sie nicht als Tests.
// ignore_for_file: invalid_use_of_visible_for_testing_member
@TestOn('windows')
library;
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:matrix/matrix.dart';
import 'package:path/path.dart' as p;
import 'package:pyramid/core/app_database.dart';
import 'package:pyramid/features/search/local_message_search.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import '../test/support/synthetic_matrix_data.dart';
import 'support/bench.dart';
/// Datenbank-Benchmark: Was kostet die SQLCipher-Verschlüsselung im Alltag?
///
/// Misst – jeweils für eine Klartext-DB und eine SQLCipher-DB mit DERSELBEN
/// Bibliothek aus dem App-Build – die vier Wege, auf denen Nutzer die DB
/// spüren:
/// 1. Sync-Schreiben (Nachrichten landen in Transaktionen wie beim Sync),
/// 2. App-Start (DB öffnen + SDK-Schema + Raumliste laden),
/// 3. Chat öffnen (50 neueste Nachrichten eines Raums),
/// 4. lokale Suche (Strg+K) – ohne Treffer = Worst Case, scannt alles.
///
/// Daten sind synthetisch (`test/support/synthetic_matrix_data.dart`), laufen
/// aber über die echte SDK-Speicherschicht. Lauf:
/// flutter test benchmark/db_benchmark_test.dart
/// oder alles zusammen über `scripts/test.ps1 -Bench`.
const _modes = ['klartext', 'sqlcipher'];
const _benchKey =
'0b1e2c3d4f5a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e';
void main() {
final skip = findCipherDll() == null
? 'SQLCipher-DLL fehlt – erst "flutter build windows" ausführen'
: null;
test(
'DB: Klartext vs. SQLCipher',
() async {
final rooms = scaled(20);
final eventsPerRoom = scaled(500);
final totalEvents = rooms * eventsPerRoom;
final report = BenchReport(
'db',
params: {'rooms': rooms, 'eventsPerRoom': eventsPerRoom},
);
final factory = cipherFactory();
final tempDir = await Directory.systemTemp.createTemp('pyramid_bench_db');
Future<Database> openBenchDb(String mode, String path) async {
final db = mode == 'sqlcipher'
? await AppDatabase.openEncrypted(factory, path, _benchKey)
: await factory.openDatabase(path);
await applyAppPragmas(db);
return db;
}
try {
// Ungemessener Aufwärm-Durchlauf (JIT, DB-Isolate, SQLCipher-Init).
// Bewusst mit voller Raumgröße: Der JIT optimiert die SDK-Pfade erst
// nach vielen Aufrufen mit wachsender Fragment-Liste – mit einem
// Mini-Durchlauf war die erste Messrunde noch ~45 % langsamer.
for (final mode in _modes) {
final path = p.join(tempDir.path, 'warmup_$mode.sqlite');
final client = await SyntheticMatrixData.openClient(
await openBenchDb(mode, path),
);
await SyntheticMatrixData.populate(
client,
rooms: (rooms ~/ 4).clamp(1, rooms),
eventsPerRoom: eventsPerRoom,
);
await SyntheticMatrixData.loadRooms(client);
await localMessageSearch(client, 'aufwärmen');
await client.dispose(closeDatabase: true);
}
// Gemessen wird abwechselnd A-B-B-A: Die Schreibkosten sind von
// Dart-Code (SDK-JSON) dominiert, und der JIT wird während des Laufs
// weiter schneller. Ohne das Muster gewinnt einfach der später
// gemessene Modus (2026-10-07 nachgemessen: ±20 % je nach Reihenfolge).
const order = ['klartext', 'sqlcipher', 'sqlcipher', 'klartext'];
final timings = {
for (final mode in _modes) mode: <String, List<double>>{},
};
void record(String mode, String metric, Iterable<double> values) =>
timings[mode]!.putIfAbsent(metric, () => []).addAll(values);
// 1. Sync-Schreiben – je Runde eine frische DB.
final paths = <String, String>{};
for (var round = 0; round < order.length; round++) {
final mode = order[round];
final path = p.join(tempDir.path, '${mode}_$round.sqlite');
final client = await SyntheticMatrixData.openClient(
await openBenchDb(mode, path),
);
final watch = Stopwatch()..start();
await SyntheticMatrixData.populate(
client,
rooms: rooms,
eventsPerRoom: eventsPerRoom,
needleEvery: 97,
);
watch.stop();
await client.dispose(closeDatabase: true);
record(mode, 'write', [watch.elapsedMicroseconds / 1000]);
paths[mode] = path;
}
// 2. App-Start: DB öffnen + SDK-Schema + Raumliste, danach wieder
// schließen – wie ein Neustart. Eigene Runde VOR den Lese-Messungen:
// sqflite liefert für denselben Pfad dieselbe Verbindung zurück,
// ein Schließen hier würde sonst deren offene Verbindung kappen.
for (final mode in order) {
record(
mode,
'startup',
await sample(() async {
final c = await SyntheticMatrixData.openClient(
await openBenchDb(mode, paths[mode]!),
);
await SyntheticMatrixData.loadRooms(c);
await c.dispose(closeDatabase: true);
}, runs: 3),
);
}
// 3.–4. Lesen auf der jeweils zuletzt geschriebenen DB.
final clients = <String, Client>{};
final roomLists = <String, List<Room>>{};
for (final mode in _modes) {
final client = await SyntheticMatrixData.openClient(
await openBenchDb(mode, paths[mode]!),
);
clients[mode] = client;
roomLists[mode] = await SyntheticMatrixData.loadRooms(client);
expect(
roomLists[mode],
hasLength(rooms),
reason: 'Raumliste vollständig ($mode)',
);
}
for (final mode in order) {
final client = clients[mode]!;
record(
mode,
'open_chat',
(await sample(() async {
for (final room in roomLists[mode]!) {
final page = await client.database.getEventList(
room,
limit: 50,
);
if (page.length != 50) throw StateError('Seite unvollständig');
}
}, runs: 3)).map((t) => t / rooms),
);
record(
mode,
'search_miss',
await sample(
() => localMessageSearch(client, 'gibtsnichtwort'),
runs: 2,
),
);
record(
mode,
'search_hit',
await sample(
() => localMessageSearch(client, 'nadelwort'),
runs: 2,
),
);
}
for (final client in clients.values) {
await client.dispose(closeDatabase: true);
}
for (final mode in _modes) {
final t = timings[mode]!;
final writeMs = median(t['write']!);
report.addTimings(
'$mode.write_total_ms',
'[$mode] Sync-Schreiben, $totalEvents Nachrichten',
t['write']!,
);
report.addValue(
'$mode.write_per_event_us',
'[$mode] Sync-Schreiben je Nachricht',
writeMs * 1000 / totalEvents,
'µs',
compare: true,
);
report.addTimings(
'$mode.startup_ms',
'[$mode] App-Start: DB + Raumliste',
t['startup']!,
);
report.addTimings(
'$mode.open_chat_ms',
'[$mode] Chat öffnen (50 Nachr.)',
t['open_chat']!,
);
report.addTimings(
'$mode.search_miss_ms',
'[$mode] Suche ohne Treffer (Worst Case)',
t['search_miss']!,
);
report.addTimings(
'$mode.search_hit_ms',
'[$mode] Suche mit Treffern',
t['search_hit']!,
);
report.addValue(
'$mode.size_mb',
'[$mode] DB-Größe inkl. WAL',
dbSizeMb(paths[mode]!),
'MB',
);
}
for (final (id, label) in [
('write_total_ms', 'Sync-Schreiben'),
('startup_ms', 'App-Start'),
('open_chat_ms', 'Chat öffnen'),
('search_miss_ms', 'Suche ohne Treffer'),
]) {
final plain = report.valueOf('klartext.$id');
final enc = report.valueOf('sqlcipher.$id');
report.addValue(
'cipher_overhead.$id',
'Aufschlag Verschlüsselung: $label',
(enc / plain - 1) * 100,
'%',
);
}
final regressions = await report.finish();
if (strictBench) expect(regressions, isEmpty);
} finally {
try {
await tempDir.delete(recursive: true);
} catch (_) {}
}
},
skip: skip,
timeout: const Timeout(Duration(minutes: 20)),
);
}
+124
View File
@@ -0,0 +1,124 @@
// Benchmarks sind Test-Code, liegen aber bewusst außerhalb von test/ (sie
// sollen in einem normalen `flutter test` NICHT mitlaufen) – deshalb kennt
// der Analyzer sie nicht als Tests.
// ignore_for_file: invalid_use_of_visible_for_testing_member
@TestOn('windows')
library;
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:pyramid/core/app_database.dart';
import '../test/support/synthetic_matrix_data.dart';
import 'support/bench.dart';
/// Migrations-Benchmark: Wie lange dauert die einmalige Umstellung
/// Klartext → SQLCipher beim ersten Start nach dem Update (Utas Fall)?
///
/// Baut eine Klartext-DB über die echte SDK-Speicherschicht, kopiert sie vor
/// jedem Durchlauf frisch und misst `AppDatabase.migratePlaintextToEncrypted`
/// (exakt der App-Codepfad: Checkpoint, Backup, Export, Verifikation, Tausch).
/// Danach wird geprüft, dass die Raumliste aus der verschlüsselten DB wieder
/// vollständig lädt. Ergebnis zusätzlich als ms pro MB – damit lässt sich die
/// Dauer für eine echte DB-Größe hochrechnen.
const _benchKey =
'5e4d3c2b1a0f9e8d7c6b5a4f3e2d1c0b5e4d3c2b1a0f9e8d7c6b5a4f3e2d1c0b';
void main() {
final skip = findCipherDll() == null
? 'SQLCipher-DLL fehlt – erst "flutter build windows" ausführen'
: null;
test(
'Migration Klartext → SQLCipher',
() async {
final rooms = scaled(20);
final eventsPerRoom = scaled(1000);
final report = BenchReport(
'migration',
params: {'rooms': rooms, 'eventsPerRoom': eventsPerRoom},
);
final factory = cipherFactory();
final tempDir = await Directory.systemTemp.createTemp(
'pyramid_bench_migration',
);
try {
// Vorlage: Klartext-DB wie vor der SQLCipher-Einführung.
final templatePath = p.join(tempDir.path, 'template.sqlite');
var db = await factory.openDatabase(templatePath);
await applyAppPragmas(db);
var client = await SyntheticMatrixData.openClient(db);
await SyntheticMatrixData.populate(
client,
rooms: rooms,
eventsPerRoom: eventsPerRoom,
);
await client.dispose(closeDatabase: true);
// WAL in die Hauptdatei schreiben, damit die Kopie vollständig ist.
db = await factory.openDatabase(templatePath);
await db.rawQuery('PRAGMA wal_checkpoint(TRUNCATE)');
await db.close();
final sizeMb = dbSizeMb(templatePath);
final workPath = p.join(tempDir.path, 'pyramid.sqlite');
Future<void> freshCopy() async {
for (final suffix in ['', '-wal', '-shm', '.enc', '.premigration']) {
final f = File('$workPath$suffix');
if (f.existsSync()) f.deleteSync();
}
await File(templatePath).copy(workPath);
}
final times = await sample(
() async {
final ok = await AppDatabase.migratePlaintextToEncrypted(
factory: factory,
dbPath: workPath,
hexKey: _benchKey,
);
if (!ok) throw StateError('Migration fehlgeschlagen');
},
setup: freshCopy,
warmup: 0,
runs: 3,
);
// Ergebnis der letzten Migration prüfen: verschlüsselt + vollständig.
expect(await AppDatabase.isPlaintextSqlite(File(workPath)), isFalse);
db = await AppDatabase.openEncrypted(factory, workPath, _benchKey);
client = await SyntheticMatrixData.openClient(db);
final loaded = await SyntheticMatrixData.loadRooms(client);
final firstPage = await client.database.getEventList(
loaded.first,
limit: 50,
);
await client.dispose(closeDatabase: true);
expect(loaded, hasLength(rooms));
expect(firstPage, hasLength(50));
report.addValue('db_size_mb', 'DB-Größe vor Migration', sizeMb, 'MB');
report.addTimings('migrate_ms', 'Migration gesamt', times);
report.addValue(
'migrate_ms_per_mb',
'Migration je MB',
median(times) / sizeMb,
'ms/MB',
compare: true,
);
final regressions = await report.finish();
if (strictBench) expect(regressions, isEmpty);
} finally {
try {
await tempDir.delete(recursive: true);
} catch (_) {}
}
},
skip: skip,
timeout: const Timeout(Duration(minutes: 20)),
);
}
+271
View File
@@ -0,0 +1,271 @@
import 'dart:convert';
import 'dart:ffi';
import 'dart:io';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:sqlite3/open.dart';
/// Kleines Benchmark-Gerüst für `benchmark/` (bewusst ohne Zusatzpaket).
///
/// Jede Suite sammelt Messwerte in einem [BenchReport]. Am Ende:
/// * Tabelle in der Konsole (inkl. Vergleich mit der Baseline),
/// * JSON nach `build/benchmarks/<suite>.json` (für spätere Vergleiche),
/// * Vergleich mit `benchmark/baseline/<suite>.json`, falls vorhanden.
///
/// Umgebungsvariablen:
/// * `PYRAMID_BENCH_UPDATE_BASELINE=1` – aktuelle Werte als neue Baseline
/// speichern (nach einer bewussten Änderung / auf neuer Hardware).
/// * `PYRAMID_BENCH_STRICT=1` – Regressionen lassen den Lauf scheitern
/// (sonst nur Warnung, weil Messrauschen normal ist).
/// * `PYRAMID_BENCH_SCALE` – Datenmenge skalieren (Standard 1.0;
/// z. B. 0.2 für einen schnellen Probelauf, 3 für „Power-User").
/// Ab so viel Prozent langsamer als die Baseline gilt ein Wert als Regression.
const regressionThresholdPercent = 25.0;
/// Unterhalb dieser absoluten Differenz (ms) wird nie gewarnt – bei sehr
/// kleinen Werten ist das reines Rauschen.
const regressionFloorMs = 2.0;
double get benchScale =>
double.tryParse(Platform.environment['PYRAMID_BENCH_SCALE'] ?? '') ?? 1.0;
int scaled(int value) => (value * benchScale).round().clamp(1, 1 << 30);
/// Führt [body] erst [warmup]-mal ungemessen, dann [runs]-mal gemessen aus
/// und liefert die Laufzeiten in Millisekunden. [setup] läuft vor JEDEM
/// Durchlauf und wird nicht mitgemessen.
Future<List<double>> sample(
Future<void> Function() body, {
int warmup = 1,
int runs = 5,
Future<void> Function()? setup,
}) async {
for (var i = 0; i < warmup; i++) {
if (setup != null) await setup();
await body();
}
final out = <double>[];
for (var i = 0; i < runs; i++) {
if (setup != null) await setup();
final sw = Stopwatch()..start();
await body();
sw.stop();
out.add(sw.elapsedMicroseconds / 1000.0);
}
return out;
}
double median(List<double> values) {
final sorted = [...values]..sort();
final mid = sorted.length ~/ 2;
return sorted.length.isOdd
? sorted[mid]
: (sorted[mid - 1] + sorted[mid]) / 2;
}
class BenchMetric {
BenchMetric({
required this.id,
required this.label,
required this.unit,
required this.value,
this.min,
this.runs = 1,
this.compare = true,
});
final String id;
final String label;
final String unit;
/// Median (bzw. Einzelwert) – der Wert, der verglichen wird.
final double value;
final double? min;
final int runs;
/// `false` für reine Info-Werte (z. B. Dateigröße), die nicht als
/// Regression gewertet werden.
final bool compare;
Map<String, Object?> toJson() => {
'label': label,
'unit': unit,
'value': double.parse(value.toStringAsFixed(3)),
if (min != null) 'min': double.parse(min!.toStringAsFixed(3)),
'runs': runs,
'compare': compare,
};
}
class BenchReport {
BenchReport(this.suite, {this.params = const {}});
final String suite;
final Map<String, Object?> params;
final _metrics = <BenchMetric>[];
/// Laufzeit-Messreihe (ms) als Metrik ablegen.
void addTimings(String id, String label, List<double> timingsMs) {
_metrics.add(
BenchMetric(
id: id,
label: label,
unit: 'ms',
value: median(timingsMs),
min: timingsMs.reduce((a, b) => a < b ? a : b),
runs: timingsMs.length,
),
);
}
void addValue(
String id,
String label,
double value,
String unit, {
bool compare = false,
}) {
_metrics.add(
BenchMetric(
id: id,
label: label,
unit: unit,
value: value,
compare: compare,
),
);
}
double valueOf(String id) => _metrics.firstWhere((m) => m.id == id).value;
/// Schreibt Bericht + ggf. Baseline und liefert die Liste der Regressionen
/// (leer = alles im Rahmen).
Future<List<String>> finish() async {
final baselineFile = File('benchmark/baseline/$suite.json');
Map<String, Object?>? baseline;
if (baselineFile.existsSync()) {
final json =
jsonDecode(baselineFile.readAsStringSync()) as Map<String, Object?>;
baseline = (json['metrics'] as Map).cast<String, Object?>();
}
final regressions = <String>[];
final buf = StringBuffer()
..writeln()
..writeln(
'══ Benchmark "$suite" '
'${params.isEmpty ? '' : params.entries.map((e) => '${e.key}=${e.value}').join(', ')}',
)
..writeln(
'${'Messung'.padRight(52)}${'Wert'.padLeft(12)} ${'Baseline'.padLeft(10)} Δ',
);
for (final m in _metrics) {
final base = (baseline?[m.id] as Map?)?['value'] as num?;
var delta = '';
// Für Prozent-Werte (z. B. Verschlüsselungs-Aufschlag) wäre ein
// relatives Δ irreführend (4 % → 11 % = „+150 %“) – dort nur die Werte.
if (base != null && base > 0 && m.unit != '%') {
final pct = (m.value - base) / base * 100;
delta = '${pct >= 0 ? '+' : ''}${pct.toStringAsFixed(0)} %';
if (m.compare &&
pct > regressionThresholdPercent &&
(m.unit != 'ms' || m.value - base > regressionFloorMs)) {
delta += ' ⚠ LANGSAMER';
regressions.add(
'${m.label}: ${_fmt(m.value)} ${m.unit} '
'(Baseline ${_fmt(base.toDouble())} ${m.unit}, $delta)',
);
}
}
buf.writeln(
'${m.label.padRight(52)}'
'${'${_fmt(m.value)} ${m.unit}'.padLeft(12)} '
'${base == null ? '–'.padLeft(10) : _fmt(base.toDouble()).padLeft(10)} $delta',
);
}
// ignore: avoid_print
print(buf);
final report = {
'suite': suite,
'timestamp': DateTime.now().toIso8601String(),
'git': await _gitRevision(),
'host': {
'os': Platform.operatingSystemVersion,
'cpus': Platform.numberOfProcessors,
},
'params': params,
'metrics': {for (final m in _metrics) m.id: m.toJson()},
};
const encoder = JsonEncoder.withIndent(' ');
final outFile = File('build/benchmarks/$suite.json');
outFile.parent.createSync(recursive: true);
outFile.writeAsStringSync(encoder.convert(report));
if (Platform.environment['PYRAMID_BENCH_UPDATE_BASELINE'] == '1') {
baselineFile.parent.createSync(recursive: true);
baselineFile.writeAsStringSync('${encoder.convert(report)}\n');
// ignore: avoid_print
print('Baseline aktualisiert: ${baselineFile.path}');
}
return regressions;
}
static String _fmt(double v) =>
v >= 100 ? v.toStringAsFixed(0) : v.toStringAsFixed(v >= 10 ? 1 : 2);
static Future<String?> _gitRevision() async {
try {
final r = await Process.run('git', ['rev-parse', '--short', 'HEAD']);
return r.exitCode == 0 ? (r.stdout as String).trim() : null;
} catch (_) {
return null;
}
}
}
bool get strictBench => Platform.environment['PYRAMID_BENCH_STRICT'] == '1';
// ── SQLCipher aus dem App-Build ────────────────────────────────────────────
/// Die vom sqlcipher_flutter_libs-Plugin gebaute `sqlite3.dll` – dieselbe
/// Bibliothek, die die App benutzt. `null`, wenn noch nie gebaut wurde.
String? findCipherDll() {
for (final config in ['Release', 'Profile', 'Debug']) {
final file = File('build/windows/x64/runner/$config/sqlite3.dll');
if (file.existsSync()) return file.absolute.path;
}
return null;
}
/// Läuft (wie in der App) im Hintergrund-Isolate von sqflite_common_ffi –
/// deshalb top-level und ohne eingefangene Variablen.
void cipherFfiInit() {
final dll = findCipherDll();
if (dll == null) throw StateError('SQLCipher-DLL fehlt');
open.overrideFor(OperatingSystem.windows, () => DynamicLibrary.open(dll));
}
/// Factory wie in der App: eigenes DB-Isolate, SQLCipher-Bibliothek.
/// Ohne `PRAGMA key` verhält sich SQLCipher wie normales SQLite – so laufen
/// Klartext- und verschlüsselte Messung mit DERSELBEN Bibliothek und sind
/// fair vergleichbar.
DatabaseFactory cipherFactory() =>
createDatabaseFactoryFfi(ffiInit: cipherFfiInit);
/// Dieselben PRAGMAs wie `AppDatabase._applyPragmas` (dort privat).
Future<void> applyAppPragmas(Database db) async {
await db.execute('PRAGMA journal_mode=WAL');
await db.execute('PRAGMA busy_timeout = 5000');
}
/// Gesamtgröße der DB inkl. WAL-Sidecar in MB.
double dbSizeMb(String path) {
var bytes = 0;
for (final suffix in ['', '-wal']) {
final f = File('$path$suffix');
if (f.existsSync()) bytes += f.lengthSync();
}
return bytes / (1024 * 1024);
}
+27 -4
View File
@@ -1,9 +1,32 @@
# LiveKit-Token server-seitig minten (apiSecret aus dem Client entfernen) # LiveKit-Token server-seitig minten (apiSecret aus dem Client entfernen)
**Status:** geplant, NICHT umgesetzt (braucht echten Call-Test auf einem Gerät – **Status (2026-10-07):** Server-Route UND Client umgestellt.
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 - Server: `POST /api/livekit-token` im Dashboard-Server auf dem Pi
kann. Analog zu `docs/SQLCIPHER_MIGRATION.md`. (`/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 ## Problem
+35 -1
View File
@@ -70,6 +70,39 @@ Ergebnis: Die Benachrichtigung erscheint ~4 s nach der Nachricht **direkt mit
echtem Text** (kein „Neue Nachricht"-Zwischenschritt). Die ~4 s sind der echtem Text** (kein „Neue Nachricht"-Zwischenschritt). Die ~4 s sind der
Engine-Bootstrap (~1,7 s) + Client-Init/Entschlüsselung (~2,3 s). Engine-Bootstrap (~1,7 s) + Client-Init/Entschlüsselung (~2,3 s).
## Darstellung: Chat-Verlauf statt Einzelnachricht (seit 2026-10-07)
Auf Bernds Beta-Feedback („nur eine Nachricht, wenig Text, kein Bild“):
- **Pro Raum ein Chat-Verlauf** (Android `MessagingStyle`) mit den letzten 8
Nachrichten, Absendername + Avatar je Nachricht, Gruppenname als Titel.
Der Verlauf liegt in SharedPreferences (`pyramid_notif_history`), weil bei
gekillter App jeder Push in einem neuen Prozess ankommt. Er wird nur
fortgeführt, solange die Benachrichtigung noch sichtbar ist (weggewischt
oder Raum geöffnet → nächster Push beginnt neu).
- **Gleiche eventId = Aktualisierung**, nicht neue Nachricht: der
„Neue Nachricht“-Platzhalter wird durch den Klartext ersetzt, später kommen
lautlos Avatar/Bild dazu. Nur wirklich neue Nachrichten klingeln
(`setOnlyAlertOnce(!isNew)`).
- **Text:** nicht mehr auf 100 Zeichen gekürzt (Deckel 1000), Antwort-Zitat
entfernt, Medien als „📷 Foto“/„🎤 Sprachnachricht“/„📎 datei.pdf“ usw.
Logik in `lib/core/notification_content.dart` (Tests:
`test/notification_content_test.dart`). Einstellung „Nachrichtenvorschau“
gilt jetzt auch bei gekillter App.
- **Bilder/Avatare:** Dart legt sie unter `<cache>/downloads/notif/` ab
(Vorschaubild, bei E2EE entschlüsselt; Avatar aus dem MediaCache), native
reicht sie per FileProvider-URI an die System-UI weiter
(`grantUriPermission("com.android.systemui")`). Dateien älter als 2 Tage
werden aufgeräumt. Scheitert das Anzeigen mit Medien, fällt
`NotificationHelper` auf reinen Text zurück.
- **„Gelesen“** setzt jetzt eine echte Lesebestätigung bis zur neuesten
Nachricht (Haupt-Engine `markReadFromNotification` bzw. BgEngine
`markRead`), statt nur zu schließen.
- **Testen ohne Login (nur Debug-Build):** `DebugNotifyReceiver`
(`android/app/src/debug/`), z. B.
`adb shell "am broadcast -n chat.pyramid.pyramid/.DebugNotifyReceiver --es roomName Familie --es sender Uta --es event e1 --ez avatar true --es body 'Hallo'"`
(`--ez image true` für ein Testbild). Nicht im Release-APK.
## Verschlüsselt antworten (gekillt, vollständig still) ## Verschlüsselt antworten (gekillt, vollständig still)
- Die Reply-Action ist ein **BroadcastReceiver** (`getBroadcast` → `ReplyReceiver`), - Die Reply-Action ist ein **BroadcastReceiver** (`getBroadcast` → `ReplyReceiver`),
@@ -103,7 +136,8 @@ Engine-Bootstrap (~1,7 s) + Client-Init/Entschlüsselung (~2,3 s).
| `android/.../PushService.kt` | FCM-Empfang, Heartbeat-Check, decrypt-first-Routing | | `android/.../PushService.kt` | FCM-Empfang, Heartbeat-Check, decrypt-first-Routing |
| `android/.../BgEngine.kt` | Headless-Engine-Bootstrap, Fallback-Platzhalter | | `android/.../BgEngine.kt` | Headless-Engine-Bootstrap, Fallback-Platzhalter |
| `android/.../ReplyReceiver.kt` | Stilles verschlüsseltes Antworten (Broadcast) | | `android/.../ReplyReceiver.kt` | Stilles verschlüsseltes Antworten (Broadcast) |
| `android/.../NotificationHelper.kt` | Native Benachrichtigung (Reply/Tap/Dismiss, setOnlyAlertOnce) | | `android/.../NotificationHelper.kt` | Native Benachrichtigung: Chat-Verlauf (MessagingStyle), Bilder, Antworten/Gelesen/Tippen |
| `lib/core/notification_content.dart` | Inhalt einer Benachrichtigung (Text, Medien-Labels, Bild/Avatar-Dateien) |
| `android/.../MainActivity.kt` | MethodChannel `install`, Cold-Start-Reply-Fallback | | `android/.../MainActivity.kt` | MethodChannel `install`, Cold-Start-Reply-Fallback |
| `lib/core/background_push.dart` | Dart-Hintergrund-Isolate: decrypt + reply | | `lib/core/background_push.dart` | Dart-Hintergrund-Isolate: decrypt + reply |
| `lib/core/fcm_push_service.dart` | Pusher-Registrierung bei Sygnal | | `lib/core/fcm_push_service.dart` | Pusher-Registrierung bei Sygnal |
+4
View File
@@ -21,6 +21,10 @@ Schnellster Weg: `flutter run -d windows`.
VoicePrefs-Vertragstests in `test/settings_prefs_test.dart`), VoicePrefs-Vertragstests in `test/settings_prefs_test.dart`),
mehrfach gelaufen (vor/nach jedem Umbau des Tages). mehrfach gelaufen (vor/nach jedem Umbau des Tages).
- [x] **Seit 2026-10-07: alles Automatisierbare mit EINEM Befehl** –
`pwsh scripts/test.ps1 -All` (Analyse, 41 Unit-Tests, Live-Test mit
Test-Account, Benchmarks). Details: `docs/TESTS_UND_BENCHMARKS.md`.
## 1. Härtetest Login/Krypto (M1 – PFLICHT, zuerst!) ## 1. Härtetest Login/Krypto (M1 – PFLICHT, zuerst!)
Deckt die Fixes 63e4919, 9dc83f7, 891f348 ab (Uta-Random-Logout + Guard). Deckt die Fixes 63e4919, 9dc83f7, 891f348 ab (Uta-Random-Logout + Guard).
+137
View File
@@ -0,0 +1,137 @@
# Tests & Benchmarks
Stand: 2026-10-07. Alles läuft mit **einem Befehl** aus dem Projektordner:
```
pwsh scripts/test.ps1 # Analyse + Unit-Tests (~1 min) – vor jedem Commit
pwsh scripts/test.ps1 -Live # + Live-Test gegen steggi-matrix.work (Test-Account)
pwsh scripts/test.ps1 -Bench # + Benchmarks (Datenbank/Verschlüsselung, Migration)
pwsh scripts/test.ps1 -Ui # + UI-Durchlauf im Handy-Format mit Screenshots
pwsh scripts/test.ps1 -All # alles (~4 min)
```
Am Ende steht eine Zusammenfassung mit OK/FEHLER je Schritt. Fehlt die
SQLCipher-Bibliothek (frischer Checkout), baut das Skript einmalig
`flutter build windows --debug`.
**Für Bernd in einem Satz:** Die Tests prüfen automatisch die heiklen Stellen
(Login-/Logout-Schutz, Verschlüsselung der Datenbank, Suche, Einstellungen),
die Benchmarks messen, ob die App dabei langsamer wird. Was man nur mit Klicken
oder zwei echten Geräten prüfen kann, steht weiterhin in `docs/PC_TESTPLAN.md`.
## Tests (`test/`)
| Datei | Prüft | Läuft wann |
|---|---|---|
| `app_database_test.dart` | SQLCipher: Bibliothek echt, Header-Erkennung, Klartext→verschlüsselt-Migration, falscher Schlüssel, Fehlschlag lässt Daten unangetastet | immer (braucht die DLL aus dem Windows-Build) |
| `soft_logout_guard_test.dart` | Uta-Random-Logout-Fix: Token-Refresh-Fehler enden nie im stillen Logout | immer |
| `auth_log_test.dart` | Login-Protokoll (Diagnose für Logout-Bugs) | immer |
| `power_levels_safety_test.dart` | Space-Admin kann sich nicht selbst aussperren | immer |
| `settings_prefs_test.dart` | VoicePrefs-Fassade (gespeicherte Spracheinstellungen) | immer |
| `streaming_presets_test.dart` | 60-FPS-Streaming-Presets (Auflösung/FPS/Bitrate) | immer |
| `local_message_search_test.dart` | Lokale Nachrichtensuche (Strg+K): Treffer, Sortierung, Groß-/Kleinschreibung inkl. Umlaute, Medien-Filter, Trefferdeckel | immer (neu 2026-10-07) |
| `live_encryption_e2e_test.dart` | Echter Login (pyramidtest1) → Platte verschlüsselt → Neustart → Logout → Re-Login → alte Nachricht lesbar; Utas Update-Fall mit echter Session | nur mit `-Live` |
| `live_logout_race_test.dart` | Abmelden + sofortige Neuanmeldung auf demselben Client: neue Session muss bleiben (SDK-Wettlauf, `logoutWithoutStraySync`); mit `PYRAMID_LIVE_RACE_PROOF=1` zusätzlich Nachweis mit altem Weg | nur mit `-Live` |
| `live_ui_fixture_test.dart` | Kein Test im engeren Sinn: legt Testinhalte (DM + Gruppe mit Bildern) für den UI-Durchlauf an | nur manuell mit `PYRAMID_LIVE_TEST=1` |
| `live_call_setup_test.dart` | Kein Test im engeren Sinn: legt den Voice-Testraum für GUI-Call-Tests mit zwei Test-Accounts an | nur manuell mit `PYRAMID_LIVE_TEST=1` |
`test/support/synthetic_matrix_data.dart` erzeugt synthetische Räume und
Nachrichten über die **echte SDK-Speicherschicht** – komplett offline, ohne
Konten. Tests und Benchmarks nutzen sie gemeinsam.
Live-Tests: Zugangsdaten liegen AUSSERHALB des Repos
(`%USERPROFILE%\.pyramid-autopilot\test-accounts.txt` bzw. `PYRAMID_TEST_ACCOUNTS`).
Es sind die Test-Accounts `pyramidtest1/2`, nie Bernds oder Utas Konten.
## UI-Durchlauf (`integration_test/`, seit 2026-10-07)
`integration_test/mobile_ui_screens_test.dart` startet die ECHTE App
(Windows-Build) mit dem Testprofil `pyramidtest1`
(`PYRAMID_PROFILE_DIR=%USERPROFILE%\.pyramid-autopilot\profile1`) in
simulierter Handy-Größe (1080×2340, Statusleiste 24 dp, Gestenleiste 16 dp),
tippt sich durch Chatliste → Chat → Bild und prüft dabei: Vollansicht öffnet,
Tippen daneben schließt, Tippen aufs Bild schließt nicht, Doppeltipp zoomt,
Wischen nach unten schließt. Screenshots landen in `build/ui_screens/` – damit
lassen sich UI-Änderungen ohne Handy ansehen.
- Testinhalte (DM von pyramidtest2 mit langem Text, Antwort, Hoch- und
Querformat-Bild) legt `test/live_ui_fixture_test.dart` an
(`PYRAMID_LIVE_TEST=1`).
- Läuft nur, wenn keine Pyramid-Instanz offen ist (nie zwei Clients auf
einer Datenbank) – das Skript prüft das.
- Simulierte Fensterklicks (PostMessage) erreichen Flutter unter Windows
NICHT; deshalb dieser Weg über `integration_test`.
## Benchmarks (`benchmark/`)
Laufen bewusst NICHT im normalen `flutter test` mit (nur `-Bench` bzw.
`flutter test benchmark --concurrency=1`).
| Suite | Misst |
|---|---|
| `db_benchmark_test.dart` | Klartext- vs. SQLCipher-DB mit derselben Bibliothek: Sync-Schreiben (Transaktionen wie beim echten Sync), App-Start (DB öffnen + Raumliste), Chat öffnen (50 Nachrichten), lokale Suche (ohne Treffer = Worst Case, mit Treffern) |
| `migration_benchmark_test.dart` | Dauer der einmaligen Klartext→SQLCipher-Migration beim ersten Start nach dem Update (exakt der App-Codepfad), danach Vollständigkeitsprüfung |
**Wie gemessen wird:** DB im eigenen Isolate wie in der App; ungemessener
Aufwärm-Durchlauf; Modi abwechselnd A-B-B-A (ohne das gewann einfach der
später gemessene Modus um ±20 %, weil der Dart-JIT während des Laufs schneller
wird); Median mehrerer Durchläufe.
**Baseline & Regressionen:** Jeder Lauf schreibt `build/benchmarks/<suite>.json`
und vergleicht mit `benchmark/baseline/<suite>.json`. Mehr als 25 % langsamer
(und bei Zeiten mehr als 2 ms absolut) wird als „⚠ LANGSAMER" markiert.
Umgebungsvariablen:
- `-UpdateBaseline` bzw. `PYRAMID_BENCH_UPDATE_BASELINE=1` – aktuelle Werte als
neue Baseline speichern (nach bewusster Änderung oder auf neuer Hardware).
Die Baseline gilt nur für den Rechner, auf dem sie entstand.
- `PYRAMID_BENCH_STRICT=1` – Regressionen lassen den Lauf scheitern.
- `PYRAMID_BENCH_SCALE=0.2` – kleinere Datenmenge für einen schnellen Probelauf
(andere Mengen sind mit der Baseline nicht vergleichbar).
### Erste Ergebnisse (2026-10-07, Bernds PC, 12 Kerne, Commit b2413ef)
20 Räume × 500 Nachrichten (DB-Suite), 20 × 1000 (Migration):
| Messung | Klartext | SQLCipher |
|---|---|---|
| Sync-Schreiben je Nachricht | 0,25 ms | 0,26 ms |
| App-Start (DB + Raumliste) | 4,1 ms | 4,3 ms |
| Chat öffnen (50 Nachrichten) | 0,33 ms | 0,32 ms |
| Suche ohne Treffer (10 000 Nachrichten) | 64 ms | 64 ms |
| Suche mit Treffern | 32 ms | 32 ms |
| Migration Klartext → SQLCipher | – | 325 ms für 7,8 MB (≈ 42 ms/MB) |
Was das heißt:
- **Die Verschlüsselung kostet praktisch nichts.** Alle Unterschiede liegen im
Messrauschen (±5–10 %, zwei Läufe hintereinander). Die Kosten stecken im
Matrix-SDK selbst (JSON-Verarbeitung), nicht in SQLCipher.
- **Sync-Schreiben** liegt bei ~0,26 ms pro Nachricht und hängt kaum von der
Raumgröße ab (nachgemessen: 250 vs. 4 000 Nachrichten pro Raum ≈ +15 %).
- **Suche** wächst linear mit der Zahl der gespeicherten Nachrichten
(gedeckelt auf 1 500 pro Raum). Hochrechnung, nicht gemessen: 100 aktive
Räume am Deckel ≈ 150 000 Nachrichten ≈ 1 s pro Suche auf diesem PC –
auf Handys entsprechend länger. Kandidat für einen Suchindex (FTS), falls
das in der Praxis stört.
- **Migration**: Hochrechnung für eine 100-MB-DB ≈ 4 s einmalig auf diesem
PC; auf Utas Handy vermutlich ein Mehrfaches davon (nicht gemessen).
## Bekannte Grenzen (ehrlich)
- Synthetische Daten: unverschlüsselte Räume, keine Olm/Megolm-Sessions
(vodozemac läuft im Host-Test nicht). Die DB-Struktur ist aber die echte.
- Gemessen auf Windows im JIT-Modus von `flutter test`; die Release-App läuft
AOT-kompiliert und ist eher schneller. Für Vergleiche über die Zeit taugt
das, für absolute Aussagen über Handys nicht.
- Nicht automatisiert: alles mit Klicks, echte Calls/Audio, 60-FPS-Streaming
zwischen zwei Geräten, Android-Push → `docs/PC_TESTPLAN.md`.
## Nächste sinnvolle Messungen
- **Streaming (M4 „Ursache messen"):** WebRTC-`getStats()` des Senders
auswerten (`framesPerSecond`, `qualityLimitationReason`, Encoder-Name) –
zeigt direkt, ob CPU, Bandbreite oder Encoder bremst. Braucht eine kleine
Diagnose-Anzeige im Call und zwei echte Geräte.
- **UI-Flüssigkeit:** `integration_test` mit Frame-Timing beim Scrollen der
Raumliste/Timeline (Windows, Test-Account-Profil über `PYRAMID_PROFILE_DIR`).
@@ -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)),
);
}
+478
View File
@@ -0,0 +1,478 @@
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);
// 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)),
);
}
@@ -0,0 +1,312 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package: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/.
///
/// Läuft nur mit gesetztem PYRAMID_PROFILE_DIR (eingeloggtes Testprofil, z. B.
/// pyramidtest1 unter %USERPROFILE%\.pyramid-autopilot\profile1) – nie gegen
/// Bernds echtes Profil. Testinhalte vorher mit
/// `test/live_ui_fixture_test.dart` anlegen (DM + Gruppe mit pyramidtest2).
///
/// $env:PYRAMID_PROFILE_DIR="$env:USERPROFILE\.pyramid-autopilot\profile1"
/// flutter test integration_test/mobile_ui_screens_test.dart -d windows
///
/// Achtung: Keine zweite Pyramid-Instanz auf demselben Profil laufen lassen
/// (zwei Clients auf einer olm-DB). Ansichten (Darstellung, Farbmodus, Raum)
/// werden nur über den App-Zustand umgeschaltet – nichts wird gespeichert.
///
/// Simuliert wird ein typisches Handy: 1080×2340 px, Dichte 2,625
/// (≈ 411×891 dp), Statusleiste oben 24 dp, Gestenleiste unten 16 dp.
const _otherUser = 'pyramidtest2';
const _groupName = 'UI-Test Gruppe';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final profile = Platform.environment['PYRAMID_PROFILE_DIR'];
testWidgets(
'Handy-UI: Chatliste, Chat, Bild-Vollansicht, Gruppe, Einstellungen',
(tester) async {
tester.view
..physicalSize = const Size(1080, 2340)
..devicePixelRatio = 2.625
..padding = const FakeViewPadding(top: 63, bottom: 42)
..viewPadding = const FakeViewPadding(top: 63, bottom: 42);
addTearDown(tester.view.reset);
final size = tester.view.physicalSize / tester.view.devicePixelRatio;
app.main();
await _pumpUntil(
tester,
find.text(_otherUser),
timeout: const Duration(seconds: 90),
);
final container = ProviderScope.containerOf(
tester.element(find.byType(MaterialApp).first),
);
await _shot(tester, '01_chatliste');
// ── Direktnachricht: Sprechblasen (Handy-Standard) und Liste ──
await tester.tap(find.text(_otherUser).first);
// Chat gleitet federnd über die Liste.
await tester.pump(const Duration(milliseconds: 90));
await _shot(tester, '01b_chat_gleitet_herein');
await _pumpFor(tester, const Duration(seconds: 6));
await _scrollToNewest(tester);
await _shot(tester, '02_chat');
// Kontakt-Banner aufklappen (Tipp auf den Namen im Kopf) und wieder zu.
await tester.tap(find.text(_otherUser).first);
await _pumpFor(tester, const Duration(seconds: 1));
await _shot(tester, '02c_dm_kopf_aufgeklappt');
await tester.tap(find.text(_otherUser).first);
await _pumpFor(tester, const Duration(seconds: 1));
// Antwort-Zitat antippen → springt zur zitierten Nachricht.
final quote = find.byWidgetPredicate(
(w) => w is SpringPress && w.pressedScale == 0.97,
);
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');
container.read(messageStyleProvider.notifier).state = MessageStyle.auto;
await _pumpFor(tester, const Duration(seconds: 2));
// ── Bild-Vollansicht ──
// Größtes Bild im Chat = eines der Testbilder; erst sichtbar scrollen.
Future<void> openViewer() async {
final image = _largestImage(tester);
expect(image, isNotNull, reason: 'kein Bild im Test-Chat gefunden');
await tester.ensureVisible(image!);
await _pumpFor(tester, const Duration(milliseconds: 600));
await tester.tap(image);
await _pumpFor(tester, const Duration(seconds: 2));
expect(
find.byType(InteractiveViewer),
findsOneWidget,
reason: 'Antippen des Bildes hat die Vollansicht nicht geöffnet',
);
}
await openViewer();
await _shot(tester, '03_vollansicht');
// Tippen NEBEN das Bild (ganz unten, unter dem Bild) schließt.
await tester.tapAt(Offset(size.width / 2, size.height - 30));
await _pumpFor(tester, const Duration(seconds: 2));
await _shot(tester, '04_nach_tippen_daneben');
expect(
find.byType(InteractiveViewer),
findsNothing,
reason: 'Tippen neben das Bild hat die Vollansicht nicht geschlossen',
);
// Tippen AUFS Bild schließt NICHT (blendet nur die Leiste aus).
await openViewer();
await tester.tapAt(Offset(size.width / 2, size.height / 2));
await _pumpFor(tester, const Duration(seconds: 1));
expect(
find.byType(InteractiveViewer),
findsOneWidget,
reason: 'Tippen aufs Bild darf die Vollansicht nicht schließen',
);
// Doppeltipp zoomt hinein, nochmal doppelt wieder heraus.
Future<void> doubleTapCenter() async {
await tester.tapAt(Offset(size.width / 2, size.height / 2));
await tester.pump(const Duration(milliseconds: 80));
await tester.tapAt(Offset(size.width / 2, size.height / 2));
await _pumpFor(tester, const Duration(seconds: 1));
}
await doubleTapCenter();
final viewer = tester.widget<InteractiveViewer>(
find.byType(InteractiveViewer),
);
expect(
viewer.transformationController!.value.getMaxScaleOnAxis(),
greaterThan(1.5),
reason: 'Doppeltipp hat nicht gezoomt',
);
await _shot(tester, '05_doppeltipp_zoom');
await doubleTapCenter();
// Wischen nach unten schließt.
await tester.dragFrom(
Offset(size.width / 2, size.height / 2),
const Offset(0, 320),
);
await _pumpFor(tester, const Duration(seconds: 2));
expect(
find.byType(InteractiveViewer),
findsNothing,
reason: 'Wischen nach unten hat die Vollansicht nicht geschlossen',
);
await _shot(tester, '06_nach_wischen');
// ── Farbmodus Hell (nur Zustand) ──
final savedVariant = container.read(themeVariantProvider);
container.read(themeVariantProvider.notifier).state = ThemeVariant.light;
await _pumpFor(tester, const Duration(seconds: 2));
await _shot(tester, '07_chat_hell');
container.read(themeVariantProvider.notifier).state = savedVariant;
await _pumpFor(tester, const Duration(seconds: 1));
// ── Gruppenraum (Namen + Avatare in den Sprechblasen) ──
container.read(activeRoomIdProvider.notifier).state = null;
container.read(activeSpaceIdProvider.notifier).state = 'rooms';
await _pumpUntil(
tester,
find.text(_groupName),
timeout: const Duration(seconds: 30),
);
await tester.tap(find.text(_groupName).first);
await _pumpFor(tester, const Duration(seconds: 5));
await _scrollToNewest(tester);
await _shot(tester, '08_gruppe');
// ── Einstellungen → Erscheinungsbild ──
container.read(activeModalProvider.notifier).state = ModalKind.settings;
await _pumpFor(tester, const Duration(seconds: 2));
await tester.tap(find.text('Erscheinungsbild').first);
await _pumpFor(tester, const Duration(seconds: 2));
await _shot(tester, '09_einstellungen_erscheinungsbild');
await tester.drag(
find.text('Schriftgröße').first,
const Offset(0, -500),
warnIfMissed: false,
);
await _pumpFor(tester, const Duration(seconds: 1));
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)),
);
}
Finder? _largestImage(WidgetTester tester) {
Element? best;
var bestArea = 150.0 * 150.0;
for (final element in find.byType(Image).evaluate()) {
final box = element.renderObject;
if (box is! RenderBox || !box.hasSize) continue;
final area = box.size.width * box.size.height;
if (area > bestArea) {
bestArea = area;
best = element;
}
}
return best == null ? null : find.byElementPredicate((e) => e == best);
}
/// Ganz nach unten zu den neuesten Nachrichten scrollen.
Future<void> _scrollToNewest(WidgetTester tester) async {
for (var i = 0; i < 6; i++) {
final lists = find.byType(Scrollable);
if (lists.evaluate().isEmpty) return;
await tester.fling(lists.first, const Offset(0, -1500), 3000,
warnIfMissed: false);
await _pumpFor(tester, const Duration(milliseconds: 500));
}
await _pumpFor(tester, const Duration(seconds: 2));
}
Future<void> _pumpFor(WidgetTester tester, Duration duration) async {
final end = DateTime.now().add(duration);
while (DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 100));
}
}
Future<void> _pumpUntil(
WidgetTester tester,
Finder finder, {
required Duration timeout,
}) async {
final end = DateTime.now().add(timeout);
while (DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 200));
if (finder.evaluate().isNotEmpty) return;
}
fail('Zeitüberschreitung: $finder nicht gefunden');
}
/// Speichert, was gerade auf dem (simulierten) Bildschirm ist.
Future<void> _shot(WidgetTester tester, String name) async {
await tester.pump();
final renderView = tester.binding.renderViews.first;
final layer = renderView.debugLayer! as OffsetLayer;
final physical = tester.view.physicalSize;
final image = await tester.binding.runAsync(
() => layer.toImage(Offset.zero & physical),
);
final bytes = await tester.binding.runAsync(
() => image!.toByteData(format: ui.ImageByteFormat.png),
);
final file = File('build/ui_screens/$name.png');
file.parent.createSync(recursive: true);
file.writeAsBytesSync(bytes!.buffer.asUint8List());
// ignore: avoid_print
print('Screenshot: ${file.path}');
}
+24 -6
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pyramid/core/app_state.dart'; import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/notification_service.dart' show checkPendingReply, setAppForeground; import 'package:pyramid/core/notification_service.dart' show checkPendingReply, setAppForeground;
import 'package:pyramid/core/router.dart'; import 'package:pyramid/core/router.dart';
import 'package:pyramid/core/settings_prefs.dart'; import 'package:pyramid/core/settings_prefs.dart';
@@ -52,7 +53,9 @@ class _PyramidAppState extends ConsumerState<PyramidApp>
Future<void> _loadPersistedPrefs() async { Future<void> _loadPersistedPrefs() async {
final saved = await loadThemePrefs(); final saved = await loadThemePrefs();
if (!mounted) return; if (!mounted) return;
ref.read(themeModeProvider.notifier).state = saved.isDark; ref.read(themeVariantProvider.notifier).state = saved.variant;
ref.read(textScaleProvider.notifier).state = saved.textScale;
ref.read(messageStyleProvider.notifier).state = saved.messageStyle;
ref.read(accentProvider.notifier).state = saved.accentIdx; ref.read(accentProvider.notifier).state = saved.accentIdx;
ref.read(radiusProvider.notifier).state = saved.radius; ref.read(radiusProvider.notifier).state = saved.radius;
ref.read(motionProvider.notifier).state = saved.motion; ref.read(motionProvider.notifier).state = saved.motion;
@@ -75,7 +78,8 @@ class _PyramidAppState extends ConsumerState<PyramidApp>
} }
final router = ref.watch(routerProvider); final router = ref.watch(routerProvider);
final isDark = ref.watch(themeModeProvider); final variant = ref.watch(themeVariantProvider);
final textScale = ref.watch(textScaleProvider);
final accentIdx = ref.watch(accentProvider); final accentIdx = ref.watch(accentProvider);
final radius = ref.watch(radiusProvider); final radius = ref.watch(radiusProvider);
final density = ref.watch(densityProvider); final density = ref.watch(densityProvider);
@@ -83,17 +87,31 @@ class _PyramidAppState extends ConsumerState<PyramidApp>
final accent = accentPresets[accentIdx].color; final accent = accentPresets[accentIdx].color;
return PyramidTheme( return PyramidTheme(
isDark: isDark, variant: variant,
accent: accent, accent: accent,
radius: radius, radius: radius,
density: density, density: density,
motion: motion, motion: motion,
child: MaterialApp.router( child: MaterialApp.router(
title: 'Pyramid', title: 'Pyramid',
theme: buildPyramidThemeData(false), theme: buildPyramidThemeData(ThemeVariant.light),
darkTheme: buildPyramidThemeData(true), darkTheme: buildPyramidThemeData(
themeMode: isDark ? ThemeMode.dark : ThemeMode.light, variant.isDark ? variant : ThemeVariant.soft),
themeMode: variant.isDark ? ThemeMode.dark : ThemeMode.light,
routerConfig: router, routerConfig: router,
// Schriftgröße: Pyramid-Regler × Handy-/Systemeinstellung, für alle
// Seiten und Dialoge (liegt über dem Navigator).
builder: (context, child) {
final mq = MediaQuery.of(context);
final factor = textScale ??
defaultTextScale(mobile: isMobileLayout(mq.size));
return MediaQuery(
data: mq.copyWith(
textScaler: PyramidTextScaler(mq.textScaler, factor),
),
child: child!,
);
},
localizationsDelegates: const [ localizationsDelegates: const [
GlobalMaterialLocalizations.delegate, GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate, GlobalWidgetsLocalizations.delegate,
+9 -1
View File
@@ -1,5 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/settings_prefs.dart'; import 'package:pyramid/core/settings_prefs.dart';
import 'package:pyramid/core/theme.dart'; import 'package:pyramid/core/theme.dart';
import 'package:pyramid/core/matrix_client.dart'; import 'package:pyramid/core/matrix_client.dart';
@@ -7,7 +8,14 @@ import 'package:pyramid/features/rooms/rooms_provider.dart';
// ─── Theme state ────────────────────────────────────────────────────────────── // ─── Theme state ──────────────────────────────────────────────────────────────
final themeModeProvider = StateProvider<bool>((ref) => true); // true = dark final themeVariantProvider =
StateProvider<ThemeVariant>((ref) => ThemeVariant.soft);
/// Pyramid-Schriftfaktor; `null` = automatisch (Handy größer, PC 1,0).
final textScaleProvider = StateProvider<double?>((ref) => null);
final messageStyleProvider =
StateProvider<MessageStyle>((ref) => MessageStyle.auto);
final accentProvider = StateProvider<int>((ref) => 0); // index into accent presets final accentProvider = StateProvider<int>((ref) => 0); // index into accent presets
const accentPresets = [ const accentPresets = [
+74
View File
@@ -0,0 +1,74 @@
import 'package:flutter/widgets.dart';
/// Darstellungs-Einstellungen, die nicht reine Farben sind (die stehen in
/// theme.dart): Handy-Layout-Erkennung, Schriftgröße, Nachrichten-Darstellung.
/// Unterhalb dieser Breite (oder sehr flach, z. B. Handy quer) zeigt Pyramid
/// das Handy-Layout (eine Spalte, Chat als eigene Seite).
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 (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;
/// Kombiniert die Schriftgröße des Systems (Handy-Einstellung) mit dem
/// Pyramid-eigenen Regler: erst Pyramid-Faktor, dann die (ggf. nichtlineare)
/// Systemskalierung.
class PyramidTextScaler extends TextScaler {
const PyramidTextScaler(this.system, this.factor);
final TextScaler system;
final double factor;
@override
double scale(double fontSize) => system.scale(fontSize * factor);
@override
// ignore: deprecated_member_use
double get textScaleFactor => system.textScaleFactor * factor;
@override
bool operator ==(Object other) =>
other is PyramidTextScaler &&
other.system == system &&
other.factor == factor;
@override
int get hashCode => Object.hash(system, factor);
}
// ── Nachrichten-Darstellung ────────────────────────────────────────────────
/// Wie Nachrichten im Chat aussehen (Einstellungen → Erscheinungsbild).
enum MessageStyle {
/// Handy: Sprechblasen, PC: Liste.
auto,
/// Sprechblasen wie WhatsApp/Signal (eigene rechts, andere links).
bubbles,
/// Liste wie Discord/Element (Name + Text untereinander).
list;
String get label => switch (this) {
MessageStyle.auto => 'Automatisch',
MessageStyle.bubbles => 'Sprechblasen',
MessageStyle.list => 'Liste',
};
/// Tatsächlich anzuwendende Darstellung.
bool useBubbles({required bool mobile}) => switch (this) {
MessageStyle.auto => mobile,
MessageStyle.bubbles => true,
MessageStyle.list => false,
};
static MessageStyle fromName(String? name) => MessageStyle.values
.firstWhere((v) => v.name == name, orElse: () => MessageStyle.auto);
}
+47 -30
View File
@@ -8,6 +8,8 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod; import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod;
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:pyramid/core/app_database.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:pyramid/core/soft_logout_guard.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -64,6 +66,11 @@ void notificationEngineMain() {
args['room_id'] as String?, args['room_id'] as String?,
args['text'] as String?, args['text'] as String?,
); );
case 'markRead':
return await _bgMarkRead(
args['room_id'] as String?,
args['event_id'] as String?,
);
} }
} catch (e) { } catch (e) {
debugPrint('[NOTIF-BGENGINE] handler error: $e'); debugPrint('[NOTIF-BGENGINE] handler error: $e');
@@ -88,26 +95,27 @@ Future<bool> _bgDecryptAndShow(String? roomId, String? eventId, int? notifId) as
) )
.timeout(const Duration(seconds: 20)); .timeout(const Duration(seconds: 20));
if (event == null) return false; if (event == null) return false;
// Bad-encrypted body still starts with "**" — keep the native placeholder.
if (event.body.startsWith('**')) return false;
final room = client.getRoomById(roomId); final prefs = await SharedPreferences.getInstance();
final sender = room?.unsafeGetUserFromMemoryOrFallback(event.senderId).displayName ?? final content = MessageNotificationContent.fromEvent(
event.senderId.split(':').first.replaceFirst('@', ''); event,
final title = (room?.isDirectChat ?? true) showPreview: prefs.getBool('notif_preview') ?? true,
? sender );
: '$sender · ${room?.getLocalizedDisplayname() ?? ''}'; // Nicht entschlüsselt / nichts Anzeigbares → nativer Platzhalter bleibt.
if (content == null) return false;
// Hand the decrypted content back to native so it re-shows the SAME // Hand the decrypted content back to native so it re-shows the SAME
// notification via NotificationHelper — that keeps the reliable // notification via NotificationHelper — that keeps the reliable
// MainActivity-based reply action (the flutter_local_notifications reply // ReplyReceiver-based reply action (the flutter_local_notifications reply
// path doesn't fire dependably when the app is killed). // path doesn't fire dependably when the app is killed). Erst der Text,
await const MethodChannel(_kBgEngineChannel).invokeMethod('showDecrypted', { // dann lautlos Avatar/Bild (deliverMessageNotification).
'title': title, await deliverMessageNotification(
'body': _buildBody(event), event,
'room_id': roomId, content,
'notif_id': notifId ?? _stableId(roomId), notifId: notifId ?? _stableId(roomId),
}); send: (a) => const MethodChannel(_kBgEngineChannel)
.invokeMethod('showDecrypted', a),
);
return true; return true;
} catch (e) { } catch (e) {
debugPrint('[NOTIF-BGENGINE] decryptAndShow error: $e'); debugPrint('[NOTIF-BGENGINE] decryptAndShow error: $e');
@@ -152,6 +160,29 @@ Future<bool> _bgSendReply(String? roomId, String? text) async {
} }
} }
/// „Gelesen“ bei beendeter App: Lesebestätigung bis [eventId] setzen (reiner
/// HTTP-Aufruf, keine Verschlüsselung nötig).
Future<bool> _bgMarkRead(String? roomId, String? eventId) async {
if (roomId == null || eventId == null) return false;
final client = await _buildClient();
if (client == null) return false;
try {
final room = client.getRoomById(roomId) ?? Room(id: roomId, client: client);
await markRoomReadUpTo(
room,
eventId,
publicReceipt: await loadReadReceiptsPublic(),
).timeout(const Duration(seconds: 15));
debugPrint('[NOTIF-BGENGINE] read marker set for $roomId');
return true;
} catch (e) {
debugPrint('[NOTIF-BGENGINE] markRead error: $e');
return false;
} finally {
await client.dispose().catchError((_) {});
}
}
/// Stores the raw callback handle of [notificationEngineMain] so native code can /// Stores the raw callback handle of [notificationEngineMain] so native code can
/// bootstrap the background engine. Call once at app startup. /// bootstrap the background engine. Call once at app startup.
Future<void> registerBgEngineHandle() async { Future<void> registerBgEngineHandle() async {
@@ -217,20 +248,6 @@ Future<Client?> _buildClient() async {
} }
} }
String _buildBody(Event event) {
final msgType = event.messageType;
final rawBody = event.body;
return switch (msgType) {
'm.text' => rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody,
'm.image' => '📷 Bild',
'm.video' => '🎥 Video',
'm.audio' => '🎵 Audio',
'm.file' => '📄 Datei',
'm.sticker' => '🏷️ Sticker',
_ => rawBody.isEmpty ? 'Neue Nachricht' : (rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody),
};
}
// ── Notification action handler ─────────────────────────────────────────────── // ── Notification action handler ───────────────────────────────────────────────
// Called in a fresh Dart isolate when the user presses Reply or Gelesen. // Called in a fresh Dart isolate when the user presses Reply or Gelesen.
// Must return quickly — Android keeps the notification's "sending" spinner // Must return quickly — Android keeps the notification's "sending" spinner
+84
View File
@@ -0,0 +1,84 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
/// Anruf-Vordergrunddienst auf Android (android/.../CallService.kt).
///
/// * Solange ein Anruf läuft, hält der Dienst das Mikrofon auch im
/// Hintergrund aktiv (Android schaltet es sonst stumm) und zeigt eine
/// „Anruf läuft“-Benachrichtigung, über die man zurück in die App kommt.
/// * Für eine Bildschirmübertragung MUSS ab Android 14 vorher ein Dienst vom
/// Typ mediaProjection laufen – und der darf erst NACH der Zustimmung des
/// Nutzers starten. [prepareScreenCapture] erledigt beides in der richtigen
/// Reihenfolge; flutter_webrtc benutzt danach die gespeicherte Zustimmung.
///
/// Beide Anrufwege (1:1 über Matrix, Sprachkanäle über LiveKit) melden sich
/// mit eigenem Namen an; der Dienst endet erst, wenn keiner mehr läuft.
/// Auf anderen Plattformen passiert nichts.
class CallForeground {
CallForeground._();
static const _channel = MethodChannel('chat.pyramid.pyramid/call');
static final _owners = <String>{};
static final _screenOwners = <String>{};
static String _title = 'Pyramid';
static bool get _android => !kIsWeb && Platform.isAndroid;
/// true = Dienst läuft (bzw. wurde beendet) wie gewünscht.
static Future<bool> _apply() async {
if (!_android) return true;
try {
if (_owners.isEmpty) {
await _channel.invokeMethod('stop');
return true;
}
final ok = await _channel.invokeMethod<bool>('start', {
'title': _title,
'screen': _screenOwners.isNotEmpty,
});
return ok ?? false;
} catch (e) {
debugPrint('[CallForeground] $e');
return false;
}
}
/// Anruf von [owner] läuft (z. B. 'voip', 'livekit').
static Future<void> callStarted(String owner, {required String title}) async {
_owners.add(owner);
_title = title;
await _apply();
}
/// Anruf von [owner] ist beendet.
static Future<void> callEnded(String owner) async {
final changed = _owners.remove(owner) | _screenOwners.remove(owner);
if (changed) await _apply();
}
/// Vor einer Bildschirmübertragung auf Android: Zustimmung einholen, dann
/// den Dienst um „mediaProjection“ erweitern. false = Nutzer hat abgelehnt.
static Future<bool> prepareScreenCapture(String owner) async {
if (!_android) return true;
final granted = await rtc.Helper.requestCapturePermission();
if (!granted) return false;
_owners.add(owner);
_screenOwners.add(owner);
if (!await _apply()) {
// Ohne laufenden mediaProjection-Dienst würde der Mitschnitt die App
// abstürzen lassen – dann lieber gar nicht übertragen.
_screenOwners.remove(owner);
await _apply();
return false;
}
return true;
}
/// Übertragung von [owner] beendet – Dienst wieder ohne mediaProjection.
static Future<void> screenCaptureStopped(String owner) async {
if (_screenOwners.remove(owner)) await _apply();
}
}
+84 -32
View File
@@ -1,37 +1,89 @@
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; import 'dart:async';
import 'dart:convert';
import 'dart:io';
class LiveKitTokenGenerator { import 'package:http/http.dart' as http;
// Your LiveKit Credentials
static const String apiKey = 'LKMatrixPi';
static const String apiSecret = 'rYUT2PRaKLedp5VLQCQE83eZG7fjuBWtFPXGiveBmIE';
static String generate({ /// LiveKit-Beitritts-Token vom Pyramid-Server holen.
required String roomName, ///
required String identity, /// Früher hat die App die Tokens selbst signiert – dafür steckte das
String? displayName, /// LiveKit-Secret im App-Code, und wer die App hatte, konnte sich für jeden
int ttlSeconds = 3600, // Valid for 1 hour by default /// 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
final jwt = JWT( /// Matrix-Login per whoami und setzt die LiveKit-Identität selbst auf die
{ /// geprüfte Matrix-ID. Siehe docs/LIVEKIT_TOKEN_MIGRATION.md.
'video': { class LiveKitTokenService {
'roomJoin': true, static const endpoint =
'room': roomName, 'https://dashboard.steggi-matrix.work/api/livekit-token';
'canPublish': true, static const fallbackUrl = 'wss://livekit.steggi-matrix.work';
'canSubscribe': true, static const _timeout = Duration(seconds: 15);
'canPublishData': true,
},
'metadata': displayName ?? identity,
},
issuer: apiKey,
subject: identity,
);
// Sign the token with your secret /// Liefert Token und LiveKit-Adresse für [room]. Wirft
final token = jwt.sign( /// [LiveKitTokenException] mit einer verständlichen Meldung.
SecretKey(apiSecret), static Future<({String token, String url})> fetch({
expiresIn: Duration(seconds: ttlSeconds), required String room,
); required String matrixToken,
http.Client? httpClient,
return token; }) async {
final client = httpClient ?? http.Client();
try {
final res = await client
.post(
Uri.parse(endpoint),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'room': room, 'matrix_token': matrixToken}),
)
.timeout(_timeout);
if (res.statusCode == 401) {
throw const LiveKitTokenException(
'Anmeldung vom Anrufserver abgelehnt – bitte neu anmelden.',
);
}
if (res.statusCode != 200) {
throw LiveKitTokenException(
'Anrufserver antwortet nicht richtig (HTTP ${res.statusCode}).',
);
}
final data = jsonDecode(res.body);
final token = data is Map ? data['token'] : null;
if (token is! String || token.isEmpty) {
throw const LiveKitTokenException(
'Anrufserver hat kein Zugangstoken geliefert.',
);
}
final url = (data as Map)['url'];
return (
token: token,
url: url is String && url.startsWith('wss://') ? url : fallbackUrl,
);
} on LiveKitTokenException {
rethrow;
} on TimeoutException {
throw const LiveKitTokenException(
'Anrufserver nicht erreichbar (Zeitüberschreitung).',
);
} on SocketException {
throw const LiveKitTokenException(
'Anrufserver nicht erreichbar – Internetverbindung prüfen.',
);
} on http.ClientException {
throw const LiveKitTokenException(
'Anrufserver nicht erreichbar – Internetverbindung prüfen.',
);
} on FormatException {
throw const LiveKitTokenException(
'Anrufserver hat eine unlesbare Antwort geschickt.',
);
} finally {
if (httpClient == null) client.close();
}
} }
} }
class LiveKitTokenException implements Exception {
final String message;
const LiveKitTokenException(this.message);
@override
String toString() => message;
}
+293
View File
@@ -0,0 +1,293 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:matrix/matrix.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:pyramid/core/media_cache.dart';
/// Was eine Nachrichten-Benachrichtigung zeigt – egal, ob sie aus der
/// laufenden App (`notification_service.dart`) oder dem Hintergrund-Isolate
/// bei beendeter App (`background_push.dart`) kommt. Beide Wege übergeben
/// [toNativeArgs] an `NotificationHelper.kt`, das daraus pro Raum einen
/// Chat-Verlauf (Android MessagingStyle) baut.
class MessageNotificationContent {
const MessageNotificationContent({
required this.roomId,
required this.eventId,
required this.senderId,
required this.senderName,
required this.roomName,
required this.body,
required this.timestamp,
this.isImage = false,
});
final String roomId;
final String eventId;
final String senderId;
final String senderName;
/// `null` bei Direktnachrichten – dann zeigt Android nur den Absender.
final String? roomName;
final String body;
final DateTime timestamp;
/// Bild-Nachricht: Vorschaubild wird nachgeladen ([cacheNotificationImage]).
final bool isImage;
bool get isGroup => roomName != null;
/// Einzeiliger Titel für Plattformen ohne Chat-Verlauf (Windows).
String get title => roomName == null ? senderName : '$senderName · $roomName';
/// Liest den Inhalt aus einem (entschlüsselten) Event. `null`, wenn es
/// nichts Anzeigbares ist (nicht entschlüsselt, gelöscht, kein
/// Nachrichten-Event) – dann bleibt ein evtl. Platzhalter stehen.
/// Ist [showPreview] aus (Einstellung „Vorschau“), steht nur
/// [notificationPlaceholderBody] da und es wird kein Bild geladen.
static MessageNotificationContent? fromEvent(
Event event, {
bool showPreview = true,
}) {
final body = notificationBody(event);
if (body == null) return null;
final room = event.room;
final displayName = room
.unsafeGetUserFromMemoryOrFallback(event.senderId)
.displayName
?.trim();
return MessageNotificationContent(
roomId: room.id,
eventId: event.eventId,
senderId: event.senderId,
senderName: (displayName == null || displayName.isEmpty)
? _localpart(event.senderId)
: displayName,
roomName: room.isDirectChat ? null : room.getLocalizedDisplayname(),
body: showPreview ? body : notificationPlaceholderBody,
timestamp: event.originServerTs,
isImage: showPreview && event.messageType == MessageTypes.Image,
);
}
/// Argumente für den nativen Kanal (`showNativeNotification` bzw.
/// `showDecrypted`). `title`/`body` bleiben für ältere Native-Pfade drin.
Map<String, Object?> toNativeArgs({
required int notifId,
String? imagePath,
String? avatarPath,
}) => {
'title': title,
'body': body,
'roomId': roomId,
'notifId': notifId,
'eventId': eventId,
'senderId': senderId,
'senderName': senderName,
'roomName': roomName,
'isGroup': isGroup,
'timestamp': timestamp.millisecondsSinceEpoch,
'imagePath': imagePath,
'avatarPath': avatarPath,
};
}
/// Text, den Android anzeigt, solange der echte Inhalt fehlt.
const notificationPlaceholderBody = 'Neue Nachricht';
/// Längere Texte werden gekürzt (aufgeklappt zeigt Android ohnehin nicht mehr).
const notificationBodyMaxChars = 1000;
/// Anzeigetext einer Nachricht für die Benachrichtigung, oder `null`, wenn
/// das Event nichts Anzeigbares enthält.
String? notificationBody(Event event) {
if (event.redacted) return null;
if (event.type == EventTypes.Sticker) return '🏷️ Sticker';
if (event.type == _pollStartType) return '📊 Umfrage';
if (event.type != EventTypes.Message) return null;
final content = event.content;
final caption = _caption(event);
final String? text = switch (event.messageType) {
MessageTypes.Text || MessageTypes.Notice => _plainText(event),
MessageTypes.Emote => '* ${_plainText(event)}',
MessageTypes.Image => caption == null ? '📷 Foto' : '📷 $caption',
MessageTypes.Video => caption == null ? '🎥 Video' : '🎥 $caption',
MessageTypes.Audio =>
content.containsKey(_voiceKey) ? '🎤 Sprachnachricht' : '🎵 Audio',
MessageTypes.File =>
'📎 ${content.tryGet<String>('filename') ?? event.body}',
MessageTypes.Location => '📍 Standort',
MessageTypes.Sticker => '🏷️ Sticker',
MessageTypes.BadEncrypted => null,
_ => _plainText(event),
};
final trimmed = text?.trim();
if (trimmed == null || trimmed.isEmpty) return null;
return trimmed.length > notificationBodyMaxChars
? '${trimmed.substring(0, notificationBodyMaxChars)}…'
: trimmed;
}
const _pollStartType = 'org.matrix.msc3381.poll.start';
const _voiceKey = 'org.matrix.msc3245.voice';
/// Klartext ohne Antwort-Zitat („> <@x> …“) und ohne HTML.
String _plainText(Event event) => event.calcUnlocalizedBody(
hideReply: true,
hideEdit: true,
plaintextBody: true,
);
/// MSC2530: Steht neben `filename` ein abweichender `body`, ist das die
/// Bildunterschrift.
String? _caption(Event event) {
final filename = event.content.tryGet<String>('filename');
final body = event.content.tryGet<String>('body')?.trim();
if (filename == null || body == null || body.isEmpty || body == filename) {
return null;
}
return body;
}
String _localpart(String userId) =>
userId.split(':').first.replaceFirst('@', '');
// ── Bilder für die Benachrichtigung ─────────────────────────────────────────
// Android zeigt Bilder in Benachrichtigungen nur über eine content://-URI.
// Die Dateien liegen deshalb unter <cache>/downloads/notif/ – genau dort gibt
// der FileProvider der App frei (res/xml: cache-path "downloads/").
const _maxImageBytes = 8 * 1024 * 1024;
const _maxAvatarBytes = 2 * 1024 * 1024;
const _keepFilesFor = Duration(days: 2);
/// Lädt das Vorschaubild einer Bild-Nachricht (bei E2EE entschlüsselt) in
/// den Cache und liefert den Dateipfad – oder `null`, wenn es nicht klappt.
Future<String?> cacheNotificationImage(Event event) async {
if (event.messageType != MessageTypes.Image) return null;
try {
final file = await event
.downloadAndDecryptAttachment(getThumbnail: event.hasThumbnail)
.timeout(const Duration(seconds: 20));
return await _writeNotifFile(
'img_${_hash(event.eventId)}',
file.bytes,
maxBytes: _maxImageBytes,
);
} catch (_) {
return null;
}
}
/// Avatar des Absenders als Datei (zuerst aus dem Bild-Cache der App).
Future<String?> cacheNotificationAvatar(Event event) async {
try {
final client = event.room.client;
final mxc = event.room
.unsafeGetUserFromMemoryOrFallback(event.senderId)
.avatarUrl;
if (mxc == null) return null;
final name = 'avatar_${_hash(mxc.toString())}';
final existing = File(p.join((await _notifDir()).path, name));
if (await existing.exists()) return existing.path;
final cacheKey = 'img:$mxc';
var bytes = await MediaCache.instance.getPersistent(cacheKey);
if (bytes == null) {
final headers = {'authorization': 'Bearer ${client.accessToken}'};
try {
final thumb = await mxc.getThumbnailUri(
client,
width: 128,
height: 128,
method: ThumbnailMethod.crop,
);
final res = await client.httpClient
.get(thumb, headers: headers)
.timeout(const Duration(seconds: 8));
if (res.statusCode == 200) bytes = res.bodyBytes;
} catch (_) {}
if (bytes == null) {
// Continuwuity kann keine Thumbnails → Original (Avatare sind klein).
final full = await mxc.getDownloadUri(client);
final res = await client.httpClient
.get(full, headers: headers)
.timeout(const Duration(seconds: 8));
if (res.statusCode == 200) bytes = res.bodyBytes;
}
if (bytes != null) {
await MediaCache.instance.putPersistent(cacheKey, bytes);
}
}
if (bytes == null) return null;
return await _writeNotifFile(name, bytes, maxBytes: _maxAvatarBytes);
} catch (_) {
return null;
}
}
Future<Directory> _notifDir() async {
final dir = Directory(
p.join((await getTemporaryDirectory()).path, 'downloads', 'notif'),
);
await dir.create(recursive: true);
return dir;
}
Future<String?> _writeNotifFile(
String name,
Uint8List bytes, {
required int maxBytes,
}) async {
if (bytes.isEmpty || bytes.length > maxBytes) return null;
final dir = await _notifDir();
await _pruneOldFiles(dir);
final file = File(p.join(dir.path, name));
await file.writeAsBytes(bytes, flush: true);
return file.path;
}
Future<void> _pruneOldFiles(Directory dir) async {
try {
final cutoff = DateTime.now().subtract(_keepFilesFor);
await for (final entity in dir.list()) {
if (entity is File && (await entity.lastModified()).isBefore(cutoff)) {
await entity.delete();
}
}
} catch (_) {}
}
/// Dateiname aus Event-ID/MXC (enthält `$`, `:` und `/`).
String _hash(String input) =>
sha1.convert(utf8.encode(input)).toString().substring(0, 20);
/// Zeigt eine Nachricht auf Android in zwei Schritten: erst sofort den Text
/// (damit nichts wartet), dann – nur falls es etwas gibt – lautlos dasselbe
/// mit Avatar und Bild. Native erkennt die Aktualisierung an der eventId und
/// ersetzt die Nachricht im Verlauf, statt sie doppelt anzuhängen.
Future<void> deliverMessageNotification(
Event event,
MessageNotificationContent content, {
required int notifId,
required Future<void> Function(Map<String, Object?> args) send,
}) async {
await send(content.toNativeArgs(notifId: notifId));
final avatarPath = await cacheNotificationAvatar(event);
final imagePath = content.isImage
? await cacheNotificationImage(event)
: null;
if (avatarPath == null && imagePath == null) return;
await send(
content.toNativeArgs(
notifId: notifId,
avatarPath: avatarPath,
imagePath: imagePath,
),
);
}
+72 -77
View File
@@ -12,6 +12,8 @@ import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/background_push.dart'; import 'package:pyramid/core/background_push.dart';
import 'package:pyramid/core/fcm_push_service.dart'; import 'package:pyramid/core/fcm_push_service.dart';
import 'package:pyramid/core/matrix_client.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:pyramid/core/settings_prefs.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:windows_taskbar/windows_taskbar.dart'; import 'package:windows_taskbar/windows_taskbar.dart';
@@ -197,6 +199,26 @@ Future<void> _initAndroid(WidgetRef ref) async {
if (!share.isEmpty) { if (!share.isEmpty) {
ref.read(pendingShareProvider.notifier).state = share; ref.read(pendingShareProvider.notifier).state = share;
} }
} else if (call.method == 'markReadFromNotification') {
// „Gelesen“ in der Benachrichtigung (ReplyReceiver, App lebt).
final roomId = (args is Map ? args['room_id'] : null) as String?;
final eventId = (args is Map ? args['event_id'] : null) as String?;
if (roomId != null && eventId != null) {
try {
final client = await ref.read(matrixClientProvider.future);
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');
}
}
} else if (call.method == 'decryptAndUpdateNotification') { } else if (call.method == 'decryptAndUpdateNotification') {
// Called by PushService.kt after showing a "Neue Nachricht" placeholder. // Called by PushService.kt after showing a "Neue Nachricht" placeholder.
// We decrypt the event here (the Matrix client has the keys) and overwrite // We decrypt the event here (the Matrix client has the keys) and overwrite
@@ -458,57 +480,37 @@ Future<void> _decryptAndUpdateNotification(
final client = await ref final client = await ref
.read(matrixClientProvider.future) .read(matrixClientProvider.future)
.timeout(const Duration(seconds: 5)); .timeout(const Duration(seconds: 5));
var room = client.getRoomById(roomId); if (eventId == null) return;
if (room == null) {
debugPrint('[NOTIF] decryptAndUpdate: room $roomId not found'); // Genau das gepushte Event holen (lokal oder vom Server, entschlüsselt) –
// nicht room.lastEvent, das kann inzwischen eine Reaktion o. Ä. sein.
final event = await client
.getEventByPushNotification(
PushNotification(roomId: roomId, eventId: eventId, devices: const []),
storeInDatabase: false,
returnNullIfSeen: false,
)
.timeout(const Duration(seconds: 20));
if (event == null || event.senderId == client.userID) return;
final content = MessageNotificationContent.fromEvent(
event,
showPreview: ref.read(notifPreviewProvider),
);
if (content == null) {
debugPrint('[NOTIF] decryptAndUpdate: nichts Anzeigbares, Platzhalter bleibt');
return; return;
} }
// The event might not have synced yet — wait for the next sync (max 12 s). // Same eventId → NotificationHelper ersetzt den „Neue Nachricht“-
// We use lastEvent as a proxy: after sync it will be the event we want. // Platzhalter im Verlauf, statt eine zweite Nachricht anzuhängen.
Event? event = room.lastEvent; await deliverMessageNotification(
final bool alreadyHave = event,
eventId == null || event?.eventId == eventId; content,
if (!alreadyHave || event == null) { notifId: notifId ?? _stableRoomId(roomId),
debugPrint('[NOTIF] decryptAndUpdate: waiting for sync to deliver $eventId'); send: (a) => _kInstallChannel.invokeMethod('showNativeNotification', a),
await client.onSync.stream.first.timeout(const Duration(seconds: 12)); );
room = client.getRoomById(roomId); debugPrint('[NOTIF] decryptAndUpdate: updated notification for $eventId');
if (room == null) return;
event = room.lastEvent;
}
if (event == null) {
debugPrint('[NOTIF] decryptAndUpdate: event still null after sync');
return;
}
if (event.type != EventTypes.Message) return;
if (event.senderId == client.userID) return;
final rawBody = event.body;
// If decryption failed the body starts with "** Unable to decrypt"
if (rawBody.isEmpty || rawBody.startsWith('**')) {
debugPrint('[NOTIF] decryptAndUpdate: decryption failed, keeping placeholder');
return;
}
final notifBody =
rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody;
final sender = room.unsafeGetUserFromMemoryOrFallback(event.senderId);
final displayName = sender.displayName ??
event.senderId.split(':').first.replaceFirst('@', '');
final title = room.isDirectChat
? displayName
: '$displayName · ${room.getLocalizedDisplayname()}';
// Overwrite the "Neue Nachricht" placeholder — same notifId so Android
// replaces the existing notification rather than stacking a new one.
await _kInstallChannel.invokeMethod('showNativeNotification', {
'title': title,
'body': notifBody,
'roomId': roomId,
'notifId': notifId ?? _stableRoomId(roomId),
});
debugPrint('[NOTIF] decryptAndUpdate: updated notification → "$title: $notifBody"');
} catch (e) { } catch (e) {
debugPrint('[NOTIF] decryptAndUpdate error: $e'); debugPrint('[NOTIF] decryptAndUpdate error: $e');
} }
@@ -516,27 +518,30 @@ Future<void> _decryptAndUpdateNotification(
// ─── Show notification ──────────────────────────────────────────────────────── // ─── Show notification ────────────────────────────────────────────────────────
Future<void> showMessageNotification({ Future<void> showMessageNotification(
required String roomId, Event event,
String? roomName, MessageNotificationContent content,
required String senderName, ) async {
required String body, final roomId = content.roomId;
}) async {
final title = roomName != null ? '$senderName · $roomName' : senderName;
if (Platform.isAndroid && _androidInitialized) { if (Platform.isAndroid && _androidInitialized) {
// Use native Kotlin notification so the reply PendingIntent targets // Use native Kotlin notification so the reply PendingIntent targets
// ReplyReceiver directly — the app never opens when the user replies. // ReplyReceiver directly — the app never opens when the user replies.
try { try {
await _kInstallChannel.invokeMethod('showNativeNotification', { await deliverMessageNotification(
'title': title, event,
'body': body, content,
'roomId': roomId, notifId: _stableRoomId(roomId),
'notifId': _stableRoomId(roomId), send: (a) => _kInstallChannel.invokeMethod('showNativeNotification', a),
}); );
} catch (e) { } catch (e) {
debugPrint('[NOTIF] showNativeNotification failed: $e'); debugPrint('[NOTIF] showNativeNotification failed: $e');
} }
} else if (Platform.isWindows) { } else if (Platform.isWindows) {
final title = content.title;
// Windows-Toasts zeigen ohnehin nur wenige Zeilen.
final body = content.body.length > 300
? '${content.body.substring(0, 300)}…'
: content.body;
// Close the previous notification for this room before showing a new one. // Close the previous notification for this room before showing a new one.
_windowsNotifs.remove(roomId)?.close(); _windowsNotifs.remove(roomId)?.close();
@@ -624,23 +629,13 @@ void _watchEvents(Ref ref, Client client) {
if (!mentionedIds.contains(myId) && !bodyMentionsUs) return; if (!mentionedIds.contains(myId) && !bodyMentionsUs) return;
} }
// ── Build notification body ─────────────────────────────────────────── // ── Build notification content ────────────────────────────────────────
final rawBody = event.body; final content = MessageNotificationContent.fromEvent(
final showPreview = ref.read(notifPreviewProvider); event,
final notifBody = showPreview showPreview: ref.read(notifPreviewProvider),
? (rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody)
: 'Neue Nachricht';
final sender = room.unsafeGetUserFromMemoryOrFallback(event.senderId);
final displayName = sender.displayName ??
event.senderId.split(':').first.replaceFirst('@', '');
showMessageNotification(
roomId: roomId,
roomName: room.isDirectChat ? null : room.getLocalizedDisplayname(),
senderName: displayName,
body: notifBody,
); );
if (content == null) return;
showMessageNotification(event, content);
}); });
} }
+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);
+58
View File
@@ -0,0 +1,58 @@
import 'package:flutter/foundation.dart';
import 'package:matrix/matrix.dart';
/// Kennung der Account-Daten, mit denen wir den laufenden Sync aufwecken.
const logoutWakeAccountDataType = 'io.pyramid.logout_wake';
/// Meldet [client] ab, ohne dass eine verspätete Server-Antwort die NÄCHSTE
/// Anmeldung wieder löscht.
///
/// Hintergrund (SDK-Wettlauf, ROADMAP M1, gefunden 2026-10-07): matrix 6.2
/// (und Upstream `main`) prüft bei einer FEHLER-Antwort des Sync-Long-Polls
/// nicht, ob die Anfrage noch aktuell ist. Läuft beim Abmelden noch ein
/// Long-Poll (bis 30 s) mit dem alten Token, kommt dessen 401
/// (M_UNKNOWN_TOKEN) später zurück und das SDK ruft `clear()` – meldet sich
/// der Nutzer in der Zwischenzeit auf demselben Client neu an (Pyramid
/// benutzt nach dem Logout dasselbe Client-Objekt weiter), wird die frische
/// Session gelöscht: ein „Random-Logout“ direkt nach dem Login.
///
/// Ablauf hier:
/// 1. Sync-Schleife anhalten (nach der laufenden Anfrage keine neue).
/// 2. Laufenden Long-Poll WECKEN: geänderte Account-Daten lässt der Server
/// sofort antworten – statt bis zu 30 s zu warten.
/// 3. Die laufende Anfrage regulär (mit gültigem Token) zu Ende gehen lassen.
/// 4. Erst dann serverseitig abmelden – es ist keine Anfrage mehr unterwegs.
///
/// Schritt 2/3 sind „best effort“: scheitern sie (offline), wird nach
/// höchstens ~8 s trotzdem abgemeldet – nie schlechter als das bisherige
/// direkte `logout()`.
///
/// Live gemessen (test/live_logout_race_test.dart, 2026-10-07): die
/// verspätete 401 tritt real auf, kommt aber meist an, BEVOR ein Mensch sich
/// neu angemeldet hat – das Risiko ist klein, dieser Weg schließt es aus.
Future<void> logoutWithoutStraySync(
Client client, {
Duration settleTimeout = const Duration(seconds: 5),
}) async {
client.backgroundSync = false;
// Liefert den laufenden Sync (oder startet einen kurzen ohne Long-Poll).
final inFlight = client.oneShotSync(timeout: Duration.zero);
final userId = client.userID;
if (userId != null) {
try {
await client
.setAccountData(userId, logoutWakeAccountDataType, {
'ts': DateTime.now().millisecondsSinceEpoch,
})
.timeout(const Duration(seconds: 3));
} catch (e) {
debugPrint('[LOGOUT] Sync-Wecken fehlgeschlagen (egal): $e');
}
}
try {
await inFlight.timeout(settleTimeout);
} catch (e) {
debugPrint('[LOGOUT] laufender Sync nicht rechtzeitig fertig: $e');
}
await client.logout();
}
+41 -6
View File
@@ -2,6 +2,9 @@ import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.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 ────────────────────────── // ── Generic SharedPreferences-backed bool notifier ──────────────────────────
@@ -85,7 +88,7 @@ final privacyPresenceProvider =
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('privacy_presence', true)); StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('privacy_presence', true));
final privacyReadReceiptsProvider = final privacyReadReceiptsProvider =
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('privacy_read_receipts', true)); StateNotifierProvider<BoolPref, bool>((ref) => BoolPref(readReceiptsPrefKey, true));
final privacyTypingProvider = final privacyTypingProvider =
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('privacy_typing', true)); StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('privacy_typing', true));
@@ -143,26 +146,58 @@ class VoicePrefs {
// ── Theme persistence helpers ──────────────────────────────────────────────── // ── Theme persistence helpers ────────────────────────────────────────────────
Future<void> saveThemePrefs({ Future<void> saveThemePrefs({
bool? isDark, ThemeVariant? variant,
int? accentIdx, int? accentIdx,
double? radius, double? radius,
double? motion, double? motion,
double? textScale,
bool resetTextScale = false,
MessageStyle? messageStyle,
}) async { }) async {
final p = await SharedPreferences.getInstance(); final p = await SharedPreferences.getInstance();
if (isDark != null) await p.setBool('theme_dark', isDark); if (variant != null) {
await p.setString('theme_variant', variant.name);
// Altes Feld mitpflegen (falls jemand auf eine ältere Version zurückgeht).
await p.setBool('theme_dark', variant.isDark);
}
if (accentIdx != null) await p.setInt('theme_accent', accentIdx); if (accentIdx != null) await p.setInt('theme_accent', accentIdx);
if (radius != null) await p.setDouble('theme_radius', radius); if (radius != null) await p.setDouble('theme_radius', radius);
if (motion != null) await p.setDouble('theme_motion', motion); if (motion != null) await p.setDouble('theme_motion', motion);
if (resetTextScale) {
await p.remove('ui_text_scale');
} else if (textScale != null) {
await p.setDouble('ui_text_scale', textScale);
}
if (messageStyle != null) {
await p.setString('chat_message_style', messageStyle.name);
}
} }
Future<({bool isDark, int accentIdx, double radius, double motion})> Future<
loadThemePrefs() async { ({
ThemeVariant variant,
int accentIdx,
double radius,
double motion,
double? textScale,
MessageStyle messageStyle,
})> loadThemePrefs() async {
final p = await SharedPreferences.getInstance(); final p = await SharedPreferences.getInstance();
// Vor 2026-10-07 gab es nur hell/dunkel ('theme_dark'). Wer dunkel hatte,
// bekommt jetzt das neue „Weiche Dunkel“ (Bernds Entscheidung).
final storedVariant = p.getString('theme_variant');
final variant = storedVariant != null
? ThemeVariant.fromName(storedVariant)
: (p.getBool('theme_dark') ?? true)
? ThemeVariant.soft
: ThemeVariant.light;
return ( return (
isDark: p.getBool('theme_dark') ?? true, variant: variant,
accentIdx: p.getInt('theme_accent') ?? 0, accentIdx: p.getInt('theme_accent') ?? 0,
radius: p.getDouble('theme_radius') ?? 12.0, radius: p.getDouble('theme_radius') ?? 12.0,
motion: p.getDouble('theme_motion') ?? 1.0, motion: p.getDouble('theme_motion') ?? 1.0,
textScale: p.getDouble('ui_text_scale'),
messageStyle: MessageStyle.fromName(p.getString('chat_message_style')),
); );
} }
+123 -21
View File
@@ -1,6 +1,103 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
// Pyramid design tokens — matching mockup CSS variables exactly /// Farbschema der App (Einstellungen → Erscheinungsbild → Farbmodus).
enum ThemeVariant {
/// „Weiches Dunkel“ – Dunkelgrau statt Fast-Schwarz, mehr Kontrast bei
/// Text. Standard seit 2026-10-07 (Bernds Entscheidung).
soft,
/// Das bisherige Fast-Schwarz (spart auf OLED-Displays etwas Akku).
black,
/// Hell.
light;
bool get isDark => this != ThemeVariant.light;
String get label => switch (this) {
ThemeVariant.soft => 'Dunkel',
ThemeVariant.black => 'Schwarz',
ThemeVariant.light => 'Hell',
};
static ThemeVariant fromName(String? name) =>
ThemeVariant.values.firstWhere((v) => v.name == name,
orElse: () => ThemeVariant.soft);
}
/// Die Flächen- und Textfarben eines [ThemeVariant].
class PyramidPalette {
const PyramidPalette({
required this.bg0,
required this.bg1,
required this.bg2,
required this.bg3,
required this.bgHover,
required this.bgActive,
required this.border,
required this.borderStrong,
required this.fg,
required this.fgMuted,
required this.fgDim,
});
final Color bg0, bg1, bg2, bg3, bgHover, bgActive, border, borderStrong;
final Color fg, fgMuted, fgDim;
/// Weiches Dunkel: alle Flächen ~10 % heller als „Schwarz“, Nebentexte
/// deutlich kontrastreicher (fgDim auf bg1 ≈ 5:1 statt ≈ 3,8:1).
static const soft = PyramidPalette(
bg0: Color(0xFF15161A),
bg1: Color(0xFF1B1C21),
bg2: Color(0xFF212228),
bg3: Color(0xFF282A31),
bgHover: Color(0xFF2F3139),
bgActive: Color(0xFF373945),
border: Color(0xFF2C2E36),
borderStrong: Color(0xFF3A3C46),
fg: Color(0xFFF2F2F5),
fgMuted: Color(0xFFB7B7C2),
fgDim: Color(0xFF8B8B99),
);
static const black = PyramidPalette(
bg0: PyramidColors.bg0,
bg1: PyramidColors.bg1,
bg2: PyramidColors.bg2,
bg3: PyramidColors.bg3,
bgHover: PyramidColors.bgHover,
bgActive: PyramidColors.bgActive,
border: PyramidColors.border,
borderStrong: PyramidColors.borderStrong,
fg: PyramidColors.fg,
fgMuted: PyramidColors.fgMuted,
fgDim: PyramidColors.fgDim,
);
static const light = PyramidPalette(
bg0: PyramidColors.bg0Light,
bg1: PyramidColors.bg1Light,
bg2: PyramidColors.bg2Light,
bg3: PyramidColors.bg3Light,
bgHover: PyramidColors.bgHoverLight,
bgActive: PyramidColors.bgActiveLight,
border: PyramidColors.borderLight,
borderStrong: PyramidColors.borderStrongLight,
fg: PyramidColors.fgLight,
fgMuted: PyramidColors.fgMutedLight,
fgDim: PyramidColors.fgDimLight,
);
static PyramidPalette of(ThemeVariant variant) => switch (variant) {
ThemeVariant.soft => soft,
ThemeVariant.black => black,
ThemeVariant.light => light,
};
}
// Pyramid design tokens — matching mockup CSS variables exactly.
// Die dunklen Werte hier sind die Variante „Schwarz“; Widgets sollten die
// Farben über PyramidTheme.of(context) holen (folgt dem gewählten Schema).
class PyramidColors { class PyramidColors {
const PyramidColors._(); const PyramidColors._();
@@ -49,7 +146,7 @@ class PyramidColors {
} }
class PyramidTheme extends InheritedWidget { class PyramidTheme extends InheritedWidget {
final bool isDark; final ThemeVariant variant;
final Color accent; final Color accent;
final double radius; final double radius;
final double density; final double density;
@@ -58,7 +155,7 @@ class PyramidTheme extends InheritedWidget {
const PyramidTheme({ const PyramidTheme({
super.key, super.key,
required super.child, required super.child,
this.isDark = true, this.variant = ThemeVariant.soft,
this.accent = PyramidColors.accentAmber, this.accent = PyramidColors.accentAmber,
this.radius = 12, this.radius = 12,
this.density = 1, this.density = 1,
@@ -69,17 +166,20 @@ class PyramidTheme extends InheritedWidget {
return context.dependOnInheritedWidgetOfExactType<PyramidTheme>()!; return context.dependOnInheritedWidgetOfExactType<PyramidTheme>()!;
} }
Color get bg0 => isDark ? PyramidColors.bg0 : PyramidColors.bg0Light; bool get isDark => variant.isDark;
Color get bg1 => isDark ? PyramidColors.bg1 : PyramidColors.bg1Light; PyramidPalette get palette => PyramidPalette.of(variant);
Color get bg2 => isDark ? PyramidColors.bg2 : PyramidColors.bg2Light;
Color get bg3 => isDark ? PyramidColors.bg3 : PyramidColors.bg3Light; Color get bg0 => palette.bg0;
Color get bgHover => isDark ? PyramidColors.bgHover : PyramidColors.bgHoverLight; Color get bg1 => palette.bg1;
Color get bgActive => isDark ? PyramidColors.bgActive : PyramidColors.bgActiveLight; Color get bg2 => palette.bg2;
Color get border => isDark ? PyramidColors.border : PyramidColors.borderLight; Color get bg3 => palette.bg3;
Color get borderStrong => isDark ? PyramidColors.borderStrong : PyramidColors.borderStrongLight; Color get bgHover => palette.bgHover;
Color get fg => isDark ? PyramidColors.fg : PyramidColors.fgLight; Color get bgActive => palette.bgActive;
Color get fgMuted => isDark ? PyramidColors.fgMuted : PyramidColors.fgMutedLight; Color get border => palette.border;
Color get fgDim => isDark ? PyramidColors.fgDim : PyramidColors.fgDimLight; Color get borderStrong => palette.borderStrong;
Color get fg => palette.fg;
Color get fgMuted => palette.fgMuted;
Color get fgDim => palette.fgDim;
Color get danger => PyramidColors.danger; Color get danger => PyramidColors.danger;
Color get online => PyramidColors.online; Color get online => PyramidColors.online;
Color get away => PyramidColors.away; Color get away => PyramidColors.away;
@@ -104,7 +204,7 @@ class PyramidTheme extends InheritedWidget {
@override @override
bool updateShouldNotify(PyramidTheme oldWidget) => bool updateShouldNotify(PyramidTheme oldWidget) =>
isDark != oldWidget.isDark || variant != oldWidget.variant ||
accent != oldWidget.accent || accent != oldWidget.accent ||
radius != oldWidget.radius || radius != oldWidget.radius ||
density != oldWidget.density || density != oldWidget.density ||
@@ -114,9 +214,11 @@ class PyramidTheme extends InheritedWidget {
// Inter ist als Asset gebündelt (pubspec `fonts:`) — kein Laufzeit-Download. // Inter ist als Asset gebündelt (pubspec `fonts:`) — kein Laufzeit-Download.
TextStyle _base(Color color) => TextStyle(fontFamily: 'Inter', color: color); TextStyle _base(Color color) => TextStyle(fontFamily: 'Inter', color: color);
ThemeData buildPyramidThemeData(bool isDark) { ThemeData buildPyramidThemeData(ThemeVariant variant) {
final fg = isDark ? PyramidColors.fg : PyramidColors.fgLight; final palette = PyramidPalette.of(variant);
final bg1 = isDark ? PyramidColors.bg1 : PyramidColors.bg1Light; final isDark = variant.isDark;
final fg = palette.fg;
final bg1 = palette.bg1;
final accent = PyramidColors.accentAmber; final accent = PyramidColors.accentAmber;
return ThemeData( return ThemeData(
@@ -137,11 +239,11 @@ ThemeData buildPyramidThemeData(bool isDark) {
), ),
textTheme: TextTheme( textTheme: TextTheme(
bodyMedium: _base(fg).copyWith(fontSize: 14), bodyMedium: _base(fg).copyWith(fontSize: 14),
bodySmall: _base(fg).copyWith(fontSize: 12, color: isDark ? PyramidColors.fgMuted : PyramidColors.fgMutedLight), bodySmall: _base(fg).copyWith(fontSize: 12, color: palette.fgMuted),
labelSmall: _base(fg).copyWith(fontSize: 11, color: isDark ? PyramidColors.fgDim : PyramidColors.fgDimLight), labelSmall: _base(fg).copyWith(fontSize: 11, color: palette.fgDim),
), ),
dividerTheme: DividerThemeData( dividerTheme: DividerThemeData(
color: isDark ? PyramidColors.border : PyramidColors.borderLight, color: palette.border,
thickness: 1, thickness: 1,
space: 1, space: 1,
), ),
@@ -49,6 +49,16 @@ abstract class CallSignalingService extends ChangeNotifier {
Future<void> changeQuality(String key); 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 /// Wendet die in den Einstellungen gewählte Ausgabelautstärke auf den
/// Remote-Stream an. Ohne [value] wird der Pref-Wert gelesen. /// Remote-Stream an. Ohne [value] wird der Pref-Wert gelesen.
Future<void> applyOutputVolume([double? value]); Future<void> applyOutputVolume([double? value]);
+280 -83
View File
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:webrtc_interface/webrtc_interface.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/ice_servers.dart';
import 'package:pyramid/core/settings_prefs.dart'; import 'package:pyramid/core/settings_prefs.dart';
import 'package:pyramid/features/call_signaling/call_signaling_service.dart'; import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
@@ -19,6 +20,7 @@ class PyramidVoipManager extends CallSignalingService
final Client client; final Client client;
late final VoIP voip; late final VoIP voip;
late final Zone _sdkZone;
@override @override
CallSession? currentCall; CallSession? currentCall;
@@ -35,6 +37,34 @@ class PyramidVoipManager extends CallSignalingService
bool _isDeafened = false; bool _isDeafened = false;
bool _isUpdating = false; bool _isUpdating = false;
String? _selectedSourceId; 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'; String _currentQualityKey = 'hd';
@override @override
@@ -52,7 +82,21 @@ class PyramidVoipManager extends CallSignalingService
PyramidVoipManager(this.client) { PyramidVoipManager(this.client) {
_instance = this; _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 // Suppress speakerphone call — not supported on Windows/Desktop
try { try {
(voip as dynamic).onConfigSpeakerphone = (bool _) async {}; (voip as dynamic).onConfigSpeakerphone = (bool _) async {};
@@ -60,6 +104,29 @@ class PyramidVoipManager extends CallSignalingService
_ensureRenderers(); _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 { Future<void> _ensureRenderers() async {
if (_renderersInitialized) return; if (_renderersInitialized) return;
try { try {
@@ -104,13 +171,86 @@ class PyramidVoipManager extends CallSignalingService
await _ensureRenderers(); await _ensureRenderers();
currentCall = call; currentCall = call;
_isCameraMuted = call.type == CallType.kVoice; _isCameraMuted = call.type == CallType.kVoice;
_speakerOn = true;
_setupCallListeners(call); _setupCallListeners(call);
_updateInternalStates(); _updateInternalStates();
notifyListeners(); 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; Timer? _renderRefreshTimer;
String? _listenedCallId;
void _setupCallListeners(CallSession call) { 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) { call.onCallStateChanged.stream.listen((state) {
debugPrint('[VOIP] State: $state'); debugPrint('[VOIP] State: $state');
_updateInternalStates(); _updateInternalStates();
@@ -120,14 +260,36 @@ class PyramidVoipManager extends CallSignalingService
debugPrint('[VOIP] Ensuring camera is unmuted on connect'); debugPrint('[VOIP] Ensuring camera is unmuted on connect');
call.setLocalVideoMuted(false); 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(); _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?.cancel();
_renderRefreshTimer = Timer.periodic( _renderRefreshTimer = Timer.periodic(
const Duration(milliseconds: 1000), // Faster polling to catch feeds 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(); _updateRenderers();
@@ -144,99 +306,66 @@ class PyramidVoipManager extends CallSignalingService
} }
Future<void> _updateRenderers() async { Future<void> _updateRenderers() async {
if (currentCall == null || _isUpdating) return; final call = currentCall;
if (call == null || _isUpdating) return;
_isUpdating = true; _isUpdating = true;
try { try {
await _ensureRenderers(); await _ensureRenderers();
final dynamic c = currentCall; // Typisierte SDK-Zugriffe. Früher lief das über ein dynamisches
rtc.MediaStream? remote; // `getFeeds()` – das gibt es im SDK gar nicht; der Fehler wurde
rtc.MediaStream? local; // geschluckt, und Bildschirmübertragungen wurden weder beim Empfänger
// angezeigt noch beim Sender als aktiv erkannt (Bernd 2026-10-08).
rtc.MediaStream? extract(dynamic obj) { rtc.MediaStream? video(WrappedMediaStream? w, {bool respectMute = false}) {
if (obj == null) return null; final s = w?.stream;
if (obj is rtc.MediaStream) return obj; // stopped: Gegenseite hat die Übertragung beendet (das SDK lässt den
try { return obj.stream as rtc.MediaStream?; } catch (_) {} // Strom stehen und markiert ihn nur).
return null; 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; final remoteScreen = video(call.remoteScreenSharingStream);
_isRemoteScreensharing = remoteScreenFound; final localScreen = video(call.localScreenSharingStream);
final remoteUser = call.remoteUserMediaStream?.stream;
if (remote == null) { var changed = false;
try { remote = extract(c.remoteUserMediaStream); } catch (_) {} final remoteSharing = remoteScreen != null;
} final localSharing = call.localScreenSharingStream != null;
if (remote == null) { if (remoteSharing != _isRemoteScreensharing ||
try { remote = extract(c.remoteStream); } catch (_) {} localSharing != _isScreensharing) {
} changed = true;
if (local == null) {
try { local = extract(c.localUserMediaStream); } catch (_) {}
} }
_isRemoteScreensharing = remoteSharing;
_isScreensharing = localSharing;
// Ausgabelautstärke (Settings-Slider) auf den Remote-Stream anwenden — // Ausgabelautstärke (Settings-Slider) auf den Ton der Gegenseite –
// nur einmal pro Stream, _updateRenderers läuft auch per Timer. // nur einmal pro Stream, _updateRenderers läuft auch per Timer.
if (remote != null && remote.id != _volumeAppliedStreamId) { if (remoteUser != null && remoteUser.id != _volumeAppliedStreamId) {
_volumeAppliedStreamId = remote.id; _volumeAppliedStreamId = remoteUser.id;
_remoteAudioStream = remote; _remoteAudioStream = remoteUser;
applyOutputVolume(); applyOutputVolume();
} }
// Final check for tracks before assigning // Bildschirm hat Vorrang vor der Kamera.
bool changed = false; final remoteVideo =
if (remote != null && remote.getVideoTracks().isNotEmpty) { remoteScreen ?? video(call.remoteUserMediaStream, respectMute: true);
if (remoteRenderer.srcObject?.id != remote.id) { final localVideo =
debugPrint('[VOIP] Assigning Remote Renderer: ${remote.id} (${remote.getVideoTracks().length} video tracks)'); localScreen ?? video(call.localUserMediaStream, respectMute: true);
remoteRenderer.srcObject = remote;
changed = true; if (remoteRenderer.srcObject?.id != remoteVideo?.id) {
} debugPrint('[VOIP] Remote-Video: ${remoteVideo?.id} (Bildschirm: $remoteSharing)');
} else if (remoteRenderer.srcObject != null) { remoteRenderer.srcObject = remoteVideo;
remoteRenderer.srcObject = null; changed = true;
}
if (localRenderer.srcObject?.id != localVideo?.id) {
debugPrint('[VOIP] Lokales Video: ${localVideo?.id} (Bildschirm: $localSharing)');
localRenderer.srcObject = localVideo;
changed = true; changed = true;
} }
if (local != null && local.getVideoTracks().isNotEmpty) {
if (localRenderer.srcObject?.id != local.id) {
debugPrint('[VOIP] Assigning Local Renderer: ${local.id} (${local.getVideoTracks().length} video tracks)');
localRenderer.srcObject = local;
changed = true;
}
} else if (localRenderer.srcObject != null) {
localRenderer.srcObject = null;
changed = true;
}
if (changed) notifyListeners(); if (changed) notifyListeners();
} catch (e) { } catch (e) {
debugPrint('[VOIP] Error in _updateRenderers: $e'); debugPrint('[VOIP] Error in _updateRenderers: $e');
@@ -245,11 +374,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 @override
Future<void> handleCallEnded(CallSession session) async { Future<void> handleCallEnded(CallSession session) async {
if (currentCall?.callId == session.callId) { if (currentCall?.callId == session.callId) {
_renderRefreshTimer?.cancel(); _renderRefreshTimer?.cancel();
_renderRefreshTimer = null; _renderRefreshTimer = null;
_stopLevelPolling();
localRenderer.srcObject = null; localRenderer.srcObject = null;
remoteRenderer.srcObject = null; remoteRenderer.srcObject = null;
_remoteAudioStream = null; _remoteAudioStream = null;
@@ -259,6 +441,7 @@ class PyramidVoipManager extends CallSignalingService
_isRemoteScreensharing = false; _isRemoteScreensharing = false;
notifyListeners(); notifyListeners();
} }
CallForeground.callEnded('voip');
} }
// ── Ausgabelautstärke (Settings-Slider "voice_output_volume") ───────────── // ── Ausgabelautstärke (Settings-Slider "voice_output_volume") ─────────────
@@ -292,6 +475,7 @@ class PyramidVoipManager extends CallSignalingService
currentCall = null; currentCall = null;
notifyListeners(); notifyListeners();
} }
CallForeground.callEnded('voip');
} }
@override @override
@@ -312,7 +496,7 @@ class PyramidVoipManager extends CallSignalingService
@override @override
Future<void> safeAction(Future<dynamic> Function() action) async { Future<void> safeAction(Future<dynamic> Function() action) async {
try { try {
await action(); await _inSdkZone(action);
await Future.delayed(const Duration(milliseconds: 1000)); await Future.delayed(const Duration(milliseconds: 1000));
} catch (e) { } catch (e) {
debugPrint('[VOIP] Action error: $e'); debugPrint('[VOIP] Action error: $e');
@@ -327,7 +511,8 @@ class PyramidVoipManager extends CallSignalingService
_isMicMuted = !_isMicMuted; _isMicMuted = !_isMicMuted;
notifyListeners(); notifyListeners();
try { try {
await currentCall!.setMicrophoneMuted(_isMicMuted); final c = currentCall!;
await _inSdkZone(() => c.setMicrophoneMuted(_isMicMuted));
} catch (e) { } catch (e) {
_isMicMuted = !_isMicMuted; _isMicMuted = !_isMicMuted;
notifyListeners(); notifyListeners();
@@ -340,7 +525,8 @@ class PyramidVoipManager extends CallSignalingService
_isCameraMuted = !_isCameraMuted; _isCameraMuted = !_isCameraMuted;
notifyListeners(); notifyListeners();
try { try {
await currentCall!.setLocalVideoMuted(_isCameraMuted); final c = currentCall!;
await _inSdkZone(() => c.setLocalVideoMuted(_isCameraMuted));
for (var i = 0; i < 5; i++) { for (var i = 0; i < 5; i++) {
await Future.delayed(Duration(milliseconds: 300 + (i * 200))); await Future.delayed(Duration(milliseconds: 300 + (i * 200)));
await _updateRenderers(); await _updateRenderers();
@@ -361,6 +547,7 @@ class PyramidVoipManager extends CallSignalingService
if (_isScreensharing) { if (_isScreensharing) {
await safeAction(() => call.setScreensharingEnabled(false)); await safeAction(() => call.setScreensharingEnabled(false));
await CallForeground.screenCaptureStopped('voip');
return; return;
} }
@@ -390,6 +577,7 @@ class PyramidVoipManager extends CallSignalingService
await currentCall!.hangup(reason: CallErrorCode.userHangup); await currentCall!.hangup(reason: CallErrorCode.userHangup);
currentCall = null; currentCall = null;
}); });
await CallForeground.callEnded('voip');
} }
@override @override
@@ -399,15 +587,17 @@ class PyramidVoipManager extends CallSignalingService
await _ensureRenderers(); await _ensureRenderers();
_isCameraMuted = !video; _isCameraMuted = !video;
_speakerOn = true;
debugPrint('[VOIP] Starting call in room $roomId (video=$video)'); 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; currentCall = call;
_setupCallListeners(call); _setupCallListeners(call);
if (video) { if (video) {
await Future.delayed(const Duration(milliseconds: 800)); await Future.delayed(const Duration(milliseconds: 800));
await call.setLocalVideoMuted(false); await _inSdkZone(() => call.setLocalVideoMuted(false));
await _updateRenderers(); await _updateRenderers();
} }
@@ -525,6 +715,13 @@ class MediaDevicesWrapper extends MediaDevices {
@override @override
Future<rtc.MediaStream> getDisplayMedia(Map<String, dynamic> constraints) async { 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; final String? sourceId = _manager._selectedSourceId;
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.windows && sourceId != null) { if (!kIsWeb && defaultTargetPlatform == TargetPlatform.windows && sourceId != null) {
final desktopConstraints = { final desktopConstraints = {
@@ -51,6 +51,9 @@ class _CallControls extends StatelessWidget {
final VoidCallback onToggleScreen; final VoidCallback onToggleScreen;
final VoidCallback onHangUp; final VoidCallback onHangUp;
final PyramidTheme pt; final PyramidTheme pt;
// Handy: Lautsprecher/Hörmuschel statt „Ton aus“ (Platz in der Leiste).
final bool isSpeakerOn;
final VoidCallback? onToggleSpeaker;
const _CallControls({ const _CallControls({
required this.isMuted, required this.isMuted,
@@ -63,10 +66,15 @@ class _CallControls extends StatelessWidget {
required this.onToggleScreen, required this.onToggleScreen,
required this.onHangUp, required this.onHangUp,
required this.pt, required this.pt,
this.isSpeakerOn = true,
this.onToggleSpeaker,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final platform = Theme.of(context).platform;
final showSpeaker = onToggleSpeaker != null &&
(platform == TargetPlatform.android || platform == TargetPlatform.iOS);
return SafeArea( return SafeArea(
top: false, top: false,
child: Container( child: Container(
@@ -77,7 +85,15 @@ class _CallControls extends StatelessWidget {
children: [ children: [
_VoiceCtrlBtn(icon: isMuted ? Icons.mic_off_rounded : Icons.mic_rounded, danger: isMuted, pt: pt, onTap: onToggleMic), _VoiceCtrlBtn(icon: isMuted ? Icons.mic_off_rounded : Icons.mic_rounded, danger: isMuted, pt: pt, onTap: onToggleMic),
const SizedBox(width: 16), const SizedBox(width: 16),
_VoiceCtrlBtn(icon: isDeafened ? Icons.headset_off_rounded : Icons.headphones_rounded, danger: isDeafened, pt: pt, onTap: onToggleDeafen), if (showSpeaker)
_VoiceCtrlBtn(
icon: isSpeakerOn ? Icons.volume_up_rounded : Icons.phone_in_talk_rounded,
active: isSpeakerOn,
pt: pt,
onTap: onToggleSpeaker!,
)
else
_VoiceCtrlBtn(icon: isDeafened ? Icons.headset_off_rounded : Icons.headphones_rounded, danger: isDeafened, pt: pt, onTap: onToggleDeafen),
const SizedBox(width: 16), const SizedBox(width: 16),
_VoiceCtrlBtn(icon: isCameraOff ? Icons.videocam_off_rounded : Icons.videocam_rounded, active: !isCameraOff, pt: pt, onTap: onToggleCam), _VoiceCtrlBtn(icon: isCameraOff ? Icons.videocam_off_rounded : Icons.videocam_rounded, active: !isCameraOff, pt: pt, onTap: onToggleCam),
const SizedBox(width: 16), const SizedBox(width: 16),
+14 -1
View File
@@ -17,6 +17,8 @@ class _UnifiedCallView extends StatefulWidget {
final bool isScreenSharing; final bool isScreenSharing;
final Function(Offset)? onQualityMenu; final Function(Offset)? onQualityMenu;
final PyramidTheme pt; final PyramidTheme pt;
final bool isSpeakerOn;
final VoidCallback? onToggleSpeaker;
const _UnifiedCallView({ const _UnifiedCallView({
required this.participants, required this.participants,
@@ -35,6 +37,8 @@ class _UnifiedCallView extends StatefulWidget {
required this.isScreenSharing, required this.isScreenSharing,
this.onQualityMenu, this.onQualityMenu,
required this.pt, required this.pt,
this.isSpeakerOn = true,
this.onToggleSpeaker,
}); });
@override @override
@@ -83,7 +87,14 @@ class _UnifiedCallViewState extends State<_UnifiedCallView> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final pt = widget.pt; final pt = widget.pt;
final all = _sorted(widget.participants); final all = _sorted(widget.participants);
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) { if (widget.isConnecting) {
return Scaffold( return Scaffold(
@@ -148,6 +159,8 @@ class _UnifiedCallViewState extends State<_UnifiedCallView> {
onToggleScreen: widget.onToggleScreen, onToggleScreen: widget.onToggleScreen,
onHangUp: widget.onHangUp, onHangUp: widget.onHangUp,
pt: pt, pt: pt,
isSpeakerOn: widget.isSpeakerOn,
onToggleSpeaker: widget.onToggleSpeaker,
), ),
], ],
), ),
@@ -3,6 +3,11 @@ part of '../voice_channel.dart';
void _handleLiveKitScreenShare(BuildContext context, VoiceChannelService call) async { void _handleLiveKitScreenShare(BuildContext context, VoiceChannelService call) async {
if (call.isScreenSharing) { if (call.isScreenSharing) {
await call.stopScreenShare(); 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 { } else {
final source = await ScreenSharePicker.show(context); final source = await ScreenSharePicker.show(context);
if (source != null) await call.startScreenShare(source.id); if (source != null) await call.startScreenShare(source.id);
@@ -58,6 +58,8 @@ class VoiceChannelView extends ConsumerWidget {
isScreenSharing: voip.isScreensharing, isScreenSharing: voip.isScreensharing,
onQualityMenu: (pos) => _showVoipQualityMenu(context, voip, pos), onQualityMenu: (pos) => _showVoipQualityMenu(context, voip, pos),
pt: pt, pt: pt,
isSpeakerOn: voip.isSpeakerOn,
onToggleSpeaker: voip.toggleSpeaker,
); );
} }
@@ -86,6 +88,8 @@ class VoiceChannelView extends ConsumerWidget {
isScreenSharing: call.isScreenSharing, isScreenSharing: call.isScreenSharing,
onQualityMenu: (pos) => _showLiveKitQualityMenu(context, call, pos), onQualityMenu: (pos) => _showLiveKitQualityMenu(context, call, pos),
pt: pt, pt: pt,
isSpeakerOn: call.isSpeakerOn,
onToggleSpeaker: call.toggleSpeaker,
); );
} }
@@ -147,6 +151,7 @@ class VoiceChannelView extends ConsumerWidget {
name: voip.currentCall?.room.getLocalizedDisplayname() ?? 'Remote', name: voip.currentCall?.room.getLocalizedDisplayname() ?? 'Remote',
videoWidget: hasRemoteVideo ? rtc.RTCVideoView(voip.remoteRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null, videoWidget: hasRemoteVideo ? rtc.RTCVideoView(voip.remoteRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null,
isMuted: false, isMuted: false,
isSpeaking: voip.isRemoteSpeaking,
hasVideo: hasRemoteVideo, hasVideo: hasRemoteVideo,
isScreenShare: isRemoteScreen, isScreenShare: isRemoteScreen,
avatarColor: _avatarColors[1], avatarColor: _avatarColors[1],
@@ -161,6 +166,7 @@ class VoiceChannelView extends ConsumerWidget {
name: 'Du', name: 'Du',
videoWidget: hasLocalVideo ? rtc.RTCVideoView(voip.localRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null, videoWidget: hasLocalVideo ? rtc.RTCVideoView(voip.localRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null,
isMuted: voip.isMicMuted, isMuted: voip.isMicMuted,
isSpeaking: voip.isLocalSpeaking,
hasVideo: hasLocalVideo, hasVideo: hasLocalVideo,
isScreenShare: isLocalScreen, isScreenShare: isLocalScreen,
avatarColor: _avatarColors[0], avatarColor: _avatarColors[0],
+100 -25
View File
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'dart:ui' as ui;
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -7,7 +8,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:pyramid/core/settings_prefs.dart'; import 'package:pyramid/core/settings_prefs.dart';
import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/theme.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/attachment_dialog.dart';
import 'package:pyramid/features/chat/emoji_picker.dart'; import 'package:pyramid/features/chat/emoji_picker.dart';
import 'package:pyramid/features/chat/gif_sticker_picker.dart'; import 'package:pyramid/features/chat/gif_sticker_picker.dart';
@@ -32,6 +35,10 @@ class ChatComposer extends ConsumerStatefulWidget {
} }
class _ChatComposerState extends ConsumerState<ChatComposer> { 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(); final _ctrl = TextEditingController();
late final FocusNode _focus; late final FocusNode _focus;
bool _canSend = false; bool _canSend = false;
@@ -44,6 +51,12 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
void initState() { void initState() {
super.initState(); super.initState();
_focus = FocusNode(onKeyEvent: _handleKeyEvent); _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); _ctrl.addListener(_onTextChanged);
} }
@@ -118,6 +131,8 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
@override @override
void dispose() { void dispose() {
final text = _ctrl.text;
if (text.trim().isNotEmpty) _drafts[widget.room.id] = text;
_typingTimer?.cancel(); _typingTimer?.cancel();
if (_isTyping) widget.room.setTyping(false).catchError((_) {}); if (_isTyping) widget.room.setTyping(false).catchError((_) {});
_gifOverlay?.remove(); _gifOverlay?.remove();
@@ -300,22 +315,40 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
final pt = PyramidTheme.of(context); final pt = PyramidTheme.of(context);
final size = MediaQuery.sizeOf(context); final size = MediaQuery.sizeOf(context);
final isSmall = size.width < 400; 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 roomName = widget.room.getLocalizedDisplayname();
final placeholder = widget.room.isDirectChat final placeholder = widget.room.isDirectChat
? 'Message…' ? 'Nachricht…'
: 'Message #$roomName'; : 'Nachricht an #$roomName';
return SafeArea( return SafeArea(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (widget.replyTo != null) // Antwort-Leiste federt auf und klappt beim Schließen weich zu.
_ReplyBar( AnimatedSwitcher(
event: widget.replyTo!, duration: motionDuration(context, 300),
onClear: widget.onClearReply ?? () {}, reverseDuration: motionDuration(context, 160),
pt: pt, 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( Padding(
// Bottom inset matches the profile block in the rooms list (8px) so // Bottom inset matches the profile block in the rooms list (8px) so
// the composer sits flush with it. // 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 // block in the rooms list (~89px on the user's display). Grows for
// multiline. // multiline.
constraints: const BoxConstraints(minHeight: 53), 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( decoration: BoxDecoration(
color: pt.bg2,
borderRadius: BorderRadius.circular(pt.rBase + 4), borderRadius: BorderRadius.circular(pt.rBase + 4),
border: Border.all( border: Border.all(
color: _focus.hasFocus ? pt.accent : pt.border, color: _focus.hasFocus ? pt.accent : pt.border,
@@ -337,7 +372,13 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
? [BoxShadow(color: pt.accentSoft, blurRadius: 0, spreadRadius: 3)] ? [BoxShadow(color: pt.accentSoft, blurRadius: 0, spreadRadius: 3)]
: null, : null,
), ),
child: Focus( child: ClipRRect(
borderRadius: BorderRadius.circular(pt.rBase + 3),
child: BackdropFilter(
filter: ui.ImageFilter.blur(sigmaX: 12, sigmaY: 12),
child: ColoredBox(
color: pt.bg2.withValues(alpha: 0.78),
child: Focus(
onFocusChange: (_) => setState(() {}), onFocusChange: (_) => setState(() {}),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
@@ -347,7 +388,7 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
icon: Icons.add_rounded, icon: Icons.add_rounded,
tooltip: 'Datei anhängen', tooltip: 'Datei anhängen',
pt: pt, pt: pt,
size: size.width < 600 ? 24 : 30, size: mobile ? 42 : 30,
onTap: _pickAttachment, onTap: _pickAttachment,
), ),
// Text input // Text input
@@ -397,14 +438,14 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
icon: Icons.gif_box_outlined, icon: Icons.gif_box_outlined,
tooltip: 'GIF', tooltip: 'GIF',
pt: pt, pt: pt,
size: size.width < 600 ? 22 : 26, size: mobile ? 40 : 26,
onTap: _toggleGifPicker, onTap: _toggleGifPicker,
), ),
_ComposerIconBtn( _ComposerIconBtn(
icon: Icons.tag_faces_rounded, icon: Icons.tag_faces_rounded,
tooltip: 'Emoji', tooltip: 'Emoji',
pt: pt, pt: pt,
size: size.width < 600 ? 22 : 26, size: mobile ? 40 : 26,
onTap: _toggleEmojiPicker, onTap: _toggleEmojiPicker,
), ),
if (size.width > 600) if (size.width > 600)
@@ -424,10 +465,14 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
canSend: _canSend, canSend: _canSend,
pt: pt, pt: pt,
onTap: _send, onTap: _send,
size: mobile ? 42 : 36,
), ),
), ),
], ],
), ),
),
),
),
), ),
), ),
), ),
@@ -461,9 +506,7 @@ class _ComposerIconBtnState extends State<_ComposerIconBtn> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final platform = Theme.of(context).platform; final isMobile = isMobileLayout(MediaQuery.sizeOf(context));
final isMobile =
platform == TargetPlatform.android || platform == TargetPlatform.iOS;
final touchSize = isMobile ? 44.0 : widget.size; final touchSize = isMobile ? 44.0 : widget.size;
return MouseRegion( return MouseRegion(
@@ -475,7 +518,9 @@ class _ComposerIconBtnState extends State<_ComposerIconBtn> {
child: Tooltip( child: Tooltip(
message: widget.tooltip, message: widget.tooltip,
waitDuration: const Duration(milliseconds: 500), waitDuration: const Duration(milliseconds: 500),
child: SizedBox( child: SpringPress(
enabled: widget.onTap != null,
child: SizedBox(
width: touchSize, width: touchSize,
height: touchSize, height: touchSize,
child: Center( child: Center(
@@ -497,6 +542,7 @@ class _ComposerIconBtnState extends State<_ComposerIconBtn> {
), ),
), ),
), ),
),
), ),
), ),
); );
@@ -507,8 +553,14 @@ class _SendButton extends StatefulWidget {
final bool canSend; final bool canSend;
final PyramidTheme pt; final PyramidTheme pt;
final VoidCallback onTap; 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 @override
State<_SendButton> createState() => _SendButtonState(); State<_SendButton> createState() => _SendButtonState();
@@ -528,11 +580,14 @@ class _SendButtonState extends State<_SendButton> {
onExit: (_) => setState(() => _hovered = false), onExit: (_) => setState(() => _hovered = false),
child: GestureDetector( child: GestureDetector(
onTap: active ? widget.onTap : null, onTap: active ? widget.onTap : null,
child: AnimatedContainer( child: SpringPress(
enabled: active,
pressedScale: 0.82,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180), duration: const Duration(milliseconds: 180),
curve: Curves.easeOutBack, curve: Curves.easeOutBack,
width: 36, width: widget.size,
height: 36, height: widget.size,
decoration: BoxDecoration( decoration: BoxDecoration(
color: active ? pt.accent : pt.bg3, color: active ? pt.accent : pt.bg3,
borderRadius: BorderRadius.circular(pt.rSm), borderRadius: BorderRadius.circular(pt.rSm),
@@ -547,13 +602,22 @@ class _SendButtonState extends State<_SendButton> {
: Matrix4.identity(), : Matrix4.identity(),
transformAlignment: Alignment.center, transformAlignment: Alignment.center,
child: Center( child: Center(
child: Icon( // Sobald gesendet werden kann, ploppt der Pfeil federnd auf.
Icons.send_rounded, child: AnimatedScale(
size: 16, scale: active ? 1 : 0.8,
color: active ? pt.accentFg : pt.fgDim, duration: active
? motionDuration(context, 520)
: const Duration(milliseconds: 140),
curve: active ? Curves.elasticOut : Curves.easeOut,
child: Icon(
Icons.send_rounded,
size: widget.size * 0.45,
color: active ? pt.accentFg : pt.fgDim,
),
), ),
), ),
), ),
),
), ),
); );
} }
@@ -599,9 +663,20 @@ class _ReplyBar extends StatelessWidget {
], ],
), ),
), ),
// Handy: größeres X mit fingergerechter Trefferfläche.
GestureDetector( GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onClear, 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> { class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
Timeline? _timeline; Timeline? _timeline;
StreamSubscription<String>? _roomUpdateSub; StreamSubscription<String>? _roomUpdateSub;
StreamSubscription<SyncUpdate>? _syncSub;
Timer? _redecryptTimer; Timer? _redecryptTimer;
String _receiptSig = '';
@override @override
Future<Timeline> build(String arg) async { Future<Timeline> build(String arg) async {
@@ -21,6 +23,7 @@ class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
_timeline?.cancelSubscriptions(); _timeline?.cancelSubscriptions();
_roomUpdateSub?.cancel(); _roomUpdateSub?.cancel();
_syncSub?.cancel();
_redecryptTimer?.cancel(); _redecryptTimer?.cancel();
final diag = ref.read(e2eeDiagnosticsProvider.notifier); 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 // Ensure device keys are downloaded for all room members so outbound
// Megolm session key sharing works on the first send. // Megolm session key sharing works on the first send.
if (room.encrypted && client.encryptionEnabled) { if (room.encrypted && client.encryptionEnabled) {
@@ -57,6 +82,7 @@ class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
ref.onDispose(() { ref.onDispose(() {
_roomUpdateSub?.cancel(); _roomUpdateSub?.cancel();
_syncSub?.cancel();
_redecryptTimer?.cancel(); _redecryptTimer?.cancel();
_timeline?.cancelSubscriptions(); _timeline?.cancelSubscriptions();
_timeline = null; _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 = final timelineProvider =
AsyncNotifierProvider.family<TimelineNotifier, Timeline, String>( AsyncNotifierProvider.family<TimelineNotifier, Timeline, String>(
TimelineNotifier.new, TimelineNotifier.new,
+4
View File
@@ -10,6 +10,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:pyramid/core/app_state.dart'; import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/appearance.dart' show isMobileLayout;
import 'package:pyramid/core/matrix_client.dart'; import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/settings_prefs.dart'; import 'package:pyramid/core/settings_prefs.dart';
import 'package:pyramid/core/theme.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/message_group.dart';
import 'package:pyramid/features/chat/pinned_messages_panel.dart'; import 'package:pyramid/features/chat/pinned_messages_panel.dart';
import 'package:pyramid/widgets/hover_region.dart'; import 'package:pyramid/widgets/hover_region.dart';
import 'package:pyramid/widgets/measure_size.dart';
import 'package:pyramid/widgets/spring_press.dart';
import 'package:pyramid/core/read_markers.dart';
import 'package:pyramid/widgets/mxc_image.dart'; import 'package:pyramid/widgets/mxc_image.dart';
import 'package:pyramid/core/notification_service.dart'; import 'package:pyramid/core/notification_service.dart';
+1 -1
View File
@@ -6,7 +6,7 @@ import 'package:archive/archive.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.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:flutter_svg/flutter_svg.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:pdfx/pdfx.dart'; import 'package:pdfx/pdfx.dart';
+1 -1
View File
@@ -628,7 +628,7 @@ class _SectionLabel extends StatelessWidget {
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4), padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
child: Text(title, child: Text(title,
style: TextStyle(color: PyramidColors.fgDim, style: TextStyle(color: PyramidTheme.of(context).fgDim,
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: 0.6)), letterSpacing: 0.6)),
+32 -2
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:typed_data'; import 'dart:typed_data';
@@ -419,6 +420,15 @@ class _ChunkyBarsPainter extends CustomPainter {
// //
// Variant: minimal — Controls immer sichtbar (overlay auf Video, kein Auto-Hide) // Variant: minimal — Controls immer sichtbar (overlay auf Video, kein Auto-Hide)
/// Maximale Höhe für Bilder/Videos im Chat. Auf dem Handy bis zur halben
/// Bildschirmhöhe – vorher galten überall 320 px, wodurch Hochkant-Fotos auf
/// dem Handy nur ~180 px schmal waren (Bernds Feedback 2026-10-07).
double inlineMediaMaxHeight(BuildContext context) {
final screen = MediaQuery.sizeOf(context);
final isMobile = screen.width < 900 || screen.height < 500;
return isMobile ? (screen.height * 0.5).clamp(240.0, 560.0) : 320.0;
}
class PyramidVideoPlayer extends StatefulWidget { class PyramidVideoPlayer extends StatefulWidget {
final Uint8List bytes; final Uint8List bytes;
final String filename; final String filename;
@@ -441,6 +451,10 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
late final Player _player; late final Player _player;
late final VideoController _controller; late final VideoController _controller;
bool _loading = true; bool _loading = true;
// Echtes Seitenverhältnis, sobald das Video seine Größe kennt (vorher fest
// 16:9 → Hochkant-Handyvideos klein zwischen schwarzen Balken).
double? _aspect;
final _subs = <StreamSubscription<int?>>[];
double _speed = 1.0; double _speed = 1.0;
String _quality = 'Auto'; String _quality = 'Auto';
@@ -452,9 +466,19 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
super.initState(); super.initState();
_player = Player(); _player = Player();
_controller = VideoController(_player); _controller = VideoController(_player);
_subs.add(_player.stream.width.listen((_) => _updateAspect()));
_subs.add(_player.stream.height.listen((_) => _updateAspect()));
_init(); _init();
} }
void _updateAspect() {
final w = _player.state.width;
final h = _player.state.height;
if (w == null || h == null || w <= 0 || h <= 0) return;
final aspect = w / h;
if (mounted && aspect != _aspect) setState(() => _aspect = aspect);
}
Future<void> _init() async { Future<void> _init() async {
final ext = widget.filename.contains('.') ? widget.filename.split('.').last : 'mp4'; final ext = widget.filename.contains('.') ? widget.filename.split('.').last : 'mp4';
final dir = await getTemporaryDirectory(); final dir = await getTemporaryDirectory();
@@ -466,6 +490,9 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
@override @override
void dispose() { void dispose() {
for (final s in _subs) {
s.cancel();
}
_player.dispose(); _player.dispose();
super.dispose(); super.dispose();
} }
@@ -506,7 +533,10 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
return Container( return Container(
margin: const EdgeInsets.only(top: 4, bottom: 4), margin: const EdgeInsets.only(top: 4, bottom: 4),
constraints: const BoxConstraints(maxWidth: 420), constraints: BoxConstraints(
maxWidth: 420,
maxHeight: inlineMediaMaxHeight(context),
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black, color: Colors.black,
borderRadius: BorderRadius.circular(pt.rLg), borderRadius: BorderRadius.circular(pt.rLg),
@@ -514,7 +544,7 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
), ),
clipBehavior: Clip.hardEdge, clipBehavior: Clip.hardEdge,
child: AspectRatio( child: AspectRatio(
aspectRatio: 16 / 9, aspectRatio: _aspect ?? 16 / 9,
child: StreamBuilder<bool>( child: StreamBuilder<bool>(
stream: _player.stream.playing, stream: _player.stream.playing,
builder: (_, playSnap) => StreamBuilder<Duration>( builder: (_, playSnap) => StreamBuilder<Duration>(
+196 -53
View File
@@ -6,9 +6,17 @@ import 'package:path_provider/path_provider.dart';
import 'package:pyramid/core/theme.dart'; import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/chat/media_player.dart'; import 'package:pyramid/features/chat/media_player.dart';
import 'package:pyramid/utils/gif_favorite_service.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. /// Overlay viewer for images, GIFs and videos.
/// Uses a semi-transparent backdrop; tapping outside the content closes it. ///
/// Bedienung (Bernds Feedback 2026-10-07, wie man es von WhatsApp & Co. kennt):
/// * Handy: randlos auf schwarzem Grund (vorher 24-px-Rand, halbdurchsichtig).
/// * Tippen NEBEN das Bild schließt, Tippen AUFS Bild blendet die Leiste ein/aus.
/// * Nach oben/unten wischen schließt (nur ohne Zoom), der Hintergrund blendet
/// dabei aus.
/// * Doppeltipp zoomt an der Stelle hinein bzw. wieder heraus; mit zwei
/// Fingern aufziehen geht weiterhin.
class MediaViewer extends StatefulWidget { class MediaViewer extends StatefulWidget {
final String senderName; final String senderName;
final Uint8List? bytes; final Uint8List? bytes;
@@ -40,10 +48,22 @@ class MediaViewer extends StatefulWidget {
context: context, context: context,
barrierDismissible: true, barrierDismissible: true,
barrierLabel: 'Schließen', barrierLabel: 'Schließen',
barrierColor: Colors.black.withAlpha(178), // Den Hintergrund zeichnet der Viewer selbst (damit er beim Wischen
transitionDuration: const Duration(milliseconds: 180), // ausblenden kann).
barrierColor: Colors.transparent,
transitionDuration: motionDuration(context, 300),
transitionBuilder: (context, animation, secondaryAnimation, child) => 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( pageBuilder: (context, animation, secondaryAnimation) => MediaViewer(
senderName: senderName, senderName: senderName,
filename: filename, filename: filename,
@@ -59,21 +79,127 @@ class MediaViewer extends StatefulWidget {
State<MediaViewer> createState() => _MediaViewerState(); State<MediaViewer> createState() => _MediaViewerState();
} }
class _MediaViewerState extends State<MediaViewer> { class _MediaViewerState extends State<MediaViewer>
with SingleTickerProviderStateMixin {
/// Ab so viel Pixel Wischweg (oder entsprechend schnell) wird geschlossen.
static const _dismissDistance = 120.0;
static const _dismissVelocity = 1000.0;
static const _doubleTapScale = 2.5;
final _tc = TransformationController(); final _tc = TransformationController();
final _contentKey = GlobalKey();
late final AnimationController _zoomAnim;
Animation<Matrix4>? _zoomTween;
bool _controlsVisible = true; bool _controlsVisible = true;
bool _saving = false; bool _saving = false;
bool _savingSticker = false; bool _savingSticker = false;
bool _zoomed = false;
bool _dragging = false;
double _dragOffset = 0;
Offset? _doubleTapGlobal;
@override
void initState() {
super.initState();
_zoomAnim = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 220),
)..addListener(() {
final tween = _zoomTween;
if (tween != null) _tc.value = tween.value;
});
_tc.addListener(_onTransform);
}
@override @override
void dispose() { void dispose() {
_zoomAnim.dispose();
_tc.removeListener(_onTransform);
_tc.dispose(); _tc.dispose();
super.dispose(); super.dispose();
} }
void _onTransform() {
final zoomed = _tc.value.getMaxScaleOnAxis() > 1.01;
if (zoomed != _zoomed) setState(() => _zoomed = zoomed);
}
void _close() => Navigator.of(context).maybePop();
void _toggle() => setState(() => _controlsVisible = !_controlsVisible); void _toggle() => setState(() => _controlsVisible = !_controlsVisible);
void _resetZoom() => _tc.value = Matrix4.identity(); void _animateTo(Matrix4 target) {
_zoomTween = Matrix4Tween(begin: _tc.value, end: target).animate(
CurvedAnimation(parent: _zoomAnim, curve: Curves.easeOutCubic),
);
_zoomAnim.forward(from: 0);
}
void _resetZoom() => _animateTo(Matrix4.identity());
/// Doppeltipp: an der getippten Stelle hineinzoomen bzw. ganz heraus.
void _toggleZoom() {
final global = _doubleTapGlobal;
final box = _contentKey.currentContext?.findRenderObject() as RenderBox?;
if (_zoomed || global == null || box == null) {
_resetZoom();
return;
}
final p = box.globalToLocal(global);
_animateTo(Matrix4.identity()
..translateByDouble(
-p.dx * (_doubleTapScale - 1),
-p.dy * (_doubleTapScale - 1),
0,
1,
)
..scaleByDouble(_doubleTapScale, _doubleTapScale, 1, 1));
}
// ── Wischen zum Schließen (über die InteractiveViewer-Callbacks, damit es
// nicht mit dem Zwei-Finger-Zoom um die Gesten konkurriert) ─────────────
void _onInteractionStart(ScaleStartDetails d) {
_dragging = !_zoomed && d.pointerCount == 1;
}
void _onInteractionUpdate(ScaleUpdateDetails d) {
if (!_dragging) return;
if (d.pointerCount > 1) {
// Zweiter Finger dazu → das ist ein Zoom, kein Wischen.
setState(() {
_dragging = false;
_dragOffset = 0;
});
return;
}
setState(() => _dragOffset += d.focalPointDelta.dy);
}
void _onInteractionEnd(ScaleEndDetails d) {
if (!_dragging) return;
_dragging = false;
final fast = d.velocity.pixelsPerSecond.dy.abs() > _dismissVelocity;
if (_dragOffset.abs() > _dismissDistance || fast) {
_close();
} else {
setState(() => _dragOffset = 0);
}
}
// Video: kein InteractiveViewer, also normales vertikales Wischen.
void _onVideoDragUpdate(DragUpdateDetails d) =>
setState(() => _dragOffset += d.delta.dy);
void _onVideoDragEnd(DragEndDetails d) {
final fast = d.velocity.pixelsPerSecond.dy.abs() > _dismissVelocity;
if (_dragOffset.abs() > _dismissDistance || fast) {
_close();
} else {
setState(() => _dragOffset = 0);
}
}
Future<void> _saveAsSticker() async { Future<void> _saveAsSticker() async {
final event = widget.event; final event = widget.event;
@@ -127,9 +253,16 @@ class _MediaViewerState extends State<MediaViewer> {
final pt = PyramidTheme.of(context); final pt = PyramidTheme.of(context);
final isVideo = widget.isVideo && widget.bytes != null; final isVideo = widget.isVideo && widget.bytes != null;
final size = MediaQuery.sizeOf(context); final size = MediaQuery.sizeOf(context);
final isMobile = size.width < 900 || size.height < 500;
// Beim Wischen blendet der Hintergrund aus und zeigt, dass gleich zu ist.
final dismissProgress = (_dragOffset.abs() / (_dismissDistance * 2.5)).clamp(0.0, 1.0);
final bgAlpha = (isMobile ? 1.0 : 0.82) * (1 - dismissProgress);
// Handy: randlos; Desktop: etwas Luft, damit das Fenster dahinter erkennbar bleibt.
final margin = isMobile ? 0.0 : 24.0;
final header = AnimatedOpacity( final header = AnimatedOpacity(
opacity: _controlsVisible ? 1 : 0, opacity: _controlsVisible && _dragOffset == 0 ? 1 : 0,
duration: const Duration(milliseconds: 180), duration: const Duration(milliseconds: 180),
child: IgnorePointer( child: IgnorePointer(
ignoring: !_controlsVisible, ignoring: !_controlsVisible,
@@ -146,7 +279,7 @@ class _MediaViewerState extends State<MediaViewer> {
child: Row( child: Row(
children: [ children: [
IconButton( IconButton(
onPressed: () => Navigator.of(context).pop(), onPressed: _close,
icon: const Icon(Icons.close_rounded, color: Colors.white), icon: const Icon(Icons.close_rounded, color: Colors.white),
tooltip: 'Schließen', tooltip: 'Schließen',
), ),
@@ -181,7 +314,7 @@ class _MediaViewerState extends State<MediaViewer> {
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Icon(Icons.download_rounded, color: Colors.white), : const Icon(Icons.download_rounded, color: Colors.white),
), ),
if (!isVideo) if (!isVideo && _zoomed)
IconButton( IconButton(
onPressed: _resetZoom, onPressed: _resetZoom,
tooltip: 'Zoom zurücksetzen', tooltip: 'Zoom zurücksetzen',
@@ -194,61 +327,71 @@ class _MediaViewerState extends State<MediaViewer> {
), ),
); );
// Content area — constrained so the transparent Material outside it lets final Widget content;
// pointer events through to the barrier (which dismisses on tap).
final contentMaxW = (size.width - 48).clamp(200.0, 1200.0);
final contentMaxH = (size.height - 48).clamp(200.0, 900.0);
if (isVideo) { if (isVideo) {
return Material( content = GestureDetector(
type: MaterialType.transparency, onVerticalDragUpdate: _onVideoDragUpdate,
onVerticalDragEnd: _onVideoDragEnd,
child: FullscreenVideoPlayer(bytes: widget.bytes!, filename: widget.filename),
);
} else {
Widget img;
if (widget.bytes != null) {
img = Image.memory(widget.bytes!, fit: BoxFit.contain, gaplessPlayback: true);
} else if (widget.networkUrl != null) {
img = Image.network(widget.networkUrl!, fit: BoxFit.contain);
} else {
img = Icon(Icons.broken_image_outlined, size: 64, color: pt.fgDim);
}
content = InteractiveViewer(
transformationController: _tc,
minScale: 1.0,
maxScale: 10.0,
// Ohne Zoom gibt es nichts zu verschieben – die Ein-Finger-Bewegung
// gehört dann dem Wischen zum Schließen.
panEnabled: _zoomed,
onInteractionStart: _onInteractionStart,
onInteractionUpdate: _onInteractionUpdate,
onInteractionEnd: _onInteractionEnd,
child: Center( child: Center(
child: SizedBox( key: _contentKey,
width: contentMaxW, // Nur das Bild selbst fängt Tipps ab; daneben greift das Schließen.
height: contentMaxH, child: GestureDetector(
child: Stack( onTap: _toggle,
fit: StackFit.expand, onDoubleTapDown: (d) => _doubleTapGlobal = d.globalPosition,
children: [ onDoubleTap: _toggleZoom,
FullscreenVideoPlayer(bytes: widget.bytes!, filename: widget.filename), child: img,
Positioned(top: 0, left: 0, right: 0, child: header),
],
),
), ),
), ),
); );
} }
Widget img;
if (widget.bytes != null) {
img = Image.memory(widget.bytes!, fit: BoxFit.contain, gaplessPlayback: true);
} else if (widget.networkUrl != null) {
img = Image.network(widget.networkUrl!, fit: BoxFit.contain);
} else {
img = Icon(Icons.broken_image_outlined, size: 64, color: pt.fgDim);
}
return Material( return Material(
type: MaterialType.transparency, type: MaterialType.transparency,
child: Center( child: Stack(
child: SizedBox( fit: StackFit.expand,
width: contentMaxW, children: [
height: contentMaxH, // Hintergrund + „daneben tippen schließt“.
child: GestureDetector( GestureDetector(
onTap: _toggle, behavior: HitTestBehavior.opaque,
child: Stack( onTap: _close,
fit: StackFit.expand, child: ColoredBox(color: Colors.black.withValues(alpha: bgAlpha)),
children: [ ),
InteractiveViewer( Padding(
transformationController: _tc, padding: EdgeInsets.all(margin),
minScale: 0.5, child: Transform.translate(
maxScale: 10.0, offset: Offset(0, _dragOffset),
child: Center(child: img), child: GestureDetector(
), // Tipps auf die leere Fläche um das Bild (innerhalb des
Positioned(top: 0, left: 0, right: 0, child: header), // Inhaltsbereichs) schließen ebenfalls.
], behavior: HitTestBehavior.translucent,
onTap: _close,
child: content,
),
), ),
), ),
), Positioned(top: 0, left: 0, right: 0, child: header),
],
), ),
); );
} }
@@ -0,0 +1,261 @@
part of '../message_group.dart';
// Sprechblasen-Darstellung (Einstellung „Nachrichten: Sprechblasen“, auf dem
// Handy Standard – Bernds Entscheidung 2026-10-07). Eigene Nachrichten rechts
// in Akzentfarbe, andere links; in Gruppenräumen mit Name + Avatar, in
// Direktnachrichten ohne. Bilder/Videos ohne Blase, dafür in voller Größe.
// Inhalt (Text, Antwort-Zitat, Medien), Reaktionen, Auswahl, Hover-Aktionen
// und Wischen-zum-Antworten kommen aus denselben Bausteinen wie die Liste.
const _kBubbleMaxWidthFactor = 0.82;
const _kBubbleAvatarSize = 30.0;
const _kBubbleAvatarGap = 8.0;
extension _BubbleBuilder on _MessageGroupState {
Widget _buildBubbles(BuildContext context, PyramidTheme pt) {
final firstEvent = widget.events.first;
final isOwn = firstEvent.senderId == widget.currentUserId;
final isDm = widget.room?.isDirectChat ?? false;
// Name + Avatar nur bei fremden Nachrichten in Gruppenräumen.
final showSender = !isOwn && !isDm;
final sender = firstEvent.senderFromMemoryOrFallback;
final name = sender.calcDisplayname();
final initial = name.isNotEmpty ? name[0].toUpperCase() : '?';
final color = _colorFromId(firstEvent.senderId);
return LayoutBuilder(builder: (context, constraints) {
final rowWidth = constraints.maxWidth - 24; // 12 px Rand links/rechts
final senderSpace =
showSender ? _kBubbleAvatarSize + _kBubbleAvatarGap : 0.0;
final maxBubbleWidth =
((rowWidth - senderSpace) * _kBubbleMaxWidthFactor).clamp(120.0, 560.0);
final rows = <Widget>[];
for (var j = 0; j < widget.events.length; j++) {
final event = widget.events[j];
final isFirst = j == 0;
final isLast = j == widget.events.length - 1;
final bubbleColumn = Column(
crossAxisAlignment:
isOwn ? CrossAxisAlignment.end : CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_Bubble(
event: event,
pt: pt,
isOwn: isOwn,
isFirst: isFirst,
isLast: isLast,
senderName: showSender && isFirst ? name : null,
senderColor: color,
maxWidth: maxBubbleWidth,
timeline: widget.timeline,
),
if (widget.timeline != null)
_ReactionsRow(
event: event,
timeline: widget.timeline!,
room: widget.room,
currentUserId: widget.currentUserId,
pt: pt,
),
],
);
final line = Row(
mainAxisAlignment:
isOwn ? MainAxisAlignment.end : MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showSender) ...[
SizedBox(
width: _kBubbleAvatarSize,
child: isFirst
? _Avatar(
initial: initial,
color: color,
pt: pt,
size: _kBubbleAvatarSize,
avatarUri: sender.avatarUrl,
)
: null,
),
const SizedBox(width: _kBubbleAvatarGap),
],
Flexible(child: bubbleColumn),
],
);
rows.add(_selectableRow(
event,
pt,
Padding(
padding: EdgeInsets.only(
top: isFirst ? pt.msgGap / 2 : 3,
left: 12,
right: 12,
),
child: line,
),
));
if (widget.readEventIds.contains(event.eventId)) {
rows.add(_ReadMarker(pt: pt));
}
if (widget.fullyReadId == event.eventId && !isLast) {
rows.add(_InlineUnreadDivider(key: widget.unreadDividerKey, pt: pt, fading: widget.unreadFading));
}
}
if (widget.showStatusIndicator &&
isOwn &&
widget.room != null &&
!widget.readEventIds.contains(widget.events.last.eventId)) {
rows.add(Padding(
padding: const EdgeInsets.only(right: 16, bottom: 1),
child: Align(
alignment: Alignment.centerRight,
child: _MessageStatus(lastEvent: widget.events.last, pt: pt),
),
));
}
rows.add(const SizedBox(height: 2));
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: rows,
);
});
}
}
/// Eine einzelne Sprechblase (bzw. bei Bildern/Videos: das Medium ohne Blase).
class _Bubble extends StatelessWidget {
final Event event;
final PyramidTheme pt;
final bool isOwn;
final bool isFirst;
final bool isLast;
final String? senderName;
final Color senderColor;
final double maxWidth;
final Timeline? timeline;
const _Bubble({
required this.event,
required this.pt,
required this.isOwn,
required this.isFirst,
required this.isLast,
required this.senderName,
required this.senderColor,
required this.maxWidth,
this.timeline,
});
Event get _displayEvent =>
timeline != null ? event.getDisplayEvent(timeline!) : event;
bool get _isVisualMedia {
final e = _displayEvent;
if (e.type == EventTypes.Sticker) return true;
return e.type == EventTypes.Message &&
(e.messageType == MessageTypes.Image ||
e.messageType == MessageTypes.Video);
}
/// Eigene Blase: Akzentfarbe, mit der Fläche verrechnet (bleibt lesbar).
static Color ownColor(PyramidTheme pt) => Color.alphaBlend(
pt.accent.withValues(alpha: pt.isDark ? 0.30 : 0.24),
pt.bg1,
);
BorderRadius get _radius {
const big = Radius.circular(18);
const small = Radius.circular(6);
// Aufeinanderfolgende Blasen „kleben“ auf der Absenderseite zusammen.
final topSide = isFirst ? big : small;
final bottomSide = isLast ? big : small;
return isOwn
? BorderRadius.only(
topLeft: big, bottomLeft: big, topRight: topSide, bottomRight: bottomSide)
: BorderRadius.only(
topRight: big, bottomRight: big, topLeft: topSide, bottomLeft: bottomSide);
}
@override
Widget build(BuildContext context) {
final time = Text(
_formatMessageTime(event.originServerTs),
style: TextStyle(color: pt.fgDim, fontSize: 11),
);
final failed = event.status.isError
? _SendFailedRow(event: event, pt: pt)
: null;
if (_isVisualMedia) {
// Ohne Blase: Bilder/Videos sollen so groß wie möglich sein.
final box = _reservedImageSize(
_displayEvent,
maxW: maxWidth.clamp(80.0, 420.0),
maxH: inlineMediaMaxHeight(context),
);
return Column(
crossAxisAlignment:
isOwn ? CrossAxisAlignment.end : CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (senderName != null) _senderLabel(),
SizedBox(
width: box?.width ?? maxWidth,
child: _MessageContent(event: event, pt: pt, timeline: timeline),
),
?failed,
Padding(padding: const EdgeInsets.only(top: 2), child: time),
],
);
}
return ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth),
child: Container(
padding: const EdgeInsets.fromLTRB(12, 7, 12, 6),
decoration: BoxDecoration(
color: isOwn ? ownColor(pt) : pt.bg3,
borderRadius: _radius,
),
// Außen „end“: Inhalt (breitestes Kind) füllt die Blase, die Uhrzeit
// sitzt unten rechts – ohne die Blase auf volle Breite zu ziehen.
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (senderName != null) _senderLabel(),
_MessageContent(event: event, pt: pt, timeline: timeline),
?failed,
],
),
const SizedBox(height: 2),
time,
],
),
),
);
}
Widget _senderLabel() => Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(
senderName!,
style: TextStyle(
color: senderColor,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
);
}
@@ -1,5 +1,15 @@
part of '../message_group.dart'; 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) { String _formatMessageTime(DateTime t) {
final h = t.hour.toString().padLeft(2, '0'); final h = t.hour.toString().padLeft(2, '0');
final m = t.minute.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 /// 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. /// "Neue Nachrichten" divider is rendered after that event's row.
final String? fullyReadId; 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 /// Jump-to-message (from search): the row matching [jumpEventId] gets
/// [jumpKey] attached (for ensureVisible) and [highlightEventId] is tinted. /// [jumpKey] attached (for ensureVisible) and [highlightEventId] is tinted.
final String? jumpEventId; final String? jumpEventId;
final GlobalKey? jumpKey; final GlobalKey? jumpKey;
final String? highlightEventId; final String? highlightEventId;
final bool highlightOn; 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({ const MessageGroup({
super.key, super.key,
@@ -41,10 +58,13 @@ class MessageGroup extends StatefulWidget {
this.showStatusIndicator = false, this.showStatusIndicator = false,
this.readEventIds = const {}, this.readEventIds = const {},
this.fullyReadId, this.fullyReadId,
this.unreadFading = false,
this.unreadDividerKey,
this.jumpEventId, this.jumpEventId,
this.jumpKey, this.jumpKey,
this.highlightEventId, this.highlightEventId,
this.highlightOn = false, this.highlightOn = false,
this.popInAfter,
}); });
@override @override
@@ -64,7 +84,23 @@ class _MessageGroupState extends State<MessageGroup> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final pt = PyramidTheme.of(context); // Darstellung laut Einstellung (Erscheinungsbild → Nachrichten):
// Sprechblasen (message_bubbles.dart) oder Liste wie bisher.
return Consumer(builder: (context, ref, _) {
final style = ref.watch(messageStyleProvider);
final pt = PyramidTheme.of(context);
final bubbles =
style.useBubbles(mobile: isMobileLayout(MediaQuery.sizeOf(context)));
return _withSwipeToReply(
pt,
bubbles ? _buildBubbles(context, pt) : _buildList(context, pt),
);
});
}
// Listen-Darstellung (Discord-Stil): Avatar + Name + Zeit, darunter die
// Nachrichten des Absenders bündig untereinander.
Widget _buildList(BuildContext context, PyramidTheme pt) {
final firstEvent = widget.events.first; final firstEvent = widget.events.first;
final sender = firstEvent.senderFromMemoryOrFallback; final sender = firstEvent.senderFromMemoryOrFallback;
final name = sender.calcDisplayname(); final name = sender.calcDisplayname();
@@ -124,7 +160,7 @@ class _MessageGroupState extends State<MessageGroup> {
// Inline unread divider after the first event when it's the fullyRead // Inline unread divider after the first event when it's the fullyRead
// marker and there are more events in this group. // marker and there are more events in this group.
if (widget.fullyReadId == firstEvent.eventId && widget.events.length > 1) { 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) ── // ── Continuation event rows (siblings, same indentation) ──
@@ -143,7 +179,7 @@ class _MessageGroupState extends State<MessageGroup> {
if (widget.readEventIds.contains(event.eventId)) rows.add(_ReadMarker(pt: pt)); 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. // Inline divider after this event if it's the fullyRead and not the last.
if (widget.fullyReadId == event.eventId && j < widget.events.length - 1) { 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));
} }
} }
@@ -168,11 +204,14 @@ class _MessageGroupState extends State<MessageGroup> {
// Hover highlight + actions are now per-row (see _selectableRow), so the // Hover highlight + actions are now per-row (see _selectableRow), so the
// group is just the column of rows. // group is just the column of rows.
Widget content = Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: rows, children: rows,
); );
}
// Handy: nach links wischen = antworten (beide Darstellungen).
Widget _withSwipeToReply(PyramidTheme pt, Widget content) {
if (!_isMobile) return content; if (!_isMobile) return content;
return GestureDetector( return GestureDetector(
@@ -282,15 +321,39 @@ class _MessageGroupState extends State<MessageGroup> {
), ),
); );
if (_isMobile) return row; if (_isMobile) return _popIn(event, row);
// Desktop: per-row hover highlight + actions. // Desktop: per-row hover highlight + actions.
return MouseRegion( return _popIn(event, MouseRegion(
onEnter: (_) => setState(() => _hoveredEventId = event.eventId), onEnter: (_) => setState(() => _hoveredEventId = event.eventId),
onExit: (_) => setState(() { onExit: (_) => setState(() {
if (_hoveredEventId == event.eventId) _hoveredEventId = null; if (_hoveredEventId == event.eventId) _hoveredEventId = null;
}), }),
child: row, 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. // matching WhatsApp/Discord. Falls back to stripped text for m.text.
final (IconData? icon, String body) = _previewFor(replyEvent); 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), padding: const EdgeInsets.only(bottom: 4),
child: IntrinsicHeight( child: IntrinsicHeight(
child: ClipRRect( child: ClipRRect(
@@ -204,6 +215,9 @@ class _ReplyPreview extends StatelessWidget {
), ),
), ),
), ),
),
),
),
), ),
); );
} }
+2 -2
View File
@@ -70,7 +70,7 @@ class _ImageContentState extends ConsumerState<_ImageContent> {
final maxW = constraints.maxWidth.isFinite final maxW = constraints.maxWidth.isFinite
? constraints.maxWidth.clamp(80.0, 420.0) ? constraints.maxWidth.clamp(80.0, 420.0)
: 420.0; : 420.0;
const maxH = 320.0; final maxH = inlineMediaMaxHeight(context);
// Reserve the final display box up-front from the event's info dimensions, // Reserve the final display box up-front from the event's info dimensions,
// so the layout doesn't shift (and yank the scroll position) when the image // so the layout doesn't shift (and yank the scroll position) when the image
// finishes loading. Null when dimensions are absent. // finishes loading. Null when dimensions are absent.
@@ -378,7 +378,7 @@ class _MediaContentState extends ConsumerState<_MediaContent> {
final pt = widget.pt; final pt = widget.pt;
final isSticker = widget.event.type == EventTypes.Sticker; final isSticker = widget.event.type == EventTypes.Sticker;
final maxW = isSticker ? 160.0 : 420.0; final maxW = isSticker ? 160.0 : 420.0;
final maxH = isSticker ? 160.0 : 320.0; final maxH = isSticker ? 160.0 : inlineMediaMaxHeight(context);
return FutureBuilder<_MediaResult>( return FutureBuilder<_MediaResult>(
future: _future, future: _future,
@@ -22,11 +22,15 @@ class _ReactionsRow extends StatelessWidget {
// Group by emoji key → list of senders // Group by emoji key → list of senders
final Map<String, List<String>> grouped = {}; final Map<String, List<String>> grouped = {};
final newest = <String, DateTime>{};
for (final r in reactionEvents) { for (final r in reactionEvents) {
final key = r.content.tryGetMap<String, dynamic>('m.relates_to')?['key'] as String?; final key = r.content.tryGetMap<String, dynamic>('m.relates_to')?['key'] as String?;
if (key == null || key.isEmpty) continue; if (key == null || key.isEmpty) continue;
(grouped[key] ??= []).add(r.senderId); (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(); if (grouped.isEmpty) return const SizedBox.shrink();
return Padding( return Padding(
@@ -37,6 +41,9 @@ class _ReactionsRow extends StatelessWidget {
children: grouped.entries.map((e) { children: grouped.entries.map((e) {
final myReaction = e.value.contains(currentUserId); final myReaction = e.value.contains(currentUserId);
return _ReactionChip( return _ReactionChip(
key: ValueKey(e.key),
// Gerade erst vergeben → federnd aufploppen.
fresh: now.difference(newest[e.key]!) < const Duration(seconds: 4),
emoji: e.key, emoji: e.key,
count: e.value.length, count: e.value.length,
mine: myReaction, mine: myReaction,
@@ -69,8 +76,11 @@ class _ReactionChip extends StatefulWidget {
final bool mine; final bool mine;
final PyramidTheme pt; final PyramidTheme pt;
final VoidCallback onTap; final VoidCallback onTap;
final bool fresh;
const _ReactionChip({ const _ReactionChip({
super.key,
this.fresh = false,
required this.emoji, required this.emoji,
required this.count, required this.count,
required this.mine, required this.mine,
@@ -88,12 +98,22 @@ class _ReactionChipState extends State<_ReactionChip> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final pt = widget.pt; 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, cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true), onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false), onExit: (_) => setState(() => _hovered = false),
child: GestureDetector( child: GestureDetector(
onTap: widget.onTap, onTap: widget.onTap,
child: SpringPress(
pressedScale: 0.85,
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 120), duration: const Duration(milliseconds: 120),
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
@@ -122,6 +142,9 @@ class _ReactionChipState extends State<_ReactionChip> {
], ],
), ),
), ),
),
),
),
), ),
); );
} }
+11 -2
View File
@@ -93,11 +93,19 @@ class _InitialBox extends StatelessWidget {
// fullyRead event is in the middle of the group (same sender, <5 min window). // fullyRead event is in the middle of the group (same sender, <5 min window).
class _InlineUnreadDivider extends StatelessWidget { class _InlineUnreadDivider extends StatelessWidget {
final PyramidTheme pt; final PyramidTheme pt;
const _InlineUnreadDivider({required this.pt}); final bool fading;
const _InlineUnreadDivider({super.key, required this.pt, this.fading = false});
@override @override
Widget build(BuildContext context) { 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), padding: const EdgeInsets.fromLTRB(0, 8, 0, 4),
child: Row( child: Row(
children: [ children: [
@@ -112,6 +120,7 @@ class _InlineUnreadDivider extends StatelessWidget {
Expanded(child: Divider(color: pt.accent.withAlpha(160), height: 1)), Expanded(child: Divider(color: pt.accent.withAlpha(160), height: 1)),
], ],
), ),
),
); );
} }
} }
+2 -2
View File
@@ -213,7 +213,7 @@ class _MatrixHtmlText extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final spans = _parseHtml(html, baseStyle, linkColor); final spans = _parseHtml(html, baseStyle, linkColor);
return SelectionArea( return SelectionArea(
child: RichText(text: TextSpan(children: spans, style: baseStyle)), child: Text.rich(TextSpan(children: spans, style: baseStyle)),
); );
} }
@@ -388,7 +388,7 @@ class _PlainLinkText extends StatelessWidget {
} }
return SelectionArea( return SelectionArea(
child: RichText(text: TextSpan(children: spans, style: baseStyle)), child: Text.rich(TextSpan(children: spans, style: baseStyle)),
); );
} }
} }
+5
View File
@@ -9,6 +9,9 @@ import 'package:pyramid/features/chat/media_player.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:pyramid/core/app_state.dart'
show messageStyleProvider, pendingJumpEventProvider;
import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/matrix_client.dart'; import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/media_cache.dart'; import 'package:pyramid/core/media_cache.dart';
import 'package:pyramid/core/theme.dart'; import 'package:pyramid/core/theme.dart';
@@ -19,9 +22,11 @@ import 'package:pyramid/features/chat/media_viewer.dart';
import 'package:pyramid/features/chat/reaction_service.dart'; import 'package:pyramid/features/chat/reaction_service.dart';
import 'package:pyramid/utils/gif_favorite_service.dart'; import 'package:pyramid/utils/gif_favorite_service.dart';
import 'package:pyramid/widgets/mxc_image.dart'; import 'package:pyramid/widgets/mxc_image.dart';
import 'package:pyramid/widgets/spring_press.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
part 'message/message_group_core.dart'; part 'message/message_group_core.dart';
part 'message/message_bubbles.dart';
part 'message/message_text.dart'; part 'message/message_text.dart';
part 'message/message_link_preview.dart'; part 'message/message_link_preview.dart';
part 'message/message_media.dart'; part 'message/message_media.dart';
+309 -203
View File
@@ -42,11 +42,13 @@ class _ChatHeader extends ConsumerWidget {
children: [ children: [
// Back button on mobile // Back button on mobile
if (isMobile) ...[ if (isMobile) ...[
GestureDetector( PyrIconBtn(
onTap: () => ref.read(activeRoomIdProvider.notifier).state = null, icon: Icon(Icons.arrow_back_rounded, color: pt.fg),
child: Icon(Icons.arrow_back_rounded, size: 22, color: pt.fg), tooltip: 'Zurück',
onPressed: () =>
ref.read(activeRoomIdProvider.notifier).state = null,
), ),
const SizedBox(width: 12), const SizedBox(width: 4),
], ],
// Room icon / avatar // Room icon / avatar
if (isDm && client != null) ...[ if (isDm && client != null) ...[
@@ -141,19 +143,27 @@ class _ChatHeader extends ConsumerWidget {
onPressed: onTogglePinned, onPressed: onTogglePinned,
), ),
], ],
PyrIconBtn( if (isMobile)
size: 28, _ChatOverflowMenu(
icon: const Icon(Icons.search_rounded), pt: pt,
tooltip: 'Search', onSearch: onOpenSearch,
onPressed: onOpenSearch, onMembers: onToggleMembers,
), )
PyrIconBtn( else ...[
size: 28, PyrIconBtn(
icon: const Icon(Icons.group_rounded), size: 28,
tooltip: 'Members', icon: const Icon(Icons.search_rounded),
active: membersOpen, tooltip: 'Suchen',
onPressed: onToggleMembers, onPressed: onOpenSearch,
), ),
PyrIconBtn(
size: 28,
icon: const Icon(Icons.group_rounded),
tooltip: 'Mitglieder',
active: membersOpen,
onPressed: onToggleMembers,
),
],
], ],
), ),
], ],
@@ -176,8 +186,10 @@ class _ChatHeader extends ConsumerWidget {
await ref.read(voiceChannelProvider).startCall( await ref.read(voiceChannelProvider).startCall(
roomName: roomId, roomName: roomId,
roomDisplayName: room.getLocalizedDisplayname(), roomDisplayName: room.getLocalizedDisplayname(),
identity: 'user_${DateTime.now().millisecondsSinceEpoch}', identity: room.client.userID ?? '',
audioOnly: !cam, 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; Profile? _profile;
String? _bannerMxcUri; 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; String? _statusMsg;
bool _online = false; bool _online = false;
@@ -291,6 +317,7 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
WidgetsBinding.instance.addPostFrameCallback((_) => _measureActions());
final pt = widget.pt; final pt = widget.pt;
final expanded = widget.expanded; final expanded = widget.expanded;
final size = MediaQuery.sizeOf(context); final size = MediaQuery.sizeOf(context);
@@ -361,198 +388,222 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
), ),
), ),
), ),
Padding( // Profilbereich: gleitet beim Aufklappen unter die oberste Zeile und
padding: const EdgeInsets.symmetric(horizontal: 16), // nutzt dann die volle Breite (vorher teilte er sie mit den Symbolen –
child: Row( // aufgeklappt wurde der Name zu „p…“ gekürzt).
children: [ AnimatedPositioned(
if (isMobile) ...[ duration: _animDuration,
GestureDetector( curve: _animCurve,
onTap: () => left: expanded ? 16 : (isMobile ? 54 : 16),
ref.read(activeRoomIdProvider.notifier).state = null, right: expanded ? 16 : _actionsWidth + (isMobile ? 14 : 24),
child: top: expanded ? 44 : 0,
Icon(Icons.arrow_back_rounded, size: 22, color: pt.fg), bottom: 0,
), child: MouseRegion(
const SizedBox(width: 12), cursor: SystemMouseCursors.click,
], child: GestureDetector(
// Klickbarer Profilbereich: Avatar + Name + Details behavior: HitTestBehavior.opaque,
Expanded( onTap: widget.onToggle,
child: MouseRegion( child: Row(
cursor: SystemMouseCursors.click, children: [
child: GestureDetector( // Avatar — skaliert mit (rund, wie im Mockup)
behavior: HitTestBehavior.opaque, AnimatedContainer(
onTap: widget.onToggle, duration: _animDuration,
child: Row( curve: _animCurve,
children: [ width: avatarSize,
// Avatar — skaliert mit (rund, wie im Mockup) height: avatarSize,
AnimatedContainer( decoration: BoxDecoration(
duration: _animDuration, shape: BoxShape.circle,
curve: _animCurve, color: pt.bg3,
width: avatarSize, ),
height: avatarSize, clipBehavior: Clip.antiAlias,
decoration: BoxDecoration( child: avatarUrl != null && client != null
shape: BoxShape.circle, ? MxcImage(
color: pt.bg3, mxcUri: avatarUrl.toString(),
), client: client,
clipBehavior: Clip.antiAlias, fit: BoxFit.cover,
child: avatarUrl != null && client != null placeholder:
? MxcImage( _initialBox(initial, pt, avatarSize),
mxcUri: avatarUrl.toString(), )
client: client, : _initialBox(initial, pt, avatarSize),
fit: BoxFit.cover, ),
placeholder: _initialBox(initial, pt), AnimatedContainer(
) duration: _animDuration,
: _initialBox(initial, pt), curve: _animCurve,
), width: expanded ? 20 : 10,
AnimatedContainer( ),
duration: _animDuration, Expanded(
curve: _animCurve, child: Column(
width: expanded ? 20 : 10, mainAxisAlignment: MainAxisAlignment.center,
), crossAxisAlignment: CrossAxisAlignment.start,
Expanded( children: [
child: Column( // Name skaliert mit; Status blendet daneben ein
mainAxisAlignment: MainAxisAlignment.center, Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
// Name skaliert mit; Status blendet daneben ein Flexible(
Row( child: AnimatedDefaultTextStyle(
crossAxisAlignment: CrossAxisAlignment.center, duration: _animDuration,
children: [ curve: _animCurve,
Flexible( style: TextStyle(
child: AnimatedDefaultTextStyle( color: pt.fg,
duration: _animDuration, fontSize: expanded ? 24 : 15,
curve: _animCurve, fontWeight: expanded
style: TextStyle( ? FontWeight.w700
color: pt.fg, : FontWeight.w600,
fontSize: expanded ? 24 : 15,
fontWeight: expanded
? FontWeight.w700
: FontWeight.w600,
),
child: Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
), ),
AnimatedOpacity( child: Text(
opacity: expanded ? 1 : 0, name,
duration: _animDuration, maxLines: 1,
child: Padding( overflow: TextOverflow.ellipsis,
padding:
const EdgeInsets.only(left: 8),
child: Text(
_online ? 'online' : 'offline',
style: TextStyle(
color: _online
? pt.online
: pt.fgMuted,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
), ),
], ),
), ),
// Eingeklappt: "zuletzt online vor…", AnimatedOpacity(
// aufgeklappt: Handle + Biographie. opacity: expanded ? 1 : 0,
AnimatedCrossFade(
duration: _animDuration, duration: _animDuration,
sizeCurve: _animCurve, child: Padding(
crossFadeState: expanded padding:
? CrossFadeState.showSecond const EdgeInsets.only(left: 8),
: CrossFadeState.showFirst, child: Text(
firstChild: client != null _online ? 'online' : 'offline',
? _PresenceLine( style: TextStyle(
userId: _partnerId, color: _online
client: client, ? pt.online
pt: pt, : pt.fgMuted,
) fontSize: 13,
: const SizedBox.shrink(), fontWeight: FontWeight.w500,
secondChild: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text(
userId,
style: TextStyle(
color: pt.fgMuted, fontSize: 13),
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
if (_statusMsg != null && ),
_statusMsg!.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
_statusMsg!,
style: TextStyle(
color: pt.fgMuted,
fontSize: 13,
height: 1.35),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
), ),
), ),
], ],
), ),
), // Eingeklappt: "zuletzt online vor…",
], // aufgeklappt: Handle + Biographie.
AnimatedCrossFade(
duration: _animDuration,
sizeCurve: _animCurve,
crossFadeState: expanded
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
firstChild: client != null
? _PresenceLine(
userId: _partnerId,
client: client,
pt: pt,
)
: const SizedBox.shrink(),
secondChild: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text(
userId,
style: TextStyle(
color: pt.fgMuted, fontSize: 13),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (_statusMsg != null &&
_statusMsg!.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
_statusMsg!,
style: TextStyle(
color: pt.fgMuted,
fontSize: 13,
height: 1.35),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
],
),
), ),
), ],
), ),
),
),
),
// Zurück-Pfeil und Aktionen: fest in der obersten 52-px-Zeile –
// sie rutschen beim Aufklappen nicht mehr in die Mitte.
if (isMobile)
Positioned(
left: 6,
top: 0,
height: 52,
child: Center(
child: PyrIconBtn(
icon: Icon(Icons.arrow_back_rounded, color: pt.fg),
tooltip: 'Zurück',
onPressed: () =>
ref.read(activeRoomIdProvider.notifier).state = null,
), ),
const SizedBox(width: 12), ),
// Aktionen — bleiben immer sichtbar ),
Row( Positioned(
right: isMobile ? 6 : 16,
top: 0,
height: 52,
child: KeyedSubtree(
key: _actionsKey,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
PyrIconBtn( PyrIconBtn(
size: 28, size: 28,
icon: const Icon(Icons.phone_rounded), icon: const Icon(Icons.phone_rounded),
tooltip: 'Voice call', tooltip: 'Voice call',
onPressed: () => _startCall(cam: false), onPressed: () => _startCall(cam: false),
), ),
PyrIconBtn( PyrIconBtn(
size: 28, size: 28,
icon: const Icon(Icons.videocam_rounded), icon: const Icon(Icons.videocam_rounded),
tooltip: 'Video call', tooltip: 'Video call',
onPressed: () => _startCall(cam: true), onPressed: () => _startCall(cam: true),
), ),
if (size.width > 700) if (size.width > 700)
Container( Container(
width: 1, width: 1,
height: 20, height: 20,
margin: const EdgeInsets.symmetric(horizontal: 4), margin: const EdgeInsets.symmetric(horizontal: 4),
color: pt.border, color: pt.border,
), ),
if (size.width > 800) if (size.width > 800)
PyrIconBtn( PyrIconBtn(
size: 28, size: 28,
icon: const Icon(Icons.push_pin_rounded), icon: const Icon(Icons.push_pin_rounded),
tooltip: 'Angepinnte Nachrichten', tooltip: 'Angepinnte Nachrichten',
active: widget.pinnedOpen, active: widget.pinnedOpen,
onPressed: widget.onTogglePinned, onPressed: widget.onTogglePinned,
), ),
PyrIconBtn( if (isMobile)
size: 28, _ChatOverflowMenu(
icon: const Icon(Icons.search_rounded), pt: pt,
tooltip: 'Search', onSearch: widget.onOpenSearch,
onPressed: widget.onOpenSearch, onMembers: widget.onToggleMembers,
), )
PyrIconBtn( else ...[
size: 28, PyrIconBtn(
icon: const Icon(Icons.group_rounded), size: 28,
tooltip: 'Members', icon: const Icon(Icons.search_rounded),
active: widget.membersOpen, tooltip: 'Suchen',
onPressed: widget.onToggleMembers, onPressed: widget.onOpenSearch,
), ),
], PyrIconBtn(
size: 28,
icon: const Icon(Icons.group_rounded),
tooltip: 'Mitglieder',
active: widget.membersOpen,
onPressed: widget.onToggleMembers,
), ),
], ],
],
),
), ),
), ),
], ],
@@ -560,15 +611,25 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
); );
} }
Widget _initialBox(String initial, PyramidTheme pt) => Center( // Kürzel exakt mittig: Schrift wächst mit dem Avatar mit (vorher feste
child: FittedBox( // 28 px in FittedBox mit Innenabstand – wirkte aufgeklappt verschoben).
fit: BoxFit.scaleDown, Widget _initialBox(String initial, PyramidTheme pt, double avatarSize) =>
child: Padding( Center(
padding: const EdgeInsets.all(6), child: AnimatedDefaultTextStyle(
child: Text( duration: _animDuration,
initial, curve: _animCurve,
style: TextStyle( textAlign: TextAlign.center,
color: pt.fg, fontSize: 28, fontWeight: FontWeight.w700), style: TextStyle(
color: pt.fg,
fontSize: avatarSize * 0.42,
fontWeight: FontWeight.w700,
height: 1.0,
),
child: Text(
initial,
textHeightBehavior: const TextHeightBehavior(
applyHeightToFirstAscent: false,
applyHeightToLastDescent: false,
), ),
), ),
), ),
@@ -733,3 +794,48 @@ class _PresenceLineState extends State<_PresenceLine> {
return 'vor ${(diff.inDays / 365).floor()}j'; 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),
],
);
}
}
+19 -5
View File
@@ -19,6 +19,13 @@ class _MessageList extends StatelessWidget {
final String? highlightEventId; final String? highlightEventId;
final bool highlightOn; 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({ const _MessageList({
required this.timeline, required this.timeline,
required this.scrollCtrl, required this.scrollCtrl,
@@ -35,6 +42,8 @@ class _MessageList extends StatelessWidget {
this.jumpKey, this.jumpKey,
this.highlightEventId, this.highlightEventId,
this.highlightOn = false, this.highlightOn = false,
this.bottomInset = 0,
this.popInAfter,
}); });
@override @override
@@ -207,10 +216,11 @@ class _MessageList extends StatelessWidget {
// Pass fullyReadId to the group only when the event is mid-group // Pass fullyReadId to the group only when the event is mid-group
// (not the last event) so MessageGroup can render an inline divider. // (not the last event) so MessageGroup can render an inline divider.
// groupContainsFullyRead already guarantees fullyReadId != null. // groupContainsFullyRead already guarantees fullyReadId != null.
final passedFullyReadId = final passedFullyReadId = (hasUnread &&
(groupContainsFullyRead && group.last.eventId != fullyReadId) groupContainsFullyRead &&
? fullyReadId group.last.eventId != fullyReadId)
: null; ? fullyReadId
: null;
itemFullyReadIds.add(passedFullyReadId); itemFullyReadIds.add(passedFullyReadId);
if (isLastOfDay) { if (isLastOfDay) {
@@ -241,7 +251,7 @@ class _MessageList extends StatelessWidget {
controller: scrollCtrl, controller: scrollCtrl,
reverse: true, reverse: true,
physics: const ClampingScrollPhysics(), physics: const ClampingScrollPhysics(),
padding: const EdgeInsets.only(bottom: 8), padding: EdgeInsets.only(bottom: 8 + bottomInset),
scrollCacheExtent: const ScrollCacheExtent.pixels(2500), scrollCacheExtent: const ScrollCacheExtent.pixels(2500),
itemCount: itemKeys.length, itemCount: itemKeys.length,
findChildIndexCallback: (Key key) { findChildIndexCallback: (Key key) {
@@ -283,10 +293,14 @@ class _MessageList extends StatelessWidget {
showStatusIndicator: showStatus, showStatusIndicator: showStatus,
readEventIds: readEventIds, readEventIds: readEventIds,
fullyReadId: itemFullyReadIds[i], fullyReadId: itemFullyReadIds[i],
unreadFading: unreadFading,
unreadDividerKey:
itemFullyReadIds[i] != null ? unreadDividerKey : null,
jumpEventId: jumpEventId, jumpEventId: jumpEventId,
jumpKey: jumpKey, jumpKey: jumpKey,
highlightEventId: highlightEventId, highlightEventId: highlightEventId,
highlightOn: highlightOn, highlightOn: highlightOn,
popInAfter: popInAfter,
); );
}, },
); );
+230 -1
View File
@@ -115,7 +115,7 @@ class NoChatSelected extends StatelessWidget {
Icon(Icons.forum_outlined, size: 64, color: pt.fgDim), Icon(Icons.forum_outlined, size: 64, color: pt.fgDim),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'Select a room to start chatting', 'Wähle einen Chat aus',
style: TextStyle(color: pt.fgMuted, fontSize: 16), style: TextStyle(color: pt.fgMuted, fontSize: 16),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -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),
],
),
),
),
),
);
}
}
+214 -42
View File
@@ -28,9 +28,25 @@ class _ChatViewState extends ConsumerState<ChatView> {
// Fade-out of the "Neue Nachrichten" divider once the user reaches the bottom. // Fade-out of the "Neue Nachrichten" divider once the user reaches the bottom.
bool _unreadFading = false; bool _unreadFading = false;
Timer? _unreadFadeTimer; 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. // Jump-to-message (from search): scroll to + briefly highlight a target event.
final _jumpKey = GlobalKey(); final _jumpKey = GlobalKey();
// Höhe der schwebenden Eingabeleiste (gemessen, wächst mit Antwort-Leiste
// oder mehrzeiligem Text).
double _composerHeight = 72;
String? _jumpEventId; String? _jumpEventId;
String? _highlightEventId; String? _highlightEventId;
bool _highlightOn = false; bool _highlightOn = false;
@@ -77,11 +93,25 @@ class _ChatViewState extends ConsumerState<ChatView> {
setState(() { _jumpInProgress = false; _highlightEventId = null; _jumpEventId = null; _highlightOn = false; }); setState(() { _jumpInProgress = false; _highlightEventId = null; _jumpEventId = null; _highlightOn = false; });
return; 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; 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((_) {}); 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((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 120), () => _attemptJump(attempt + 1)); Future.delayed(const Duration(milliseconds: 120), () => _attemptJump(attempt + 1));
}); });
@@ -93,6 +123,9 @@ class _ChatViewState extends ConsumerState<ChatView> {
bool get _inSelectionMode => _selectedIds.isNotEmpty; bool get _inSelectionMode => _selectedIds.isNotEmpty;
double get _scrollBtnSize =>
isMobileLayout(MediaQuery.sizeOf(context)) ? 44 : 36;
void _toggleSelect(String eventId) { void _toggleSelect(String eventId) {
setState(() { setState(() {
if (_selectedIds.contains(eventId)) { if (_selectedIds.contains(eventId)) {
@@ -109,12 +142,22 @@ class _ChatViewState extends ConsumerState<ChatView> {
void initState() { void initState() {
super.initState(); super.initState();
_scrollCtrl.addListener(_onScroll); _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 @override
void didUpdateWidget(ChatView oldWidget) { void didUpdateWidget(ChatView oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.roomId != widget.roomId) { 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; _profileExpanded = false;
_sessionFullyReadId = null; _sessionFullyReadId = null;
_sessionHasUnread = false; _sessionHasUnread = false;
@@ -124,6 +167,9 @@ class _ChatViewState extends ConsumerState<ChatView> {
_historyExhausted = false; _historyExhausted = false;
_unreadFading = false; _unreadFading = false;
_unreadFadeTimer?.cancel(); _unreadFadeTimer?.cancel();
_unreadDwellTimer?.cancel();
_lastMarkedEventId = null;
_popInAfter = null;
_jumpEventId = null; _jumpEventId = null;
_highlightEventId = null; _highlightEventId = null;
_highlightOn = false; _highlightOn = false;
@@ -137,6 +183,7 @@ class _ChatViewState extends ConsumerState<ChatView> {
@override @override
void dispose() { void dispose() {
_unreadFadeTimer?.cancel(); _unreadFadeTimer?.cancel();
_unreadDwellTimer?.cancel();
_highlightTimer?.cancel(); _highlightTimer?.cancel();
_scrollCtrl.dispose(); _scrollCtrl.dispose();
super.dispose(); super.dispose();
@@ -174,6 +221,7 @@ class _ChatViewState extends ConsumerState<ChatView> {
setState(() { setState(() {
_sessionFullyReadId = newFullyReadId; _sessionFullyReadId = newFullyReadId;
_sessionHasUnread = newHasUnread; _sessionHasUnread = newHasUnread;
_popInAfter = events.firstOrNull?.originServerTs ?? DateTime.now();
}); });
if (newHasUnread) { if (newHasUnread) {
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToUnread(timeline)); WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToUnread(timeline));
@@ -206,10 +254,12 @@ class _ChatViewState extends ConsumerState<ChatView> {
duration: const Duration(milliseconds: 250), duration: const Duration(milliseconds: 250),
curve: Curves.easeOut, curve: Curves.easeOut,
); );
_scheduleUnreadFadeIfAtNewest();
return; return;
} }
if (attempt >= 24) { if (attempt >= 24) {
_didScrollToUnread = true; // give up — stay at the newest messages _didScrollToUnread = true; // give up — stay at the newest messages
_scheduleUnreadFadeIfAtNewest();
return; return;
} }
if (attempt > 0 && attempt % 4 == 0 && _scrollCtrl.hasClients) { if (attempt > 0 && attempt % 4 == 0 && _scrollCtrl.hasClients) {
@@ -224,21 +274,100 @@ class _ChatViewState extends ConsumerState<ChatView> {
} }
void _markRead([Timeline? tl]) { 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). // Cancel any pending notification for this room (user is now reading it).
cancelNotificationForRoom(widget.roomId); cancelNotificationForRoom(widget.roomId);
final sendReceipts = ref.read(privacyReadReceiptsProvider);
if (!sendReceipts) return;
final room = ref.read(roomProvider(widget.roomId)); 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 timeline = tl ?? ref.read(timelineProvider(widget.roomId)).valueOrNull;
final latest = timeline?.events.firstOrNull; final latest = timeline?.events
if (latest != null && latest.status.isSynced) { .where((e) => e.status.isSynced)
room.setReadMarker(latest.eventId, mRead: latest.eventId) .firstOrNull;
.catchError((_) {}); if (latest == null || latest.eventId == _lastMarkedEventId) return;
} else { _lastMarkedEventId = latest.eventId;
room.markUnread(false).catchError((_) {}); 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 { Future<void> _onScroll() async {
@@ -246,18 +375,10 @@ class _ChatViewState extends ConsumerState<ChatView> {
final pixels = _scrollCtrl.position.pixels; final pixels = _scrollCtrl.position.pixels;
final maxExtent = _scrollCtrl.position.maxScrollExtent; final maxExtent = _scrollCtrl.position.maxScrollExtent;
if (pixels < 60) { if (pixels < 60) _markRead(null);
_markRead(null); // Bei den neuesten Nachrichten angekommen → Trenner nach einer
// Reached the newest messages → the "Neue Nachrichten" divider is no // Verweildauer ausblenden.
// longer useful. Fade it out, then remove it. if (_atNewest) _scheduleUnreadFade();
if (_sessionHasUnread && !_unreadFading) {
setState(() => _unreadFading = true);
_unreadFadeTimer?.cancel();
_unreadFadeTimer = Timer(const Duration(milliseconds: 900), () {
if (mounted) setState(() { _sessionHasUnread = false; _unreadFading = false; });
});
}
}
final shouldShow = pixels > 400; final shouldShow = pixels > 400;
if (shouldShow != _showScrollDown) { if (shouldShow != _showScrollDown) {
setState(() => _showScrollDown = shouldShow); 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 // listen) so it also fires when this ChatView is freshly mounted for a
// DIFFERENT room — listen would miss a value set before registration. // DIFFERENT room — listen would miss a value set before registration.
final pendingJump = ref.watch(pendingJumpEventProvider); 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((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && if (mounted &&
!_jumpInProgress && !_jumpInProgress &&
@@ -324,6 +447,9 @@ class _ChatViewState extends ConsumerState<ChatView> {
} }
final isDesktop = Platform.isWindows || Platform.isMacOS || Platform.isLinux; 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( final body = Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
@@ -394,8 +520,11 @@ class _ChatViewState extends ConsumerState<ChatView> {
setState(() => _pinnedOpen = !_pinnedOpen), setState(() => _pinnedOpen = !_pinnedOpen),
), ),
const _ConnectionBanner(), const _ConnectionBanner(),
const _ActiveCallBar(),
Expanded( Expanded(
child: Stack( child: isInvite
? _InvitePanel(room: room!, pt: pt)
: Stack(
children: [ children: [
timelineAsync.when( timelineAsync.when(
loading: () => Center( loading: () => Center(
@@ -429,28 +558,36 @@ class _ChatViewState extends ConsumerState<ChatView> {
jumpKey: _jumpKey, jumpKey: _jumpKey,
highlightEventId: _highlightEventId, highlightEventId: _highlightEventId,
highlightOn: _highlightOn, highlightOn: _highlightOn,
bottomInset: room != null ? _composerHeight : 0,
popInAfter: _popInAfter,
); );
}, },
), ),
if (_showScrollDown) Positioned(
Positioned(
right: 16, right: 16,
bottom: 12, bottom: (room != null ? _composerHeight : 0) + 12,
child: AnimatedOpacity( child: IgnorePointer(
opacity: _showScrollDown ? 1 : 0, ignoring: !_showScrollDown,
duration: const Duration(milliseconds: 150), child: AnimatedScale(
scale: _showScrollDown ? 1 : 0,
duration: motionDuration(
context, _showScrollDown ? 520 : 180),
curve: _showScrollDown
? Curves.elasticOut
: Curves.easeInBack,
child: GestureDetector( child: GestureDetector(
onTap: () => _scrollCtrl.animateTo( onTap: () => _scrollCtrl.animateTo(
0, 0,
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
curve: Curves.easeOut, curve: Curves.easeOut,
), ),
child: SpringPress(
child: Container( child: Container(
width: 36, width: _scrollBtnSize,
height: 36, height: _scrollBtnSize,
decoration: BoxDecoration( decoration: BoxDecoration(
color: pt.bg0, color: pt.bg0,
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(_scrollBtnSize / 2),
border: Border.all(color: pt.border), border: Border.all(color: pt.border),
boxShadow: [ boxShadow: [
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),
),
], ],
), ),
), ),
+1 -1
View File
@@ -43,7 +43,7 @@ class MembersPanel extends ConsumerWidget {
Icon(Icons.group_rounded, size: 16, color: pt.fg), Icon(Icons.group_rounded, size: 16, color: pt.fg),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'Members', 'Mitglieder',
style: TextStyle( style: TextStyle(
color: pt.fg, color: pt.fg,
fontSize: 14, fontSize: 14,
+2 -2
View File
@@ -29,7 +29,7 @@ class _Header extends ConsumerWidget {
final name = isRealSpace final name = isRealSpace
? (activeSpace?.getLocalizedDisplayname() ?? 'Space') ? (activeSpace?.getLocalizedDisplayname() ?? 'Space')
: (spaceId == 'dms' ? 'Chats' : 'Rooms'); : (spaceId == 'dms' ? 'Chats' : 'Räume');
void handleMenu(_SpaceMenuItem item) { void handleMenu(_SpaceMenuItem item) {
switch (item) { switch (item) {
@@ -478,7 +478,7 @@ class _FilterInput extends StatelessWidget {
onChanged: onChanged, onChanged: onChanged,
style: TextStyle(color: pt.fg, fontSize: 13), style: TextStyle(color: pt.fg, fontSize: 13),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Filter rooms', hintText: 'Chats durchsuchen',
hintStyle: TextStyle(color: pt.fgDim, fontSize: 13), hintStyle: TextStyle(color: pt.fgDim, fontSize: 13),
contentPadding: const EdgeInsets.fromLTRB(28, 7, 10, 7), contentPadding: const EdgeInsets.fromLTRB(28, 7, 10, 7),
filled: true, filled: true,
@@ -16,7 +16,7 @@ class _InviteItemState extends ConsumerState<_InviteItem> {
Future<void> _accept() async { Future<void> _accept() async {
setState(() => _accepting = true); setState(() => _accepting = true);
try { try {
await widget.room.join(); await acceptInvite(ref, widget.room);
if (mounted) { if (mounted) {
ref.read(activeRoomIdProvider.notifier).state = widget.room.id; ref.read(activeRoomIdProvider.notifier).state = widget.room.id;
ref.read(viewModeProvider.notifier).state = ViewMode.chat; ref.read(viewModeProvider.notifier).state = ViewMode.chat;
+14 -2
View File
@@ -99,10 +99,10 @@ class _RoomsList extends ConsumerWidget {
if (filtered.isEmpty && invites.isEmpty) { if (filtered.isEmpty && invites.isEmpty) {
return Center( return Center(
child: child:
Text('No rooms', style: TextStyle(color: pt.fgDim, fontSize: 13))); Text(isDms ? 'Noch keine Chats' : 'Keine Räume', style: TextStyle(color: pt.fgDim, fontSize: 13)));
} }
final sectionTitle = isDms ? 'Direct Messages' : 'Rooms'; final sectionTitle = isDms ? 'Direktnachrichten' : 'Räume';
final sectionId = isDms ? 'dms' : 'rooms'; final sectionId = isDms ? 'dms' : 'rooms';
final isCollapsed = collapsed[sectionId] ?? false; final isCollapsed = collapsed[sectionId] ?? false;
@@ -513,6 +513,18 @@ class _RoomItemState extends ConsumerState<_RoomItem> {
final voiceParticipants = isVoice ? ref.watch(voiceParticipantsProvider(room.id)) : <Map<String, dynamic>>[]; final voiceParticipants = isVoice ? ref.watch(voiceParticipantsProvider(room.id)) : <Map<String, dynamic>>[];
final client = ref.watch(matrixClientProvider).valueOrNull; final client = ref.watch(matrixClientProvider).valueOrNull;
// Handy: Messenger-Kachel mit Vorschau (rooms_mobile_tile.dart).
if (!isVoice && isMobileLayout(MediaQuery.sizeOf(context))) {
return _MobileRoomTile(
room: room,
isActive: active,
pt: pt,
myUserId: client?.userID,
onTap: widget.onTap,
onLongPress: _showContextMenu,
);
}
Color textColor = (active || inVoice || unread > 0 || _hovered) ? pt.fg : pt.fgMuted; Color textColor = (active || inVoice || unread > 0 || _hovered) ? pt.fg : pt.fgMuted;
return MouseRegion( return MouseRegion(
@@ -0,0 +1,167 @@
part of '../rooms_panel.dart';
// Chatlisten-Eintrag im Handy-Layout (Bernds Feedback 2026-10-07: die Handy-App
// soll sich wie ein gewohnter Messenger anfühlen): großer Avatar, Name mit
// Uhrzeit der letzten Nachricht, darunter die letzte Nachricht als Vorschau
// und der Ungelesen-Zähler. Am PC bleibt die kompakte Discord-Liste
// (_RoomItem); Voice-Kanäle behalten ihre eigene Darstellung.
const _kTileAvatar = 46.0;
class _MobileRoomTile extends StatelessWidget {
final Room room;
final bool isActive;
final PyramidTheme pt;
final String? myUserId;
final VoidCallback onTap;
final VoidCallback onLongPress;
const _MobileRoomTile({
required this.room,
required this.isActive,
required this.pt,
required this.myUserId,
required this.onTap,
required this.onLongPress,
});
@override
Widget build(BuildContext context) {
final name = room.getLocalizedDisplayname();
final unread = room.notificationCount;
final mention = room.highlightCount > 0;
final last = room.lastEvent;
final preview = _roomPreview(room, myUserId);
final time = last == null ? null : chatListTime(last.originServerTs);
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
onLongPress: onLongPress,
child: Container(
color: isActive ? pt.accentSoft : Colors.transparent,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
child: Row(
children: [
room.isDirectChat
? _DmAvatar(room: room, pt: pt, size: _kTileAvatar)
: _RoomTileAvatar(room: room, pt: pt),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
child: Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: pt.fg,
fontSize: 15,
fontWeight:
unread > 0 ? FontWeight.w700 : FontWeight.w600,
),
),
),
if (time != null) ...[
const SizedBox(width: 8),
Text(
time,
style: TextStyle(
color: unread > 0 ? pt.accent : pt.fgDim,
fontSize: 12,
fontWeight:
unread > 0 ? FontWeight.w600 : FontWeight.w400,
),
),
],
],
),
const SizedBox(height: 3),
Row(
children: [
Expanded(
child: Text(
preview ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: unread > 0 ? pt.fgMuted : pt.fgDim,
fontSize: 13.5,
),
),
),
// 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(),
),
],
),
],
),
),
],
),
),
);
}
}
/// Avatar für Räume (kein DM): Raumbild oder abgerundetes Symbol.
class _RoomTileAvatar extends ConsumerWidget {
final Room room;
final PyramidTheme pt;
const _RoomTileAvatar({required this.room, required this.pt});
@override
Widget build(BuildContext context, WidgetRef ref) {
final client = ref.watch(matrixClientProvider).valueOrNull;
final fallback = Container(
width: _kTileAvatar,
height: _kTileAvatar,
decoration: BoxDecoration(
color: pt.bg3,
borderRadius: BorderRadius.circular(14),
),
child: Icon(
room.encrypted ? Icons.lock_outline_rounded : Icons.tag_rounded,
size: 22,
color: pt.fgMuted,
),
);
if (client == null || room.avatar == null) return fallback;
return MxcAvatar(
mxcUri: room.avatar,
client: client,
size: _kTileAvatar,
borderRadius: BorderRadius.circular(14),
placeholder: (_) => fallback,
);
}
}
/// Vorschau der letzten Nachricht: gleiche Texte wie in Benachrichtigungen
/// (Medien als „📷 Foto“ usw., Antwort-Zitat entfernt), in Gruppen mit
/// Absender, eigene Nachrichten mit „Du:“.
String? _roomPreview(Room room, String? myUserId) {
final e = room.lastEvent;
if (e == null) return null;
if (e.type == EventTypes.Encrypted) return '🔒 Verschlüsselte Nachricht';
final body = notificationBody(e);
if (body == null) return null;
if (e.senderId == myUserId) return 'Du: $body';
if (room.isDirectChat) return body;
return '${e.senderFromMemoryOrFallback.calcDisplayname()}: $body';
}
+12 -11
View File
@@ -3,7 +3,8 @@ part of '../rooms_panel.dart';
class _DmAvatar extends ConsumerStatefulWidget { class _DmAvatar extends ConsumerStatefulWidget {
final Room room; final Room room;
final PyramidTheme pt; final PyramidTheme pt;
const _DmAvatar({required this.room, required this.pt}); final double size;
const _DmAvatar({required this.room, required this.pt, this.size = 22});
@override @override
ConsumerState<_DmAvatar> createState() => _DmAvatarState(); ConsumerState<_DmAvatar> createState() => _DmAvatarState();
@@ -69,13 +70,13 @@ class _DmAvatarState extends ConsumerState<_DmAvatar> {
avatar = MxcAvatar( avatar = MxcAvatar(
mxcUri: widget.room.avatar, mxcUri: widget.room.avatar,
client: client, client: client,
size: 22, size: widget.size,
borderRadius: BorderRadius.circular(11), borderRadius: BorderRadius.circular(widget.size / 2),
placeholder: (_) => placeholder: (_) =>
_InitialCircle(initial: initial, color: color, size: 22), _InitialCircle(initial: initial, color: color, size: widget.size),
); );
} else { } else {
avatar = _InitialCircle(initial: initial, color: color, size: 22); avatar = _InitialCircle(initial: initial, color: color, size: widget.size);
} }
final dot = _dotColor(); final dot = _dotColor();
@@ -83,11 +84,11 @@ class _DmAvatarState extends ConsumerState<_DmAvatar> {
return Stack(clipBehavior: Clip.none, children: [ return Stack(clipBehavior: Clip.none, children: [
avatar, avatar,
Positioned( Positioned(
right: -2, right: widget.size > 30 ? 0 : -2,
bottom: -2, bottom: widget.size > 30 ? 0 : -2,
child: Container( child: Container(
width: 9, width: widget.size > 30 ? 13 : 9,
height: 9, height: widget.size > 30 ? 13 : 9,
decoration: BoxDecoration( decoration: BoxDecoration(
color: dot, color: dot,
shape: BoxShape.circle, shape: BoxShape.circle,
@@ -123,9 +124,9 @@ class _InitialCircle extends StatelessWidget {
decoration: BoxDecoration(color: color, shape: BoxShape.circle), decoration: BoxDecoration(color: color, shape: BoxShape.circle),
child: Center( child: Center(
child: Text(initial, child: Text(initial,
style: const TextStyle( style: TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 10, fontSize: size * 0.45,
fontWeight: FontWeight.w600))), fontWeight: FontWeight.w600))),
); );
} }
+6
View File
@@ -7,18 +7,23 @@ import 'package:matrix/matrix.dart';
// ignore: implementation_imports // ignore: implementation_imports
import 'package:matrix/src/utils/space_child.dart'; import 'package:matrix/src/utils/space_child.dart';
import 'package:pyramid/core/app_state.dart'; import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/matrix_client.dart'; import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/notification_content.dart' show notificationBody;
import 'package:pyramid/core/theme.dart'; import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/rooms/rooms_provider.dart'; import 'package:pyramid/features/rooms/rooms_provider.dart';
import 'package:pyramid/features/chat/chat_provider.dart' show acceptInvite;
import 'package:pyramid/features/call_ui/mini_call_widget.dart'; import 'package:pyramid/features/call_ui/mini_call_widget.dart';
import 'package:pyramid/features/call_signaling/call_signaling_service.dart'; import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
import 'package:pyramid/features/voice_channel/voice_channel_service.dart'; import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
import 'package:pyramid/features/rooms/user_panel.dart'; import 'package:pyramid/features/rooms/user_panel.dart';
import 'package:pyramid/features/spaces/space_admin_dialog.dart'; import 'package:pyramid/features/spaces/space_admin_dialog.dart';
import 'package:pyramid/utils/date_labels.dart';
import 'package:pyramid/widgets/create_join_dialog.dart'; import 'package:pyramid/widgets/create_join_dialog.dart';
import 'package:pyramid/widgets/hover_region.dart'; import 'package:pyramid/widgets/hover_region.dart';
import 'package:pyramid/widgets/mxc_image.dart'; import 'package:pyramid/widgets/mxc_image.dart';
import 'package:pyramid/widgets/new_dm_dialog.dart'; import 'package:pyramid/widgets/new_dm_dialog.dart';
import 'package:pyramid/widgets/spring_press.dart' show SpringBump;
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -31,3 +36,4 @@ part 'panel/rooms_list.dart';
part 'panel/rooms_voice.dart'; part 'panel/rooms_voice.dart';
part 'panel/rooms_invite_item.dart'; part 'panel/rooms_invite_item.dart';
part 'panel/rooms_shared.dart'; part 'panel/rooms_shared.dart';
part 'panel/rooms_mobile_tile.dart';
@@ -0,0 +1,62 @@
import 'package:matrix/matrix.dart';
/// Lokale Nachrichtensuche über die entschlüsselten Events in der Client-DB.
///
/// Die server-seitige `/search`-API sieht bei E2EE nur Chiffretext und liefert
/// für verschlüsselte Räume nichts – deshalb wird (wie bei Element) die lokale
/// Datenbank durchsucht. Aus `search_modal.dart` herausgelöst, damit Tests und
/// `benchmark/` genau diesen Codepfad prüfen/messen können.
///
/// Bei [mediaOnly] werden nur Medien-Nachrichten geliefert, deren
/// Dateiname/Beschriftung passt. Ergebnis: neueste zuerst, höchstens
/// [searchTotalCap] Treffer; pro Raum werden höchstens
/// [searchPerRoomScanCap] Events gescannt.
Future<List<Event>> localMessageSearch(
Client client,
String query, {
bool mediaOnly = false,
}) async {
final db = client.database;
final lower = query.toLowerCase();
const mediaTypes = {'m.image', 'm.video', 'm.file', 'm.audio'};
final results = <Event>[];
for (final room in client.rooms.where((r) => !r.isSpace)) {
var start = 0;
var scanned = 0;
try {
while (scanned < searchPerRoomScanCap) {
final events = await db.getEventList(
room,
start: start,
limit: searchScanChunk,
);
if (events.isEmpty) break;
for (final e in events) {
if (e.type != EventTypes.Message || e.redacted) continue;
final msgtype = e.content.tryGet<String>('msgtype') ?? '';
if (mediaOnly && !mediaTypes.contains(msgtype)) continue;
if (!e.body.toLowerCase().contains(lower)) continue;
results.add(e);
}
start += searchScanChunk;
scanned += events.length;
final roomExhausted = events.length < searchScanChunk;
if (roomExhausted || results.length >= searchTotalCap) break;
}
} catch (_) {}
if (results.length >= searchTotalCap) break;
}
results.sort((a, b) => b.originServerTs.compareTo(a.originServerTs));
return results.take(searchTotalCap).toList();
}
/// Höchstens so viele Events werden pro Raum gescannt.
const searchPerRoomScanCap = 1500;
/// Höchstens so viele Treffer liefert die Suche insgesamt.
const searchTotalCap = 60;
/// So viele Events werden pro DB-Abfrage geladen.
const searchScanChunk = 500;
+2 -2
View File
@@ -71,7 +71,7 @@ class SpacesRail extends ConsumerWidget {
id: 'rooms', id: 'rooms',
label: 'R', label: 'R',
color: const Color(0xFFF59E0B), color: const Color(0xFFF59E0B),
name: 'Rooms', name: 'Räume',
active: activeSpaceId == 'rooms', active: activeSpaceId == 'rooms',
unread: 0, unread: 0,
pt: pt, pt: pt,
@@ -179,7 +179,7 @@ class _HomeButtonState extends State<_HomeButton> {
? (Matrix4.translationValues(0, -2, 0)..rotateZ(-0.05)) ? (Matrix4.translationValues(0, -2, 0)..rotateZ(-0.05))
: Matrix4.identity(), : Matrix4.identity(),
child: Tooltip( child: Tooltip(
message: 'Direct Messages', message: 'Direktnachrichten',
waitDuration: const Duration(milliseconds: 400), waitDuration: const Duration(milliseconds: 400),
preferBelow: false, preferBelow: false,
child: Stack( child: Stack(
@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'package:livekit_client/livekit_client.dart'; import 'package:livekit_client/livekit_client.dart';
import 'package:matrix/matrix.dart' hide Room; 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/ice_servers.dart';
import 'package:pyramid/core/livekit_token.dart'; import 'package:pyramid/core/livekit_token.dart';
import 'package:pyramid/core/settings_prefs.dart'; import 'package:pyramid/core/settings_prefs.dart';
@@ -98,8 +99,35 @@ class LiveKitCallManager extends VoiceChannelService {
void _onRoomChanged() => notifyListeners(); 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() { void _cleanUpAfterDisconnect() {
_callGeneration++; _callGeneration++;
CallForeground.callEnded('livekit');
_stopStatsLogging(); _stopStatsLogging();
_presenceHeartbeat?.cancel(); _presenceHeartbeat?.cancel();
_presenceHeartbeat = null; _presenceHeartbeat = null;
@@ -173,8 +201,6 @@ class LiveKitCallManager extends VoiceChannelService {
if (isActive) return; if (isActive) return;
final gen = ++_callGeneration; final gen = ++_callGeneration;
final url = 'wss://livekit.steggi-matrix.work';
isConnecting = true; isConnecting = true;
isVoiceChannel = voiceChannel; isVoiceChannel = voiceChannel;
error = null; error = null;
@@ -191,12 +217,21 @@ class LiveKitCallManager extends VoiceChannelService {
notifyListeners(); notifyListeners();
try { try {
final token = LiveKitTokenGenerator.generate( // Token holt der Server (prüft den Matrix-Login, Identität = eigene
roomName: roomName, // Matrix-ID); das LiveKit-Secret liegt nicht mehr in der App.
identity: identity, // [identity] wird dafür nicht mehr gebraucht.
displayName: identity, final accessToken = matrixClient?.accessToken;
ttlSeconds: 21600, 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( final room = Room(
roomOptions: const RoomOptions( roomOptions: const RoomOptions(
@@ -247,6 +282,10 @@ class LiveKitCallManager extends VoiceChannelService {
_room = room; _room = room;
isConnecting = false; isConnecting = false;
notifyListeners(); 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) { if (voiceChannel && matrixClient != null && matrixRoomId != null) {
await _writePresence(true); await _writePresence(true);
@@ -262,6 +301,7 @@ class LiveKitCallManager extends VoiceChannelService {
isScreenSharing = false; isScreenSharing = false;
_currentScreenShareSourceId = null; _currentScreenShareSourceId = null;
_screenShareTrack = null; _screenShareTrack = null;
CallForeground.screenCaptureStopped('livekit');
} }
notifyListeners(); 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); await _room?.localParticipant?.setScreenShareEnabled(true);
isScreenSharing = true; isScreenSharing = true;
notifyListeners(); notifyListeners();
@@ -580,6 +625,7 @@ class LiveKitCallManager extends VoiceChannelService {
} }
} }
try { await _room?.localParticipant?.setScreenShareEnabled(false); } catch (_) {} try { await _room?.localParticipant?.setScreenShareEnabled(false); } catch (_) {}
await CallForeground.screenCaptureStopped('livekit');
isScreenSharing = false; isScreenSharing = false;
_currentScreenShareSourceId = null; _currentScreenShareSourceId = null;
_screenShareTrack = null; _screenShareTrack = null;
@@ -590,6 +636,7 @@ class LiveKitCallManager extends VoiceChannelService {
Future<void> hangUp() async { Future<void> hangUp() async {
// Entwertet einen evtl. noch laufenden Verbindungsaufbau (s. _callGeneration). // Entwertet einen evtl. noch laufenden Verbindungsaufbau (s. _callGeneration).
_callGeneration++; _callGeneration++;
unawaited(CallForeground.callEnded('livekit'));
_stopStatsLogging(); _stopStatsLogging();
_presenceHeartbeat?.cancel(); _presenceHeartbeat?.cancel();
_presenceHeartbeat = null; _presenceHeartbeat = null;
@@ -53,6 +53,11 @@ abstract class VoiceChannelService extends ChangeNotifier {
Future<void> hangUp(); Future<void> hangUp();
Future<void> toggleMute(); 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> toggleDeafen();
Future<void> toggleCamera(); Future<void> toggleCamera();
Future<void> toggleScreenShare(); Future<void> toggleScreenShare();
+56 -19
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/members/members_panel.dart';
import 'package:pyramid/features/rooms/rooms_panel.dart'; import 'package:pyramid/features/rooms/rooms_panel.dart';
import 'package:pyramid/features/spaces/spaces_rail.dart'; import 'package:pyramid/features/spaces/spaces_rail.dart';
import 'package:pyramid/layout/mobile_page_stack.dart';
import 'package:pyramid/widgets/spring_press.dart' show motionDuration;
import 'package:pyramid/widgets/search_modal.dart'; import 'package:pyramid/widgets/search_modal.dart';
import 'package:pyramid/widgets/settings_modal.dart'; import 'package:pyramid/widgets/settings_modal.dart';
import 'package:pyramid/widgets/share_target_dialog.dart'; import 'package:pyramid/widgets/share_target_dialog.dart';
@@ -335,16 +337,31 @@ class _MobileShell extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final voip = ref.watch(callSignalingProvider); final voip = ref.watch(callSignalingProvider);
Widget body; // Die Chatliste bleibt aufgebaut; Chat bzw. Sprachkanal gleiten federnd
if (viewMode == ViewMode.voice) { // darüber (Bernds Wunsch 2026-10-07: mehr Übergänge/Elastizität).
body = const VoiceChannelView(); final Widget? front = viewMode == ViewMode.voice
} else if (activeRoomId != null) { ? const VoiceChannelView()
body = ChatView(roomId: activeRoomId!); : activeRoomId != null
} else { ? ChatView(roomId: activeRoomId!)
body = const RoomsPanel(); : null;
} final body = MobilePageStack(
base: const RoomsPanel(),
front: front,
background: pt.bg1,
);
return PopScope( // Symbole der Statusleiste passend zum Theme (hell auf dunkel und umgekehrt).
final overlayStyle = (pt.isDark
? SystemUiOverlayStyle.light
: SystemUiOverlayStyle.dark)
.copyWith(
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.transparent,
);
return AnnotatedRegion<SystemUiOverlayStyle>(
value: overlayStyle,
child: PopScope(
canPop: activeRoomId == null && viewMode == ViewMode.chat && !membersOpen, canPop: activeRoomId == null && viewMode == ViewMode.chat && !membersOpen,
onPopInvokedWithResult: (didPop, _) { onPopInvokedWithResult: (didPop, _) {
if (!didPop) { if (!didPop) {
@@ -358,13 +375,17 @@ class _MobileShell extends ConsumerWidget {
} }
}, },
child: Scaffold( child: Scaffold(
backgroundColor: pt.bg0, // bg1 = Farbe der Kopfzeilen → der Streifen hinter der Statusleiste
// geht nahtlos in sie über.
backgroundColor: pt.bg1,
drawer: Drawer( drawer: Drawer(
width: 80, width: 80,
backgroundColor: pt.bg0, backgroundColor: pt.bg0,
child: const SpacesRail(), child: const SafeArea(child: SpacesRail()),
), ),
body: Stack( // SafeArea: Abstand zu Statusleiste/Kamera-Aussparung oben und zur
// Gesten-/Navigationsleiste unten (edge-to-edge, siehe main.dart).
body: SafeArea(child: Stack(
children: [ children: [
body, body,
if (membersOpen && activeRoomId != null && viewMode != ViewMode.voice) ...[ if (membersOpen && activeRoomId != null && viewMode != ViewMode.voice) ...[
@@ -372,7 +393,12 @@ class _MobileShell extends ConsumerWidget {
Positioned.fill( Positioned.fill(
child: GestureDetector( child: GestureDetector(
onTap: () => ref.read(membersPanelOpenProvider.notifier).state = false, 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( Positioned(
@@ -381,10 +407,20 @@ class _MobileShell extends ConsumerWidget {
bottom: 0, bottom: 0,
child: Builder(builder: (ctx) { child: Builder(builder: (ctx) {
final w = MediaQuery.sizeOf(ctx).width; final w = MediaQuery.sizeOf(ctx).width;
return MembersPanel( // Gleitet von rechts herein.
roomId: activeRoomId!, return TweenAnimationBuilder<double>(
width: (w * 0.82).clamp(240.0, 320.0), tween: Tween(begin: 1, end: 0),
onClose: () => ref.read(membersPanelOpenProvider.notifier).state = false, 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,
),
); );
}), }),
), ),
@@ -400,7 +436,8 @@ class _MobileShell extends ConsumerWidget {
child: _NotificationBlockedBanner(), child: _NotificationBlockedBanner(),
), ),
], ],
), )),
),
), ),
); );
} }
@@ -576,7 +613,7 @@ class NoChatSelected extends StatelessWidget {
Icon(Icons.chat_bubble_outline_rounded, size: 64, color: pt.fgDim.withAlpha(50)), Icon(Icons.chat_bubble_outline_rounded, size: 64, color: pt.fgDim.withAlpha(50)),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'Select a chat to start messaging', 'Wähle einen Chat aus',
style: TextStyle(color: pt.fgMuted, fontSize: 15), style: TextStyle(color: pt.fgMuted, fontSize: 15),
), ),
], ],
+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,
),
),
),
),
],
);
},
);
},
);
}
}
+10 -2
View File
@@ -33,8 +33,16 @@ void main() async {
await vod.init(); await vod.init();
// Hide status bar and navigation bar for immersive feel // Status- und Navigationsleiste bleiben sichtbar (edge-to-edge): die App
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); // zeichnet darunter, hält per SafeArea (Handy-Layout in app_shell.dart)
// aber Abstand. Vorher immersiveSticky – da schob sich die Statusleiste
// beim Herunterwischen über Kopfzeile und Text (Bernds Feedback 2026-10-07).
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.transparent,
systemNavigationBarContrastEnforced: false,
));
runApp(const ProviderScope(child: PyramidBootstrap())); runApp(const ProviderScope(child: PyramidBootstrap()));
} }
+25
View File
@@ -0,0 +1,25 @@
/// Kurze Datums-/Zeitangaben für Listen (deutsch).
library;
/// Zeit in der Chatliste: heute „14:05“, gestern „Gestern“, diese Woche der
/// Wochentag, sonst das Datum.
String chatListTime(DateTime t, {DateTime? now}) {
final n = now ?? DateTime.now();
final today = DateTime(n.year, n.month, n.day);
final day = DateTime(t.year, t.month, t.day);
// Über Stunden runden: an Zeitumstellungs-Tagen hat ein Tag 23/25 Stunden,
// inDays würde dann „gestern“ als „heute“ zählen.
final diff = (today.difference(day).inHours / 24).round();
if (diff <= 0) {
return '${t.hour.toString().padLeft(2, '0')}:'
'${t.minute.toString().padLeft(2, '0')}';
}
if (diff == 1) return 'Gestern';
if (diff < 7) {
const days = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
return days[t.weekday - 1];
}
return '${t.day.toString().padLeft(2, '0')}.'
'${t.month.toString().padLeft(2, '0')}.'
'${(t.year % 100).toString().padLeft(2, '0')}';
}
+40 -29
View File
@@ -1,4 +1,9 @@
import 'dart:math' as math;
import 'package:flutter/material.dart'; 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 // Simple hover-aware widget for desktop hover effects
class HoverRegion extends StatefulWidget { 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 { class PyrIconBtn extends StatefulWidget {
final Widget icon; final Widget icon;
final VoidCallback? onPressed; final VoidCallback? onPressed;
@@ -39,6 +47,9 @@ class PyrIconBtn extends StatefulWidget {
final Color? dangerColor; final Color? dangerColor;
final double size; final double size;
/// Symbolgröße; ohne Angabe 16 (PC) bzw. 22 (Handy-Layout).
final double? iconSize;
const PyrIconBtn({ const PyrIconBtn({
super.key, super.key,
required this.icon, required this.icon,
@@ -48,6 +59,7 @@ class PyrIconBtn extends StatefulWidget {
this.activeColor, this.activeColor,
this.dangerColor, this.dangerColor,
this.size = 32, this.size = 32,
this.iconSize,
}); });
@override @override
@@ -59,20 +71,16 @@ class _PyrIconBtnState extends State<PyrIconBtn> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final pt = PyramidTheme.of(context);
final isDark = theme.brightness == Brightness.dark; final mobile = isMobileLayout(MediaQuery.sizeOf(context));
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 color = widget.active final color = widget.active
? (widget.activeColor ?? const Color(0xFFF5A614)) ? (widget.activeColor ?? pt.accent)
: _hovered ? fg : fgMuted; : _hovered ? pt.fg : pt.fgMuted;
final platform = Theme.of(context).platform; final boxSize = mobile ? math.max(widget.size, 40.0) : widget.size;
final isMobile = platform == TargetPlatform.android || platform == TargetPlatform.iOS; final touchSize = mobile ? math.max(boxSize, 44.0) : boxSize;
// On mobile, use a slightly larger touch target, but respect the requested size final iconSize = widget.iconSize ?? (mobile ? 22.0 : 16.0);
final touchSize = isMobile ? (widget.size + 8).clamp(32.0, 48.0) : widget.size;
final btn = MouseRegion( final btn = MouseRegion(
cursor: SystemMouseCursors.click, cursor: SystemMouseCursors.click,
@@ -80,23 +88,26 @@ class _PyrIconBtnState extends State<PyrIconBtn> {
onExit: (_) => setState(() => _hovered = false), onExit: (_) => setState(() => _hovered = false),
child: GestureDetector( child: GestureDetector(
onTap: widget.onPressed, onTap: widget.onPressed,
child: SizedBox( child: SpringPress(
width: touchSize, enabled: widget.onPressed != null,
height: touchSize, child: SizedBox(
child: Center( width: touchSize,
child: AnimatedContainer( height: touchSize,
duration: const Duration(milliseconds: 150), child: Center(
curve: Curves.easeOutCubic, child: AnimatedContainer(
width: widget.size, duration: const Duration(milliseconds: 150),
height: widget.size, curve: Curves.easeOutCubic,
decoration: BoxDecoration( width: boxSize,
color: _hovered ? bgHover : Colors.transparent, height: boxSize,
borderRadius: BorderRadius.circular(7), decoration: BoxDecoration(
), color: _hovered ? pt.bgHover : Colors.transparent,
child: Center( borderRadius: BorderRadius.circular(mobile ? boxSize / 2 : 7),
child: IconTheme( ),
data: IconThemeData(color: color, size: 16), child: Center(
child: widget.icon, child: IconTheme(
data: IconThemeData(color: color, size: iconSize),
child: widget.icon,
),
), ),
), ),
), ),
+37
View File
@@ -0,0 +1,37 @@
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
/// Meldet die gezeichnete Größe des Kindes, sobald sie sich ändert (z. B.
/// die Höhe der schwebenden Eingabeleiste, die mit Antwort-Leiste oder
/// mehrzeiligem Text wächst). Die Meldung kommt nach dem Layout, damit der
/// Empfänger gefahrlos setState aufrufen kann.
class MeasureSize extends SingleChildRenderObjectWidget {
const MeasureSize({super.key, required this.onChange, super.child});
final ValueChanged<Size> onChange;
@override
RenderObject createRenderObject(BuildContext context) =>
_MeasureSizeRender(onChange);
@override
void updateRenderObject(BuildContext context, RenderObject renderObject) {
(renderObject as _MeasureSizeRender).onChange = onChange;
}
}
class _MeasureSizeRender extends RenderProxyBox {
_MeasureSizeRender(this.onChange);
ValueChanged<Size> onChange;
Size? _last;
@override
void performLayout() {
super.performLayout();
final size = this.size;
if (size == _last) return;
_last = size;
WidgetsBinding.instance.addPostFrameCallback((_) => onChange(size));
}
}
+214
View File
@@ -0,0 +1,214 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:pyramid/core/settings_prefs.dart';
import 'package:pyramid/core/theme.dart';
/// Mikrofontest mit Pegelanzeige (Einstellungen → Sprache & Video).
///
/// Nimmt über DENSELBEN Weg wie ein Anruf auf (gewähltes Gerät aus den
/// Einstellungen, WebRTC) und zeigt den Pegel live. Anlass: Bernds Test
/// 2026-10-07 – das Handy hörte nichts vom PC, weil (mit Voicemeeter und
/// vielen virtuellen Geräten) das Standardgerät nur Stille lieferte. Ohne
/// Pegelanzeige war das nicht zu erkennen.
///
/// Technik: lokale Verbindung zu sich selbst (zwei PeerConnections im selben
/// Prozess) – erst dann startet libwebrtc die Aufnahme und liefert den Pegel
/// in den Statistiken. Es wird nichts verschickt oder gespeichert. Nach
/// 30 Sekunden endet der Test von selbst.
class MicLevelTest extends StatefulWidget {
const MicLevelTest({super.key, required this.pt, this.enabled = true});
final PyramidTheme pt;
/// false z. B. während eines Anrufs (zwei Aufnahmen gleichzeitig vermeiden).
final bool enabled;
@override
State<MicLevelTest> createState() => _MicLevelTestState();
}
class _MicLevelTestState extends State<MicLevelTest> {
MediaStream? _stream;
RTCPeerConnection? _a;
RTCPeerConnection? _b;
Timer? _poll;
Timer? _autoStop;
bool _running = false;
bool _starting = false;
double _level = 0; // 0..1 (geglättet)
double _peak = 0;
String? _error;
@override
void dispose() {
_stop(notify: false);
super.dispose();
}
Future<void> _start() async {
setState(() {
_starting = true;
_error = null;
_peak = 0;
_level = 0;
});
try {
final mic = (await VoicePrefs.loadDevices()).mic;
final audio = mic == null
? true
: {
'deviceId': mic,
'optional': [
{'sourceId': mic},
],
};
final stream = await navigator.mediaDevices
.getUserMedia({'audio': audio, 'video': false});
final a = await createPeerConnection({});
final b = await createPeerConnection({});
a.onIceCandidate = (c) => b.addCandidate(c);
b.onIceCandidate = (c) => a.addCandidate(c);
await a.addTrack(stream.getAudioTracks().first, stream);
final offer = await a.createOffer();
await a.setLocalDescription(offer);
await b.setRemoteDescription(offer);
final answer = await b.createAnswer();
await b.setLocalDescription(answer);
await a.setRemoteDescription(answer);
if (!mounted) {
await _release(stream, a, b);
return;
}
_stream = stream;
_a = a;
_b = b;
_poll = Timer.periodic(const Duration(milliseconds: 120), (_) => _read());
_autoStop = Timer(const Duration(seconds: 30), _stop);
setState(() {
_running = true;
_starting = false;
});
} catch (e) {
if (mounted) {
setState(() {
_starting = false;
_error = 'Mikrofon ließ sich nicht öffnen: ${e.toString().split('\n').first}';
});
}
}
}
Future<void> _read() async {
final a = _a;
if (a == null) return;
try {
for (final r in await a.getStats()) {
if (r.type != 'media-source') continue;
final raw = (r.values['audioLevel'] as num?)?.toDouble() ?? 0;
// Wurzel-Skala: leise Sprache soll sichtbar ausschlagen.
final shown = math.sqrt(raw.clamp(0.0, 1.0));
if (!mounted) return;
setState(() {
_level = _level * 0.4 + shown * 0.6;
_peak = math.max(_peak, shown);
});
}
} catch (_) {}
}
Future<void> _release(MediaStream? s, RTCPeerConnection? a, RTCPeerConnection? b) async {
for (final t in s?.getTracks() ?? <MediaStreamTrack>[]) {
try {
await t.stop();
} catch (_) {}
}
try {
await s?.dispose();
} catch (_) {}
try {
await a?.close();
} catch (_) {}
try {
await b?.close();
} catch (_) {}
}
void _stop({bool notify = true}) {
_poll?.cancel();
_autoStop?.cancel();
_poll = null;
_autoStop = null;
final s = _stream, a = _a, b = _b;
_stream = null;
_a = null;
_b = null;
_release(s, a, b);
if (notify && mounted) {
setState(() {
_running = false;
_level = 0;
});
} else {
_running = false;
}
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final silent = _running && _peak < 0.02;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.mic_rounded, size: 15, color: pt.fgMuted),
const SizedBox(width: 8),
Text('Mikrofontest',
style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500)),
const Spacer(),
TextButton(
onPressed: !widget.enabled || _starting
? null
: (_running ? _stop : _start),
child: Text(_running ? 'Beenden' : 'Testen'),
),
],
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Container(
height: 8,
color: pt.bg3,
alignment: Alignment.centerLeft,
child: AnimatedFractionallySizedBox(
duration: const Duration(milliseconds: 110),
widthFactor: _level.clamp(0.0, 1.0),
heightFactor: 1,
child: Container(color: _level > 0.85 ? pt.danger : pt.accent),
),
),
),
const SizedBox(height: 6),
Text(
!widget.enabled
? 'Während eines Anrufs nicht möglich.'
: _error ??
(_running
? (silent
? 'Kein Ton zu hören – sprich etwas. Bleibt der Balken leer, oben ein anderes Mikrofon wählen.'
: 'Sprich etwas – der Balken zeigt, was die anderen hören würden.')
: 'Prüft das oben gewählte Mikrofon. Es wird nichts gesendet oder gespeichert.'),
style: TextStyle(
color: _error != null || silent ? pt.away : pt.fgDim,
fontSize: 11,
),
),
],
);
}
}
+18 -18
View File
@@ -104,9 +104,9 @@ class _ProfilePopoverState extends State<ProfilePopover> {
child: Container( child: Container(
width: popoverW, width: popoverW,
decoration: BoxDecoration( decoration: BoxDecoration(
color: PyramidColors.bg0, color: PyramidTheme.of(context).bg0,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: PyramidColors.border), border: Border.all(color: PyramidTheme.of(context).border),
boxShadow: const [ boxShadow: const [
BoxShadow(color: Color(0x60000000), blurRadius: 24, offset: Offset(0, 8)) BoxShadow(color: Color(0x60000000), blurRadius: 24, offset: Offset(0, 8))
], ],
@@ -172,7 +172,7 @@ class _ProfilePopoverState extends State<ProfilePopover> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: color, color: color,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all(color: PyramidColors.bg0, width: 3), border: Border.all(color: PyramidTheme.of(context).bg0, width: 3),
), ),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: avatarUrl != null child: avatarUrl != null
@@ -212,14 +212,14 @@ class _ProfilePopoverState extends State<ProfilePopover> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(displayName, Text(displayName,
style: const TextStyle( style: TextStyle(
color: PyramidColors.fg, color: PyramidTheme.of(context).fg,
fontSize: 17, fontSize: 17,
fontWeight: FontWeight.w700)), fontWeight: FontWeight.w700)),
const SizedBox(height: 2), const SizedBox(height: 2),
Text(handle, Text(handle,
style: const TextStyle( style: TextStyle(
color: PyramidColors.fgMuted, fontSize: 12)), color: PyramidTheme.of(context).fgMuted, fontSize: 12)),
const SizedBox(height: 12), const SizedBox(height: 12),
// Action buttons // Action buttons
Row( Row(
@@ -248,9 +248,9 @@ class _ProfilePopoverState extends State<ProfilePopover> {
), ),
if (powerLevel > 0) ...[ if (powerLevel > 0) ...[
const SizedBox(height: 14), const SizedBox(height: 14),
const Text('Rollen', Text('Rollen',
style: TextStyle( style: TextStyle(
color: PyramidColors.fgDim, color: PyramidTheme.of(context).fgDim,
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: 0.8)), letterSpacing: 0.8)),
@@ -270,16 +270,16 @@ class _ProfilePopoverState extends State<ProfilePopover> {
], ],
if (joinDate != null) ...[ if (joinDate != null) ...[
const SizedBox(height: 14), const SizedBox(height: 14),
const Text('Mitglied seit', Text('Mitglied seit',
style: TextStyle( style: TextStyle(
color: PyramidColors.fgDim, color: PyramidTheme.of(context).fgDim,
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: 0.8)), letterSpacing: 0.8)),
const SizedBox(height: 4), const SizedBox(height: 4),
Text(joinDate, Text(joinDate,
style: const TextStyle( style: TextStyle(
color: PyramidColors.fgMuted, fontSize: 12)), color: PyramidTheme.of(context).fgMuted, fontSize: 12)),
], ],
], ],
), ),
@@ -337,7 +337,7 @@ class _PresenceDot extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: color, color: color,
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all(color: PyramidColors.bg0, width: 2), border: Border.all(color: PyramidTheme.of(context).bg0, width: 2),
), ),
); );
} }
@@ -380,10 +380,10 @@ class _ProfileBtnState extends State<_ProfileBtn> {
? (_hover ? (_hover
? PyramidTheme.of(context).accent.withAlpha(200) ? PyramidTheme.of(context).accent.withAlpha(200)
: PyramidTheme.of(context).accent) : PyramidTheme.of(context).accent)
: (_hover ? PyramidColors.bgHover : PyramidColors.bg2), : (_hover ? PyramidTheme.of(context).bgHover : PyramidTheme.of(context).bg2),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all( border: Border.all(
color: widget.primary ? Colors.transparent : PyramidColors.border, color: widget.primary ? Colors.transparent : PyramidTheme.of(context).border,
), ),
), ),
child: Row( child: Row(
@@ -393,14 +393,14 @@ class _ProfileBtnState extends State<_ProfileBtn> {
size: 14, size: 14,
color: widget.primary color: widget.primary
? PyramidColors.accentFg ? PyramidColors.accentFg
: PyramidColors.fg), : PyramidTheme.of(context).fg),
if (hasLabel) ...[ if (hasLabel) ...[
const SizedBox(width: 6), const SizedBox(width: 6),
Text(widget.label!, Text(widget.label!,
style: TextStyle( style: TextStyle(
color: widget.primary color: widget.primary
? PyramidColors.accentFg ? PyramidColors.accentFg
: PyramidColors.fg, : PyramidTheme.of(context).fg,
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w500)), fontWeight: FontWeight.w500)),
], ],
+7 -40
View File
@@ -7,6 +7,7 @@ import 'package:matrix/matrix.dart';
import 'package:pyramid/core/app_state.dart'; import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/matrix_client.dart'; import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/theme.dart'; import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/search/local_message_search.dart';
import 'package:pyramid/widgets/mxc_image.dart'; import 'package:pyramid/widgets/mxc_image.dart';
// ─── State ──────────────────────────────────────────────────────────────────── // ─── State ────────────────────────────────────────────────────────────────────
@@ -110,7 +111,11 @@ class _SearchModalState extends ConsumerState<SearchModal> {
List<Event> messageResults = []; List<Event> messageResults = [];
if (_filter != _Filter.people) { if (_filter != _Filter.people) {
try { try {
messageResults = await _localMessageSearch(client, q); messageResults = await localMessageSearch(
client,
q,
mediaOnly: _filter == _Filter.media,
);
} catch (e) { } catch (e) {
setState(() => _state = _state.copyWith( setState(() => _state = _state.copyWith(
loading: false, loading: false,
@@ -129,45 +134,6 @@ class _SearchModalState extends ConsumerState<SearchModal> {
} }
} }
// Scans the local (decrypted) event database across all rooms for messages
// whose body matches the query. For the media filter, only media messages
// whose filename/caption matches are returned.
Future<List<Event>> _localMessageSearch(Client client, String q) async {
final db = client.database;
final lower = q.toLowerCase();
final media = _filter == _Filter.media;
const mediaTypes = {'m.image', 'm.video', 'm.file', 'm.audio'};
const perRoomScanCap = 1500;
const totalCap = 60;
const chunk = 500;
final results = <Event>[];
for (final room in client.rooms.where((r) => !r.isSpace)) {
var start = 0;
var scanned = 0;
try {
while (scanned < perRoomScanCap) {
final events = await db.getEventList(room, start: start, limit: chunk);
if (events.isEmpty) break;
for (final e in events) {
if (e.type != EventTypes.Message || e.redacted) continue;
final msgtype = e.content.tryGet<String>('msgtype') ?? '';
if (media && !mediaTypes.contains(msgtype)) continue;
if (!e.body.toLowerCase().contains(lower)) continue;
results.add(e);
}
start += chunk;
scanned += events.length;
if (events.length < chunk || results.length >= totalCap) break;
}
} catch (_) {}
if (results.length >= totalCap) break;
}
results.sort((a, b) => b.originServerTs.compareTo(a.originServerTs));
return results.take(totalCap).toList();
}
void _openRoom(String roomId, {String? eventId}) { void _openRoom(String roomId, {String? eventId}) {
widget.onClose(); widget.onClose();
ref.read(activeRoomIdProvider.notifier).state = roomId; ref.read(activeRoomIdProvider.notifier).state = roomId;
@@ -657,6 +623,7 @@ class _MessageItemState extends State<_MessageItem> {
]), ]),
const SizedBox(height: 1), const SizedBox(height: 1),
RichText( RichText(
textScaler: MediaQuery.textScalerOf(context),
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
text: TextSpan(children: [ text: TextSpan(children: [
+1 -1
View File
@@ -130,7 +130,7 @@ class _AboutSection extends ConsumerWidget {
); );
if (confirm == true) { if (confirm == true) {
unawaited(AuthLog.write('User-initiated logout (Settings)')); unawaited(AuthLog.write('User-initiated logout (Settings)'));
await client.logout().catchError((_) {}); await logoutWithoutStraySync(client).catchError((_) {});
} }
} }
} }
+1 -1
View File
@@ -192,7 +192,7 @@ class _DeleteAccountDialogState extends ConsumerState<_DeleteAccountDialog> {
} }
// Konto wurde gelöscht — ausloggen // Konto wurde gelöscht — ausloggen
unawaited(AuthLog.write('User-initiated logout (Konto gelöscht)')); unawaited(AuthLog.write('User-initiated logout (Konto gelöscht)'));
try { await client.logout(); } catch (_) {} try { await logoutWithoutStraySync(client); } catch (_) {}
} }
@override @override
+146 -9
View File
@@ -2,7 +2,9 @@ part of '../settings_modal.dart';
class _AppearanceSection extends StatelessWidget { class _AppearanceSection extends StatelessWidget {
final PyramidTheme pt; final PyramidTheme pt;
final bool isDark; final ThemeVariant variant;
final double? textScale;
final MessageStyle messageStyle;
final int accentIdx; final int accentIdx;
final double radius; final double radius;
final double density; final double density;
@@ -11,7 +13,9 @@ class _AppearanceSection extends StatelessWidget {
const _AppearanceSection({ const _AppearanceSection({
required this.pt, required this.pt,
required this.isDark, required this.variant,
required this.textScale,
required this.messageStyle,
required this.accentIdx, required this.accentIdx,
required this.radius, required this.radius,
required this.density, required this.density,
@@ -21,6 +25,12 @@ class _AppearanceSection extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final mobile = isMobileLayout(MediaQuery.sizeOf(context));
final autoScale = defaultTextScale(mobile: mobile);
final effectiveScale = textScale ?? autoScale;
final variants = ThemeVariant.values;
final styles = MessageStyle.values;
return SingleChildScrollView( return SingleChildScrollView(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -30,18 +40,18 @@ class _AppearanceSection extends StatelessWidget {
subtitle: 'Gestalte Pyramid nach deinem Geschmack.', subtitle: 'Gestalte Pyramid nach deinem Geschmack.',
pt: pt), pt: pt),
_SettingsGroup(title: 'Theme', pt: pt, children: [ _SettingsGroup(title: 'Theme', pt: pt, children: [
_SettingsRow( _AdaptiveSettingsRow(
title: 'Farbmodus', title: 'Farbmodus',
desc: 'Hell oder dunkel', desc: 'Dunkel = weiches Dunkelgrau, Schwarz = wie früher (spart '
'auf OLED-Displays Akku)',
pt: pt, pt: pt,
control: _SegControl( control: _SegControl(
options: const ['Dunkel', 'Hell'], options: [for (final v in variants) v.label],
selected: isDark ? 0 : 1, selected: variants.indexOf(variant),
pt: pt, pt: pt,
onSelect: (i) { onSelect: (i) {
final dark = i == 0; ref.read(themeVariantProvider.notifier).state = variants[i];
ref.read(themeModeProvider.notifier).state = dark; saveThemePrefs(variant: variants[i]);
saveThemePrefs(isDark: dark);
}, },
), ),
), ),
@@ -80,6 +90,85 @@ class _AppearanceSection extends StatelessWidget {
), ),
]), ]),
const SizedBox(height: 20), const SizedBox(height: 20),
_SettingsGroup(title: 'Text & Chat', pt: pt, children: [
_AdaptiveSettingsRow(
title: 'Schriftgröße',
desc: textScale == null
? 'Automatisch (${(autoScale * 100).round()} %) – zusätzlich '
'zur Schriftgröße des Geräts'
: '${(effectiveScale * 100).round()} %',
pt: pt,
control: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: mobile ? 250 : 170,
child: SliderTheme(
data: SliderThemeData(
activeTrackColor: pt.accent,
thumbColor: pt.accent,
inactiveTrackColor: pt.border,
trackHeight: 3,
thumbShape:
const RoundSliderThumbShape(enabledThumbRadius: 7),
),
child: Slider(
value: effectiveScale.clamp(minTextScale, maxTextScale),
min: minTextScale,
max: maxTextScale,
divisions: ((maxTextScale - minTextScale) / 0.05).round(),
onChanged: (v) {
final rounded = (v * 20).round() / 20;
ref.read(textScaleProvider.notifier).state = rounded;
saveThemePrefs(textScale: rounded);
},
),
),
),
if (textScale != null)
TextButton(
onPressed: () {
ref.read(textScaleProvider.notifier).state = null;
saveThemePrefs(resetTextScale: true);
},
child: Text('Standard',
style: TextStyle(color: pt.accent, fontSize: 12)),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: pt.bg3,
borderRadius: BorderRadius.circular(pt.rBase),
),
child: Text(
'Vorschau: So groß erscheinen Nachrichten im Chat.',
style: TextStyle(color: pt.fg, fontSize: 14),
),
),
),
_Divider(pt: pt),
_AdaptiveSettingsRow(
title: 'Nachrichten',
desc: 'Automatisch: Handy Sprechblasen, PC Liste',
pt: pt,
control: _SegControl(
options: [for (final m in styles) m.label],
selected: styles.indexOf(messageStyle),
pt: pt,
onSelect: (i) {
ref.read(messageStyleProvider.notifier).state = styles[i];
saveThemePrefs(messageStyle: styles[i]);
},
),
),
]),
const SizedBox(height: 20),
_SettingsGroup(title: 'Form & Bewegung', pt: pt, children: [ _SettingsGroup(title: 'Form & Bewegung', pt: pt, children: [
_SettingsRow( _SettingsRow(
title: 'Eckenrundung', title: 'Eckenrundung',
@@ -185,3 +274,51 @@ class _AppearanceSection extends StatelessWidget {
); );
} }
} }
/// Wie [_SettingsRow], aber auf schmalen Bildschirmen steht das Bedienelement
/// UNTER dem Text statt daneben (sonst quetschen sich Schalter mit drei
/// Optionen auf dem Handy).
class _AdaptiveSettingsRow extends StatelessWidget {
final String title;
final String? desc;
final PyramidTheme pt;
final Widget control;
const _AdaptiveSettingsRow({
required this.title,
required this.pt,
required this.control,
this.desc,
});
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, constraints) {
if (constraints.maxWidth >= 520) {
return _SettingsRow(title: title, desc: desc, pt: pt, control: control);
}
// Volle Breite: _SettingsGroup zentriert seine Kinder sonst.
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: TextStyle(
color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500)),
if (desc != null) ...[
const SizedBox(height: 2),
Text(desc!, style: TextStyle(color: pt.fgDim, fontSize: 12)),
],
const SizedBox(height: 10),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: control,
),
],
),
);
});
}
}
+1 -1
View File
@@ -218,7 +218,7 @@ class _LogoutNavButtonState extends ConsumerState<_LogoutNavButton> {
try { try {
widget.onClose(); widget.onClose();
unawaited(AuthLog.write('User-initiated logout (Settings-Sidebar)')); unawaited(AuthLog.write('User-initiated logout (Settings-Sidebar)'));
await client.logout(); await logoutWithoutStraySync(client);
} catch (_) {} } catch (_) {}
} }
+16
View File
@@ -270,6 +270,22 @@ class _VoiceSectionState extends ConsumerState<_VoiceSection> {
]), ]),
const SizedBox(height: 16), const SizedBox(height: 16),
_SettingsGroup(title: 'Pegel', pt: pt, children: [ _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(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Column( child: Column(
+32 -16
View File
@@ -21,12 +21,14 @@ import 'package:pretty_qr_code/pretty_qr_code.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:pointycastle/export.dart' as pc; import 'package:pointycastle/export.dart' as pc;
import 'package:pyramid/core/app_state.dart'; import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/auth_log.dart'; import 'package:pyramid/core/auth_log.dart';
import 'package:pyramid/core/e2ee_diagnostics.dart'; import 'package:pyramid/core/e2ee_diagnostics.dart';
import 'package:pyramid/features/auth/bootstrap_dialog.dart' show isKeyBackupMissing; import 'package:pyramid/features/auth/bootstrap_dialog.dart' show isKeyBackupMissing;
import 'package:pyramid/features/call_signaling/call_signaling_service.dart'; import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
import 'package:pyramid/features/voice_channel/voice_channel_service.dart'; import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
import 'package:pyramid/core/matrix_client.dart'; import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/session_logout.dart';
import 'package:pyramid/core/settings_prefs.dart'; import 'package:pyramid/core/settings_prefs.dart';
import 'package:pyramid/core/theme.dart'; import 'package:pyramid/core/theme.dart';
import 'package:pyramid/core/update_checker.dart'; import 'package:pyramid/core/update_checker.dart';
@@ -34,6 +36,7 @@ import 'package:pyramid/widgets/update_download_dialog.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:pyramid/widgets/banner_crop_dialog.dart'; import 'package:pyramid/widgets/banner_crop_dialog.dart';
import 'package:pyramid/widgets/hover_region.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/mxc_image.dart';
import 'package:pyramid/widgets/pyramid_loader.dart'; import 'package:pyramid/widgets/pyramid_loader.dart';
@@ -85,7 +88,7 @@ class _SettingsModalState extends ConsumerState<SettingsModal> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final pt = PyramidTheme.of(context); final pt = PyramidTheme.of(context);
final isDark = ref.watch(themeModeProvider); final isDark = ref.watch(themeVariantProvider).isDark;
final accentIdx = ref.watch(accentProvider); final accentIdx = ref.watch(accentProvider);
final radius = ref.watch(radiusProvider); final radius = ref.watch(radiusProvider);
final density = ref.watch(densityProvider); final density = ref.watch(densityProvider);
@@ -101,24 +104,30 @@ class _SettingsModalState extends ConsumerState<SettingsModal> {
child: Center( child: Center(
child: GestureDetector( child: GestureDetector(
onTap: () {}, onTap: () {},
// Handy: ganze Seite (wie System-Einstellungen) statt schwebender
// Karte mit abgedunkeltem Rand – mehr Platz, weniger Gequetsche.
child: Container( child: Container(
constraints: const BoxConstraints(maxWidth: 820, maxHeight: 660), constraints: isMobile
width: isMobile ? size.width * 0.97 : 740, ? null
height: isMobile ? size.height * 0.92 : 560, : const BoxConstraints(maxWidth: 820, maxHeight: 660),
width: isMobile ? size.width : 740,
height: isMobile ? size.height : 560,
decoration: BoxDecoration( decoration: BoxDecoration(
color: pt.bg1, color: pt.bg1,
borderRadius: BorderRadius.circular(pt.rXl), borderRadius: BorderRadius.circular(isMobile ? 0 : pt.rXl),
border: Border.all(color: pt.border), border: isMobile ? null : Border.all(color: pt.border),
boxShadow: [ boxShadow: isMobile
BoxShadow( ? null
color: Colors.black.withAlpha(130), : [
blurRadius: 40, BoxShadow(
offset: const Offset(0, 12), color: Colors.black.withAlpha(130),
), blurRadius: 40,
], offset: const Offset(0, 12),
),
],
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(pt.rXl), borderRadius: BorderRadius.circular(isMobile ? 0 : pt.rXl),
child: isMobile child: isMobile
? _mobileLayout(pt, isDark, accentIdx, radius, density, motion) ? _mobileLayout(pt, isDark, accentIdx, radius, density, motion)
: _desktopLayout(pt, isDark, accentIdx, radius, density, motion), : _desktopLayout(pt, isDark, accentIdx, radius, density, motion),
@@ -242,10 +251,15 @@ class _SettingsModalState extends ConsumerState<SettingsModal> {
Widget _buildContent(PyramidTheme pt, bool isDark, int accentIdx, Widget _buildContent(PyramidTheme pt, bool isDark, int accentIdx,
double radius, double density, double motion, String section) { double radius, double density, double motion, String section) {
final variant = ref.watch(themeVariantProvider);
final textScale = ref.watch(textScaleProvider);
final messageStyle = ref.watch(messageStyleProvider);
return switch (section) { return switch (section) {
'appearance' => _AppearanceSection( 'appearance' => _AppearanceSection(
pt: pt, pt: pt,
isDark: isDark, variant: variant,
textScale: textScale,
messageStyle: messageStyle,
accentIdx: accentIdx, accentIdx: accentIdx,
radius: radius, radius: radius,
density: density, density: density,
@@ -261,7 +275,9 @@ class _SettingsModalState extends ConsumerState<SettingsModal> {
'about' => _AboutSection(pt: pt), 'about' => _AboutSection(pt: pt),
_ => _AppearanceSection( _ => _AppearanceSection(
pt: pt, pt: pt,
isDark: isDark, variant: variant,
textScale: textScale,
messageStyle: messageStyle,
accentIdx: accentIdx, accentIdx: accentIdx,
radius: radius, radius: radius,
density: density, density: density,
+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,
),
);
}
}
+44 -5
View File
@@ -202,7 +202,7 @@ packages:
source: hosted source: hosted
version: "1.0.2" version: "1.0.2"
dart_jsonwebtoken: dart_jsonwebtoken:
dependency: "direct main" dependency: transitive
description: description:
name: dart_jsonwebtoken name: dart_jsonwebtoken
sha256: ad84e60181696513d04d5f2078e0bbc20365b911f46f647797317414bdc88fbe sha256: ad84e60181696513d04d5f2078e0bbc20365b911f46f647797317414bdc88fbe
@@ -350,6 +350,11 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_driver:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_launcher_icons: flutter_launcher_icons:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -395,14 +400,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_markdown: flutter_markdown_plus:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_markdown name: flutter_markdown_plus
sha256: "08fb8315236099ff8e90cb87bb2b935e0a724a3af1623000a9cec930468e0f27" sha256: fce641d6c2106cc495de1cd603f97a4f9d615a97a64011928ded380dbdade935
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.7+1" version: "1.0.12"
flutter_plugin_android_lifecycle: flutter_plugin_android_lifecycle:
dependency: transitive dependency: transitive
description: description:
@@ -517,6 +522,11 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.0" version: "4.0.0"
fuchsia_remote_debug_protocol:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
glob: glob:
dependency: transitive dependency: transitive
description: description:
@@ -581,6 +591,11 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.8.0" version: "4.8.0"
integration_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
intl: intl:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1005,6 +1020,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.6.0" version: "3.6.0"
process:
dependency: transitive
description:
name: process
sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891"
url: "https://pub.dev"
source: hosted
version: "5.0.6"
protobuf: protobuf:
dependency: transitive dependency: transitive
description: description:
@@ -1274,6 +1297,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" version: "1.4.1"
sync_http:
dependency: transitive
description:
name: sync_http
sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961"
url: "https://pub.dev"
source: hosted
version: "0.3.1"
synchronized: synchronized:
dependency: transitive dependency: transitive
description: description:
@@ -1515,6 +1546,14 @@ packages:
url: "https://github.com/google/webcrypto.dart.git" url: "https://github.com/google/webcrypto.dart.git"
source: git source: git
version: "0.6.0" version: "0.6.0"
webdriver:
dependency: transitive
description:
name: webdriver
sha256: "28b82ec894fed45dd71c23ba62d1af973ed97dd59a4f5790a4d38b0b13e5657e"
url: "https://pub.dev"
source: hosted
version: "3.2.0"
webrtc_interface: webrtc_interface:
dependency: "direct main" dependency: "direct main"
description: description:
+5 -3
View File
@@ -1,7 +1,7 @@
name: pyramid name: pyramid
description: A flexible Matrix client with resizable panel layout. description: A flexible Matrix client with resizable panel layout.
publish_to: none publish_to: none
version: 0.1.36+37 version: 0.1.40+41
environment: environment:
sdk: ">=3.11.1 <4.0.0" sdk: ">=3.11.1 <4.0.0"
@@ -26,7 +26,6 @@ dependencies:
livekit_client: ^2.4.0 livekit_client: ^2.4.0
flutter_webrtc: ^1.4.1 flutter_webrtc: ^1.4.1
webrtc_interface: ^1.5.1 webrtc_interface: ^1.5.1
dart_jsonwebtoken: ^3.3.1
# State management & routing # State management & routing
flutter_riverpod: ^2.6.1 flutter_riverpod: ^2.6.1
@@ -68,7 +67,7 @@ dependencies:
# Document viewer # Document viewer
pdfx: ^2.7.0 pdfx: ^2.7.0
archive: ^4.0.0 archive: ^4.0.0
flutter_markdown: ^0.7.7 flutter_markdown_plus: ^1.0.3
# Clipboard image paste (Windows FFI) & image decoding # Clipboard image paste (Windows FFI) & image decoding
ffi: ^2.1.0 ffi: ^2.1.0
@@ -82,6 +81,9 @@ dependencies:
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
# UI-Durchläufe mit der echten App (integration_test/), Teil des Flutter-SDKs.
integration_test:
sdk: flutter
flutter_lints: ^6.0.0 flutter_lints: ^6.0.0
flutter_launcher_icons: ^0.13.1 flutter_launcher_icons: ^0.13.1
# Nur für Tests: offizieller Weg, path_provider ohne echte Plattform zu # Nur für Tests: offizieller Weg, path_provider ohne echte Plattform zu
+153
View File
@@ -0,0 +1,153 @@
#Requires -Version 7.0
# Pyramid – Tests & Benchmarks mit einem Befehl
#
# pwsh scripts/test.ps1 Analyse + Unit-Tests (Standard, ~2 min)
# pwsh scripts/test.ps1 -Live + Live-Test gegen steggi-matrix.work
# (Test-Accounts pyramidtest1/2, NICHT Bernd/Uta)
# pwsh scripts/test.ps1 -Bench + Benchmarks (Datenbank/Verschlüsselung, Migration)
# pwsh scripts/test.ps1 -Ui + UI-Durchlauf im Handy-Format mit Screenshots
# (echte App, Testprofil pyramidtest1 → build/ui_screens/)
# pwsh scripts/test.ps1 -All alles zusammen
# pwsh scripts/test.ps1 -Bench -UpdateBaseline
# Benchmark-Werte als neue Baseline speichern
# (nach bewusster Änderung / auf neuer Hardware)
# -SkipAnalyze flutter analyze überspringen
#
# Details: docs/TESTS_UND_BENCHMARKS.md
param(
[switch]$Live,
[switch]$Bench,
[switch]$Ui,
[switch]$All,
[switch]$UpdateBaseline,
[switch]$SkipAnalyze
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
# Immer aus dem Projektordner arbeiten, egal von wo das Skript aufgerufen wird.
Set-Location (Split-Path $PSScriptRoot -Parent)
if ($All) { $Live = $true; $Bench = $true; $Ui = $true }
$results = [System.Collections.Generic.List[object]]::new()
function Invoke-Step([string]$Name, [scriptblock]$Action) {
Write-Host ""
Write-Host "━━ $Name" -ForegroundColor Cyan
$watch = [Diagnostics.Stopwatch]::StartNew()
$global:LASTEXITCODE = 0
& $Action
$ok = ($LASTEXITCODE -eq 0)
$results.Add([pscustomobject]@{
Schritt = $Name
Ergebnis = if ($ok) { "OK" } else { "FEHLER" }
Dauer = "{0:n0} s" -f $watch.Elapsed.TotalSeconds
})
if (-not $ok) { Write-Host "✗ $Name fehlgeschlagen (Exit-Code $LASTEXITCODE)" -ForegroundColor Red }
}
function Add-Skipped([string]$Name, [string]$Reason) {
Write-Host ""
Write-Host "━━ $Name – übersprungen: $Reason" -ForegroundColor Yellow
$results.Add([pscustomobject]@{ Schritt = $Name; Ergebnis = "ÜBERSPRUNGEN"; Dauer = "–" })
}
# ── SQLCipher-Bibliothek ─────────────────────────────────────────────────────
# Die Datenbank-Tests und Benchmarks benutzen dieselbe sqlite3.dll (SQLCipher)
# wie die App – sie entsteht beim Windows-Build. Fehlt sie, einmal bauen.
$cipherDll = Get-ChildItem "build/windows/x64/runner/*/sqlite3.dll" -ErrorAction SilentlyContinue |
Select-Object -First 1
if (-not $cipherDll) {
Invoke-Step "Windows-Build (einmalig, liefert die SQLCipher-DLL)" {
flutter build windows --debug
}
}
if (-not $SkipAnalyze) {
# Infos (z. B. der bekannte onUpdate-Hinweis) werden angezeigt, lassen den
# Lauf aber nicht scheitern – Warnungen und Fehler schon.
Invoke-Step "Statische Analyse (flutter analyze)" { flutter analyze --no-fatal-infos }
}
Invoke-Step "Unit-Tests (test/)" { flutter test }
if ($Live) {
$accounts = if ($env:PYRAMID_TEST_ACCOUNTS) { $env:PYRAMID_TEST_ACCOUNTS }
else { Join-Path $env:USERPROFILE ".pyramid-autopilot\test-accounts.txt" }
if (-not (Test-Path $accounts)) {
Add-Skipped "Live-Test (echter Homeserver)" "Zugangsdatei fehlt: $accounts"
} else {
$env:PYRAMID_LIVE_TEST = "1"
try {
Invoke-Step "Live-Test: SQLCipher mit echtem Login (pyramidtest1)" {
flutter test test/live_encryption_e2e_test.dart --concurrency=1
}
Invoke-Step "Live-Test: Logout + sofortiger Re-Login (SDK-Wettlauf)" {
flutter test test/live_logout_race_test.dart --concurrency=1
}
} finally {
Remove-Item Env:PYRAMID_LIVE_TEST -ErrorAction SilentlyContinue
}
}
}
if ($Bench) {
if ($UpdateBaseline) { $env:PYRAMID_BENCH_UPDATE_BASELINE = "1" }
try {
# concurrency=1: Benchmarks dürfen sich nicht gegenseitig die CPU klauen.
Invoke-Step "Benchmarks (benchmark/)" { flutter test benchmark --concurrency=1 }
} finally {
Remove-Item Env:PYRAMID_BENCH_UPDATE_BASELINE -ErrorAction SilentlyContinue
}
Write-Host "Benchmark-Berichte: build/benchmarks/*.json (Baseline: benchmark/baseline/)"
}
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 |
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
}
Write-Host "Screenshots: build/ui_screens/ (Testinhalte ggf. mit test/live_ui_fixture_test.dart anlegen)"
}
}
Write-Host ""
Write-Host "━━ Zusammenfassung" -ForegroundColor Cyan
$results | Format-Table -AutoSize | Out-String | Write-Host
$failed = @($results | Where-Object { $_.Ergebnis -eq "FEHLER" })
if ($failed.Count -gt 0) {
Write-Host "✗ $($failed.Count) Schritt(e) fehlgeschlagen." -ForegroundColor Red
exit 1
}
Write-Host "✓ Alles grün." -ForegroundColor Green
exit 0
+40
View File
@@ -0,0 +1,40 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pyramid/utils/date_labels.dart';
void main() {
// Mittwoch, 7. Oktober 2026, 15:00
final now = DateTime(2026, 10, 7, 15);
test('heute: Uhrzeit', () {
expect(chatListTime(DateTime(2026, 10, 7, 9, 5), now: now), '09:05');
});
test('gestern: „Gestern“ (auch kurz nach Mitternacht)', () {
expect(chatListTime(DateTime(2026, 10, 6, 23, 59), now: now), 'Gestern');
expect(
chatListTime(DateTime(2026, 10, 6, 22), now: DateTime(2026, 10, 7, 0, 5)),
'Gestern',
);
});
test('diese Woche: Wochentag', () {
expect(chatListTime(DateTime(2026, 10, 5, 12), now: now), 'Mo');
expect(chatListTime(DateTime(2026, 10, 1, 12), now: now), 'Do');
});
test('älter: Datum', () {
expect(chatListTime(DateTime(2026, 9, 30, 12), now: now), '30.09.26');
expect(chatListTime(DateTime(2025, 12, 24, 18), now: now), '24.12.25');
});
test('Zeitumstellung (25.10.2026): gestern bleibt gestern', () {
expect(
chatListTime(DateTime(2026, 10, 24, 20), now: DateTime(2026, 10, 25, 10)),
'Gestern',
);
expect(
chatListTime(DateTime(2026, 3, 28, 20), now: DateTime(2026, 3, 29, 10)),
'Gestern',
);
});
}
+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);
});
}
+4 -3
View File
@@ -12,6 +12,7 @@ import 'package:matrix/matrix.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:sqlite3/open.dart' as sqlite_open; import 'package:sqlite3/open.dart' as sqlite_open;
import 'package:pyramid/core/app_database.dart'; import 'package:pyramid/core/app_database.dart';
import 'package:pyramid/core/session_logout.dart';
/// LIVE-End-to-End-Test der SQLCipher-Verschlüsselung mit einem ECHTEN /// LIVE-End-to-End-Test der SQLCipher-Verschlüsselung mit einem ECHTEN
/// Matrix-Login gegen steggi-matrix.work (Test-Account, NICHT Bernd/Uta). /// Matrix-Login gegen steggi-matrix.work (Test-Account, NICHT Bernd/Uta).
@@ -340,7 +341,7 @@ void main() {
expect(client.userID?.toLowerCase(), acc.user1.toLowerCase()); expect(client.userID?.toLowerCase(), acc.user1.toLowerCase());
// ── Phase 4: Logout → Re-Login → alte Nachricht wieder lesbar ── // ── Phase 4: Logout → Re-Login → alte Nachricht wieder lesbar ──
await client.logout(); await logoutWithoutStraySync(client); // App-Baustein (session_logout.dart)
expect(client.isLogged(), isFalse); expect(client.isLogged(), isFalse);
await _login(client, acc, await _login(client, acc,
user: acc.user1, pass: acc.pass1, device: 'Autopilot Enc-Test 2'); user: acc.user1, pass: acc.pass1, device: 'Autopilot Enc-Test 2');
@@ -348,7 +349,7 @@ void main() {
expect(await _timelineContains(roomAfter, marker), isTrue, expect(await _timelineContains(roomAfter, marker), isTrue,
reason: 'Alte Nachricht nach Logout+Re-Login nicht mehr lesbar'); reason: 'Alte Nachricht nach Logout+Re-Login nicht mehr lesbar');
// Aufräumen: Session serverseitig beenden, DB wird dabei geleert. // Aufräumen: Session serverseitig beenden, DB wird dabei geleert.
await client.logout(); await logoutWithoutStraySync(client); // App-Baustein (session_logout.dart)
await client.dispose(closeDatabase: true); await client.dispose(closeDatabase: true);
}, },
skip: skip, skip: skip,
@@ -414,7 +415,7 @@ void main() {
expect(roomAfter, isNotNull, expect(roomAfter, isNotNull,
reason: 'Testraum nach Migration nicht mehr in der DB'); reason: 'Testraum nach Migration nicht mehr in der DB');
// Aufräumen (Session serverseitig beenden). // Aufräumen (Session serverseitig beenden).
await client.logout(); await logoutWithoutStraySync(client); // App-Baustein (session_logout.dart)
await client.dispose(closeDatabase: true); await client.dispose(closeDatabase: true);
}, },
skip: skip, skip: skip,
+182
View File
@@ -0,0 +1,182 @@
@TestOn('windows')
library;
import 'dart:ffi';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/session_logout.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:sqlite3/open.dart' as sqlite_open;
/// LIVE-Test des SDK-Logout-Wettlaufs (ROADMAP M1, gefunden 2026-10-07) mit
/// dem Test-Account pyramidtest1 – gegated wie die anderen Live-Tests
/// (`PYRAMID_LIVE_TEST=1`), läuft nie im normalen `flutter test`.
///
/// Szenario wie in der App: Abmelden und SOFORT auf demselben Client-Objekt
/// wieder anmelden, dann 45 s beobachten. Kommt die 401 des alten Long-Polls
/// verspätet zurück, löscht das SDK die NEUE Session (`clear()`).
///
/// * „Nachweis“ (nur mit zusätzlich `PYRAMID_LIVE_RACE_PROOF=1`): der alte
/// Weg (direktes `client.logout()`) – zeigt, ob der Wettlauf auftritt.
/// * Regressionstest: `logoutWithoutStraySync` – die neue Session MUSS
/// eingeloggt bleiben.
Map<String, String>? _loadAccounts() {
final override = Platform.environment['PYRAMID_TEST_ACCOUNTS'];
final profile = Platform.environment['USERPROFILE'] ?? '';
final file =
File(override ?? '$profile\\.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;
}
String? _findSqliteDll() {
for (final config in ['Debug', 'Profile', 'Release']) {
final file = File('build/windows/x64/runner/$config/sqlite3.dll');
if (file.existsSync()) return file.absolute.path;
}
return null;
}
Future<Client> _newClient(String name) async {
final dll = _findSqliteDll()!;
final factory = createDatabaseFactoryFfi(
ffiInit: () {
sqlite_open.open.overrideFor(
sqlite_open.OperatingSystem.windows,
() => DynamicLibrary.open(dll),
);
},
noIsolate: true,
);
final db = await factory.openDatabase(inMemoryDatabasePath);
final client =
Client(name, database: await MatrixSdkDatabase.init(name, database: db));
await client.init(
waitForFirstSync: false, waitUntilLoadCompletedLoaded: false);
return client;
}
Future<void> _login(Client client, Map<String, String> acc, String device) async {
await client.checkHomeserver(Uri.parse(acc['homeserver']!));
await client.login(
LoginType.mLoginPassword,
identifier: AuthenticationUserIdentifier(user: acc['user1']!),
password: acc['pass1']!,
initialDeviceDisplayName: device,
refreshToken: true,
);
}
/// Abmelden → sofort neu anmelden → [watch] lang beobachten. Liefert, ob die
/// NEUE Session dabei verloren ging.
Future<bool> _reloginLosesSession(
Map<String, String> acc,
Future<void> Function(Client) logout,
String label, {
Duration watch = const Duration(seconds: 45),
bool logoutRightAfterLogin = false,
}) async {
final client = await _newClient('PyramidRace$label');
try {
await _login(client, acc, 'Autopilot Race $label 1');
if (!logoutRightAfterLogin) {
// Warten, bis die Sync-Schleife im Long-Poll hängt (erster Sync hat
// timeout 0, danach 30-s-Long-Polls).
await client.oneShotSync().timeout(const Duration(seconds: 60));
await Future.delayed(const Duration(seconds: 3));
}
// Sonst: sofort abmelden, während die Schleife nach dem ersten (schnellen)
// Sync gerade neue Anfragen mit dem alten Token losschickt.
await logout(client);
expect(client.isLogged(), isFalse);
await _login(client, acc, 'Autopilot Race $label 2');
final newToken = client.accessToken;
var lost = false;
final sub = client.onLoginStateChanged.stream.listen((s) {
if (s == LoginState.loggedOut) lost = true;
});
final end = DateTime.now().add(watch);
while (DateTime.now().isBefore(end) && !lost) {
await Future.delayed(const Duration(seconds: 1));
}
await sub.cancel();
lost = lost || !client.isLogged() || client.accessToken != newToken;
// ignore: avoid_print
print('[$label] neue Session nach ${watch.inSeconds}s '
'${lost ? 'VERLOREN' : 'noch eingeloggt'}');
return lost;
} finally {
if (client.isLogged()) {
try {
await logoutWithoutStraySync(client);
} catch (_) {}
}
await client.dispose(closeDatabase: true);
}
}
void main() {
final acc = _loadAccounts();
final skip = Platform.environment['PYRAMID_LIVE_TEST'] != '1'
? 'Live-Test nur mit PYRAMID_LIVE_TEST=1 (echter Homeserver!)'
: acc == null
? 'Test-Account-Datei fehlt'
: _findSqliteDll() == null
? 'SQLite-DLL fehlt (erst flutter build windows)'
: null;
for (final immediately in [false, true]) {
final when = immediately ? 'direkt nach dem Login' : 'nach Leerlauf';
final tag = immediately ? 'sofort' : 'leerlauf';
test(
'Nachweis ($when): direktes logout() + sofortiger Re-Login (alter Weg)',
() async {
final lost = await _reloginLosesSession(
acc!,
(c) => c.logout(),
'alt-$tag',
logoutRightAfterLogin: immediately,
);
// Kein expect: dokumentiert nur, ob der Wettlauf auftritt.
// ignore: avoid_print
print('Wettlauf ($when) mit altem Weg: ${lost ? 'JA' : 'nein'}');
},
skip: skip ??
(Platform.environment['PYRAMID_LIVE_RACE_PROOF'] != '1'
? 'Nachweis nur mit PYRAMID_LIVE_RACE_PROOF=1'
: null),
timeout: const Timeout(Duration(minutes: 4)),
);
test(
'logoutWithoutStraySync ($when): neue Session bleibt nach sofortigem '
'Re-Login',
() async {
final lost = await _reloginLosesSession(
acc!,
logoutWithoutStraySync,
'neu-$tag',
logoutRightAfterLogin: immediately,
);
expect(lost, isFalse,
reason: 'verspätete 401 einer alten Sync-Anfrage hat die neue '
'Session gelöscht');
},
skip: skip,
timeout: const Timeout(Duration(minutes: 4)),
);
}
}
+254
View File
@@ -0,0 +1,254 @@
@TestOn('windows')
library;
import 'dart:ffi';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img;
import 'package:matrix/matrix.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:sqlite3/open.dart' as sqlite_open;
/// Setup-Helfer für UI-Prüfungen (gegated wie die anderen Live-Tests, läuft
/// NIE im normalen `flutter test`): pyramidtest2 schickt pyramidtest1 eine
/// Direktnachricht mit typischem Chat-Inhalt – langer Text,
/// Antwort, Hochkant- und Querformat-Bild. Danach zeigt die GUI-Instanz mit
/// dem Testprofil (PYRAMID_PROFILE_DIR=…\profile1) echte Inhalte, an denen
/// sich Layout, Bildgrößen und Vollansicht prüfen lassen.
///
/// $env:PYRAMID_LIVE_TEST=1; flutter test test/live_ui_fixture_test.dart
///
/// Der Raum ist unverschlüsselt (der Host-Test hat kein vodozemac). Nur die
/// Skript-Geräte melden sich am Ende ab, GUI-Sessions bleiben unberührt.
Map<String, String>? _loadAccounts() {
final override = Platform.environment['PYRAMID_TEST_ACCOUNTS'];
final profile = Platform.environment['USERPROFILE'] ?? '';
final file = File(
override ?? '$profile\\.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;
}
String? _findSqliteDll() {
for (final config in ['Debug', 'Profile', 'Release']) {
final file = File('build/windows/x64/runner/$config/sqlite3.dll');
if (file.existsSync()) return file.absolute.path;
}
return null;
}
Future<Client> _login(String name, String user, String pass, Uri hs) async {
final dll = _findSqliteDll()!;
final factory = createDatabaseFactoryFfi(
ffiInit: () {
sqlite_open.open.overrideFor(
sqlite_open.OperatingSystem.windows,
() => DynamicLibrary.open(dll),
);
},
noIsolate: true,
);
final db = await factory.openDatabase(inMemoryDatabasePath);
final client = Client(
name,
database: await MatrixSdkDatabase.init(name, database: db),
);
await client.init(
waitForFirstSync: false,
waitUntilLoadCompletedLoaded: false,
);
await client.checkHomeserver(hs);
await client.login(
LoginType.mLoginPassword,
identifier: AuthenticationUserIdentifier(user: user),
password: pass,
initialDeviceDisplayName: name,
);
return client;
}
/// Logout ohne verspätete 401 des laufenden Long-Polls (siehe
/// live_encryption_e2e_test.dart, `_logoutWithoutStraySync`).
Future<void> _logout(Client c) async {
try {
c.backgroundSync = false;
await c
.oneShotSync(timeout: Duration.zero)
.timeout(const Duration(seconds: 60));
await c.logout();
} catch (_) {}
}
/// Einfaches, gut erkennbares Testbild (Farbfläche + Kreis + Rahmen).
Uint8List _testImage(int w, int h, img.Color bg) {
final image = img.Image(width: w, height: h);
img.fill(image, color: bg);
img.fillCircle(
image,
x: w ~/ 2,
y: h ~/ 2,
radius: (w < h ? w : h) ~/ 4,
color: img.ColorRgb8(245, 166, 20),
);
img.drawRect(
image,
x1: 0,
y1: 0,
x2: w - 1,
y2: h - 1,
color: img.ColorRgb8(255, 255, 255),
thickness: 6,
);
return Uint8List.fromList(img.encodeJpg(image, quality: 85));
}
void main() {
final acc = _loadAccounts();
final skip = Platform.environment['PYRAMID_LIVE_TEST'] != '1'
? 'Live-Setup nur mit PYRAMID_LIVE_TEST=1'
: acc == null
? 'Test-Account-Datei fehlt'
: _findSqliteDll() == null
? 'SQLite-DLL fehlt (erst flutter build windows)'
: null;
test(
'UI-Testinhalte: DM pyramidtest2 → pyramidtest1 mit Text und Bildern',
() async {
final hs = Uri.parse(acc!['homeserver']!);
final c1 = await _login(
'AutopilotUiFixture1',
acc['user1']!,
acc['pass1']!,
hs,
);
final c2 = await _login(
'AutopilotUiFixture2',
acc['user2']!,
acc['pass2']!,
hs,
);
try {
await c1.oneShotSync().timeout(const Duration(seconds: 60));
await c2.oneShotSync().timeout(const Duration(seconds: 60));
final roomId = await c2.startDirectChat(
c1.userID!,
enableEncryption: false,
preset: CreateRoomPreset.privateChat,
);
final room = c2.getRoomById(roomId)!;
// user1 nimmt die Einladung an (falls noch nicht drin) – über
// room.join(), damit sie wie in der App als Direktnachricht zählt.
await c1.oneShotSync().timeout(const Duration(seconds: 60));
var r1 = c1.getRoomById(roomId);
if (r1 == null || r1.membership != Membership.join) {
if (r1 != null && r1.membership == Membership.invite) {
await r1.join();
} else {
await c1.joinRoom(roomId);
}
await c1
.waitForRoomInSync(roomId, join: true)
.timeout(const Duration(seconds: 60));
r1 = c1.getRoomById(roomId);
}
// „Chats“ in Pyramid zeigt nur Direktnachrichten (m.direct).
if (r1 != null && !r1.isDirectChat) {
await r1.addToDirectChat(c2.userID!);
}
final first = await room.sendTextEvent(
'Hallo! Das ist ein UI-Test mit einer etwas längeren Nachricht, damit '
'man sieht, wie Zeilenumbrüche, Schriftgröße und Abstände auf dem '
'Handy wirken. Umlaute: äöü ÄÖÜ ß.',
);
expect(first, isNotNull);
await c2.oneShotSync().timeout(const Duration(seconds: 30));
final firstEvent = await room.getEventById(first!);
await room.sendTextEvent(
'Kurze Antwort auf die erste Nachricht',
inReplyTo: firstEvent,
);
await room.sendFileEvent(
MatrixImageFile(
bytes: _testImage(720, 1280, img.ColorRgb8(59, 109, 17)),
name: 'hochkant.jpg',
mimeType: 'image/jpeg',
width: 720,
height: 1280,
),
);
await room.sendFileEvent(
MatrixImageFile(
bytes: _testImage(1280, 720, img.ColorRgb8(24, 95, 165)),
name: 'querformat.jpg',
mimeType: 'image/jpeg',
width: 1280,
height: 720,
),
);
await room.sendTextEvent('Und noch eine letzte Nachricht 👋');
// Eigene Nachrichten (aus Sicht von pyramidtest1 = rechts).
final myDm = c1.getRoomById(roomId)!;
await myDm.sendTextEvent('Danke, sieht gut aus!');
await myDm.sendTextEvent(
'Und eine etwas längere eigene Antwort, damit man sieht, wie '
'Sprechblasen umbrechen, wenn der Text über mehrere Zeilen geht.',
);
// Gruppenraum (Name + Avatar je Absender in der Sprechblasen-Ansicht).
const groupName = 'UI-Test Gruppe';
Room? group;
for (final r in c2.rooms) {
if (r.membership == Membership.join && r.name == groupName) {
group = r;
}
}
if (group == null) {
final groupId = await c2.createRoom(
name: groupName,
preset: CreateRoomPreset.privateChat,
invite: [c1.userID!],
);
await c2
.waitForRoomInSync(groupId, join: true)
.timeout(const Duration(seconds: 60));
group = c2.getRoomById(groupId)!;
await c1.oneShotSync().timeout(const Duration(seconds: 60));
await c1.getRoomById(groupId)?.join();
await c1
.waitForRoomInSync(groupId, join: true)
.timeout(const Duration(seconds: 60));
}
await group.sendTextEvent('Hallo zusammen! Wer ist am Samstag dabei?');
await group.sendTextEvent('Ich bringe Kuchen mit.');
await c1.getRoomById(group.id)!.sendTextEvent('Ich komme gern!');
// ignore: avoid_print
print('UI-Testraum bereit: $roomId, Gruppe: ${group.id}');
} finally {
await _logout(c1);
await _logout(c2);
await c1.dispose(closeDatabase: false);
await c2.dispose(closeDatabase: false);
}
},
skip: skip,
timeout: const Timeout(Duration(minutes: 5)),
);
}

Some files were not shown because too many files have changed in this diff Show More