Compare commits

...
110 Commits
Author SHA1 Message Date
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
Bernd Steckmeister b2413ef627 chore: Version 0.1.36+37 (Beta-Release) 2026-07-07 06:22:30 +02:00
Bernd Steckmeister a4f9fd70e6 chore: Anzeigename gross geschrieben -> 'Pyramid' (Launcher-Label, Fenstertitel, Windows-Produktname); Paket-ID/Binaername unveraendert 2026-07-07 06:22:29 +02:00
Bernd Steckmeister bf552ee194 feat: Windows-Audiogeraete-Erkennung reparieren - Dummy-PeerConnection weckt das Audio-Device-Modul (M3) 2026-07-07 06:22:28 +02:00
Bernd SteckmeisterandClaude Fable 5 e0179ad907 chore: auth_log-Check 23:12-03:06 - Mehr-Stunden-Fenster erfuellt, Langzeit-Punkt abgehakt
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-07 03:07:40 +02:00
Bernd SteckmeisterandClaude Fable 5 8b36d16cdd chore: auth_log-Check 18:29-22:49 + 22:49-23:12 im PC_TESTPLAN dokumentiert
Dritter Netzausfall (18:41-18:46, ~4,5 min) selbst erholt ohne Logout;
8. Start mit verschluesselter DB sauber, Beobachtung laeuft weiter.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 23:13:40 +02:00
Bernd SteckmeisterandClaude Fable 5 f8f5332d90 feat: Call-Robustheit + Standalone-Voice-Channel per Klick beitretbar (M3)
Mit zwei echten Test-Accounts (zwei GUI-Instanzen via PYRAMID_PROFILE_DIR)
durchgeklickt: Beitreten, Teilnehmerliste, bidirektionaler Screenshare,
Live-Qualitaetswechsel (Full HD 60fps), sauberes Verlassen.

- rooms_list.dart: Voice-Channel in der normalen Raumliste war per Klick
  nicht beitretbar (oeffnete nur Chat) -> _toggleVoiceChannel wie Space-Liste.
- livekit_call_manager.dart: _callGeneration entwertet laufenden connect
  bei Auflegen/Wechsel (keine Geisterverbindung mit offenem Mikro);
  Fehlerpfade setzen error sichtbar statt still zu verschwinden.
- rooms_panel_core.dart: raeumt activeVoiceRoomIdProvider auf, wenn der
  Voice-Service inaktiv wird, + Fehler-SnackBar.
- test/live_call_setup_test.dart: gegateter Setup-Helfer fuer den Testraum.

UNGETESTET: echtes 2-Peer-Audio, Netz-Abbruch mitten im Call.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 22:50:58 +02:00
Bernd SteckmeisterandClaude Fable 5 ffd70caffd feat: 60-FPS-Streaming-Presets (hd60/fhd60) fuer Bildschirm-Streams (M4)
Nach docs/STREAMING_60FPS.md: neue Presets 720p/1080p mit 60 FPS und
hoher Bitrate (5/8 Mbit/s), Preset-Aufloesung+FPS werden jetzt bis in
die Capture-Constraints durchgereicht (vorher blieb der LiveKit-Default
1080p/15 aktiv), 60-FPS-Presets publishen ohne Simulcast mit
DegradationPreference.maintainFramerate (unter Last Aufloesung statt
Frames opfern). contentHint 'motion' ist in flutter_webrtc 1.4.1 aus
Dart nicht setzbar (geprueft) - im Code dokumentiert.
4 neue Vertragstests fuer die Preset-Tabellen.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 22:19:25 +02:00
Bernd SteckmeisterandClaude Fable 5 9f797279eb test: Live-E2E-Verifikation SQLCipher mit echtem Login (Test-Account)
Neuer gegateter Test (PYRAMID_LIVE_TEST=1, Windows): echter Login als
pyramidtest1, Byte-Pruefung der Platte (kein SQLite-Header, Token +
Nachricht nicht im Klartext, Positiv-Kontrolle in Klartext-DB),
Neustart aus verschluesselter DB, Logout/Re-Login, Migration einer
Klartext-DB mit echter eingeloggter Session. Zugangsdaten bleiben
ausserhalb des Repos.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 22:11:48 +02:00
Bernd SteckmeisterandClaude Fable 5 dba4b64df8 feat: PYRAMID_PROFILE_DIR-Override fuer Desktop-Testprofile (app_dirs.dart)
DB, auth_log und Media-Cache laufen jetzt ueber appSupportDir(); nur auf
Desktop und nur wenn PYRAMID_PROFILE_DIR gesetzt ist, landet alles in einem
eigenen Profilverzeichnis - fuer Autopilot-Testlogins parallel zur echten
Session. Ohne Variable exakt das bisherige Verhalten. secure_storage/
shared_preferences bewusst nicht umgeleitet (Begruendung im Dateikopf).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 18:55:30 +02:00
Bernd Steckmeister fdc16afa70 docs: Streaming-60fps-Task fuer Autopilot (M4) - hohe Aufloesung + 60 FPS, Ist-Analyse + Umsetzungsschritte 2026-07-06 18:51:59 +02:00
Bernd SteckmeisterandClaude Fable 5 9950e1eb29 chore: CHANGES-Hook-Log nachgezogen
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 18:29:49 +02:00
Bernd SteckmeisterandClaude Fable 5 17b3798dc0 docs: zweiter Netz-Blip 18:25 ebenfalls ohne Logout ueberstanden; Frage an Bernd (Test-Account fuer Emulator-Tests) notiert
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 18:29:36 +02:00
Bernd SteckmeisterandClaude Fable 5 f2b9afbfea test: SQLCipher auf Android-Emulator verifiziert (Neuanlage verschluesselt, Klartext-Migration, Neustart ohne Re-Migration)
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 18:28:25 +02:00
Bernd SteckmeisterandClaude Fable 5 9693174de9 docs: auth_log-Check 17:43-Fenster - echter 7-min-Netzausfall ohne Logout ueberstanden, Sync selbst erholt
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 18:20:24 +02:00
Bernd SteckmeisterandClaude Fable 5 bca7abb206 docs: Session-Abschluss protokolliert (Analyse-Korrektur, VoicePrefs-Tests, 32 Tests im Testplan, Beobachtung laeuft seit 17:43)
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 17:55:03 +02:00
Bernd SteckmeisterandClaude Fable 5 3698048b26 test: VoicePrefs-Fassade abgesichert - historische Keys, null/leer-Loeschen, Fallback 1.0 (3 neue Tests, gesamt 32)
Nagelt den dokumentierten Vertrag der M2-Fassade (d22f9c2) fest, damit
bestehende Nutzer-Einstellungen (Utas Geraet) bei weiteren Umbauten der
Call-Schicht nachweislich erhalten bleiben.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 17:54:06 +02:00
Bernd SteckmeisterandClaude Fable 5 3300a44c4a docs: State-Management-Doc korrigiert - MediaCache-Umstellung ist NICHT risikoarm (Chat-Timeline haengt dran), zurueckgestellt
Der erste Befund beruhte auf einem durch head-limit abgeschnittenen grep;
message_media.dart (am wenigsten geprueft laut PC_TESTPLAN 3) nutzt den
Cache ebenfalls, inkl. statischem Wrapper ohne ref-Zugang.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 17:52:09 +02:00
Bernd SteckmeisterandClaude Fable 5 6b0ae21f2b docs: State-Management-Ist-Analyse + Umbauplan (M2), ROADMAP-Punkt annotiert
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 17:50:05 +02:00
Bernd SteckmeisterandClaude Fable 5 41bcaf2a88 chore: toten incomingCallProvider aus app_state.dart entfernt (seit M2-Call-Pilot unreferenziert)
Repo-weit verifiziert: einzige Fundstelle war die Definition selbst -
der Pilot hat die Call-Provider ersatzlos in die Module verlegt
(callSignalingProvider/voiceChannelProvider), dieser StateProvider
blieb ungenutzt zurueck. analyze sauber, 29 Tests gruen.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 17:48:16 +02:00
Bernd SteckmeisterandClaude Fable 5 52b0ae975a docs: settings_encryption-Split + auth_log-Check 17:14-Fenster protokolliert, Beobachtung auf 17:43 umgestellt
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 17:45:36 +02:00
Bernd SteckmeisterandClaude Fable 5 6128953e28 refactor: settings_encryption.dart (1470 Zeilen) in 4 thematische part-Dateien unter encryption/ geteilt (M2, zeichenidentisch verifiziert)
Vorbereitung fuer Schnitt-Doc Schritt 3 (verification-Modul), ohne den
Verschluesselungs-heiligen Code anzufassen: megolm_crypto (Export/Import-
Krypto-Helfer), encryption_section (Enums + _EncryptionSection),
recovery_key_dialogs (_ChangeRecoveryKeyDialog/_ShowKeyBody/_ConfirmKeyBody),
fingerprint_passphrase (_FingerprintGroup/_PassphraseDialog).
Rekonstruktion zeichenidentisch (1468 Code-Zeilen, ordinaler Vergleich),
analyze sauber, 29 Tests gruen, App-Start ok (auth_log 17:43:14 loggedIn).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 17:43:53 +02:00
Bernd Steckmeister c8cc1480a8 docs: PC_TESTPLAN - Beobachtungsfenster auf 17:14 aktualisiert (Neustarts durch Refactoring-Praxistests) 2026-07-06 17:16:20 +02:00
Bernd Steckmeister 643ba662dd docs: voice_channel-Split protokolliert, ROADMAP-Nachtrag (app_shell bleibt bewusst ungeteilt) 2026-07-06 17:15:46 +02:00
Bernd Steckmeister 95d9b07dbd refactor: call_ui/voice_channel.dart (1123 Zeilen) in 5 thematische part-Dateien unter view/ geteilt (M2, zeichenidentisch verifiziert) 2026-07-06 17:15:12 +02:00
Bernd Steckmeister b131f56b72 docs: M2-Teilschritte call_ui-Modul + VoicePrefs-Fassade protokolliert, ROADMAP/Schnitt-Doc nachgezogen 2026-07-06 17:10:37 +02:00
Bernd Steckmeister d22f9c2961 refactor: voice_*-Prefs hinter VoicePrefs-Fassade in settings_prefs (M2 storage-Leitplanke, gleiche Keys/Semantik) 2026-07-06 17:09:41 +02:00
Bernd Steckmeister b2335da2fb refactor: Call-UI als Modul lib/features/call_ui (M2, Pilot-Schritt vervollstaendigt - reine Umbenennung) 2026-07-06 17:09:40 +02:00
Bernd Steckmeister b84d31ca87 docs: auth_log-Langzeit-Check - 12:43-Lauf endete frueh (kein Logout), Beobachtung 16:57 neu gestartet 2026-07-06 16:59:34 +02:00
Bernd Steckmeister f87db183ea docs: Haertetest-Stand in PC_TESTPLAN/ROADMAP/PROGRESS - automatisierbarer Teil erledigt
- flutter test gruen (29), Session ueber 3 Starts + DB-Migration stabil,
  E2EE-Empfang belegt, auth_log unauffaellig
- Langzeit-Beobachtung laeuft (App entkoppelt gestartet 12:43); Klick-Teile
  (Senden, Logout/Re-Login, Splits 2-8, Voice-Join) fuer Bernd abgegrenzt
2026-07-06 12:45:20 +02:00
Bernd Steckmeister 3c7eb9ddca refactor: M2-Call-Pilot – Module call_signaling + voice_channel mit Fassaden (Entscheidung 2)
- CallSignalingService/callSignalingProvider und VoiceChannelService/
  voiceChannelProvider als einzige oeffentliche Schnittstellen
- voip_manager.dart / livekit_call_manager.dart in ihre Module verschoben,
  Logik unangetastet (nur Klassen-Koepfe, @override, Imports, Provider-Namen)
- Alle Konsumenten entkoppelt; Kompositions-Punkt matrix_client.dart als
  dokumentierte Ausnahme. analyze sauber, 29 Tests gruen, Windows-Start ok.
  Call-Klick-/Geraetetest steht aus (PROGRESS.md)
2026-07-06 12:42:59 +02:00
Bernd Steckmeister f64398d3c6 feat: SQLCipher-Verschluesselung der pyramid.sqlite (M1) via neue Storage-Fassade app_database.dart
- AppDatabase.open()/openForPush() kapseln Factory, Schluessel (Keystore),
  Klartext->SQLCipher-Migration (Backup/Verify/atomarer Tausch) und PRAGMAs
- Android/Windows/Linux verschluesselt, iOS/macOS bewusst Klartext
- 5 neue Tests mit echter SQLCipher-DLL; Windows-Praxistest mit echter DB
  erfolgreich (23 Tabellen verifiziert, E2EE intakt); Android UNGETESTET
2026-07-06 12:31:38 +02:00
Bernd SteckmeisterandClaude Fable 5 cead62ccb2 fix: Autopilot fest auf nordmann-Konto (Bernd 2026-07-06) – Review (aa): private E-Mail vor Publikation entfernt, *.bak-* gitignored
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-06 11:58:32 +02:00
Bernd SteckmeisterandClaude Fable 5 4d16d49012 docs: Fable-5-Review (z) – (y)-Protokoll geprüft (kein Befund), Ende-Kriterium der Review-Kette geschärft
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-05 19:03:56 +02:00
Bernd SteckmeisterandClaude Fable 5 725f03abb0 docs: Fable-5-Review (y) – (x)-Hinterlassenschaft geprüft/nachgeholt, kein Pi-bearbeitbarer ROADMAP-Punkt offen
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-05 18:41:20 +02:00
Bernd SteckmeisterandClaude Fable 5 15001ae80a docs: Fable-5-Review (x) – Protokoll-Commit der abgebrochenen Vorsession nachgeholt
Der (x)-Eintrag (Review von 66bf54b, kein Befund) lag fertig geschrieben,
aber uncommitted im Arbeitsbaum – die Session wurde vor ihrem
Protokoll-Commit abgebrochen. Inhalt in dieser Session gegengeprüft
(66bf54b-Diff, keine lib/-Änderungen seit 925aafb, A/B-Frage überall
geschlossen) und unverändert übernommen.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-05 18:40:06 +02:00
Bernd Steckmeister 1ef32c0022 chore: CHANGES.md (auto-Hook) 2026-07-05 14:54:38 +02:00
Bernd SteckmeisterandClaude Fable 5 66bf54bdff docs: Dashboard bleibt Heimnetz-only (Bernd Nr. 3) – ROADMAP-Punkt geschlossen, Doku nachgezogen, Nr.-4-Vermerk am Secrets-Punkt
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-05 14:53:27 +02:00
Bernd SteckmeisterandClaude Fable 5 81ec06398f docs: Fable-5-Review (v)+(w) – PC-Commits 825481e/4477ed9 geprüft, Fix korrekt, 1 Doku-Lücke (Entscheidung 3 nicht nachgezogen)
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-05 14:52:09 +02:00
Bernd SteckmeisterandClaude Fable 5 4477ed9101 fix: Hauptkonto-Profil ohne CLAUDE_CONFIG_DIR ansprechen (Config-Pfad-Falle)
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CPrAGBxBT6GfPXzeWQ4AXb
2026-07-05 14:47:35 +02:00
Bernd Steckmeister 825481e6fa docs: Bernds Richtungsfragen beantwortet (ANTWORTEN_BERND.md) - SQLCipher vor M2, Call-Pilot zuerst, Dashboard Heimnetz-only, History unangetastet 2026-07-05 07:41:36 +02:00
Bernd SteckmeisterandClaude Fable 5 db4d001ef1 chore: CHANGES.md (auto-Hook)
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 20:06:53 +02:00
Bernd SteckmeisterandClaude Fable 5 af7c538e18 docs: Fable-5-Review (u) – (t)-Fix verifiziert, server.py-Review-Kette konvergiert
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 19:44:55 +02:00
Bernd SteckmeisterandClaude Fable 5 4c6efa2a7a chore: CHANGES.md (auto-Hook)
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 19:22:29 +02:00
Bernd SteckmeisterandClaude Fable 5 d3e75c36a1 security: server.py Restbefund aus Review (t) – registration-status antwortet generisch statt str(e)
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 19:21:19 +02:00
Bernd SteckmeisterandClaude Fable 5 326cc598c7 chore: CHANGES.md (auto-Hook)
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 18:57:29 +02:00
Bernd SteckmeisterandClaude Fable 5 13b333cbec security: server.py Restbefunde aus Review (s) – ban/unban-Body vor ADMIN_LOCK, generische 500er auf oeffentlichen Routen
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 18:56:42 +02:00
Bernd SteckmeisterandClaude Fable 5 4d4044b888 chore: CHANGES.md (auto-Hook)
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 14:48:07 +02:00
Bernd SteckmeisterandClaude Fable 5 5ada0e5038 security: server.py Restbefunde aus Review (r) – GET-Leser-Lock, Content-Length-Haertung, secrets-Token
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 14:46:27 +02:00
Bernd SteckmeisterandClaude Fable 5 c8ff850c98 security: server.py Admin-Routen serialisieren (ADMIN_LOCK, Review-Befund q)
Threading-Umstellung aus (p) hatte eine Nebenlaeufigkeits-Race in den
Admin-Routen erzeugt: conduit.toml Read-Modify-Write + docker restart und
der send_admin-Readback-nach-sleep waren seit multithreaded race-faehig.
ADMIN_LOCK serialisiert NUR die Admin-Mutationen; die oeffentlichen Routen
(livekit-token, e2ee-diagnostics) laufen zuerst und bleiben nebenlaeufig,
der Slow-Loris-Schutz aus (p) bleibt erhalten. Headless verifiziert.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 14:20:42 +02:00
Bernd SteckmeisterandClaude Fable 5 d42beaf7ab chore: CHANGES.md (auto-Hook)
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 13:52:07 +02:00
Bernd SteckmeisterandClaude Fable 5 343545b59e security: server.py multithreaded + Diag-Log-Lock (Review-Befund p, Threading-Haertung vollendet)
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 13:51:32 +02:00
Bernd SteckmeisterandClaude Fable 5 455742d1bf security: Dashboard-Admin-Routen per Interims-Gate auf Heimnetz begrenzt (Befund o)
server.py (auf dem Pi, nicht im Repo): /api/ban, /api/unban,
/api/toggle-registration, /api/create-invite, /api/run-stats nur noch fuer
Heimnetz-/localhost-Socket-IPs ohne Cloudflare-Header; sonst 403 mit
Erklaerung. Headless verifiziert (9 Pruefungen). Bernds A/B-Entscheidung
fuer den Fernzugriff bleibt offen (docs/DASHBOARD_AUTH_HARDENING.md).
Zusatzbefund: Repo privat schalten wuerde In-App-Updater brechen (ROADMAP).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 09:17:56 +02:00
Bernd SteckmeisterandClaude Fable 5 e97a748b35 security: KRITISCH – Dashboard-Admin-Endpoints ohne Auth (Review-Befund o)
Fable-5-Review (n)+(o) beim Gegenlesen von server.py auf dem Pi:

(o) KRITISCH: /api/ban, /api/unban, /api/toggle-registration, /api/create-invite,
/api/run-stats sind über https://dashboard.steggi-matrix.work oeffentlich
erreichbar und pruefen KEINEN Token (per curl belegt: App-eigene 400-Antwort
ueber die oeffentliche URL). Fremde koennten Uta sperren oder offene
Registrierung aktivieren; Hostname ist ueber CT-Logs auffindbar. Nicht blind
gefixt (jede Auth-Ergaenzung legt Bernds Dashboard bis zur Token-Eingabe lahm
-> kein Aussperren). Fertiger Plan mit zwei Wegen (Cloudflare Access / DASH_TOKEN)
in docs/DASHBOARD_AUTH_HARDENING.md, ROADMAP M0 dringend, wartet auf Bernds Wahl.

(n) LiveKit-Token-Route POST /api/livekit-token wurde von einer abgebrochenen
Session bereits live in server.py gebaut (unprotokolliert). Geprueft + headless
verifiziert (401/400/413/200, JWT-Signatur unabhaengig gegen livekit.yaml
gueltig, Identitaet = gepruefte user_id). Server-Seite des M0-LiveKit-Punkts
erledigt; Client-Umstellung + Rotation bleiben PC/Geraet (heiliger Call-Pfad).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 08:43:42 +02:00
Bernd SteckmeisterandClaude Fable 5 ba97f942d6 security: Diagnose-Endpoint gegen Disk-Fill haerten (Review-Befund l)
Fable-5-Review (l)+(m): /api/e2ee-diagnostics auf dem Pi las den Body ohne
Groessenlimit und haengte ihn unbegrenzt ans Log – bei oeffentlich bekanntem
DIAG_TOKEN ein Disk-Fill-DoS-Risiko. server.py: 256-KB-Body-Limit (413) +
20-MB-Log-Kappung, headless verifiziert (200/403/413). LiveKit-Plan (m)
gegen Code + Pi-Konfig gegengeprueft, stimmt.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 04:32:38 +02:00
Bernd SteckmeisterandClaude Fable 5 88af6c016c chore: CHANGES.md (auto-Hook)
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 04:07:39 +02:00
Bernd SteckmeisterandClaude Fable 5 78e4174658 docs: LiveKit-Token-Minting-Migrationsplan (apiSecret raus aus Client)
Fertiger Umsetzungsplan fuer den offenen M0-Punkt: Token-Endpoint am
Dashboard-Server mintet LiveKit-JWTs server-seitig (Matrix-whoami-Auth,
stdlib-HMAC), Client holt nur das JWT. Braucht echten Call-Test -> nur Plan.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 04:06:53 +02:00
Bernd SteckmeisterandClaude Fable 5 925aafb2fe security: Admin-Token aus Client entfernen (E2EE-Diagnose entkoppeln)
Der Dashboard-Server benutzte fuer /api/e2ee-diagnostics denselben String
wie den Matrix-Admin-Token, weshalb der Client den vollen Server-Admin-Token
einbetten musste (jeder Nutzer, auch Uta, trug ihn im Geraet).

- server.py (Pi): eigener, eng begrenzter DIAG_TOKEN statt Admin-TOKEN
- settings_encryption.dart: benutzt den neuen Diagnose-Token
- grep J5lax lib/ jetzt leer; Endpoint verifiziert (neu 200, alt 403)

Admin-Token-Rotation bleibt offen (Bernds Sache, ROADMAP M0).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 04:04:15 +02:00
Bernd SteckmeisterandClaude Opus 4.8 fe6427bfba security: geleakte Secrets aus Repo entfernen (Review-Befund k)
Secret-Scan im Fable-5-Review fand vier aktive Zugangsdaten im
oeffentlich erreichbaren Gitea-Repo. Gefahrlos entfernbare Stellen:
- scripts/release.ps1: Gitea-Token -> Umgebungsvariable PYRAMID_GITEA_TOKEN
- docs/matrix-sdk/13-server-admin: 19x Admin-Token -> <ADMIN_TOKEN>

Client-eingebettete Secrets (LiveKit apiSecret, Admin-Token im
E2EE-Diagnose-Upload, Giphy-Key) brauchen Rotation + Server-Umbau
und stehen als neue M0-ROADMAP-Punkte (nicht blind auf dem Pi fixbar).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-04 03:32:37 +02:00
Bernd SteckmeisterandClaude Fable 5 a5a5c4baf5 docs: Fable-5-Review um Punkte (h)-(j) ergänzt, Testplan auf 24 Tests
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 23:22:15 +02:00
Bernd SteckmeisterandClaude Fable 5 7b90f20e36 fix: Lade-Race in BoolPref/StringSetPref (Review-Befund)
StringSetPref.add/remove machten Read-Modify-Write auf möglicherweise noch
ungeladenem State: ein add() direkt nach Erstellung persistierte nur den
neuen Eintrag und LÖSCHTE alle bereits gespeicherten (per neuem Test am
alten Code bewiesen). Jetzt warten add/remove auf _loaded. BoolPref bekommt
einen _userSet-Guard, damit ein spät landender persistierter Wert nie eine
schon getätigte Nutzeraktion zurücküberschreibt (Reihenfolge ist plattform-
abhängig). In der Praxis kaum treffbar (Provider laden beim App-Start),
aber die Fassade wird beim M2-Storage-Umbau Pflicht-Schnittstelle für
neue, ggf. früh laufende Aufrufer. 2 neue Regressionstests, alle 24 grün.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 23:20:49 +02:00
Bernd SteckmeisterandClaude Fable 5 03b84e0e0e docs: PC-Testplan Schritt 0 auf aktuellen Teststand (22 Tests) gebracht
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 22:53:42 +02:00
Bernd SteckmeisterandClaude Fable 5 7dfdbf1159 test: Vertragsnetz für die Storage-Fassade settings_prefs.dart
8 Tests mit dem offiziellen SharedPreferences-In-Memory-Mock: BoolPref,
StringSetPref, Theme-Prefs (Roundtrip/Teil-Save), Server-Banner inkl.
korruptes-JSON-Fehlerpfad. Sichert die Schnittstelle ab, die beim
M2-Storage-Umbau zur Pflicht-Fassade wird. Alle 22 Tests grün auf dem Pi.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 22:53:26 +02:00
Bernd SteckmeisterandClaude Fable 5 e0ac5cb9fd test: AuthLog-Regressionstests (wirft nie, 500-Zeilen-Kappung)
AuthLog.write ist der einzige Aufruf im catch-Pfad des Soft-Logout-Guards;
ein Throw dort würde im SDK zu logout()+clear() eskalieren. 4 neue Tests
sichern genau das ab (kaputte Plattform, leeres Log, Zeitstempel, Kappung).
path_provider_platform_interface als dev_dependency (war schon transitiv).
Alle 14 Tests grün auf dem Pi, flutter analyze unverändert.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 22:51:35 +02:00
Bernd SteckmeisterandClaude Fable 5 eebf8ae7dd docs: Fable-5-Review um Nachzügler-Punkte (e-g) ergänzt
Tests 99cde9a, Doku-Commits 16561bb/566938f und Autopilot-Konto-Wechsel
bfe5876 kritisch geprüft: keine Fehler, alle Behauptungen gegen Code/SDK
verifiziert. Damit sind ALLE als erledigt markierten Punkte reviewt.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 22:49:00 +02:00
Bernd SteckmeisterandClaude Fable 5 566938f004 docs: PC-Testplan um flutter-test-Vorabschritt ergänzt
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 22:23:42 +02:00
Bernd SteckmeisterandClaude Fable 5 1630d0e389 chore: Commit-Hash im PROGRESS-Eintrag nachgetragen + Hook-Log
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 22:23:12 +02:00
Bernd SteckmeisterandClaude Fable 5 99cde9a1e2 test: Regressionstests für Soft-Logout-Guard und Power-Levels-Schutz
Erste Testsuite des Projekts, gezielt für die zwei heiligsten Codepfade:
guardedSoftLogoutRefresh (wirft nie -> kein SDK-logout()+clear(), Uta-Bug)
und updatePowerLevelsSafely (Selbst-Aussperr-Schutz, Server-Stand bleibt
erhalten). 10 Tests, alle gruen auf dem Pi (flutter test laeuft headless).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 22:22:06 +02:00
Bernd SteckmeisterandClaude Fable 5 bfe58760ee feat: automatischer Konto-Wechsel (pyramid<->haupt) bei 5h-Limit
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CPrAGBxBT6GfPXzeWQ4AXb
2026-07-03 19:33:13 +02:00
Bernd SteckmeisterandClaude Fable 5 87ff15d760 chore: CHANGES.md Hook-Log nachführen
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 17:52:32 +02:00
Bernd SteckmeisterandClaude Fable 5 16561bbdeb docs: SQLCipher-Migrationsplan + konsolidierter PC-Testplan (M1-Vorbereitung)
Die zwei obersten offenen ROADMAP-Punkte sind PC-only in der Umsetzung,
aber jetzt maximal vorbereitet: docs/SQLCIPHER_MIGRATION.md (gegen beide
DB-Öffnungsstellen und matrix-6.2.0 verifiziert, inkl. Push-Isolate-Falle
und Migration-Race) und docs/PC_TESTPLAN.md (alle UNGETESTET(Pi)-Punkte
aus 7 verstreuten PROGRESS-Einträgen an einer Stelle).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 17:50:58 +02:00
Bernd SteckmeisterandClaude Fable 5 87b29b5912 docs: Fable-5-Review abgeschlossen und protokolliert (M0-Punkt abgehakt)
Review-Protokoll in PROGRESS.md nachgeholt (die Vorsession hatte f9979e4
und 891f348 gepusht, wurde aber vor dem Protokollieren abgebrochen) und
den Review zu Ende geführt: (a) Splits unabhängig per Zeilen-Multiset
verifiziert, (b) Soft-Logout-Guard gegen SDK-Quelle gegenverifiziert,
(c) Lint-Fixes äquivalent (onReorderItem gegen Flutter-SDK geprüft),
(d) ROADMAP-Haken stimmen mit dem Code überein. Nebenbefund: docs/
PROGRESS.md als Archiv markiert (Verwechslungsgefahr mit dem echten
Protokoll im Root).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 17:46:55 +02:00
Bernd SteckmeisterandClaude Fable 5 73420fe245 chore: Autopilot auf eigenes Claude-Profil (~/.claude-pyramid) + Review-Prompt
Bernds lokale Umstellung committen (Push = Backup): CLAUDE_CONFIG_DIR
zeigt auf das Pyramid-eigene Profil, Prompt um den Fable-5-Review-Pass
erweitert. *.bak in .gitignore (autopilot.sh.bak bleibt lokal).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 17:46:55 +02:00
Bernd SteckmeisterandClaude Fable 5 891f3489bd fix: Soft-Logout darf nie mehr destruktiv enden (onSoftLogout-Guard)
Qualitäts-Review-Befund (b): Der Refresh-Token-Fix (9dc83f7) war richtig
analysiert, aber unvollständig – drei Restlücken gegen das matrix-SDK
6.2.0 verifiziert und geschlossen:

1. Client._handleSoftLogout ruft bei JEDEM Refresh-Fehler logout() auf,
   und logout() macht 'finally clear()' – die lokale Session inkl.
   Krypto-Schlüssel wird selbst bei einem transienten Netzwerkfehler
   zerstört. Mit refreshToken:true laufen Access-Tokens künftig ab,
   Refreshes werden Routine – das Restrisiko wäre also GESTIEGEN.
   Fix: eigener onSoftLogout-Handler (soft_logout_guard.dart) mit
   3 Versuchen, der nie wirft → SDK ruft nie logout() auf; bei
   Dauerfehler bleibt der Client soft-logged-out und der nächste
   Sync versucht es erneut (CLAUDE.md: Fehlerpfade dürfen NIE in
   einem stillen Logout enden).

2. Das Push-Hintergrund-Isolate (background_push.dart) teilt sich die
   pyramid.sqlite mit der Haupt-App; getEventByPushNotification()
   ruft ensureNotSoftLoggedOut() auf. Ohne Guard hätte ein
   Refresh-Fehler im Isolate (inkl. Token-Rotations-Race mit der
   Haupt-App) die Session der GESAMTEN App gelöscht. Guard auch dort
   gesetzt; das Race heilt der Retry, weil refreshAccessToken() den
   Token bei jedem Versuch frisch aus der DB liest.

3. isLoggedInProvider behandelte softLoggedOut wie ausgeloggt – die UI
   wäre bei jedem Routine-Token-Refresh kurz auf den Login-Screen
   gesprungen (für Nutzer nicht von einem 'Random Logout' zu
   unterscheiden). Jetzt zählt nur echtes loggedOut.

UNGETESTET (Pi) – gehört zum selben ausstehenden PC-Praxistest wie
9dc83f7: Login → Nachrichten → Logout → Neu-Login → alte Nachrichten
lesbar; zusätzlich auth_log.txt auf die neuen Guard-Einträge prüfen.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 14:42:15 +02:00
Bernd SteckmeisterandClaude Fable 5 f9979e4f32 fix: beim Gott-Datei-Split verlorene Erklärkommentare wiederhergestellt
Qualitäts-Review-Befund (a): Alle 6 part/part-of-Aufteilungen hatten Code
vollständig übernommen (Zeilen-Multiset-Vergleich Original vs. heutiger
Stand: keine Code-Zeile verloren, keine hinzugefügt), ABER erklärende
Kommentare über den Klassen gingen verloren (24 Blöcke, u. a. die
HTML-Subset-Doku über _MatrixHtmlText, die Selbst-Aussperr-Warnung über
updatePowerLevelsSafely, TURN-Verbrauchskarte, PDF-Cache/Viewer-Doku).
Alle an den richtigen Klassen wiederhergestellt; reine Trennlinien und
ALL-CAPS-Abschnittslabels bewusst nicht (Gott-Datei-Navigation, durch
die neuen Dateinamen ersetzt).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 14:37:48 +02:00
Bernd SteckmeisterandClaude Fable 5 d254a36de7 chore: Autopilot auf Fable 5 umgestellt + Qualitäts-Review-Auftrag in ROADMAP
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CPrAGBxBT6GfPXzeWQ4AXb
2026-07-03 14:31:51 +02:00
Bernd SteckmeisterandClaude Sonnet 5 daf4cf3dad refactor: Gott-Datei document_viewer.dart in 5 Dateien aufgeteilt
Sechster Durchgang derselben part/part-of-Technik wie bei den 5 vorigen
Umbauten - reine Verschiebung, keine Logikänderung. document_viewer.dart
schrumpft von 1773 auf 20 Zeilen (nur noch Bibliothekskopf), 34
Top-Level-Bloecke (31 Klassen/Enums + 3 lose Funktionen) auf 5 thematische
Dateien unter lib/features/chat/document/ verteilt. Verifiziert per
automatisiertem Blockvergleich gegen das Original und flutter analyze
(weiterhin nur 1 bekannter Hinweis).

voice_channel.dart und app_shell.dart bewusst nicht angefasst - haengen an
der noch unentschiedenen Call-Fassade bzw. dem Bootstrap/Login-Pfad.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-03 12:59:03 +02:00
Bernd Steckmeister a963262de2 docs: Session-Check – Call-Fassade und M1-Punkte weiterhin PC-only/Bernd-Entscheidung
Keine neue Information seit letzter Session: SQLCipher und Härtetest brauchen
PC/GUI, Call-Schicht-Fassade ist kein reiner Datei-Split (Singleton +
WebRTCDelegate + Timer-Polling) und wartet weiterhin auf Bernds Go, um nicht
blind ein produktiv genutztes Call-Feature zu riskieren.
2026-07-03 12:34:48 +02:00
Bernd SteckmeisterandClaude Sonnet 5 31e4001fb0 refactor: Gott-Datei chat_view.dart in 6 Dateien aufgeteilt
Fünfter und letzter Durchgang der part/part-of-Aufteilung - reine
Verschiebung, keine Logikänderung. chat_view.dart schrumpft von 2116 auf
33 Zeilen (nur noch Bibliothekskopf). Verifiziert per automatisiertem
Blockvergleich gegen das Original und flutter analyze.

Damit sind alle 5 in der M2-Ist-Analyse gefundenen Gott-Dateien aufgeteilt;
ROADMAP-Punkt "Toten Code & Duplikate entfernen, Ordnerstruktur
vereinheitlichen" abgehakt.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-03 11:03:31 +02:00
Bernd SteckmeisterandClaude Sonnet 5 0c7d359076 refactor: Gott-Datei space_admin_dialog.dart in 6 Dateien aufgeteilt
Gleiche Technik wie bei den vorigen drei Umbauten: reine Verschiebung per
Dart part/part-of, keine Logikänderung. space_admin_dialog.dart schrumpft
von 2278 auf 19 Zeilen (nur noch Bibliothekskopf). Verifiziert per
automatisiertem Blockvergleich gegen das Original und flutter analyze.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-03 11:01:58 +02:00
Bernd SteckmeisterandClaude Sonnet 5 a863fa3b9a refactor: Gott-Datei rooms_panel.dart in 8 Dateien aufgeteilt
Gleiche Technik wie bei settings_modal.dart/message_group.dart: reine
Verschiebung per Dart part/part-of, keine Logikänderung. rooms_panel.dart
schrumpft von 2555 auf 31 Zeilen (nur noch Bibliothekskopf). Verifiziert
per automatisiertem Blockvergleich gegen das Original und flutter analyze.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-03 11:00:22 +02:00
Bernd SteckmeisterandClaude Sonnet 5 5bf45a9366 refactor: Gott-Datei message_group.dart in 7 Dateien aufgeteilt
Gleiche Technik wie bei settings_modal.dart: reine Verschiebung per Dart
part/part-of, keine Logikänderung. message_group.dart schrumpft von 2775
auf 30 Zeilen (nur noch Bibliothekskopf). Verifiziert per automatisiertem
Blockvergleich gegen das Original und flutter analyze (Baseline unverändert).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-03 10:58:46 +02:00
Bernd SteckmeisterandClaude Sonnet 5 5d772389d6 refactor: Gott-Datei settings_modal.dart in 12 Dateien aufgeteilt
Reine Verschiebung per Dart part/part-of (keine Umbenennung, keine
Logikänderung) - settings_modal.dart schrumpft von 5541 auf 270 Zeilen.
Verifiziert per automatisiertem Blockvergleich gegen das Original und
flutter analyze (Baseline unverändert: 1 bekannter Hinweis).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-03 10:56:48 +02:00
Bernd Steckmeister 6400fb639c refactor: toten Code entfernen, doppelte Zeit-Formatierung zusammenführen
message_bubble.dart war vollständig unbenutzt (durch message_group.dart
abgelöst). Die zwei identischen _formatTime-Methoden in message_group.dart
zu einer Top-Level-Funktion zusammengeführt - reine Extraktion, keine
Verhaltensänderung. flutter analyze bleibt sauber.
2026-07-03 10:29:54 +02:00
Bernd Steckmeister f5b9ce9cd7 docs: M2-Modul-Schnitt definieren (docs/M2_MODULE_SCHNITT.md)
Öffentliche Schnittstellen für call_signaling, voice_channel, call_ui,
settings, verification, storage u.a. entworfen. Wichtigste Erkenntnis:
"Calls" ist architektonisch zwei getrennte Module (Matrix-VoIP vs.
LiveKit-SFU-Voice-Channels), keine gemeinsame Fassade. Korrigiert
nebenbei einen falschen Ist-Analyse-Befund (rooms_provider-Import war
fälschlich als toter Code eingestuft).
2026-07-03 10:27:35 +02:00
Bernd Steckmeister f3fd451034 docs: M2-Ist-Analyse der Architektur dokumentieren
Gott-Dateien, unsauber verteilte Call-Schicht, fehlende Storage-Fassade
und tote/duplizierte Code-Stellen per Code-Lektüre kartiert; 8 Modul-
Kandidaten für den nächsten Roadmap-Punkt festgehalten.
2026-07-03 10:25:10 +02:00
Bernd Steckmeister db653ea7a3 chore: CHANGES.md Hook-Log nachführen 2026-07-03 10:00:22 +02:00
Bernd Steckmeister 254be1b8da docs: M1-Bestandsaufnahme – 3 Punkte bereits vorhanden, SQLCipher-Lücke offen
Key-Backup-Flows und Geräteverwaltung waren schon implementiert (nur nicht
abgehakt). Neue Sicherheitslücke dokumentiert: sqlcipher_flutter_libs ist
eine ungenutzte Abhängigkeit, die Krypto-DB liegt unverschlüsselt vor.
Bewusst nicht blind gefixt (Migrationsrisiko für Bestandsinstallationen),
Frage an Bernd zur Priorität ergänzt.
2026-07-03 10:00:05 +02:00
Bernd Steckmeister b5762ea08d feat: Logout-Warnung bei fehlendem Key-Backup (M1)
Beide Logout-Bestätigungsdialoge prüfen jetzt vorab per neuem
isKeyBackupMissing(client) (bootstrap_dialog.dart), ob Cross-Signing/
Key-Backup eingerichtet ist. Fehlt es, zeigt der Dialog eine
Klartext-Warnung: alte verschlüsselte Nachrichten wären nach dem
Logout auf keinem Gerät mehr lesbar. Ohne fehlendes Backup bleibt der
bisherige Text unverändert.
2026-07-03 09:59:10 +02:00
Bernd Steckmeister e8ab626c92 docs: Push-Regression vermutlich identische Ursache wie Random-Logout
getEventByPushNotification() ruft ensureNotSoftLoggedOut() auf, das ohne
Refresh-Token (siehe 9dc83f7) denselben destruktiven Auto-Logout auslösen
kann wie beim normalen Sync — nur eben aus dem Hintergrund-Isolat, mit
Zugriff auf dieselbe DB. Der bestehende Fix deckt vermutlich beide
Symptome ab. ROADMAP abgehakt, UNGETESTET (Pi) bis Praxistest am PC.
2026-07-03 09:56:02 +02:00
Bernd Steckmeister fb7669a65b docs: Uta-Random-Logout-Fund in PROGRESS/ROADMAP protokollieren
Dokumentiert Root-Cause-Analyse und Fix aus 63e4919/9dc83f7, markiert
UNGETESTET (Pi) mit Pflicht-Praxistest am PC vor endgültigem Haken.
2026-07-03 09:52:22 +02:00
Bernd Steckmeister 9dc83f7bcb fix: Refresh-Token beim Login anfordern (Uta-Random-Logout, M1)
Ursache gefunden: Ohne Refresh-Token eskaliert das matrix-SDK jeden
Soft-Logout (z. B. abgelaufenes Access-Token, M_UNKNOWN_TOKEN mit
soft_logout=true) automatisch zu einem echten, destruktiven Logout —
_handleSoftLogout() versucht refreshAccessToken(), das ohne
gespeicherten Refresh-Token sofort mit "No refresh token available"
scheitert, und ruft danach client.logout() auf (client.dart:2374-2384
im matrix-Paket, Version 6.2.0).

Mit refreshToken: true beim Login bekommt der Client vom Server einen
Refresh-Token und kann einen Soft-Logout künftig durch stillen
Token-Refresh überstehen, statt die Sitzung zu zerstören. Falls der
Server keine Refresh-Tokens unterstützt, bleibt das Verhalten
unverändert (response.refreshToken ist dann null).

Wichtig: gilt nur für neue Logins. Bereits eingeloggte Geräte (z. B.
Uta) profitieren erst nach einmaligem Neu-Login. Zur Diagnose siehe
den mit 63e4919 eingeführten AuthLog.

UNGETESTET (Pi, kein GUI/Login hier möglich) – nächster PC-Lauf muss
den vollen Kreislauf prüfen: Login → Nachrichten → Logout → erneuter
Login → alte verschlüsselte Nachrichten noch lesbar?
2026-07-03 09:51:40 +02:00
Bernd Steckmeister 63e491952d feat: persistentes Auth-Log für Logout-Diagnose (M1)
Neuer AuthLog schreibt Login-Status-Wechsel und SDK-Warnungen/-Fehler
in eine Datei im App-Support-Verzeichnis, die einen Neustart übersteht.
Zusätzlich werden alle drei bewusst ausgelösten Logout-Buttons geloggt,
damit sich künftig unterscheiden lässt: Nutzer-Logout vs. vom SDK
automatisch ausgelöster Logout (Soft-Logout/M_UNKNOWN_TOKEN).

Reiner Logging-Zusatz, keine Verhaltensänderung – erster Schritt laut
ROADMAP M1 ("Erst Logging an allen Logout-Pfaden einbauen").
2026-07-03 09:50:55 +02:00
Bernd Steckmeister 13512064a3 docs: M0 in PROGRESS/ROADMAP abschließen
Alle M0-Punkte abgehakt (Commits, flutter analyze, call/calls-Leiche,
README). Nächster Schritt laut ROADMAP: M1 Uta-Logout-Bug.
2026-07-03 09:46:27 +02:00
Bernd Steckmeister 74d38fd267 docs: README durch echte Projektbeschreibung ersetzen
Ersetzt das Flutter-Boilerplate-README durch Funktionsübersicht,
Build-Anleitung und Projektstruktur.
2026-07-03 09:45:49 +02:00
Bernd Steckmeister 401bd6a7f8 style: weitere flutter-analyze Hinweise beheben
- document_viewer: Matrix4.translate/scale durch translateByDouble/
  scaleByDouble ersetzt (PDF-Zoom-Transformation, gleiches Verhalten)
- rooms_panel: onReorder durch onReorderItem ersetzt (Index-Korrektur
  jetzt intern erledigt), implementation_imports-Hinweis kommentiert
  (SpaceChild wird vom matrix-Paket nicht öffentlich exportiert)
- voip_manager: getSources()-Override kommentiert (erzwungen durch
  abstrakte MediaDevices-Klasse, die die Methode selbst deprecated hat)
- settings_modal: BuildContext-Nutzung nach await mit context.mounted
  abgesichert (Geräte umbenennen, Passwort-Abfrage bei Massenlogout)

Verbleibend: chat_provider.dart onUpdate (Timeline-Redecrypt-Pfad,
bewusst nicht angefasst ohne dedizierten E2EE-Test, siehe PROGRESS.md).
2026-07-03 09:45:05 +02:00
Bernd Steckmeister 057ac1e4ea style: verbleibende flutter-analyze Deprecations beheben
withOpacity/activeColor/cacheExtent/PublicRoomsChunk durch die neuen
Flutter-3.44-Äquivalente ersetzt.
2026-07-03 09:40:10 +02:00
Bernd SteckmeisterandClaude Fable 5 90593d2f81 docs: Regression in ROADMAP: Push-Benachrichtigungen werden nicht mehr entschlüsselt
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CPrAGBxBT6GfPXzeWQ4AXb
2026-07-03 06:33:37 +02:00
Bernd SteckmeisterandClaude Fable 5 d2fcc59f7a feat: Matrix-Statusberichte nach jeder Autopilot-Session
Nach jeder Session mit neuen Commits postet der Autopilot Commits + nächsten
Schritt in den Raum '🔺 Pyramid Autopilot' (Token vom Gatus-Bot, nur auf dem Pi).

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CPrAGBxBT6GfPXzeWQ4AXb
2026-07-03 06:32:54 +02:00
158 changed files with 29731 additions and 18521 deletions
+1
View File
@@ -1,4 +1,5 @@
{
"model": "claude-fable-5",
"hooks": {
"PostToolUse": [
{
+4
View File
@@ -45,3 +45,7 @@ app.*.map.json
/android/app/release
autopilot.log
.claude/worktrees/
*.bak
# Skript-Backups vor riskanten Aenderungen (bleiben lokal als Rollback)
*.bak-*
+42
View File
@@ -0,0 +1,42 @@
# Bernds Entscheidungen – Richtungsfragen aus PROGRESS.md beantwortet
Diese Datei beantwortet die über mehrere Sessions in PROGRESS.md gesammelten
„Fragen an Bernd". Sie ist ab jetzt **maßgeblich** – die betroffenen Punkte in
ROADMAP.md/PROGRESS.md gelten als entschieden.
Zuletzt aktualisiert: **2026-07-05** (Bernd + Claude am PC).
## 1. SQLCipher (lokale DB + Access-Token verschlüsseln) → VOR M2 priorisieren
- **Entscheidung:** Ja. Die Verschlüsselung der lokalen `pyramid.sqlite` hat Vorrang
vor dem M2-Refactoring. Betrifft den M1-Punkt „Sichere Speicherung von Access-Token
& Krypto-DB".
- Umsetzung nach `docs/SQLCIPHER_MIGRATION.md`. **Wichtig:** Die Migration bestehender
Klartext-DBs (Utas Gerät!) ist der riskante Teil und braucht einen echten
PC-/Gerätetest. Auf dem Pi (kein GUI) nur so weit vorbereiten, wie es OHNE GUI sicher
testbar ist; alles Verhaltensrelevante klar als UNGETESTET markieren und erst nach
`docs/PC_TESTPLAN.md` als erledigt abhaken. Grundsatz „Kein Datenverlust" – im Zweifel
für den PC-Termin liegen lassen.
## 2. M2 – erstes Umbau-Modul → Call-Pilot zuerst
- **Entscheidung:** Wenn M2 startet (nach SQLCipher), zuerst die Call-Schicht entwirren
(Call-Pilot / gemeinsame Fassade über `voip_manager.dart` + `livekit_call_manager.dart`),
wie in `docs/M2_MODULE_SCHNITT.md` vorgeschlagen. **Nicht** zuerst `settings_modal.dart`
aufsplitten.
- **Wichtig:** heiliger Call-Pfad → braucht zwingend echten Call-Test auf einem Gerät.
Auf dem Pi nicht blind umbauen; Vorbereitung/Analyse ok, Umsetzung + Test am PC.
## 3. Dashboard-Fernzugriff (stats.html) → Heimnetz-only lassen
- **Entscheidung:** KEIN Fernzugriff. Das Interims-Gate (nur Heimnetz/localhost) bleibt so.
Die offene A/B-Frage (Cloudflare Access vs. DASH_TOKEN) aus
`docs/DASHBOARD_AUTH_HARDENING.md` ist damit **geschlossen** – nicht weiter daran
arbeiten. Unterwegs per WireGuard erreichbar.
## 4. Git-History / Admin-Token → vorerst NICHTS tun
- **Entscheidung:** Kein Git-History-Rewrite und aktuell keine Token-Rotation durch den
Autopilot. Der Admin-Token ist bereits aus dem App-Code heraus (eigener `DIAG_TOKEN`).
Eine spätere Rotation bleibt Bernds manuelle Sache – nicht eigenmächtig anstoßen.
---
**Kurzfassung fürs Arbeiten:** SQLCipher vor M2, dann Call-Pilot als erstes M2-Modul.
Dashboard bleibt Heimnetz-only. History unangetastet. Riskante/GUI-relevante Teile
warten auf den PC-Termin (`docs/PC_TESTPLAN.md`); auf dem Pi nur sicher Vorbereitbares.
+384
View File
@@ -1108,3 +1108,387 @@
2026-07-03 06:31 [Edit] /home/steggi/pyramid/lib/widgets/create_join_dialog.dart
2026-07-03 06:32 [Edit] /home/steggi/pyramid/lib/widgets/settings_modal.dart
2026-07-03 06:32 [Edit] /home/steggi/pyramid/lib/widgets/settings_modal.dart
2026-07-03 06:33 [Edit] /home/steggi/pyramid/lib/features/chat/attachment_dialog.dart
2026-07-03 06:33 [Edit] /home/steggi/pyramid/lib/widgets/create_join_dialog.dart
2026-07-03 06:33 [Edit] /home/steggi/pyramid/lib/features/chat/chat_view.dart
2026-07-03 06:34 [Edit] /home/steggi/pyramid/lib/widgets/pyramid_loader.dart
2026-07-03 06:35 [Edit] /home/steggi/pyramid/lib/features/chat/chat_view.dart
2026-07-03 06:35 [Edit] /home/steggi/pyramid/lib/features/chat/chat_view.dart
2026-07-03 06:35 [Edit] /home/steggi/pyramid/lib/features/chat/chat_view.dart
2026-07-03 09:43 [Edit] /home/steggi/pyramid/lib/features/chat/document_viewer.dart
2026-07-03 09:43 [Edit] /home/steggi/pyramid/lib/features/rooms/rooms_panel.dart
2026-07-03 09:43 [Edit] /home/steggi/pyramid/lib/features/rooms/rooms_panel.dart
2026-07-03 09:43 [Edit] /home/steggi/pyramid/lib/core/voip_manager.dart
2026-07-03 09:44 [Edit] /home/steggi/pyramid/lib/core/voip_manager.dart
2026-07-03 09:44 [Edit] /home/steggi/pyramid/lib/widgets/settings_modal.dart
2026-07-03 09:44 [Edit] /home/steggi/pyramid/lib/widgets/settings_modal.dart
2026-07-03 09:45 [Write] /home/steggi/pyramid/README.md
2026-07-03 09:46 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-03 09:46 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-03 09:49 [Write] /home/steggi/pyramid/lib/core/auth_log.dart
2026-07-03 09:49 [Edit] /home/steggi/pyramid/lib/core/matrix_client.dart
2026-07-03 09:50 [Edit] /home/steggi/pyramid/lib/core/matrix_client.dart
2026-07-03 09:50 [Edit] /home/steggi/pyramid/lib/core/matrix_client.dart
2026-07-03 09:50 [Edit] /home/steggi/pyramid/lib/widgets/settings_modal.dart
2026-07-03 09:50 [Edit] /home/steggi/pyramid/lib/widgets/settings_modal.dart
2026-07-03 09:50 [Edit] /home/steggi/pyramid/lib/widgets/settings_modal.dart
2026-07-03 09:50 [Edit] /home/steggi/pyramid/lib/widgets/settings_modal.dart
2026-07-03 09:51 [Edit] /home/steggi/pyramid/lib/features/auth/login_notifier.dart
2026-07-03 09:51 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-03 09:52 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-03 09:55 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-03 09:55 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-03 09:57 [Edit] /home/steggi/pyramid/lib/features/auth/bootstrap_dialog.dart
2026-07-03 09:58 [Edit] /home/steggi/pyramid/lib/widgets/settings_modal.dart
2026-07-03 09:58 [Edit] /home/steggi/pyramid/lib/widgets/settings_modal.dart
2026-07-03 09:58 [Edit] /home/steggi/pyramid/lib/widgets/settings_modal.dart
2026-07-03 09:58 [Edit] /home/steggi/pyramid/lib/widgets/settings_modal.dart
2026-07-03 09:58 [Edit] /home/steggi/pyramid/lib/widgets/settings_modal.dart
2026-07-03 09:59 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-03 10:00 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-03 10:24 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-03 10:25 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-03 10:26 [Write] /home/steggi/pyramid/docs/M2_MODULE_SCHNITT.md
2026-07-03 10:26 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-03 10:27 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-03 10:27 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-03 10:28 [Edit] /home/steggi/pyramid/lib/features/chat/message_group.dart
2026-07-03 10:28 [Edit] /home/steggi/pyramid/lib/features/chat/message_group.dart
2026-07-03 10:28 [Edit] /home/steggi/pyramid/lib/features/chat/message_group.dart
2026-07-03 10:29 [Edit] /home/steggi/pyramid/lib/features/chat/message_group.dart
2026-07-03 10:29 [Edit] /home/steggi/pyramid/lib/features/chat/message_group.dart
2026-07-03 10:29 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-03 10:29 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-03 10:56 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-03 10:56 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-03 10:58 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-03 10:58 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-03 11:00 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-03 11:00 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-03 11:01 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-03 11:01 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-03 11:03 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-03 11:03 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-03 12:34 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-03 12:57 [Write] /home/steggi/pyramid/lib/features/chat/document_viewer.dart
2026-07-03 12:58 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-03 12:58 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-03 14:33 [Edit] /home/steggi/pyramid/lib/widgets/settings/settings_voice.dart
2026-07-03 14:36 [Edit] /home/steggi/pyramid/lib/features/chat/document/document_viewer_pdf.dart
2026-07-03 14:36 [Edit] /home/steggi/pyramid/lib/features/chat/document/document_viewer_pdf.dart
2026-07-03 14:36 [Edit] /home/steggi/pyramid/lib/features/chat/document/document_viewer_pdf.dart
2026-07-03 14:36 [Edit] /home/steggi/pyramid/lib/features/chat/document/document_viewer_archive.dart
2026-07-03 14:36 [Edit] /home/steggi/pyramid/lib/features/chat/document/document_viewer_archive.dart
2026-07-03 14:36 [Edit] /home/steggi/pyramid/lib/widgets/settings/settings_encryption.dart
2026-07-03 14:36 [Edit] /home/steggi/pyramid/lib/features/chat/message/message_text.dart
2026-07-03 14:41 [Write] /home/steggi/pyramid/lib/core/soft_logout_guard.dart
2026-07-03 14:41 [Edit] /home/steggi/pyramid/lib/core/matrix_client.dart
2026-07-03 14:41 [Edit] /home/steggi/pyramid/lib/core/matrix_client.dart
2026-07-03 14:41 [Edit] /home/steggi/pyramid/lib/core/background_push.dart
2026-07-03 14:41 [Edit] /home/steggi/pyramid/lib/core/background_push.dart
2026-07-03 14:41 [Edit] /home/steggi/pyramid/lib/features/auth/login_notifier.dart
2026-07-03 17:45 [Edit] /home/steggi/pyramid/docs/PROGRESS.md
2026-07-03 17:46 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-03 17:46 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-03 17:49 [Write] /home/steggi/pyramid/docs/SQLCIPHER_MIGRATION.md
2026-07-03 17:49 [Edit] /home/steggi/pyramid/docs/SQLCIPHER_MIGRATION.md
2026-07-03 17:50 [Write] /home/steggi/pyramid/docs/PC_TESTPLAN.md
2026-07-03 17:50 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-03 17:50 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-03 17:50 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-03 17:51 [Write] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid_push.md
2026-07-03 17:52 [Write] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
2026-07-03 17:52 [Write] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/MEMORY.md
2026-07-03 18:16 [Write] /home/steggi/pyramid/test/soft_logout_guard_test.dart
2026-07-03 18:17 [Write] /home/steggi/pyramid/test/power_levels_safety_test.dart
2026-07-03 22:21 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-03 22:22 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-03 22:23 [Write] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
2026-07-03 22:23 [Edit] /home/steggi/pyramid/docs/PC_TESTPLAN.md
2026-07-03 22:48 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-03 22:50 [Write] /home/steggi/pyramid/test/auth_log_test.dart
2026-07-03 22:50 [Edit] /home/steggi/pyramid/pubspec.yaml
2026-07-03 22:51 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-03 22:52 [Write] /home/steggi/pyramid/test/settings_prefs_test.dart
2026-07-03 22:53 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-03 22:53 [Edit] /home/steggi/pyramid/docs/PC_TESTPLAN.md
2026-07-03 22:54 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
2026-07-03 23:18 [Edit] /home/steggi/pyramid/test/settings_prefs_test.dart
2026-07-03 23:18 [Edit] /home/steggi/pyramid/test/settings_prefs_test.dart
2026-07-03 23:19 [Edit] /home/steggi/pyramid/lib/core/settings_prefs.dart
2026-07-03 23:20 [Edit] /home/steggi/pyramid/lib/core/settings_prefs.dart
2026-07-03 23:21 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-03 23:21 [Edit] /home/steggi/pyramid/docs/PC_TESTPLAN.md
2026-07-03 23:22 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 03:28 [Edit] /home/steggi/pyramid/scripts/release.ps1
2026-07-04 03:31 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 03:32 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 03:32 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-04 04:01 [Edit] /home/steggi/matrix/server.py
2026-07-04 04:02 [Edit] /home/steggi/pyramid/lib/widgets/settings/settings_encryption.dart
2026-07-04 04:03 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-04 04:03 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 04:06 [Write] /home/steggi/pyramid/docs/LIVEKIT_TOKEN_MIGRATION.md
2026-07-04 04:06 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-04 04:06 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 04:07 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
2026-07-04 04:30 [Edit] /home/steggi/matrix/server.py
2026-07-04 04:30 [Edit] /home/steggi/matrix/server.py
2026-07-04 04:31 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 04:32 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 04:33 [Edit] /home/steggi/matrix/server.py
2026-07-04 04:33 [Edit] /home/steggi/matrix/server.py
2026-07-04 04:33 [Edit] /home/steggi/matrix/server.py
2026-07-04 08:42 [Write] /home/steggi/pyramid/docs/DASHBOARD_AUTH_HARDENING.md
2026-07-04 08:42 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 08:43 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 08:43 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-04 09:14 [Edit] /home/steggi/matrix/server.py
2026-07-04 09:14 [Edit] /home/steggi/matrix/server.py
2026-07-04 09:15 [Edit] /home/steggi/pyramid/docs/DASHBOARD_AUTH_HARDENING.md
2026-07-04 09:15 [Edit] /home/steggi/pyramid/docs/DASHBOARD_AUTH_HARDENING.md
2026-07-04 09:16 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-04 09:16 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-04 09:17 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 09:17 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 09:18 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
2026-07-04 09:43 [Edit] /home/steggi/matrix/server.py
2026-07-04 09:43 [Edit] /home/steggi/matrix/server.py
2026-07-04 09:43 [Edit] /home/steggi/matrix/server.py
2026-07-04 13:46 [Edit] /home/steggi/matrix/server.py
2026-07-04 13:46 [Edit] /home/steggi/matrix/server.py
2026-07-04 13:50 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 13:51 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 13:51 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-04 13:51 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
2026-07-04 14:17 [Write] /home/steggi/matrix/server.py
2026-07-04 14:19 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 14:20 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 14:20 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-04 14:43 [Edit] /home/steggi/matrix/server.py
2026-07-04 14:44 [Edit] /home/steggi/matrix/server.py
2026-07-04 14:44 [Edit] /home/steggi/matrix/server.py
2026-07-04 14:44 [Edit] /home/steggi/matrix/server.py
2026-07-04 14:44 [Edit] /home/steggi/matrix/server.py
2026-07-04 14:45 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 14:46 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 14:46 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-04 14:47 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
2026-07-04 18:54 [Edit] /home/steggi/matrix/server.py
2026-07-04 18:54 [Edit] /home/steggi/matrix/server.py
2026-07-04 18:54 [Edit] /home/steggi/matrix/server.py
2026-07-04 18:54 [Edit] /home/steggi/matrix/server.py
2026-07-04 18:54 [Edit] /home/steggi/matrix/server.py
2026-07-04 18:56 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 18:56 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 18:56 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-04 18:57 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
2026-07-04 19:19 [Edit] /home/steggi/matrix/server.py
2026-07-04 19:20 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 19:21 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 19:21 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-04 19:22 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
2026-07-04 19:44 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 19:44 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-04 19:44 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-04 19:45 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
2026-07-05 14:51 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-05 14:52 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-05 14:52 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-05 14:52 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-05 14:52 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-05 14:52 [Edit] /home/steggi/pyramid/docs/DASHBOARD_AUTH_HARDENING.md
2026-07-05 14:52 [Edit] /home/steggi/pyramid/docs/DASHBOARD_AUTH_HARDENING.md
2026-07-05 14:52 [Edit] /home/steggi/pyramid/docs/DASHBOARD_AUTH_HARDENING.md
2026-07-05 14:53 [Edit] /home/steggi/pyramid/docs/DASHBOARD_AUTH_HARDENING.md
2026-07-05 14:53 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-05 14:54 [Write] /home/steggi/.claude/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand-haupt.md
2026-07-05 14:54 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
2026-07-05 14:54 [Edit] /home/steggi/.claude/projects/-home-steggi-pyramid/memory/MEMORY.md
2026-07-05 15:18 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-05 15:18 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-05 18:40 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-05 18:41 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-05 19:03 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-05 19:03 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-05 19:03 [Edit] /home/steggi/.claude/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand-haupt.md
2026-07-06 11:56 [Edit] /home/steggi/pyramid/autopilot.sh
2026-07-06 11:57 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-06 11:57 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-06 11:57 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-06 11:57 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-06 11:58 [Edit] /home/steggi/.claude/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand-haupt.md
2026-07-06 11:58 [Edit] /home/steggi/.claude/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand-haupt.md
2026-07-06 12:16 [Write] C:\Users\nordm\pyramid - Kopie\lib\core\app_database.dart
2026-07-06 12:17 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_database.dart
2026-07-06 12:18 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_database.dart
2026-07-06 12:18 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\matrix_client.dart
2026-07-06 12:18 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart
2026-07-06 12:18 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart
2026-07-06 12:18 [Edit] C:\Users\nordm\pyramid - Kopie\pubspec.yaml
2026-07-06 12:22 [Write] C:\Users\nordm\pyramid - Kopie\test\app_database_test.dart
2026-07-06 12:24 [Edit] C:\Users\nordm\pyramid - Kopie\test\app_database_test.dart
2026-07-06 12:24 [Edit] C:\Users\nordm\pyramid - Kopie\test\app_database_test.dart
2026-07-06 12:24 [Edit] C:\Users\nordm\pyramid - Kopie\test\app_database_test.dart
2026-07-06 12:29 [Edit] C:\Users\nordm\pyramid - Kopie\docs\SQLCIPHER_MIGRATION.md
2026-07-06 12:30 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
2026-07-06 12:31 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
2026-07-06 12:33 [Write] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\call_signaling_service.dart
2026-07-06 12:33 [Write] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\voice_channel_service.dart
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 12:36 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_state.dart
2026-07-06 12:36 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_state.dart
2026-07-06 12:36 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\matrix_client.dart
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\mini_call_widget.dart
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\mini_call_widget.dart
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\mini_call_widget.dart
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\mini_call_widget.dart
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart
2026-07-06 12:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart
2026-07-06 12:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart
2026-07-06 12:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart
2026-07-06 12:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\mini_call_widget.dart
2026-07-06 12:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\mini_call_widget.dart
2026-07-06 12:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\view\chat_header.dart
2026-07-06 12:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\view\chat_header.dart
2026-07-06 12:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart
2026-07-06 12:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart
2026-07-06 12:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
2026-07-06 12:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
2026-07-06 12:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart
2026-07-06 12:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\call_signaling_service.dart
2026-07-06 12:41 [Edit] C:\Users\nordm\pyramid - Kopie\docs\M2_MODULE_SCHNITT.md
2026-07-06 12:41 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
2026-07-06 12:42 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
2026-07-06 12:44 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
2026-07-06 12:44 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
2026-07-06 12:45 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
2026-07-06 16:58 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
2026-07-06 16:59 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
2026-07-06 17:01 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart
2026-07-06 17:01 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart
2026-07-06 17:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\settings_prefs.dart
2026-07-06 17:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
2026-07-06 17:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
2026-07-06 17:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 17:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
2026-07-06 17:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 17:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
2026-07-06 17:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
2026-07-06 17:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
2026-07-06 17:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
2026-07-06 17:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
2026-07-06 17:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
2026-07-06 17:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
2026-07-06 17:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart
2026-07-06 17:10 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
2026-07-06 17:10 [Edit] C:\Users\nordm\pyramid - Kopie\docs\M2_MODULE_SCHNITT.md
2026-07-06 17:10 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
2026-07-06 17:12 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\179608a1-16b3-4b6b-a9ed-c7969b88b37f\scratchpad\split_voice_channel.ps1
2026-07-06 17:13 [Edit] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\179608a1-16b3-4b6b-a9ed-c7969b88b37f\scratchpad\split_voice_channel.ps1
2026-07-06 17:15 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
2026-07-06 17:15 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
2026-07-06 17:16 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
2026-07-06 17:41 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\0954ef5a-3f8c-4521-ac42-f4ee83e4873b\scratchpad\split_encryption.ps1
2026-07-06 17:41 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart
2026-07-06 17:44 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
2026-07-06 17:44 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
2026-07-06 17:45 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
2026-07-06 17:47 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_state.dart
2026-07-06 17:49 [Write] C:\Users\nordm\pyramid - Kopie\docs\M2_STATE_MANAGEMENT.md
2026-07-06 17:49 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
2026-07-06 17:49 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
2026-07-06 17:51 [Edit] C:\Users\nordm\pyramid - Kopie\docs\M2_STATE_MANAGEMENT.md
2026-07-06 17:51 [Edit] C:\Users\nordm\pyramid - Kopie\docs\M2_STATE_MANAGEMENT.md
2026-07-06 17:51 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
2026-07-06 17:53 [Edit] C:\Users\nordm\pyramid - Kopie\test\settings_prefs_test.dart
2026-07-06 17:54 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
2026-07-06 17:54 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
2026-07-06 18:19 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
2026-07-06 18:20 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
2026-07-06 18:22 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\b2380513-3ca6-4879-b9df-23f8ba907354\scratchpad\make_plain_db.dart
2026-07-06 18:27 [Edit] C:\Users\nordm\pyramid - Kopie\docs\SQLCIPHER_MIGRATION.md
2026-07-06 18:27 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
2026-07-06 18:28 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
2026-07-06 18:29 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
2026-07-06 18:29 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
2026-07-06 18:48 [Write] C:\Users\nordm\pyramid - Kopie\lib\core\app_dirs.dart
2026-07-06 18:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_database.dart
2026-07-06 18:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_database.dart
2026-07-06 18:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_database.dart
2026-07-06 18:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\auth_log.dart
2026-07-06 18:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\auth_log.dart
2026-07-06 18:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\media_cache.dart
2026-07-06 18:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\media_cache.dart
2026-07-06 18:50 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\5be37250-1cea-473c-b62c-923f3d071abd\scratchpad\ui.ps1
2026-07-06 22:04 [Write] C:\Users\nordm\pyramid - Kopie\test\live_encryption_e2e_test.dart
2026-07-06 22:08 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_encryption_e2e_test.dart
2026-07-06 22:08 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_encryption_e2e_test.dart
2026-07-06 22:10 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
2026-07-06 22:11 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
2026-07-06 22:15 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 22:15 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 22:15 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_ui\view\quality_menu.dart
2026-07-06 22:16 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\voice_channel_service.dart
2026-07-06 22:16 [Write] C:\Users\nordm\pyramid - Kopie\test\streaming_presets_test.dart
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
2026-07-06 22:24 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\panel\rooms_panel_core.dart
2026-07-06 22:26 [Write] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
2026-07-06 22:26 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
2026-07-06 22:27 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
2026-07-06 22:29 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
2026-07-06 22:29 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
2026-07-06 22:29 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
2026-07-06 22:29 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
2026-07-06 22:31 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\6aa736cb-f5e2-471d-9167-7695c1c38655\scratchpad\win_tools.ps1
2026-07-06 22:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\panel\rooms_list.dart
2026-07-06 22:50 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
2026-07-06 22:50 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
2026-07-06 22:50 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
2026-07-06 22:51 [Write] C:\Users\nordm\.claude-nordmann\projects\C--Users-nordm-pyramid---Kopie\memory\test-accounts-gui.md
2026-07-06 22:51 [Write] C:\Users\nordm\.claude-nordmann\projects\C--Users-nordm-pyramid---Kopie\memory\MEMORY.md
2026-07-06 23:13 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
2026-07-06 23:23 [Write] C:\Users\nordm\pyramid - Kopie\tool\device_probe.dart
2026-07-07 03:07 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
2026-07-07 03:07 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
2026-07-07 03:16 [Edit] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\fdefa80f-c201-47b8-8bd3-ea082af15729\scratchpad\probe154\windows\runner\main.cpp
2026-07-07 03:19 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\fdefa80f-c201-47b8-8bd3-ea082af15729\scratchpad\mmprobe.cpp
2026-07-07 03:23 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\fdefa80f-c201-47b8-8bd3-ea082af15729\scratchpad\probe154\lib\main.dart
2026-07-07 03:24 [Write] C:\Users\nordm\pyramid - Kopie\tool\device_probe.dart
2026-07-07 03:25 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
2026-07-07 03:29 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\fdefa80f-c201-47b8-8bd3-ea082af15729\scratchpad\win_tools.ps1
+3
View File
@@ -9,6 +9,9 @@ Einsatz (u. a. Uta) – Stabilität und Datensicherheit gehen vor Feature-Tempo.
(`C:\Users\nordm\pyramid` und `C:\Users\nordm\MatrixPi\pyramid` sind veraltete
April-Schnappschüsse – dort NIE arbeiten.)
> **Bernds Entscheidungen zu den offenen Richtungsfragen stehen in `ANTWORTEN_BERND.md`
> (zuletzt 2026-07-05) – vor dem Abarbeiten von ROADMAP-Punkten lesen, sie sind maßgeblich.**
## Infrastruktur
- Homeserver: Continuwuity auf dem Pi5 (`steggi-matrix.work`)
+2822
View File
File diff suppressed because it is too large Load Diff
+52 -11
View File
@@ -1,17 +1,58 @@
# pyramid
# Pyramid
A new Flutter project.
Pyramid ist ein eigenständiger Matrix-Client (Flutter), kein Fork von FluffyChat
oder Element. Er läuft gegen den selbst gehosteten Homeserver `steggi-matrix.work`
(Continuwuity) und ist bei echten Nutzern im Einsatz – Stabilität und
Datensicherheit haben Vorrang vor Feature-Tempo.
## Getting Started
## Funktionsumfang
This project is a starting point for a Flutter application.
- Matrix-Chat mit Timeline, Reply, Medien, Link-Vorschauen, lokaler Volltextsuche
über entschlüsselte Nachrichten (server-seitige Suche funktioniert bei E2EE nicht)
- Ende-zu-Ende-Verschlüsselung (vodozemac/Olm/Megolm) inkl. Key-Backup
- Sprach-/Video-Calls und Bildschirmübertragung über LiveKit
(`wss://livekit.steggi-matrix.work`)
- Push-Benachrichtigungen mit clientseitiger Entschlüsselung auch bei gekillter
App (FCM + Sygnal, siehe `docs/NOTIFICATIONS.md`)
- Größenverstellbares Panel-Layout (Rooms/Chat/Members) statt starrer Tabs
A few resources to get you started if this is your first Flutter project:
## Build
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
Voraussetzung: Flutter (stable channel, siehe `environment.sdk` in `pubspec.yaml`
für die unterstützte Dart-Version).
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.
```
flutter pub get
flutter run -d windows # schnellster Praxistest während der Entwicklung
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
`android/`, `windows/`, `linux/`, `ios/`, `macos/` sind Standard-Flutter-Projekte).
Release-Build (signiert, Windows + Android): `scripts/release.ps1`.
## Projektstruktur
```
lib/
core/ Matrix-Client, Auth, VoIP-Manager, Storage, App-State
features/
auth/ Login, Server-Auswahl
rooms/ Raumliste, Spaces
chat/ Timeline, Composer, Medien, Dokumentenvorschau
call/ LiveKit-Calls, Voice-Channel, Mini-Call-Widget
members/ Mitgliederliste/-panel
settings/ Einstellungen
layout/ Panel-Layout (resizable)
widgets/ geteilte UI-Bausteine
utils/ Hilfsfunktionen
```
## Weiterführende Dokumentation
- `CLAUDE.md` – Arbeitsregeln und Infrastruktur für die Zusammenarbeit mit Claude
- `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/matrix-sdk/` – Hinweise zum verwendeten Matrix-SDK
+317 -22
View File
@@ -8,23 +8,196 @@ Punkte abhaken (`[x]`), wenn erledigt UND in `PROGRESS.md` protokolliert.
## M0 – Backup & Ordnung
- [x] Vollbackup vor dem Refactoring (2026-07-03 → `MatrixPi\backups\pyramid-kopie_backup_2026-07-03.tar.gz`)
- [ ] Die ~140 uncommitteten Dateien sichten, in thematische Commits aufteilen, zu Gitea pushen
- [ ] `flutter analyze` komplett sauber bekommen und sauber halten
- [ ] `lib/features/call/` vs. `lib/features/calls/` (leer?) klären – Leiche entfernen
- [ ] README.md durch echte Projektbeschreibung ersetzen (was ist Pyramid, wie baut man es, wo liegt was)
- [x] Die ~140 uncommitteten Dateien sichten, in thematische Commits aufteilen, zu Gitea pushen
(Sicherungs-Commit 25ed765 + Folge-Commits bis 74d38fd; Arbeitsbaum ist jetzt sauber)
- [x] `flutter analyze` komplett sauber bekommen und sauber halten (nur noch 1 bewusst
zurückgestellter Hinweis, siehe PROGRESS.md 2026-07-03)
- [x] `lib/features/call/` vs. `lib/features/calls/` (leer?) klären – Leiche entfernen
(geprüft: `calls/` existiert nicht mehr, nur `call/` mit den echten Dateien)
- [x] README.md durch echte Projektbeschreibung ersetzt (74d38fd)
- [x] **Fable-5-Qualitäts-Review aller bisherigen Autopilot-Commits (Bernds Wunsch, VOR allem anderen):**
ERLEDIGT – siehe Abschnitt „Fable-5-Review" oben in PROGRESS.md.
(a) alle 6 Gott-Datei-Aufteilungen unabhängig verifiziert (Zeilen-Multiset,
part-Bezüge): korrekt, verlorene Erklärkommentare waren mit f9979e4 schon
wiederhergestellt; (b) Uta-Logout-/Push-Fix gegen matrix-SDK 6.2.0
verifiziert: Analyse richtig, Lücke (SDK eskaliert Refresh-Fehler zu
logout()+clear()) war mit 891f348 (onSoftLogout-Guard) geschlossen, Guard
heute gegenverifiziert; (c) Lint-Fixes verhaltensäquivalent (onReorderItem
gegen Flutter-SDK geprüft, kein Off-by-One); (d) abgehakte Punkte stimmen
mit dem Code überein. Alles weiterhin UNGETESTET (Pi) → PC-Praxistest bleibt.
## M1 – Sicherheit: Nie wieder aussperren / kein Datenverlust
- [ ] **Uta-Random-Logout untersuchen:** Ursache finden (Soft-Logout vom Server? Token
ungültig? Fehlerpfad im Sync, der zum Logout führt?). Erst Logging an allen
Logout-Pfaden einbauen, dann Ursache fixen. Ein unerwarteter Logout darf NIE
Krypto-Schlüssel wegwerfen.
- [ ] Sichere Speicherung von Access-Token & Krypto-DB prüfen (secure storage / sqlcipher korrekt?)
- [ ] Key-Backup einrichten-Flow: Recovery-Key erzeugen, verständlich erklären, zum Sichern zwingen
- [ ] Key-Backup wiederherstellen-Flow: nach Login auf neuem Gerät alte Nachrichten entschlüsseln
- [ ] Logout-Schutz: Warnung mit Klartext-Folgen, wenn Key-Backup fehlt
- [ ] Geräte-/Sessionverwaltung in den Einstellungen (Liste, Verifizieren, Abmelden)
- [x] **Uta-Random-Logout untersuchen:** UNGETESTET (Pi) – Ursache im matrix-SDK
gefunden + Fix committet (63e4919, 9dc83f7), siehe PROGRESS.md 2026-07-03.
Nächster PC-Lauf MUSS den vollen Kreislauf praktisch prüfen (Login →
Nachrichten → Logout → erneuter Login → alte Nachrichten lesbar?), erst
dann gilt der Punkt als wirklich erledigt.
- [x] **REGRESSION: Push-Benachrichtigungen werden nicht mehr entschlüsselt** –
UNGETESTET (Pi), sehr wahrscheinlich DIESELBE Ursache wie der
Uta-Random-Logout-Bug (bereits mit 9dc83f7 mitgefixt), siehe PROGRESS.md
2026-07-03. Auf echtem Gerät prüfen: nach Neu-Login (für den Refresh-Token)
eine Weile laufen lassen und beobachten, ob Push-Nachrichten wieder mit
echtem Text statt nur „Neue Nachricht"-Platzhalter ankommen. Falls das
Problem weiterhin auftritt, liegt eine zweite, unabhängige Ursache vor
(z. B. fehlende Megolm-Schlüssel/Key-Sharing) – dann `docs/NOTIFICATIONS.md`
+ Memory „Pyramid Push" erneut konsultieren und die dort gelisteten 3
Fallstricke der Hintergrund-Entschlüsselung gegenprüfen.
- [ ] **Sichere Speicherung von Access-Token & Krypto-DB: LÜCKE GEFUNDEN.**
**Stand 2026-07-06: UMGESETZT + auf Windows praxisgetestet** – neue
Storage-Fassade `lib/core/app_database.dart` (SQLCipher auf
Android/Windows/Linux, Schlüssel im Keystore, Klartext-Migration mit
Backup/Verifikation/atomarem Tausch, Push-Isolate-Marker). Bernds echte
Windows-DB wurde erfolgreich migriert (PROGRESS.md 2026-07-06), Tests in
`test/app_database_test.dart` fahren die Migration mit echtem SQLCipher.
**Nachtrag 2026-07-06 (vierte PC-Session): Android-EMULATOR-Test grün**
(API 36: Neuanlage verschlüsselt, Klartext-Migration beim ersten Start,
Neustart mit Keystore-Schlüssel ohne Re-Migration – Details im
Status-Kopf von `docs/SQLCIPHER_MIGRATION.md`).
**Nachtrag 2026-07-06 (Nacht-Session): LIVE-E2E-Test mit ECHTEM Login grün**
(`test/live_encryption_e2e_test.dart`, Test-Account pyramidtest1 auf
steggi-matrix.work, Gate `PYRAMID_LIVE_TEST=1`): Platte nach echtem
Login/Nachricht byte-geprüft (kein SQLite-Header, Access-Token und
Nachrichtentext NICHT im Klartext auffindbar, Positiv-Kontrolle in
Klartext-DB findet beides), Neustart restauriert Session aus der
verschlüsselten DB, Logout→Re-Login→alte Nachricht lesbar, und die
Klartext-Migration mit einer ECHTEN eingeloggten Session erhält
Session+Daten. **Bleibt offen:**
echtes ARM-Gerät mit eingeloggter Session + Push-Isolate-Test
(FCM liest verschlüsselte DB, secure_storage im Background-Isolate) –
Testplan Punkt 4 in `docs/SQLCIPHER_MIGRATION.md`;
erst nach grünem Gerätetest Release + Utas Gerät (vorher ihr Key-Backup
prüfen!), dann abhaken.
`sqlcipher_flutter_libs` steht zwar in `pubspec.yaml`, wird aber nirgends
benutzt – `matrix_client.dart` und `background_push.dart` öffnen die
`pyramid.sqlite` (Access-Token, gepickelter Olm-Account, Nachrichten) über
den normalen `sqflite`/`sqflite_common_ffi`-Factory, also **unverschlüsselt**
auf der Platte. Bewusst NICHT blind auf dem Pi gefixt: eine Umstellung auf
SQLCipher braucht eine Migration für bereits bestehende Klartext-DBs
(Utas Gerät!) – ohne Testgerät hier zu riskant für „Kein Datenverlust".
Siehe PROGRESS.md 2026-07-03 + Frage an Bernd. **Umsetzungsplan liegt
fertig in `docs/SQLCIPHER_MIGRATION.md`** (2026-07-03, gegen Code und
SDK verifiziert) – PC-Termin kann direkt damit starten.
- [x] Key-Backup einrichten-Flow: bereits vorhanden (`bootstrap_dialog.dart`,
wird automatisch getriggert wenn Cross-Signing fehlt, `app_shell.dart`)
- [x] Key-Backup wiederherstellen-Flow: bereits vorhanden (`bootstrap_dialog.dart`,
`BootstrapState.askUnlockSsss` fragt nach Recovery-Key auf neuem Gerät)
- [x] Logout-Schutz: Warnung mit Klartext-Folgen, wenn Key-Backup fehlt (b5762ea)
- [x] Geräte-/Sessionverwaltung in den Einstellungen: bereits vorhanden
(`_SessionsSection` in `settings_modal.dart` – Liste, Umbenennen, Verifizieren
per SAS/QR, Abmelden, Massen-Abmeldung mit Passwort-Reauth)
- [x] **SICHERHEIT: Dashboard-Admin-Endpoints – ERLEDIGT: Loch geschlossen,
Heimnetz-only ist laut Bernd der Endzustand** (Fable-5-Review (o);
Entscheidung `ANTWORTEN_BERND.md` Nr. 3, 2026-07-05).
`server.py` nimmt `/api/ban`, `/api/unban`, `/api/toggle-registration`,
`/api/create-invite`, `/api/run-stats` jetzt NUR noch aus Heimnetz/localhost an
(Socket-IP + Cloudflare-Header-Check); Fremde aus dem Internet bekommen 403.
Headless verifiziert (öffentlich 403, Heimnetz ok, App-Endpoints unverändert),
siehe PROGRESS.md 2026-07-04. **Bernds Dashboard läuft im Heimnetz unverändert:
`http://192.168.178.71:8080/stats.html`.** Offen bleibt: Will Bernd die
Admin-Buttons auch von unterwegs? Dann Weg A (Cloudflare Access, empfohlen –
schließt auch die weiterhin öffentliche Nutzerlisten-Ansicht) oder Weg B
(`DASH_TOKEN`), Plan + Restrisiken in `docs/DASHBOARD_AUTH_HARDENING.md`.
Zusatz (Fable-5-Review (p), 2026-07-04): `server.py` läuft jetzt multithreaded
mit 30-s-Socket-Timeout – ein einzelner langsamer Client kann den Server
(und damit `/api/livekit-token`) nicht mehr blockieren; headless verifiziert.
Zusatz (Fable-5-Review (q), 2026-07-04): Der durch (p) eingeführte
Nebenläufigkeits-Race in den Admin-Routen (Read-Modify-Write auf `conduit.toml`,
`send_admin`-Readback) ist mit `ADMIN_LOCK` geschlossen, das NUR die
Admin-Mutationen serialisiert – die öffentlichen Routen bleiben nebenläufig;
headless verifiziert (2× parallel = 2× Laufzeit, öffentlicher Pfad 11 ms).
Zusatz (Fable-5-Review (r), 2026-07-04): drei Restbefunde gefixt –
`registration-status`-Leser jetzt unter ADMIN_LOCK (Truncate-Write-Race),
negative/übergroße `Content-Length` überall abgelehnt (kein Thread-Hänger,
ban/unban 4-KB-Deckel), Einladungs-Token per `secrets` statt `random`.
Zusatz (Fable-5-Review (s), 2026-07-04): ban/unban-Body wird jetzt VOR dem
ADMIN_LOCK gelesen (hängender Body-Read konnte den Lock 30 s festhalten),
und die öffentlichen Routen antworten bei internen Fehlern generisch statt
mit Exception-Text (Info-Leak); Details ins Journal. Headless verifiziert.
Zusatz (Fable-5-Review (t), 2026-07-04): auch der öffentliche GET-Leser
`registration-status` antwortet bei internen Fehlern jetzt generisch
statt mit `str(e)` (hätte den `conduit.toml`-Pfad geleakt); headless
verifiziert inkl. 500-Beweis.
Zusatz (Fable-5-Review (u), 2026-07-04): (t)-Fix gegengelesen – korrekt,
kein Befund; die server.py-Härtungskette ist damit KONVERGIERT (alle
verbliebenen `str(e)`-Antworten LAN-gegated, absichtlich).
**Abschluss (2026-07-05, `ANTWORTEN_BERND.md` Nr. 3): KEIN Fernzugriff
gewünscht – die A/B-Frage ist GESCHLOSSEN, das Heimnetz-Gate ist der
Endzustand (unterwegs nutzt Bernd WireGuard). Die Restrisiken des Gates
(öffentliche Nutzerlisten-Ansicht, theoretisches LAN-CSRF) sind bewusst
akzeptiert und in `docs/DASHBOARD_AUTH_HARDENING.md` dokumentiert.
Nicht weiter daran arbeiten.**
- [x] **LiveKit-Token-Route server-seitig gebaut + verifiziert** (Fable-5-Review (n),
PROGRESS.md 2026-07-04). `POST /api/livekit-token` läuft live in `server.py`
(whoami-Auth, stdlib-HS256-Minting aus `livekit.yaml`), headless geprüft
(401/400/413/200, JWT-Signatur unabhängig gegen `livekit.yaml` gültig, Identität =
geprüfte user_id). **Offen:** Client-Umstellung (`livekit_token.dart`/
`livekit_call_manager.dart`) + Secret-Rotation – beides PC/Gerät (heiliger
Call-Pfad), siehe `docs/LIVEKIT_TOKEN_MIGRATION.md`.
- [ ] **SICHERHEIT: geleakte Secrets rotieren + aus dem Client holen** (Fable-5-Review (k),
PROGRESS.md 2026-07-04). Das Gitea-Repo ist öffentlich (`private:false`), und es lagen
vier aktive Secrets drin – teils schon bereinigt (release.ps1, Admin-Doku), aber:
- **Rotieren (Pflicht, sie sind bereits geleakt):** Matrix-Server-Admin-Token
`J5lax…` (noch gültig, voller Admin!), Gitea-Release-Token, LiveKit-`apiKey`/
`apiSecret`, Giphy-Key. Alte jeweils widerrufen.
- **LiveKit-Token server-seitig minten:** kleiner Token-Endpoint auf dem Pi, damit
`apiSecret` aus `lib/core/livekit_token.dart` verschwindet (App holt nur das JWT).
**Umsetzungsplan liegt fertig in `docs/LIVEKIT_TOKEN_MIGRATION.md`** (2026-07-04,
gegen Code + Pi-Konfig geschrieben) – braucht echten Call-Test auf einem Gerät.
- **E2EE-Diagnose-Upload: ERLEDIGT (2026-07-04).** Der Dashboard-Server auf dem Pi
(`/home/steggi/matrix/server.py`) benutzte für `/api/e2ee-diagnostics` denselben
String wie den Matrix-Admin-Token – deshalb steckte er im Client. Jetzt hat das
Diagnose-Endpoint einen eigenen, eng begrenzten Token (`DIAG_TOKEN`, hängt nur an
den Log an, keine Admin-Rechte); `settings_encryption.dart` benutzt diesen. Der
**Admin-Token ist damit nicht mehr im App-Code** (per `grep` bestätigt). Server +
Client umgestellt und verifiziert (neuer Token 200, alter Admin-Token 403). Der
Admin-Token selbst muss trotzdem noch rotiert werden (steht oben, Bernds Sache).
- Optional: Git-History-Rewrite (Bernds Entscheidung, Repo ist auch Backup).
- **Stand 2026-07-05 (`ANTWORTEN_BERND.md` Nr. 4): Rotation und
History-Rewrite ruhen BEWUSST – beides bleibt Bernds manuelle Sache,
NICHT vom Autopilot anstoßen oder nachfragen. Der Punkt bleibt nur als
Merkposten offen; Autopilot-bearbeitbar ist hier nichts mehr.**
- **ACHTUNG bei „Repo einfach privat schalten":** Der In-App-Updater
(`lib/core/update_checker.dart`) holt Releases **anonym** über die öffentliche
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
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
(gehört zum ausstehenden PC-Praxistest der beiden Bugfixes oben;
kompletter Ablauf inkl. aller aufgelaufenen UNGETESTET-Punkte steht
jetzt in `docs/PC_TESTPLAN.md` – dort abarbeiten und abhaken)
**Teilstand 2026-07-06 (Autopilot, siehe PC_TESTPLAN):** flutter test
grün (29), Session über 3 Starts + DB-Migration stabil eingeloggt,
E2EE-Empfang live belegt, auth_log unauffällig; App läuft seit 12:43
für die Langzeit-Beobachtung weiter. **Offen (Klick/Bernd):**
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!)
@@ -32,22 +205,126 @@ Ziel: gleiche Funktionen, saubere **modulare** Struktur – als Fundament für a
Leitprinzip (Bernd): austauschbare Bausteine – jedes Modul muss sich komplett neu schreiben
lassen, ohne dass andere Module angefasst werden müssen.
- [ ] Ist-Analyse schreiben: Architektur-Schwächen auflisten (Call-Logik verteilt auf
`livekit_call_manager.dart`, `voip_manager.dart`, `voice_channel.dart`?, State-Management
uneinheitlich?, Gott-Dateien?, tote Widgets?) → als Refactoring-Plan in PROGRESS.md
- [ ] Modul-Schnitt definieren: Module (auth, chat, rooms, calls, streaming, push, storage, …)
mit je einer schmalen öffentlichen Schnittstelle; Abhängigkeiten nur über Interfaces,
keine Quer-Importe zwischen Feature-Modulen
- [x] Ist-Analyse geschrieben (PROGRESS.md 2026-07-03): Gott-Datei `settings_modal.dart`
(5541 Zeilen/50+ Klassen), Call-Schicht ohne gemeinsame Fassade (4 Module
importieren `voice_channel.dart` direkt), `app_state.dart` als Kopplungspunkt
aller Features, keine gemeinsame Storage-Schnittstelle (12 direkte
`SharedPreferences`-Zugriffe), toter Code (`message_bubble.dart`) + Duplikate
(`_formatTime`/`_formatDate` 3-fach). 8 Modul-Kandidaten dokumentiert.
- [x] Modul-Schnitt definiert (`docs/M2_MODULE_SCHNITT.md`, PROGRESS.md 2026-07-03):
„Calls" ist architektonisch zwei getrennte Module (`call_signaling` =
Matrix-1:1-VoIP, `voice_channel` = LiveKit-SFU-Kanäle) + `call_ui`;
außerdem `settings` (aufgesplittet), `verification` (neu, aus Settings
herausgelöst), `storage` (Ausbau `settings_prefs.dart`), `auth`, `push`,
`chat_timeline`/`rooms`. Umsetzungsreihenfolge vorgeschlagen, wartet auf
Bernds Go für den Call-Pilot (Frage in PROGRESS.md).
- [ ] Call-Schicht entwirren: EINE klare Zuständigkeit pro Klasse (Signalisierung /
LiveKit-Verbindung / UI-State sauber getrennt)
**Teilschritt erledigt (2026-07-06, M2-Pilot laut Entscheidung 2):**
Module `call_signaling` und `voice_channel` mit Fassaden-Interfaces +
Providern existieren (`lib/features/call_signaling/`,
`lib/features/voice_channel/`), alle Konsumenten gehen nur noch über
`callSignalingProvider`/`voiceChannelProvider` – kein UI-Import der
Implementierungen mehr (Details/Abweichungen: Status-Kopf in
`docs/M2_MODULE_SCHNITT.md`). App startet, 29 Tests grün, Sync/E2EE
laufen. **UNGETESTET:** echter 1:1-Call und Voice-Channel-Join per
Klick (braucht Gerät bzw. interaktiven Test) – deshalb nicht abgehakt.
**Weitere Teilschritte erledigt (2026-07-06, zweite PC-Session):**
`call_ui` ist jetzt ein eigenes Modul (`lib/features/call_ui/`,
b2335da) und die voice_*-Prefs laufen über die `VoicePrefs`-Fassade
in `core/settings_prefs.dart` statt direktem SharedPreferences-Zugriff
(d22f9c2) – beides verhaltensgleich, analyze/Tests grün, App-Start
geprüft. **Bewusst zurückgestellt bis Bernds Klick-Test des Piloten
(PC_TESTPLAN Abschnitt 8) grün ist:** UI-State aus den Managern
herauslösen – kein zweiter ungetesteter Logik-Umbau auf dem heiligen
Call-Pfad.
- [ ] Einheitliches State-Management (Riverpod konsequent, keine Misch-Patterns)
- [ ] Toten Code & Duplikate entfernen, Ordnerstruktur vereinheitlichen
**Ist-Analyse + Umbauplan liegen in `docs/M2_STATE_MANAGEMENT.md`**
(2026-07-06): Riverpod ist schon dominant (~50 Provider); 4
Misch-Patterns identifiziert, Schrittfolge definiert. Schritte 1–2
(Call-Manager-UI-State, Voice-Provider-Umzug) GESPERRT bis Bernds
Klick-Test (PC_TESTPLAN Abschnitt 8); MediaCache-Umstellung
zurückgestellt (hängt an der Chat-Timeline, Korrektur im Doc).
Push bleibt bewusst wie er ist (Isolate-Zwang). Toter
`incomingCallProvider` bereits entfernt (41bcaf2).
- [x] Toten Code & Duplikate entfernen, Ordnerstruktur vereinheitlichen
(PROGRESS.md 2026-07-03: `message_bubble.dart` tot entfernt, doppelte
`_formatTime` in `message_group.dart` zusammengeführt; alle 6 in der
Ist-Analyse gefundenen Gott-Dateien per `part`/`part of` in
thematische Dateien aufgeteilt – `widgets/settings_modal.dart`
(5541→270 Zeilen, 12 Teile unter `widgets/settings/`),
`features/chat/message_group.dart` (2775→30 Zeilen, 7 Teile unter
`features/chat/message/`), `features/rooms/rooms_panel.dart`
(2555→31 Zeilen, 8 Teile unter `features/rooms/panel/`),
`features/spaces/space_admin_dialog.dart` (2278→19 Zeilen, 6 Teile
unter `features/spaces/admin/`), `features/chat/chat_view.dart`
(2116→33 Zeilen, 6 Teile unter `features/chat/view/`),
`features/chat/document_viewer.dart` (1773→20 Zeilen, 5 Teile unter
`features/chat/document/`) – jeweils verifiziert per automatisiertem
Blockvergleich gegen das Original und `flutter analyze`, siehe
PROGRESS.md-Einträge. `features/call/voice_channel.dart` (1123 Zeilen)
und `layout/app_shell.dart` (1073 Zeilen) waren zunächst bewusst NICHT
mit angefasst – beide hängen eng mit der damals unentschiedenen
Call-Fassade bzw. dem Bootstrap/Login-Pfad zusammen.
**Nachtrag 2026-07-06:** Nach dem Call-Piloten ist
`call_ui/voice_channel.dart` jetzt ebenfalls geteilt (5 part-Dateien
unter `call_ui/view/`, zeichenidentisch verifiziert, 95d9b07);
`app_shell.dart` bleibt wegen des Bootstrap/Login-Pfads (heilig)
weiter unangetastet. Ebenso `settings/settings_encryption.dart`
(1470 Zeilen, größte verbliebene Teildatei) in 4 part-Dateien unter
`settings/encryption/` geteilt (zeichenidentisch verifiziert,
6128953) – Vorbereitung für das spätere verification-Modul
(Schnitt-Doc Schritt 3), Code selbst unangetastet.
UNGETESTET (Pi) im Sinne von „nicht in echter UI gesehen" – nächster
Windows-Lauf sollte alle 6 betroffenen Bereiche einmal durchklicken
(zusätzlich zu den 5 unten: eine PDF/Text/Archiv/Bild-Datei im Chat
öffnen, Vollbild-PDF-Viewer inkl. Thumbnail-Leiste und Speichern-Button
prüfen), siehe „Fragen an Bernd"/Nächster-Schritt in den jeweiligen
PROGRESS.md-Einträgen)
- [ ] 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). Offen: Umstieg auf
„Built-in Kotlin“ (Flutter kündigt Build-Fehler für das Kotlin-Plugin an;
betrifft auch Plugins wie flutter_webrtc/livekit_client).
- `flutter_markdown` ist eingestellt (pub: „replaced by
flutter_markdown_plus“) → umstellen (Markdown-Vorschau im Dokument-Viewer).
## M3 – Calls: Logik, UI & Audio (Discord als Referenz)
- [ ] Voice-Channel-Logik robust machen: Beitreten/Verlassen/Wechseln ohne Hänger,
klarer Zustand wer drin ist, sauberes Verhalten bei Verbindungsabbruch
**Teilfortschritt 2026-07-06 (Nacht, mit 2 echten Test-Accounts durchgeklickt):**
Standalone-Voice-Channel in der Raumliste war per Klick gar nicht
beitretbar → gefixt (`_toggleVoiceChannel` in rooms_list.dart, wie
Space-Liste). Zustands-Härtung in livekit_call_manager.dart:
`_callGeneration` verhindert Geisterverbindungen bei Auflegen/Wechsel
während `connect`; Fehlerpfade setzen `error` sichtbar statt still zu
verschwinden; rooms_panel_core räumt `activeVoiceRoomIdProvider` auf,
wenn der Service inaktiv wird, + Fehler-SnackBar. REAL getestet:
Beitreten (beide), Teilnehmerliste, bidirektionaler Screenshare,
Live-Qualitätswechsel, sauberes Verlassen. **UNGETESTET:** echtes
2-Peer-Audio (beide auf 1 PC), Netz-Abbruch mitten im Call (nur im
Code abgesichert). Nicht abgehakt.
- [ ] Call-UI aufräumen: aufgeräumter, intuitiver, konsistent (Mini-Call-Widget, Vollansicht)
- [ ] **Windows: Audiogeräte-Erkennung reparieren** (Mikrofon/Ausgabe erkennen und in den
Einstellungen auswählbar machen – geht aktuell gar nicht)
@@ -61,7 +338,19 @@ lassen, ohne dass andere Module angefasst werden müssen.
- [ ] **Framerate verbessern:** Ursache messen (Encoder? Auflösung? Simulcast-Settings?
Software- statt Hardware-Encoding?), dann gezielt fixen
**Teilfortschritt 2026-07-06 (Nacht, `docs/STREAMING_60FPS.md`, ffd70ca):**
Vier Ruckel-Ursachen adressiert – FPS-Deckel (jetzt hd60/fhd60 mit
echten 60 FPS), zu niedrige Bitrate (5/8 Mbit/s), Capture blieb auf
1080p/15 (Preset wird jetzt bis in ScreenShareCaptureOptions
durchgereicht), degradationPreference (60-FPS-Modi
maintainFramerate, kein Simulcast). contentHint='motion' in
flutter_webrtc 1.4.1 aus Dart nicht setzbar (dokumentiert). REAL
getestet: Umschalten auf Full HD 60fps live, Screenshare läuft weiter.
**UNGETESTET:** subjektives Ruckel-Empfinden + realer 60-FPS-RTP
(Sender/Empfänger auf 1 PC = nicht beurteilbar), braucht 2 Geräte+Netz.
- [ ] Auflösung/Qualität wählbar für den **Streamer** (z. B. 720p/1080p + FPS-Wahl)
**2026-07-06:** FPS-Wahl umgesetzt – SENDEN-Menü hat jetzt „HD 60fps"
und „Full HD 60fps" zusätzlich zu sd/hd/fhd/4k (ffd70ca).
- [ ] Qualität wählbar für den **Empfänger** (LiveKit-Simulcast-Layer auswählen)
- [ ] Sauberes Degradieren bei schwacher Bandbreite statt Ruckel-Chaos
@@ -82,6 +371,12 @@ lassen, ohne dass andere Module angefasst werden müssen.
## 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
- [ ] Design-Konsistenz-Pass über alle Icons/Buttons
- [ ] Onboarding: erster Start erklärt Server-Wahl und Verifikation verständlich
@@ -4,4 +4,10 @@
to allow setting breakpoints, to provide hot reload, etc.
-->
<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>
@@ -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
}
}
+7 -2
View File
@@ -17,10 +17,15 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<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
android:label="pyramid"
android:label="Pyramid"
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">
<service
android:name="com.cloudwebrtc.webrtc.FlutterForegroundService"
android:enabled="true"
@@ -35,14 +35,17 @@ object BgEngine {
private val fallbacks = mutableMapOf<Int, Runnable>()
/** 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
appContext = ctx
fallbackHandler.post {
cancelFallback(notifId)
val r = Runnable {
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)")
}
fallbacks[notifId] = r
@@ -114,16 +117,19 @@ object BgEngine {
}
// Dart finished decrypting — show the real content using the SAME
// 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" -> {
val title = call.argument<String>("title") ?: ""
val body = call.argument<String>("body") ?: "Neue Nachricht"
val roomId = call.argument<String>("room_id") ?: ""
val notifId = call.argument<Int>("notif_id") ?: NotificationHelper.stableId(roomId)
@Suppress("UNCHECKED_CAST")
val args = (call.arguments as? Map<String, Any?>) ?: emptyMap()
val roomId = (args["roomId"] ?: args["room_id"]) as? String ?: ""
val notifId = ((args["notifId"] ?: args["notif_id"]) as? Number)?.toInt()
?: NotificationHelper.stableId(roomId)
val ctx = appContext
if (ctx != null && roomId.isNotEmpty()) {
// Beat the fallback placeholder to it — show decrypted directly.
cancelFallback(notifId)
NotificationHelper.show(ctx, title, body, roomId, notifId)
NotificationHelper.showFromArgs(ctx, args)
Log.d(TAG, "showDecrypted → shown decrypted notification for $roomId")
}
result.success(null)
@@ -206,12 +206,11 @@ class MainActivity : FlutterActivity() {
result.success(null)
}
"showNativeNotification" -> {
val title = call.argument<String>("title") ?: ""
val body = call.argument<String>("body") ?: ""
val roomId = call.argument<String>("roomId") ?: ""
val notifId = call.argument<Int>("notifId") ?: NotificationHelper.stableId(roomId)
@Suppress("UNCHECKED_CAST")
val args = (call.arguments as? Map<String, Any?>) ?: emptyMap()
val roomId = args["roomId"] as? String ?: ""
if (roomId.isNotEmpty()) {
NotificationHelper.show(this, title, body, roomId, notifId)
NotificationHelper.showFromArgs(this, args)
result.success(null)
} else {
result.error("INVALID_ARG", "roomId missing", null)
@@ -5,13 +5,79 @@ import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Color
import android.net.Uri
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
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 {
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. */
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)
val (history, isNew) = mergeIntoHistory(context, roomId, notifId, msg)
val latest = history.last()
val mutFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
PendingIntent.FLAG_MUTABLE else 0
val style = NotificationCompat.MessagingStyle(
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.
// ReplyReceiver decrypts/sends via the background engine when killed.
// (ReplyReceiver has multiple RemoteInput key fallbacks for OEM quirks.)
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.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)
.setLabel("Antworten…").build()
val replyIntent = Intent(context, ReplyReceiver::class.java).apply {
@@ -49,59 +326,57 @@ object NotificationHelper {
putExtra(ReplyReceiver.EXTRA_NOTIF_ID, notifId)
}
val replyPi = PendingIntent.getBroadcast(
context, notifId,
replyIntent,
PendingIntent.FLAG_UPDATE_CURRENT or mutFlag,
context, notifId, replyIntent,
PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag(),
)
val replyAction = NotificationCompat.Action.Builder(0, "Antworten", replyPi)
return NotificationCompat.Action.Builder(0, "Antworten", replyPi)
.addRemoteInput(replyInput)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
.setShowsUserInterface(false)
.build()
}
// Dismiss action — also points to ReplyReceiver so notification is cancelled.
val dismissIntent = Intent(context, ReplyReceiver::class.java).apply {
// „Gelesen“ — schließt die Benachrichtigung UND setzt die Lesebestätigung
// 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
putExtra(ReplyReceiver.EXTRA_ROOM_ID, roomId)
putExtra(ReplyReceiver.EXTRA_NOTIF_ID, notifId)
putExtra(ReplyReceiver.EXTRA_EVENT_ID, eventId)
}
val dismissPi = PendingIntent.getBroadcast(
context, notifId + 1_000_000,
dismissIntent,
val pi = PendingIntent.getBroadcast(
context, notifId + 1_000_000, intent,
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 {
action = Intent.ACTION_MAIN
addCategory(Intent.CATEGORY_LAUNCHER)
putExtra("room_id", roomId)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
}
val tapPi = PendingIntent.getActivity(
context, notifId + 2_000_000,
tapIntent,
PendingIntent.FLAG_UPDATE_CURRENT or mutFlag,
return PendingIntent.getActivity(
context, notifId + 2_000_000, tapIntent,
PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag(),
)
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) =
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", "") ?: "")
// 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) {
val latch = CountDownLatch(1)
Thread {
try { title = fetchTitle(hs, token, roomId, eventId) ?: "Pyramid" }
try { meta = fetchMeta(hs, token, roomId, eventId) ?: PushMeta() }
finally { latch.countDown() }
}.start()
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")
if (engine != null) {
// App backgrounded but alive — show placeholder immediately, then the
// 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) {
val capturedRoomId = roomId
val capturedEventId = eventId
@@ -82,7 +82,7 @@ class PushService : FirebaseMessagingService() {
// real content directly. The "Neue Nachricht" placeholder is only a
// fallback if decryption takes too long or fails.
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(
"room_id" to roomId,
"event_id" to eventId,
@@ -90,7 +90,7 @@ class PushService : FirebaseMessagingService() {
))
} else {
// 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()
}
/** 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 ─────────────────────────────────────────────────────────
private fun fetchTitle(
private fun fetchMeta(
hs: String, token: String, roomId: String, eventId: String
): String? {
): PushMeta? {
return try {
val encRoom = java.net.URLEncoder.encode(roomId, "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 roomName = nameState?.optString("name")
return if (!roomName.isNullOrEmpty()) "$senderName · $roomName"
else senderName.ifEmpty { null }
return PushMeta(
senderId = senderId,
senderName = senderName.ifEmpty { "Pyramid" },
roomName = roomName?.takeIf { it.isNotEmpty() },
)
} catch (e: Exception) {
Log.w(TAG, "fetchTitle failed: ${e.message}")
null
@@ -16,6 +16,7 @@ class ReplyReceiver : BroadcastReceiver() {
const val ACTION_DISMISS = "chat.pyramid.pyramid.DISMISS"
const val EXTRA_ROOM_ID = "room_id"
const val EXTRA_NOTIF_ID = "notif_id"
const val EXTRA_EVENT_ID = "event_id"
const val REMOTE_INPUT_KEY = "reply_text"
}
@@ -23,12 +24,13 @@ class ReplyReceiver : BroadcastReceiver() {
val roomId = intent.getStringExtra(EXTRA_ROOM_ID) ?: return
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)
.cancel(notifId)
NotificationHelper.clearHistory(context, roomId)
if (intent.action == ACTION_DISMISS) {
Log.d("PYRAMID-REPLY", "dismiss action for room $roomId")
markRead(context, roomId, intent.getStringExtra(EXTRA_EVENT_ID))
return
}
@@ -81,4 +83,30 @@ class ReplyReceiver : BroadcastReceiver() {
try { pending.finish() } catch (_: Exception) {}
}, 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>
+4
View File
@@ -1,2 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
+1 -1
View File
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
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
+53 -7
View File
@@ -1,17 +1,40 @@
#!/bin/bash
# Pyramid-Autopilot (Pi/Linux): startet Claude-Sessions in Endlosschleife.
# Läuft eine Session ins 5h-Limit, wird alle 20 Minuten neu versucht.
# Laeuft eine Session ins Limit, wird alle 20 Minuten neu versucht.
# Start (am besten in tmux): tmux new -d -s pyramid ./autopilot.sh
# Wieder reinschauen: tmux attach -t pyramid (verlassen: Strg+B, dann D)
# PAUSIEREN: touch ~/pyramid/PAUSE (greift vor der nächsten Session)
# PAUSIEREN: touch ~/pyramid/PAUSE (greift vor der naechsten Session)
# FORTSETZEN: rm ~/pyramid/PAUSE
# GANZ STOPPEN: tmux kill-session -t pyramid
cd "$(dirname "$0")"
export PATH="$HOME/.local/bin:$HOME/flutter/bin:$PATH"
PROMPT="Lies CLAUDE.md und arbeite ROADMAP.md Punkt für Punkt weiter ab. \
Halte dich strikt an den Arbeitszyklus (PROGRESS.md pflegen, kleine Commits + Push). \
Arbeite so viele Punkte ab wie möglich."
# --- NUR EIN Konto: das nordmann-Konto (Profil ~/.claude = Standard-Profil) ---
# Bewusst KEINE Konto-Rotation (Bernd, 2026-07-06): der Pyramid-Autopilot arbeitet
# ausschliesslich mit dem nordmann-Konto. Das Standard-Profil liegt unter
# ~/.claude / ~/.claude.json -> CLAUDE_CONFIG_DIR NICHT setzen (sonst sucht Claude
# die Config faelschlich unter ~/.claude/.claude.json).
# (Die E-Mail-Adresse des Kontos steht absichtlich NICHT hier: das Gitea-Repo
# ist oeffentlich, PII gehoert nicht hinein.)
apply_account() { unset CLAUDE_CONFIG_DIR; }
ACCOUNT_NAME="nordmann"
# --- Matrix-Statusberichte (Raum "🔺 Pyramid Autopilot", Token vom Gatus-Bot) ---
MATRIX_URL="http://127.0.0.1:6167"
MATRIX_ROOM="!sYfOwD4Dw4jBDTlNmL:steggi-matrix.work"
MATRIX_TOKEN="$(grep -s '^MATRIX_TOKEN=' "$HOME/gatus/.env" | cut -d= -f2-)"
notify_matrix() {
[ -z "$MATRIX_TOKEN" ] && return 0
local body
body=$(python3 -c 'import json,sys; print(json.dumps({"msgtype":"m.text","body":sys.stdin.read().strip()}))' <<< "$1")
curl -s -m 10 -X PUT \
"$MATRIX_URL/_matrix/client/v3/rooms/$MATRIX_ROOM/send/m.room.message/autopilot$(date +%s%N)" \
-H "Authorization: Bearer $MATRIX_TOKEN" -H "Content-Type: application/json" \
-d "$body" > /dev/null || true
}
PROMPT="Lies CLAUDE.md. Arbeite in dieser Reihenfolge: (1) Review-Pass: Gehe die in ROADMAP.md/PROGRESS.md als erledigt markierten Punkte durch, prüfe den zugehörigen Code kritisch und überarbeite/verbessere ihn, wo nötig. Führe in PROGRESS.md einen Abschnitt 'Fable-5-Review' und hake dort jeden geprüften Punkt ab, damit bereits reviewte Punkte nicht erneut geprüft werden. (2) Wenn alle erledigten Punkte reviewt sind, arbeite ROADMAP.md Punkt für Punkt weiter ab. Halte dich strikt an den Arbeitszyklus (PROGRESS.md pflegen, kleine Commits + Push). Arbeite so viel ab wie möglich."
while true; do
if [ -f PAUSE ]; then
@@ -19,8 +42,31 @@ while true; do
sleep 600
continue
fi
echo "===== $(date '+%F %T') Neue Claude-Session =====" | tee -a autopilot.log
claude -p "$PROMPT" --dangerously-skip-permissions 2>&1 | tee -a autopilot.log
apply_account
echo "===== $(date '+%F %T') Neue Claude-Session (Konto $ACCOUNT_NAME) =====" | tee -a autopilot.log
BEFORE=$(git rev-parse HEAD 2>/dev/null)
SESSION_OUT=$(mktemp)
claude -p "$PROMPT" --model claude-fable-5 --dangerously-skip-permissions 2>&1 | tee -a autopilot.log "$SESSION_OUT"
AFTER=$(git rev-parse HEAD 2>/dev/null)
if [ -n "$BEFORE" ] && [ "$BEFORE" != "$AFTER" ]; then
COMMITS=$(git log --oneline --no-decorate "$BEFORE..$AFTER" | head -15)
NEXT=$(grep -m1 -A3 "Offen/Nächster Schritt" PROGRESS.md)
notify_matrix "🔺 Pyramid-Autopilot $(date '+%H:%M') – Session fertig.
Neue Commits:
$COMMITS
$NEXT"
fi
# Limit erkannt? -> 20 Minuten warten (KEINE Konto-Rotation mehr)
if grep -qiE "hit your.*limit|usage limit|session limit" "$SESSION_OUT"; then
rm -f "$SESSION_OUT"
echo "===== $(date '+%F %T') nordmann-Konto am Limit - naechster Versuch in 20 Minuten =====" | tee -a autopilot.log
sleep 1200
continue
fi
rm -f "$SESSION_OUT"
echo "===== $(date '+%F %T') Session beendet - Neustart in 20 Minuten (Strg+C zum Stoppen) =====" | tee -a autopilot.log
sleep 1200
done
+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);
}
+177
View File
@@ -0,0 +1,177 @@
# Dashboard-Server absichern: Admin-Endpoints haben KEINE Authentifizierung
**Status (ABGESCHLOSSEN 2026-07-05): Das Loch ist mit dem Heimnetz-Gate
(„Weg C", siehe unten) geschlossen, und Bernd hat entschieden
(`ANTWORTEN_BERND.md` Nr. 3): KEIN Fernzugriff – das Gate ist der ENDZUSTAND.**
Die fünf Admin-Endpoints akzeptieren nur noch Anfragen aus dem
Heimnetz/localhost; alles, was über den Cloudflare-Tunnel kommt, bekommt 403 mit
einer Erklärung (die das Dashboard per `alert()` anzeigt). Bernds Dashboard
funktioniert im Heimnetz unverändert unter
`http://192.168.178.71:8080/stats.html`; unterwegs kommt Bernd per WireGuard ins
Heimnetz. Die A/B-Frage (Cloudflare Access vs. `DASH_TOKEN`) ist damit
GESCHLOSSEN – die Optionen unten bleiben nur als Archiv stehen, falls sich der
Bedarf je ändert. Die unten dokumentierten Restrisiken des Gates sind bewusst
akzeptiert. **Nicht weiter an diesem Thema arbeiten.**
## Heimnetz-Gate („Weg C", aktiv seit 2026-07-04, seit 2026-07-05 Endzustand)
In `server.py` (Konstante `ADMIN_PATHS` + Helfer `lan_denied_reason`): eine
Admin-POST-Anfrage wird nur angenommen, wenn (1) **kein** `Cf-Connecting-Ip`/
`Cf-Ray`-Header vorhanden ist (= sie kam nicht durch den Cloudflare-Tunnel) und
(2) die **Socket-Absender-IP** (nicht spoofbar) `127.0.0.1`/`::1`/`192.168.*`
ist. Abgelehnte Versuche landen größenbegrenzt (5 MB-Deckel) in
`security_alerts.log` und damit in der Dashboard-Anzeige. Headless verifiziert:
öffentliche URL → 403 (ban/toggle), Heimnetz-IP und localhost → App-Validierung
erreicht (400 bei leerem Nutzer), gespoofter Cf-Header aus dem LAN → 403,
`GET /` öffentlich → 200, `/api/livekit-token` (401 bei ungültigem Token) und
`/api/e2ee-diagnostics` (403 bei falschem Token) unverändert öffentlich
erreichbar (die App braucht sie, eigene Auth vorhanden).
**Bekannte Restrisiken des Gates (Bernd bekannt und bewusst akzeptiert,
Entscheidung 2026-07-05):**
- Die **öffentliche Ansicht bleibt offen** und zeigt Nutzerliste + Präsenz
(Informations-Preisgabe). Weg A würde auch das schließen.
- **CSRF aus dem Heimnetz:** Eine bösartige Webseite in Bernds Browser könnte
theoretisch POSTs an `http://192.168.178.71:8080` schicken (moderne
Chrome-Versionen blocken Public→Private-Requests, Firefox nicht vollständig).
Erst ein Token/Access-Login (Weg A/B) schließt das sauber.
- Wer **im Heimnetz** ist, kann weiterhin ohne Anmeldung administrieren.
## Problem (belegt, nicht vermutet)
Der Dashboard-Server `/home/steggi/matrix/server.py` (läuft als
`matrix-stats.service` auf `0.0.0.0:8080`) ist über Cloudflare unter
`https://dashboard.steggi-matrix.work` **öffentlich aus dem Internet erreichbar**.
Getestet: `GET /` liefert 200 (stats.html), und die **zustandsändernden POST-Endpoints
antworten mit der App-eigenen Validierung – ohne jede Authentifizierung**:
```
$ curl -X POST https://dashboard.steggi-matrix.work/api/ban -d '{"user":""}'
{"error": "Kein Nutzer angegeben"} # HTTP 400 – App-Antwort, KEIN Login davor
```
Damit kann **jeder Fremde im Internet**, der den Hostnamen kennt, ohne Anmeldung:
- `/api/ban` / `/api/unban` – **jeden Matrix-Nutzer sperren/entsperren** (auch Uta!)
→ Aussperrung / Denial-of-Service gegen echte Nutzer.
- `/api/toggle-registration` und `/api/create-invite` – **offene Registrierung am
Homeserver aktivieren** (setzt `allow_registration = true` +
`yes_i_am_very_very_sure…`) → der Homeserver wird zur Spam-/Missbrauchsschleuder.
- `/api/run-stats` – Stats-Skript auslösen (harmlos, aber ebenfalls ungeschützt).
**Der Hostname ist nicht wirklich geheim:** Cloudflare stellt TLS-Zertifikate aus,
die in den öffentlichen Certificate-Transparency-Logs landen – jede Subdomain von
`steggi-matrix.work` ist per CT-Log-Scan auffindbar. Die bisherige „Sicherheit"
beruht also allein auf Verborgenheit eines Namens, der praktisch entdeckbar ist.
Bereits **korrekt abgesichert** (zum Vergleich, nicht betroffen):
- `/api/e2ee-diagnostics` – eigener `DIAG_TOKEN` (Bearer).
- `/api/livekit-token` – Matrix-Access-Token per `whoami` gegen Continuwuity.
Nur die vier Admin-/Stats-Endpoints oben sind offen.
## Wie das Dashboard heute aufruft
`stats.html` ruft alle Aktionen als **Same-Origin-`fetch` ohne Auth-Header** auf
(`toggleRegistration`, `createInvite`, `refreshStats`, `doUserAction` →
`/api/ban` /`/api/unban`). Es gibt keine Session, kein Cookie, keinen Token –
deshalb bricht **jede** hinzugefügte Authentifizierung das Dashboard so lange,
bis `stats.html` den Token mitschickt. Genau darum ist das eine Bernd-Entscheidung
und kein stiller Fix.
## Option A (empfohlen, robust): Cloudflare Access vor die Subdomain
Am saubersten wird das Dashboard **komplett** durch Cloudflare Access geschützt
(Zero-Trust-Login vor der Subdomain), statt in `server.py` Auth nachzubauen:
1. Im Cloudflare-Zero-Trust-Dashboard eine **Access-Application** für
`dashboard.steggi-matrix.work` anlegen.
2. Policy: nur Bernds E-Mail (`[email protected]`) per One-Time-PIN
oder Google-Login zulassen.
3. Fertig – `server.py` und `stats.html` bleiben **unverändert**, das Dashboard
funktioniert für Bernd wie bisher (nach dem Access-Login), aber Fremde kommen
gar nicht erst an die Endpoints.
Vorteil: kein Secret im Code, kein Umbau der HTML-Fetches, kein Aussperr-Risiko
durch vergessene Tokens. Nachteil: Bernd muss die Access-App einmal im
Cloudflare-Panel klicken (kein Code, aber sein Konto nötig). **Das ist der
Königsweg – Option B nur, falls Access nicht gewünscht ist.**
## Option B (Fallback, rein in server.py): eigener Dashboard-Token
Wenn Access nicht gewünscht ist, ein **neuer, zufälliger `DASH_TOKEN`** (getrennt
vom geleakten Admin-Token `J5lax…` und vom `DIAG_TOKEN`!), den die vier
Admin-/Stats-Endpoints als `Authorization: Bearer <DASH_TOKEN>` verlangen:
```python
# server.py, zu den anderen Token-Konstanten:
DASH_TOKEN = "<neu erzeugen: python3 -c 'import secrets;print(\"dash-\"+secrets.token_urlsafe(24))'>"
def _dash_authed(self):
return self.headers.get("Authorization", "") == "Bearer " + DASH_TOKEN
```
In `do_POST` **vor** der Behandlung von `/api/ban`, `/api/unban`,
`/api/toggle-registration`, `/api/create-invite`, `/api/run-stats` einfügen:
```python
if self.path in ("/api/ban", "/api/unban", "/api/toggle-registration",
"/api/create-invite", "/api/run-stats") and not self._dash_authed():
self.send_error(403)
return
```
`GET /` und `/api/registration-status` bleiben öffentlich (nur Lesen), damit die
Stats-Seite ohne Token sichtbar bleibt.
**`stats.html`** darf den Token **NIEMALS fest eingebettet** ausliefern (die Seite
ist öffentlich – der Token läge sonst für jeden offen). Stattdessen einmal im
Browser abfragen und in `localStorage` halten:
```javascript
function dashToken() {
var t = localStorage.getItem('dashToken');
if (!t) { t = prompt('Dashboard-Token:'); if (t) localStorage.setItem('dashToken', t); }
return t || '';
}
// und bei JEDEM Aktions-fetch:
fetch('/api/ban', { method:'POST',
headers: {'Content-Type':'application/json', 'Authorization':'Bearer ' + dashToken()},
body: JSON.stringify({user:u}) })
```
(betrifft `toggleRegistration`, `createInvite`, `refreshStats`, `doUserAction`).
Bei 403 den gecachten Token verwerfen (`localStorage.removeItem('dashToken')`) und
neu fragen. Bernd gibt den Token so genau **einmal pro Browser** ein.
## Rollout / Testplan
**Option A:** Access-App klicken → von einem fremden Netz/Inkognito prüfen, dass
`https://dashboard.steggi-matrix.work/` zum Access-Login umleitet statt die Seite
zu zeigen; dann als Bernd einloggen und alle Buttons testen. Kein Code-Deploy.
**Option B (headless auf dem Pi verifizierbar):**
1. `server.py` + `stats.html` ändern, Dienst-Neustart (Prozess killen,
`Restart=always` lädt neu – siehe PROGRESS 2026-07-04 zur polkit-Falle).
2. `curl -X POST .../api/ban -d '{"user":"x"}'` **ohne** Bearer → **403** erwartet.
3. Mit `Authorization: Bearer <DASH_TOKEN>` + leerem Nutzer → 400 (App-Validierung
erreicht) → beweist: Token akzeptiert, Auth greift.
4. Im Browser: Dashboard öffnen, Token eingeben, je ein Button testen
(Registrierung toggeln + sofort zurück, Stats aktualisieren; Ban/Unban an
einem **Test-Nutzer**, nicht an Uta).
## Warum erst nur der Plan kam – und dann doch das Interims-Gate
Eine Token-/Access-Absicherung (A/B) macht die Dashboard-Buttons überall
funktionslos, bis Bernd aktiv etwas tut (Token eingeben bzw. Access-App
einrichten) – das mitten in einer autonomen Session zu deployen verletzt die
CLAUDE.md-Leitplanke „kein Aussperren". Das *Wie* der Anmeldung bleibt deshalb
Bernds Richtungsentscheidung. Das später ergänzte Interims-Gate umgeht dieses
Dilemma: Es braucht keinerlei Aktion von Bernd (Heimnetz-Nutzung läuft
unverändert weiter, inkl. spontanem Spam-Bann von zu Hause), sperrt aber
Fremde aus dem Internet sofort aus. Einzige Einschränkung: Admin-Buttons über
die öffentliche URL gehen nicht – die 403-Meldung im Dashboard erklärt das und
nennt die Heimnetz-URL. Mit Bernds Entscheidung vom 2026-07-05 (Heimnetz-only,
unterwegs WireGuard) ist genau dieser Zustand der gewollte Endzustand. Rückbau
(falls je nötig) = die Gate-Zeilen in `server.py` entfernen
(Backup: `server.py.bak-20260704-dashgate`).
+143
View File
@@ -0,0 +1,143 @@
# LiveKit-Token server-seitig minten (apiSecret aus dem Client entfernen)
**Status:** geplant, NICHT umgesetzt (braucht echten Call-Test auf einem Gerät –
Calls sind laut CLAUDE.md „heilig"). Dieser Plan ist gegen den echten Code und die
echte Pi-Konfiguration geschrieben (2026-07-04), damit ein PC-Termin direkt starten
kann. Analog zu `docs/SQLCIPHER_MIGRATION.md`.
## Problem
`lib/core/livekit_token.dart` mintet die LiveKit-JWTs **im Client**. Dafür muss das
LiveKit-`apiSecret` im App-Code liegen (`livekit_token.dart:6`) – d. h. jeder
App-Nutzer (auch Uta) trägt das SFU-Secret im Gerät. Wer es hat, kann sich für
**jeden** Raum und **jede** Identität ein Beitritts-Token ausstellen. Das ist ein
Architekturproblem, kein bloßer Repo-Leak: Selbst nach einem History-Rewrite bliebe
das Secret im ausgelieferten Binary.
Aktueller Fluss (`lib/core/livekit_call_manager.dart:154`):
```dart
final token = LiveKitTokenGenerator.generate(
roomName: roomName,
identity: identity,
displayName: identity,
ttlSeconds: 21600, // 6 h
);
```
Erzeugtes JWT (HS256, `livekit_token.dart:14-33`): Grants `video` mit
`roomJoin/room/canPublish/canSubscribe/canPublishData`, `metadata` = Anzeigename,
`iss` = apiKey (`LKMatrixPi`), `sub` = identity, `exp` = jetzt+ttl.
## Ziel
Das `apiSecret` liegt **nur noch auf dem Pi**. Der Client holt sich für einen
konkreten Raum ein fertiges JWT von einem kleinen Token-Endpoint und übergibt es
unverändert an `Room.connect`.
## Server-Seite (Pi)
Es gibt bereits einen schlanken Dashboard-Server ohne Fremd-Abhängigkeiten:
`/home/steggi/matrix/server.py` (stdlib `http.server`, läuft als
`matrix-stats.service` auf `0.0.0.0:8080`, nach außen `dashboard.steggi-matrix.work`).
Der Token-Endpoint kann dort als weitere Route `POST /api/livekit-token` andocken –
kein neuer Dienst nötig. Das LiveKit-`apiSecret` steht schon auf dem Pi in
`/home/steggi/matrix/livekit.yaml` (`keys: LKMatrixPi: <secret>`); der Server liest
es von dort, statt es erneut zu hinterlegen (eine Quelle der Wahrheit).
**Wichtig – Authentifizierung:** Der Endpoint darf NICHT anonym Tokens ausstellen
(sonst ist nichts gewonnen). Der Client schickt seinen **Matrix-Access-Token** mit;
der Server prüft ihn gegen Continuwuity und leitet daraus die Identität ab:
1. `POST /api/livekit-token`, Body `{ "room": "<roomName>", "matrix_token": "<access_token>" }`.
2. Server ruft `GET http://127.0.0.1:6167/_matrix/client/v3/account/whoami`
mit `Authorization: Bearer <matrix_token>` auf → liefert `user_id`
(401 → Endpoint gibt 401 zurück, kein Token).
3. `identity` = `user_id` (NICHT vom Client wählbar – verhindert Identitäts-Spoofing).
4. Optional, aber empfohlen: Raummitgliedschaft prüfen (`/_matrix/client/v3/rooms/
{roomId}/joined_members` oder der Matrix-Raum, zu dem der Voice-Channel gehört) –
nur ausstellen, wenn der Nutzer wirklich Mitglied ist. Für Phase 1 reicht die
whoami-Authentifizierung; Mitgliedschaftsprüfung als Härtung nachziehen.
5. Server mintet das LiveKit-JWT **mit denselben Grants wie heute** und gibt
`{ "token": "<jwt>", "url": "wss://livekit.steggi-matrix.work" }` zurück.
**JWT-Minting ohne Fremd-Paket (stdlib genügt):** LiveKit-Tokens sind HS256-JWTs.
Mit `hmac`/`hashlib`/`base64`/`json` aus der stdlib:
```python
import base64, hmac, hashlib, json, time
def _b64url(b): return base64.urlsafe_b64encode(b).rstrip(b"=")
def mint_livekit(api_key, api_secret, identity, room, name, ttl=21600):
now = int(time.time())
header = {"alg": "HS256", "typ": "JWT"}
payload = {
"iss": api_key, "sub": identity, "name": name,
"nbf": now, "exp": now + ttl, "metadata": name,
"video": {"roomJoin": True, "room": room,
"canPublish": True, "canSubscribe": True,
"canPublishData": True},
}
segs = [_b64url(json.dumps(header, separators=(",", ":")).encode()),
_b64url(json.dumps(payload, separators=(",", ":")).encode())]
signing_input = b".".join(segs)
sig = hmac.new(api_secret.encode(), signing_input, hashlib.sha256).digest()
return (signing_input + b"." + _b64url(sig)).decode()
```
Verifikation lokal auf dem Pi (kein Gerät nötig): geminetes Token gegen den
laufenden LiveKit prüfen, z. B. per LiveKit-CLI `lk` oder indem man das JWT auf
jwt.io-Art dekodiert und Claims/Signatur vergleicht. Zusätzlich Gegentest mit dem
**alten** Client-Pfad: dasselbe Token muss `Room.connect` akzeptieren.
## Client-Seite
- `lib/core/livekit_token.dart`: `apiKey`/`apiSecret` **entfernen**. Die Klasse wird
entweder gelöscht oder zu einem dünnen HTTP-Client
`Future<String> fetchLiveKitToken({required String room, required String matrixToken})`,
der `POST https://dashboard.steggi-matrix.work/api/livekit-token` aufruft und das
`token`-Feld zurückgibt. `http`-Paket ist schon Abhängigkeit (siehe
`update_checker.dart`).
- `lib/core/livekit_call_manager.dart:154`: `LiveKitTokenGenerator.generate(...)`
→ `await fetchLiveKitToken(room: roomName, matrixToken: matrixClient.accessToken)`.
Der Manager hat den Matrix-Client bereits (`_matrixClient`, Zeile 149) – der
Access-Token ist also verfügbar. `identity`/`displayName` werden nicht mehr vom
Client bestimmt (Server leitet Identität aus whoami ab); den bisherigen
`identity`-Parameter entsprechend zurückbauen.
- Fehlerpfade: Netzfehler / 401 / 403 sauber als Call-Fehler anzeigen
(`error = ...; notifyListeners()`), NICHT still schlucken – sonst „Call verbindet
nicht" ohne Hinweis. TTL bleibt 21600 s (6 h), Erneuerung wie bisher beim
Neu-Beitreten.
## Rotation (Pflicht, danach)
Das alte `apiSecret` `rYUT2PRa…` ist geleakt (Client + Git-History). Nach der
Umstellung:
1. Neues `apiSecret` in `/home/steggi/matrix/livekit.yaml` (`keys:`) setzen.
2. LiveKit-Container neu starten
(`docker compose -f /home/steggi/matrix/docker-compose.yml restart livekit`).
3. `server.py` liest das Secret aus `livekit.yaml` – kein zweiter Ort zu pflegen.
4. Alte Clients (Utas installierte APK) minten mit dem alten Secret → deren
selbst-gemintete Tokens werden nach der Rotation abgelehnt. Deshalb Rotation
**zusammen mit einem neuen Release** ausrollen, das den neuen Fetch-Pfad nutzt.
## Reihenfolge / Testplan (PC + Gerät)
1. `server.py`-Route bauen, lokal auf dem Pi verifizieren (whoami-Auth greift,
geminetes Token dekodiert korrekt, `Room.connect` akzeptiert es).
2. Client umstellen, `flutter analyze` sauber, `flutter test` grün.
3. **Auf echtem Gerät** (Calls heilig, kein Headless-Test möglich): Voice-Channel
beitreten, Audio/Video/Screenshare, Verlassen/Wieder-Beitreten, zweiter
Teilnehmer. Erst wenn das steht: Secret rotieren + Release.
4. Danach ROADMAP-M0-Punkt „LiveKit-Token server-seitig minten" abhaken und den
Rotations-Teil des Secret-Punkts als erledigt markieren.
## Warum nicht jetzt (auf dem Pi) gemacht
Die Server-Route ließe sich hier bauen und headless testen – aber der zugehörige
Client-Umbau tauscht den **heiligen Call-Pfad** aus und ist auf diesem Pi ohne
GUI/Gerät nicht praktisch prüfbar. Einen halben, nicht end-to-end verifizierbaren
Umbau am Call-Pfad zu stapeln verbietet die CLAUDE.md-Leitplanke („keine
Verhaltensänderung ohne Not", Calls heilig). Deshalb hier nur der fertige Plan.
+192
View File
@@ -0,0 +1,192 @@
# M2 – Modul-Schnitt (Entwurf)
> **STATUS 2026-07-06: Pilot (Schritt 1) UMGESETZT.** Die Module
> `call_signaling` (`lib/features/call_signaling/`: `CallSignalingService`
> + `callSignalingProvider` + Implementierung `voip_manager.dart`) und
> `voice_channel` (`lib/features/voice_channel/`: `VoiceChannelService` +
> `voiceChannelProvider` + Implementierung `livekit_call_manager.dart`)
> existieren; ALLE Konsumenten (Shell, Call-UI, Rooms-Panel, Chat-Header,
> Settings) gehen nur noch über die Provider/Interfaces, kein direkter
> Implementierungs-Import mehr (einzige dokumentierte Ausnahme:
> Kompositions-Punkt `matrix_client.dart`). Bewusste Abweichungen vom
> Entwurf unten (Verhaltensgleichheit vor Schönheit, heiliger Call-Pfad):
> ChangeNotifier statt Streams, Methodennamen wie im Bestand
> (`startCall`/`hangUp` statt `join`/`leave`), `CallSession`/LiveKit-`Room`
> bleiben in den Schnittstellen sichtbar, weil die UI direkt rendert.
> Echter Call-/Voice-Channel-Test steht aus (Gerät bzw. Klick-Test) –
> siehe PROGRESS.md 2026-07-06. Schritte 2–5 unten sind weiter offen.
>
> **Nachtrag 2026-07-06 (zweite PC-Session):** `call_ui` ist jetzt ein
> eigenes Modul (`lib/features/call_ui/`, reine Umbenennung von
> `features/call/`; konsumiert weiterhin nur die zwei Service-Interfaces).
> Außerdem ist der storage-Teilschritt für die Call-Module erledigt: die
> voice_*-Prefs (Geräte + Ausgabelautstärke) laufen über die neue
> `VoicePrefs`-Kapsel in `core/settings_prefs.dart` (Punkt 6 unten) –
> kein direkter SharedPreferences-Zugriff mehr in `voip_manager.dart`/
> `livekit_call_manager.dart`/`settings_voice.dart`, Keys unverändert.
> „UI-State aus den Managern herauslösen" wartet bewusst auf Bernds
> Klick-Test des Piloten (heiliger Call-Pfad).
Stand: 2026-07-03. Reine Schnittstellen-Planung, noch kein Umbau. Grundlage:
Ist-Analyse in `PROGRESS.md` (Eintrag „M2: Ist-Analyse der Architektur
geschrieben"). Leitprinzip (Bernd, `CLAUDE.md`): jedes Modul muss sich in
einem Jahr komplett neu schreiben lassen, ohne andere Module anzufassen.
Andere Module dürfen daher nur die hier genannte öffentliche Schnittstelle
importieren, nie interne Dateien.
Korrektur zur Ist-Analyse: der Import von `rooms_provider.dart` in
`app_state.dart` ist **kein** toter Altlast-Import, wie dort vermutet –
`voiceParticipantsProvider` (Zeile 160) nutzt `roomListProvider` daraus, um
bei jedem Sync neu zu bauen. Der Eintrag in PROGRESS.md wird beim nächsten
Durchgang korrigiert.
## Wichtige Erkenntnis vor dem Schnitt: „Calls" ist eigentlich ZWEI Features
Beim genaueren Lesen von `voip_manager.dart` und `livekit_call_manager.dart`
zeigt sich: das sind keine zwei Hälften derselben Sache, sondern zwei
unabhängige Call-Mechanismen, die zufällig beide „Call" heißen:
- **`PyramidVoipManager`** – natives Matrix-VoIP (`m.call.*`-Events über das
matrix-SDK, direkte 1:1-WebRTC-Peer-Connection, kein SFU). Vermutlich für
spontane 1:1-Anrufe gedacht.
- **`LiveKitCallManager`** – SFU-basierte, dauerhafte Voice-Channels (LiveKit-
Server-Raum, Presence über Matrix-State-Events, Screensharing, Mehrpersonen).
Das ist der Discord-artige Voice-Channel aus M3/M4.
Beide Module sollten **getrennt** bleiben, nicht zu einem „calls"-Modul
verschmolzen werden. Nur die UI (`call_ui`) darf beide kennen, falls sie
gemeinsam dargestellt werden müssen (z. B. „du bist in einem Voice-Channel,
jemand ruft dich per 1:1-VoIP an").
## Modul-Liste mit öffentlicher Schnittstelle (Entwurf)
### 1. `call_signaling` (Matrix-1:1-VoIP)
**Ersetzt:** `core/voip_manager.dart`
**Öffentliche Schnittstelle (Ausschnitt):**
```dart
abstract class CallSignalingService {
Stream<CallSession?> get currentCallStream;
bool get isMicMuted;
bool get isCameraMuted;
Future<void> startCall(String roomId, {bool video});
Future<void> hangup();
Future<void> toggleMic();
Future<void> toggleCamera();
Future<void> toggleScreenSharing(BuildContext? context);
RTCVideoRenderer get localRenderer; // bleibt vorerst WebRTC-typisiert,
RTCVideoRenderer get remoteRenderer; // da UI direkt rendert
}
```
Interna (ICE-Server-Merge, `MediaDevicesWrapper`, Geräte-Prefs aus
`SharedPreferences`) bleiben privat im Modul. Geräte-Prefs sollten künftig
über die `storage`-Fassade laufen statt direktem `SharedPreferences`-Zugriff
(aktuell Zeile 260, 447).
### 2. `voice_channel` (LiveKit-SFU, Discord-artige Kanäle)
**Ersetzt:** `core/livekit_call_manager.dart`
**Öffentliche Schnittstelle (Ausschnitt):**
```dart
abstract class VoiceChannelService {
Stream<VoiceChannelState> get stateStream; // isActive, isMuted, participants…
Future<void> join({required String roomName, required String matrixRoomId, ...});
Future<void> leave();
Future<void> toggleMute();
Future<void> toggleDeafen();
Future<void> toggleCamera();
Future<void> toggleScreenShare();
Future<void> setPublishQuality(String key); // Streamer-Seite (M4)
Future<void> setSubscribeQuality(String key); // Empfänger-Seite (M4)
Future<void> setOutputVolume(double v);
}
```
Presence-Writing (`_writePresence`, Matrix-State-Event
`io.pyramid.voice.presence`) bleibt intern, aber nutzt eine injizierte
`Client`-Referenz statt globalem Singleton-Zugriff – macht das Modul für
Tests austauschbar.
### 3. `call_ui`
**Ersetzt:** `features/call/voice_channel.dart`, `features/call/mini_call_widget.dart`
Konsumiert ausschließlich `CallSignalingService` + `VoiceChannelService` über
Riverpod-Provider (`callSignalingProvider`, `voiceChannelProvider`). Keine
direkten Importe von `core/voip_manager.dart`/`core/livekit_call_manager.dart`
mehr – heute importieren `chat_view.dart`, `matrix_client.dart` und
`settings_modal.dart` diese Dateien direkt, das entfällt.
### 4. `settings` (aufgesplittet)
Kein eigenes fachliches Modul, sondern reine Aggregations-UI. Jede Sektion
wird eine eigene Datei/Klasse mit einer schmalen `Widget build(...)`-Fläche,
die ihrerseits nur die Provider ihres Fachmoduls konsumiert (z. B.
`_VoiceSection` nutzt künftig `voiceChannelProvider`/`callSignalingProvider`
statt der Manager direkt). Vorschlag für Dateien:
`settings/profile_section.dart`, `settings/notifications_section.dart`,
`settings/appearance_section.dart`, `settings/sessions_section.dart`,
`settings/account_section.dart`, `settings/voice_section.dart`,
`settings/about_section.dart`.
### 5. `verification` (neu, aus Settings herausgelöst)
**Quelle:** `widgets/settings_modal.dart` Zeile 2921–4545 (SAS-Dialog,
QR-Anzeige, Emoji-Vergleich, Recovery-Key-Flow).
```dart
abstract class VerificationService {
Future<void> startSasVerification(DeviceKeys device);
Future<void> confirmSas();
Future<void> declineSas();
Stream<VerificationStep> get stepStream;
}
```
Eigenständig, weil SAS/QR-Verifikation künftig auch außerhalb der Settings
gebraucht werden könnte (z. B. beim Login auf einem neuen Gerät –
`bootstrap_dialog.dart` macht heute schon etwas Ähnliches separat).
### 6. `storage` (Fassade, kein Rewrite)
**Ausbau von:** `core/settings_prefs.dart`
```dart
abstract class Prefs {
Future<bool> getBool(String key, {bool fallback});
Future<void> setBool(String key, bool value);
// … String, double, StringSet analog zu BoolPref/StringSetPref
}
```
Ziel ist NICHT, `SharedPreferences` zu ersetzen, sondern die bereits
vorhandene `BoolPref`/`StringSetPref`-Abstraktion zur Pflicht zu machen.
Die 12 identifizierten Direktzugriffsstellen (u. a. `voip_manager.dart:260`,
`livekit_call_manager.dart:246`, `settings_modal.dart:1804/1860/1870`) werden
schrittweise umgestellt – **nicht** in diesem Schritt, sondern sobald das
jeweilige Modul (z. B. `call_signaling`) ohnehin angefasst wird.
Krypto-/Access-Token-Speicherung (`flutter_secure_storage`,
`matrix_client.dart`) bleibt bewusst außen vor – hängt an der noch offenen
SQLCipher-Frage (M1) und wird dort separat behandelt, nicht hier.
### 7. `auth`
**Dateien:** `features/auth/login_notifier.dart`, `login_page.dart`,
`server_page.dart`, `bootstrap_dialog.dart` (Bootstrap-Teil, Verifikations-Teil
wandert ggf. zu `verification`, s. o.). Schnittstelle bleibt der bereits
vorhandene `matrixClientProvider`/`login_notifier`-Zustandsautomat – heute
schon relativ sauber gekapselt, kein akuter Handlungsbedarf.
### 8. `push`
**Dateien:** `core/background_push.dart`, `core/fcm_push_service.dart`,
`core/notification_service.dart`. Bereits über eigene Dateien getrennt
(nativ vs. Flutter-Engine, siehe Memory „Pyramid Push"). Einzige Lücke:
eigener `sqflite`-Zugriff in `background_push.dart` statt gemeinsamer
DB-Schicht – bewusst so gewollt (Background-Isolate-Zwang), daher **kein**
Refactoring-Kandidat, nur dokumentieren.
### 9. `chat_timeline` / `rooms`
Niedrigste Priorität für den Pilot-Umbau, da laut Ist-Analyse strukturell
am wenigsten verwoben (Hauptproblem dort ist Dateigröße + Duplikate, nicht
Modul-Kopplung). Wird nach dem Call-Pilot angegangen.
## Reihenfolge-Vorschlag für die Umsetzung
1. **Pilot: `call_signaling` + `voice_channel` + `call_ui`** (Punkt „Call-Schicht
entwirren" in ROADMAP.md) – kleinster klar abgegrenzter Schnitt, deckt
gleich zwei ROADMAP-Punkte ab (Modul-Schnitt beweisen + Call-Entwirrung).
2. `settings_modal.dart` aufsplitten (reine Datei-Teilung, geringes Risiko,
keine Verhaltensänderung).
3. `verification` aus Settings herauslösen.
4. `storage`-Fassade schrittweise erzwingen, immer im Zuge anderer Umbauten.
5. `chat_timeline`/`rooms` zuletzt.
**Bernd-Entscheidung nötig:** siehe Frage in PROGRESS.md – ob Punkt 1
(Call-Pilot) so wie hier vorgeschlagen als Nächstes umgesetzt werden soll.
+95
View File
@@ -0,0 +1,95 @@
# M2 – Einheitliches State-Management: Ist-Analyse + Umbauplan
> **STATUS 2026-07-06: reine Analyse, noch KEIN Umbau.** Grundlage für den
> ROADMAP-M2-Punkt „Einheitliches State-Management (Riverpod konsequent,
> keine Misch-Patterns)". Die Schritte 1–2 unten betreffen den heiligen
> Call-Pfad und sind GESPERRT, bis Bernds Klick-Test des Call-Piloten
> (`docs/PC_TESTPLAN.md` Abschnitt 8) grün ist – vorher nur Schritt 3.
## Ist-Stand (repo-weit erhoben, 2026-07-06)
Riverpod ist bereits das dominante Muster: rund 50 Provider –
`StateProvider` für UI-Zustand, `StreamProvider` auf `client.onSync`,
`FutureProvider` (Client, Update-Info), `StateNotifierProvider` für die
Prefs-Notifier (`BoolPref`/`StringSetPref` in `core/settings_prefs.dart`).
`ValueNotifier`/eigene `StreamController` gibt es NICHT. Lokales
`setState` in Widgets für rein ephemeren Zustand (Hover, Dialog-Schritte,
Textfelder) ist normales Flutter und zählt hier bewusst NICHT als
Misch-Pattern.
Gefundene Misch-Patterns, nach Gewicht:
1. **Call-Schicht: Singletons + ChangeNotifier hinter den Fassaden.**
`LiveKitCallManager.instance` (static final Singleton,
`features/voice_channel/livekit_call_manager.dart:17`) und
`PyramidVoipManager._instance` (nullable static,
`features/call_signaling/voip_manager.dart:17`; konstruiert im
Kompositions-Punkt `core/matrix_client.dart:66–71`). Die Modul-Provider
(`voiceChannelProvider`/`callSignalingProvider`) geben nur die
Singletons zurück. Das ist die im Pilot BEWUSST gewählte Zwischenform
(Verhaltensgleichheit vor Schönheit). Eigentliches Problem: UI-Zustand
(Mute-Flags, Teilnehmer, Renderer, Qualitätswahl) lebt mutierbar IN den
Managern und wird per `notifyListeners()` grob-granular publiziert.
2. **Voice-UI-Zustand außerhalb des Moduls:** `activeVoiceRoomIdProvider`
und `voiceParticipantsProvider` liegen in `core/app_state.dart`
(Kopplungspunkt laut Ist-Analyse), werden aber von Rooms-Panel und
Mini-Call-Widget gesetzt/gelesen und gehören fachlich ins
`voice_channel`-Modul (Join-Zustand + Presence sind Modulsache).
3. **`MediaCache.instance`** (`core/media_cache.dart:19`): reines
Singleton, kein UI-Zustand, nur Cache. Nutzer sind `widgets/mxc_image.dart`
(7 Stellen, Widgets – leicht umstellbar) UND
`features/chat/message/message_media.dart` (7 Stellen, darunter der
statische Wrapper `_DocBytesCache` für Dokument-Vorschauen, dessen
Aufrufer keinen `ref`-Zugang haben). Eine Umstellung müsste `ref`/die
Cache-Instanz durch die statischen Helfer der Chat-Timeline fädeln –
und genau dieser Code (Download-/Entschlüsselungs-Fehlerpfade) ist
laut PC_TESTPLAN Abschnitt 3 der am wenigsten geprüfte Teil der App.
4. **Push/Notifications:** modul-globale Mutable-Zustände in
`core/notification_service.dart` (`_windowsNotifs`-Map, Plugin-Globals)
und direkte `SharedPreferences`-Zugriffe in `background_push.dart`/
`fcm_push_service.dart`. Das ist dem Background-Isolate-Zwang
geschuldet (siehe Schnitt-Doc Punkt 8 + Memory „Pyramid Push") –
**bewusst KEIN Umbau-Kandidat**, nur hier dokumentiert.
Bereits erledigt im Zuge dieser Analyse: der seit dem Call-Piloten tote
`incomingCallProvider` ist aus `app_state.dart` entfernt (41bcaf2).
## Zielbild
- `core/app_state.dart` enthält nur noch ECHTEN App-weiten UI-Zustand
(Theme, Layout/Panels, Navigation, Toast, Space-Auswahl) – kein
Feature-Fachzustand.
- Feature-Zustand lebt im jeweiligen Modul hinter dessen Provider;
andere Module lesen ihn NUR über die öffentliche Schnittstelle.
- Kein `.instance`-Zugriff über Modulgrenzen; Konstruktion ausschließlich
im Kompositions-Punkt bzw. im Provider des Moduls.
- ChangeNotifier ist als Übergangsform ok; Endzustand der Call-Module ist
ein immutables Zustandsobjekt (z. B. `VoiceChannelState`) pro Modul,
das der Provider publiziert, plus Kommando-Methoden auf dem Service
(entspricht dem Schnitt-Doc-Entwurf, dort als Stream skizziert).
## Schrittfolge (Reihenfolge = Priorität)
1. **[GESPERRT bis Klick-Test] UI-State aus den Managern herauslösen:**
je Call-Modul ein immutables Zustandsobjekt + `Notifier`/
`StateNotifier`, Manager wird interne Implementierung ohne
UI-Zustand. Das ist der bereits in PROGRESS.md zurückgestellte
Logik-Umbau – NICHT vorher anfangen (heiliger Call-Pfad, ein
ungetesteter Umbau liegt schon auf dem Stapel).
2. **[GESPERRT bis Klick-Test] `activeVoiceRoomIdProvider` +
`voiceParticipantsProvider` ins `voice_channel`-Modul umziehen**
(zusammen mit `kVoicePresenceType`). Mechanisch klein, aber
Call-Pfad-nah – sinnvoll im selben Zug wie Schritt 1.
3. **[ZURÜCKGESTELLT] `MediaCache` hinter `Provider<MediaCache>`:**
Erst als „risikoarm, jederzeit" eingestuft – das stimmte NICHT
(Korrektur 2026-07-06, siehe Ist-Stand Punkt 3: auch die Chat-Timeline
hängt dran, inkl. statischer Helfer ohne `ref`). Gehört in den Zug des
`chat_timeline`-Moduls (Schnitt-Doc Punkt 9) bzw. frühestens nach dem
Klick-Test von PC_TESTPLAN Abschnitt 3 – nicht als Lückenfüller.
4. **[kein Umbau] Push/Notifications:** bleibt wie es ist
(Isolate-Zwang); nur bei ohnehin anstehenden Push-Arbeiten die
Prefs-Zugriffe auf die `Prefs`-Fassade ziehen (Schnitt-Doc Punkt 6).
Abhak-Kriterium für den ROADMAP-Punkt: Schritte 1–3 umgesetzt und die
Kernflows (Login, Raum, Nachricht, Voice-Join) nach jedem Schritt
praktisch geprüft; Punkt 4 braucht nur den Doku-Vermerk (steht hiermit).
+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
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)
- 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/.../BgEngine.kt` | Headless-Engine-Bootstrap, Fallback-Platzhalter |
| `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 |
| `lib/core/background_push.dart` | Dart-Hintergrund-Isolate: decrypt + reply |
| `lib/core/fcm_push_service.dart` | Pusher-Registrierung bei Sygnal |
+178
View File
@@ -0,0 +1,178 @@
# PC-Testplan – alle aufgelaufenen UNGETESTET(Pi)-Punkte
Stand: 2026-07-03; Autopilot-Ergebnisse vom **2026-07-06** eingetragen.
Alle Pi-Sessions seit dem 2026-07-03 konnten nur
`flutter analyze` + automatisierte Textvergleiche fahren (kein GUI auf dem Pi).
Dieser Plan konsolidiert JEDEN ausstehenden Praxistest aus PROGRESS.md an einer
Stelle. Beim nächsten Windows-Lauf von oben nach unten abarbeiten, Ergebnis je
Punkt hier eintragen (✅/❌ + Datum), Befunde als eigene PROGRESS-Einträge.
Schnellster Weg: `flutter run -d windows`.
> **Grenze des Autopilot-Laufs 2026-07-06:** Der Autopilot kann bauen,
> testen und die App starten/beenden, aber NICHT in der UI klicken. Alles
> Klick-Interaktive (Abschnitte 2–8, Logout/Re-Login in Abschnitt 1) braucht
> Bernd am PC – die App-Starts heute liefen mit Bernds bestehender Session.
## 0. Vorab (1 Minute)
- [x] `flutter test` – ✅ 2026-07-06: **32 Tests grün** (24 Bestand + 5 neue
SQLCipher-Tests aus `test/app_database_test.dart` + 3 neue
VoicePrefs-Vertragstests in `test/settings_prefs_test.dart`),
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!)
Deckt die Fixes 63e4919, 9dc83f7, 891f348 ab (Uta-Random-Logout + Guard).
Seit 2026-07-06 zusätzlich relevant: SQLCipher-Migration ist auf diesem PC
gelaufen – die lokale DB ist jetzt verschlüsselt (PROGRESS.md 2026-07-06).
- [ ] Login mit Test-Account → Nachrichten in verschlüsseltem Raum senden/lesen
(Teil-Evidenz ✅ 2026-07-06: Bestands-Session blieb über 3 App-Starts
und die DB-Migration hinweg eingeloggt; App hat live to_device-Events
von @uta entschlüsselt und Room-Keys forwardet → Lesen/E2EE belegt.
Aktives SENDEN + frischer Login noch offen → Klick-Test Bernd)
- [x] `auth_log.txt` (App-Support-Verzeichnis) prüfen: ✅ 2026-07-06 –
LoginState-Wechsel werden mitgeschrieben (je Start `loggedIn`), dazu
der DB-Migrations-Eintrag; keine unerwarteten Warnungen, kein
Soft-Logout-Eintrag.
- [ ] Logout (Warnung bei fehlendem Key-Backup erscheint? b5762ea) – Klick-Test
- [ ] Erneuter Login → **alte verschlüsselte Nachrichten noch lesbar?** – Klick-Test
- [x] Mehrere Stunden laufen lassen: kein Random-Logout, kein
Login-Screen-Flackern (isLoggedInProvider-Änderung), `auth_log.txt`
zeigt ggf. „Soft-Logout durch Token-Refresh abgewendet"
(ERFÜLLT 2026-07-07 03:06: Fenster 22:49–03:06 = >4 h am Stück ohne
Logout, sechs echte Netzausfälle kumulativ überstanden; ein
Login-Screen-Flackern wäre als `LoginState`-Wechsel im auth_log
sichtbar – keiner vorhanden. Historie: Zwischenstand 2026-07-06 16:57: Der 12:43-Lauf endete
schon ~13:03 (letzter DB-Write; keine Absturzspur in Event-Log/WER,
kein Logout-Eintrag – vermutlich Fenster geschlossen). Im 20-min-
Fenster KEIN Random-Logout. Session überlebte danach den 4. Start
mit verschlüsselter DB (auth_log 16:57:46 `loggedIn`, keine erneute
Migration). Danach zwei geplante Neustarts für die Refactoring-
Praxistests (17:08 VoicePrefs/call_ui, 17:14 voice_channel-Split),
jeweils sauberer `loggedIn`, die Fenster dazwischen ohne Auffälligkeit
(16:57–17:08 ≈ 50 min sauber). **Check 17:37 (dritte PC-Session):**
Fenster 17:14–17:42 sauber – kein Logout-/Fehler-Eintrag, DB-Writes
liefen durchgehend; Lauf um 17:42 vom Autopilot beendet (Rebuild für
den settings_encryption-Split). Beobachtung läuft seit 17:43 mit dem
aktuellen Code (auth_log 17:43:14 `loggedIn`, keine Migration).
**Check 18:19 (vierte PC-Session), Fenster 17:43–18:19:** kein Logout,
und obendrauf der bisher wertvollste Positiv-Befund – ein ECHTER
Netzausfall 18:09:57–18:16:39 (~7 min, „Client has not connection to
the server" alle 3 s im auth_log) wurde sauber überstanden: kein
Logout, keine Eskalation, Sync erholte sich von selbst (WAL-Write
18:18:13 nach der letzten Fehlerzeile). Genau der Fehlerpfad, den die
Fixes 63e4919/9dc83f7/891f348 absichern sollen. **Check 18:29:**
zweiter, kurzer Netz-Blip 18:25:56 (~40 s) ebenfalls selbst erholt
(WAL-Write 18:28:38 danach), wieder kein Logout. Beobachtung läuft
weiter (Lauf seit 17:43, nächstes Fenster ab 18:29); erst nach einem
echten Mehr-Stunden-Fenster abhaken.
**Check 23:12 (Nacht-Session), Fenster 18:29–22:49:** dritter
Netzausfall 18:41:32–18:46:03 (~4,5 min, wieder „Client has not
connection") ebenfalls selbst erholt, KEIN Logout, keine Eskalation;
danach bis zum geplanten Stopp (Rebuild für die Nacht-Session-Fixes)
keine weiteren Einträge. Neustart 22:49:22 `loggedIn`, KEINE
Re-Migration → 8. Start in Folge mit verschlüsselter DB sauber.
Fenster 22:49–23:12 leer, WAL-Writes laufen (23:12:33). Beobachtung
läuft weiter, nächstes Fenster ab 23:12.
**Check 03:06 (2026-07-07, Nacht-Session 2), Fenster 23:12–03:06:**
DREI weitere Netz-Blips, alle selbst erholt, KEIN Logout: 01:32:17–
01:32:20 (~3 s), 02:01:53–02:06:36 (~4,7 min), 02:33:21–02:36:37
(~3,3 min; „Client has not connection" alle 3 s). WAL-Write 03:05:43
NACH der letzten Fehlerzeile, Prozess (PID 47412, seit 22:49) lebt.
Gegenprobe: die parallel laufende pyramidtest1-Instanz (profile1)
loggt DIESELBEN Fenster → echter Netz-/Server-Ausfall, nicht
app-spezifisch; auch sie ohne Logout erholt. Server beim Check
wieder erreichbar (HTTP 200). Beobachtung läuft weiter, nächstes
Fenster ab 03:06.
Kumulativ: 2026-07-06 12:26 – 2026-07-07 03:06 über 8 Starts, sechs
echte Netzausfälle (~7 min / ~40 s / ~4,5 min / ~3 s / ~4,7 min /
~3,3 min) – ohne einen einzigen Random-Logout. Das aktuelle Fenster
22:49–03:06 ist erstmals ein echtes Mehr-Stunden-Fenster (>4 h) am
Stück.)
- [ ] Ergebnis als „Härtetest"-Eintrag in PROGRESS.md dokumentieren
(= ROADMAP-M1-Punkt „Härtetest dokumentieren" abhaken)
- [ ] Android: nach Neu-Login beobachten, ob Push-Inhalte wieder echt
entschlüsselt ankommen (statt „Neue Nachricht"-Platzhalter). Falls nicht:
zweite Ursache, siehe `docs/NOTIFICATIONS.md` + Memory „Pyramid Push"
- [ ] Danach Uta: einmal aus- und wieder einloggen (erst dann hat ihre
Session einen Refresh-Token!)
## 2. Settings-Split (5d77238, 12 Teildateien)
Alle Reiter öffnen und benutzen: Profil, Benachrichtigungen, Erscheinungsbild,
Voice, Tastaturkürzel, Privatsphäre, Sessions, Verschlüsselung, Account, Über.
- [ ] Verschlüsselung (HEILIG): Recovery-Key anzeigen, Megolm-Export UND
-Import (Passphrase), SAS/QR-Verifizierung starten
- [ ] Sessions: Gerät umbenennen, Einzel-Logout, Massen-Logout-Dialog
(Passwort-Reauth) nur bis zur Abfrage
- [ ] Account: Passwort-ändern-Dialog öffnen, Account-löschen-Dialog öffnen
(NICHT bestätigen)
- [ ] Logout-Warnungs-Text bei fehlendem Backup sichtbar (settings_about/shared)
## 3. Chat-Timeline-Split (5bf45a9) + tote-Code-Bereinigung (6400fb6)
Einen Chat mit allen Nachrichtentypen durchgehen:
- [ ] Text mit Link-Vorschau, Bild, Video, Audio, Datei-Anhang
- [ ] Download-/Entschlüsselungs-Fehlerpfade in `message_media.dart` gezielt
ansehen (am wenigsten geprüfter Teil)
- [ ] Reaktionen setzen/entfernen, Hover-Aktionsleiste, Antworten/Zitat
- [ ] Datums-Trenner, „Neue Nachrichten"-Divider
- [ ] Gescheiterte Nachricht: bleibt mit Retry/Löschen stehen, Retry geht
- [ ] Zeitanzeige bei aufeinanderfolgenden Nachrichten + Hover-Uhrzeit bei
Fortsetzungsnachrichten (`_formatMessageTime`-Zusammenführung)
## 4. Raumlisten-Split (a863fa3) + Reorder-Fix (401bd6a)
- [ ] Space wechseln, Raum beitreten/verlassen
- [ ] **Drag&Drop-Umsortieren: landet der Raum EXAKT an der Zielposition?**
(onReorder→onReorderItem, Index-Korrektur jetzt im Framework – Off-by-One
wäre hier sichtbar; nach oben UND nach unten ziehen testen)
- [ ] Voice-Channel-Teilnehmerliste in der Raumliste
- [ ] Einladung annehmen/ablehnen, Ungelesen-Badge, DM-Avatare
## 5. Space-Admin-Split (0c7d359)
An einem TEST-Space, nicht an Utas echtem:
- [ ] Übersicht: Sichtbarkeit umschalten, Banner ändern (Danger-Zone nur ansehen)
- [ ] Mitglieder: einladen, Rolle ändern (Selbst-Aussperr-Schutz
`updatePowerLevelsSafely` greift?), kicken
- [ ] Berechtigungen ändern, Kanäle: erstellen + bestehenden Raum hinzufügen
## 6. Chat-View-Split (31e4001)
- [ ] Chat-Header (normal + DM mit ausklappendem Header/Presence)
- [ ] Tippanzeige, Offline-/Reconnect-Banner (Netz kurz trennen)
- [ ] Datei per Drag&Drop in den Chat ziehen (Overlay + Senden)
## 7. Dokument-Viewer-Split (daf4cf3) + Matrix4-Fix (401bd6a)
- [ ] PDF inline im Chat, Vollbild-Viewer: **Zoom per Buttons/Strg+Mausrad**
(translateByDouble/scaleByDouble-Umbau – zoomt es zentriert wie vorher?),
Seiten-Navigation, Thumbnail-Leiste, Speichern-Button
- [ ] Text/Markdown/SVG-Vorschau, Office-Text-Extraktion
- [ ] ZIP/TAR-Archiv-Browser, PSD-Thumbnail
- [ ] Bild im Chat öffnen (Fallback-Pfade)
## 8. Kleinkram aus den Lint-Commits
- [ ] Mini-Call-Widget: Buttons zeigen jetzt Hover-Farbe (9dc1175, gewollt) –
sieht das ok aus?
- [ ] Anhang-Dialog + Raum-erstellen-Dialog: Switches (activeThumbColor) sehen
aus wie vorher
- [ ] Voice-Channel beitreten/verlassen (Kernflow laut CLAUDE.md, von den
Splits nicht berührt, aber Pflicht nach jedem Refactoring)
## Danach
- [ ] Alle ✅ hier eintragen, PROGRESS.md-Eintrag „PC-Praxistest" schreiben,
erst DANN neue riskante Punkte anfangen (Call-Fassade, SQLCipher –
siehe `docs/SQLCIPHER_MIGRATION.md`)
+4 -2
View File
@@ -1,6 +1,8 @@
# Pyramid – Fortschritt & offene Punkte
# Pyramid – Fortschritt & offene Punkte (ARCHIV, Stand 2026-06-04)
Stand: 2026-06-04
> **ACHTUNG:** Dies ist ein eingefrorener Alt-Stand von vor der neuen
> Arbeitsstruktur. Das aktuelle Arbeitsprotokoll liegt im Projekt-Root:
> `PROGRESS.md`. Diese Datei nur als historische Referenz lesen, nie pflegen.
## Erledigt
+147
View File
@@ -0,0 +1,147 @@
# SQLCipher-Migration der `pyramid.sqlite` – Umsetzungsplan (PC-Termin)
> **STATUS 2026-07-06: UMGESETZT (`lib/core/app_database.dart`) und auf
> Windows mit Bernds echter 14-MB-DB praxisgetestet** (Migration gelaufen,
> 23 Tabellen verifiziert, Login/E2EE danach intakt, Neustart öffnet die
> verschlüsselte DB, `flutter test` deckt Migration/Fehlpfade mit echtem
> SQLCipher ab).
> **Android-Emulator-Test 2026-07-06 (API 36, x86_64, Debug-APK) GRÜN:**
> (a) frische Installation legt die DB direkt verschlüsselt an
> (Header = Zufallsbytes, per run-as verifiziert), kein Cipher-Fallback im
> auth_log; (b) Update-Szenario nach `pm clear` + untergeschobener
> Klartext-DB (synthetisch, 2 Tabellen/550 Zeilen): Migration lief beim
> ersten Start („2 Tabellen verifiziert" im auth_log), `.premigration`
> liegt da, DB danach verschlüsselt; (c) Neustart öffnet mit dem
> Keystore-Schlüssel OHNE Re-Migration, App rendert normal den
> Login-Screen. Damit sind openCipherOnAndroid, sqlcipher_export und
> flutter_secure_storage auf Android (Haupt-Isolate) real belegt.
> **Offen bleibt (deshalb Punkt 4 NICHT voll abgehakt):** echtes
> ARM-Gerät, Migration einer ECHTEN eingeloggten Session auf Android und
> der Push-Test (FCM-Isolate liest verschlüsselte DB, secure_storage im
> Background-Isolate) – braucht ein Testgerät mit eingeloggtem Account.
> Erst danach Release und Uta. **Abweichung vom Plan unten:** Die Annahme „keine Desktop-Binaries"
> ist seit sqlcipher_flutter_libs 0.6.x überholt – das Plugin baut SQLCipher
> auf Windows/Linux als `sqlite3.dll`/`libsqlite3.so` mit (im Build-Output
> verifiziert). Deshalb ist Phase 1 NICHT Android-only: Windows/Linux
> verschlüsseln ebenfalls, ein Test-Override ist unnötig. iOS/macOS bleiben
> Klartext (SQLCipher-Pod kollidiert mit firebase_messaging, kein Release-Ziel).
## Für Bernd in einfach
Die Datenbank der App (Nachrichten, Zugangs-Token, Verschlüsselungs-Schlüssel)
liegt heute **unverschlüsselt** auf dem Gerät – geschützt nur durch die
Android-App-Sandbox. Ein Angreifer bräuchte Root oder physischen Zugriff mit
Entwickler-Tools, um sie zu lesen. Dieser Plan verschlüsselt die Datei selbst.
Der riskante Teil ist nicht die Verschlüsselung, sondern die **einmalige
Umwandlung bestehender Installationen** (Utas Handy!) – deshalb: erst am PC
mit Testdaten, dann eigenes Gerät, dann erst Release.
**Offene Entscheidung (Frage in PROGRESS.md):** Priorität vor/nach M2?
## Ist-Zustand (verifiziert im Code)
| Stelle | Datei | Factory heute |
|---|---|---|
| Haupt-App Android/iOS | `lib/core/matrix_client.dart:24` | `sqflite_native.databaseFactory` (System-SQLite, **kann kein SQLCipher**) |
| Haupt-App Desktop | `lib/core/matrix_client.dart:27` | `databaseFactoryFfi` (gebündeltes sqlite3) |
| Push-Isolate (nur Android) | `lib/core/background_push.dart` `_buildClient()` | `sqflite_native.databaseFactory` |
- `sqlcipher_flutter_libs ^0.6.8` ist bereits in `pubspec.yaml`, wird nirgends benutzt.
- `flutter_secure_storage ^10` ist bereits da (→ Schlüsselablage im Android Keystore).
- Beide Prozesse öffnen **dieselbe Datei** (`pyramid.sqlite`, WAL-Modus,
`busy_timeout 5000`).
## Zielarchitektur
1. **Ein gemeinsamer DB-Öffner** `lib/core/app_database.dart` (neues Storage-Modul
im Sinne von M2): kapselt Factory-Wahl, PRAGMAs, Schlüsselbeschaffung und
Migration. `matrix_client.dart` und `background_push.dart` rufen NUR noch
diese Fassade – damit verschwindet auch die heutige Code-Duplikation der
PRAGMA-Blöcke.
2. **Android:** nicht mehr `sqflite_native`, sondern `databaseFactoryFfi` mit
SQLCipher-Lib:
```dart
import 'package:sqlite3/open.dart';
import 'package:sqlcipher_flutter_libs/sqlcipher_flutter_libs.dart';
open.overrideFor(OperatingSystem.android, openCipherOnAndroid);
```
**Achtung Push-Isolate:** `databaseFactoryFfi` spawnt ein eigenes Isolate –
im Firebase-Background-Isolate ist das genau die Fallenklasse, an der schon
`NativeImplementationsIsolate` scheiterte (Kommentar in
`background_push.dart`). Dort deshalb `databaseFactoryFfiNoIsolate` benutzen.
3. **Schlüssel:** 32 Byte kryptografisch zufällig (`Random.secure()`), hex-kodiert
in `flutter_secure_storage` unter `db_cipher_key`. Beide Prozesse lesen
denselben Eintrag. Schlüssel wird bei ERSTER Nutzung erzeugt; existiert schon
eine verschlüsselte DB, aber kein Schlüssel im Storage → harter Fehler mit
Klartext-Meldung, NIEMALS still neue DB anlegen (Datenverlust-Regel!).
4. **Öffnen:** `PRAGMA key = "x'<hex>'"` als allererstes Statement
(`OpenDatabaseOptions(onConfigure: ...)`), danach wie bisher WAL + busy_timeout.
5. **Windows/Linux-Desktop:** `sqlcipher_flutter_libs` liefert dort KEINE
Binaries. Phase 1 = nur Android verschlüsseln (dort liegt das echte Risiko,
dort sind die echten Nutzer); Desktop bleibt vorerst Klartext, klar im Code
kommentiert. Desktop-SQLCipher später als eigener Punkt (eigene DLL bündeln).
## Migration bestehender Klartext-DBs (der heikle Teil)
Reihenfolge beim App-Start, VOR `MatrixSdkDatabase.init`, VOR jedem Push-Zugriff:
1. Erkennung: Datei beginnt mit `SQLite format 3\0` → Klartext → Migration nötig.
(Verschlüsselte SQLCipher-Dateien haben keinen lesbaren Header.)
2. WAL eindampfen: Klartext-DB kurz normal öffnen,
`PRAGMA wal_checkpoint(TRUNCATE)`, schließen – sonst verlieren wir Daten,
die noch in `pyramid.sqlite-wal` liegen.
3. **Backup:** `pyramid.sqlite` → `pyramid.sqlite.premigration` kopieren
(Bytes, nicht rename). Bleibt bis zum verifizierten Erfolg liegen.
4. Export in NEUE Datei (Original bleibt unangetastet):
```sql
ATTACH DATABASE 'pyramid.enc.sqlite' AS enc KEY "x'<hex>'";
SELECT sqlcipher_export('enc');
DETACH DATABASE enc;
```
(läuft über die SQLCipher-FFI-Verbindung auf der Klartext-DB)
5. Verifikation: `pyramid.enc.sqlite` mit Schlüssel öffnen,
`PRAGMA integrity_check` == ok UND Zeilenzahl einer Kerntabelle
(z. B. `box_inbound_group_session`, Name gegen matrix-6.2.0
`matrix_sdk_database.dart:139` verifiziert) identisch mit Original. Schlägt IRGENDWAS
fehl → `pyramid.enc.sqlite` löschen, App startet normal mit Klartext-DB
weiter, Fehler in `auth_log.txt`. **Kein Zustand darf die Klartext-DB
beschädigt zurücklassen.**
6. Tausch: Original + `-wal`/`-shm` löschen, `pyramid.enc.sqlite` →
`pyramid.sqlite` (rename, gleiche Partition = atomar).
7. `.premigration`-Backup erst nach N erfolgreichen Tagen / manuell löschen
(Entscheidung Bernd: sonst liegt weiter eine Klartext-Kopie herum –
Kompromiss: nach 7 Tagen beim Start automatisch löschen).
### Race mit dem Push-Isolate
Kommt ein FCM-Push, während die Haupt-App migriert, würde das Isolate die DB
mittendrin öffnen. Lösung: Marker-Datei `pyramid.sqlite.migrating` vor Schritt 2
anlegen, nach Schritt 6 löschen; `_buildClient()` im Push-Pfad bricht bei
vorhandenem Marker sofort ab (Notification zeigt dann einmalig den
„Neue Nachricht"-Platzhalter – akzeptabel). Zusätzlich erkennt das Isolate
selbst Klartext vs. verschlüsselt am Datei-Header und wählt PRAGMA key
entsprechend – es migriert aber NIE selbst.
## Testplan (PC-Termin, in dieser Reihenfolge)
1. Windows-Build mit Test-Account: Migration Klartext→SQLCipher durchlaufen
lassen (Desktop kriegt fürs TESTEN die FFI-SQLCipher-Variante per lokalem
Override, auch wenn Phase 1 sie im Release nicht aktiviert).
2. Kernflow danach: Login erhalten? Alte verschlüsselte Nachrichten lesbar?
Logout → Neu-Login → immer noch lesbar? (`docs/PC_TESTPLAN.md` Abschnitt 1)
3. Fehlerpfade erzwingen: Migration mit vollem Datenträger / Kill mitten in
Schritt 4 → App muss mit unversehrter Klartext-DB weiterlaufen.
4. Android-Testgerät (eigenes, NICHT Uta): frische Installation (Neuanlage
verschlüsselt), dann Update-Szenario (bestehende Klartext-DB migriert),
dann Push-Test (Isolate liest verschlüsselte DB; secure_storage-Zugriff
aus dem Background-Isolate explizit verifizieren – bekanntes Risiko).
5. Erst wenn 1–4 grün: Release, Uta informieren (ihr Gerät migriert beim
ersten Start automatisch; vorher ihr Key-Backup verifizieren!).
## Bewusst NICHT im Scope
- `auth_log.txt`, `SharedPreferences`, Media-Cache bleiben Klartext (keine
Geheimnisse bzw. nur Cache; einzeln bewerten, falls Bernd es wünscht).
- Passphrase-basierter Schlüssel (Nutzer-Eingabe): bewusst nein – Schlüssel
liegt im Keystore, gleiche Vertrauensstufe wie der Rest der App-Daten,
aber Datei-Exfiltration allein reicht dann nicht mehr.
+50
View File
@@ -0,0 +1,50 @@
# Streaming: hohe Auflösung + 60 FPS (M4)
**Ziel (Bernd):** Bildschirm-Streams ruckeln aktuell. Es soll eine hohe Streaming-Einstellung
geben – hohe Auflösung UND hohe FPS, idealerweise **60 FPS**, flüssig.
## Ist-Zustand (im Code gefunden, 2026-07-06)
Datei `lib/features/voice_channel/livekit_call_manager.dart`:
- `qualityPresets` (~Z. 21): Auflösungs-Presets `sd`(360p) / `hd`(720p) / `fhd`(1080p) / `4k`(2160p).
- `_screenShareEncodings` (~Z. 28): **FPS hart gedeckelt** – `sd`=10, `hd`=15, `fhd`=20, `4k`=30.
**Es gibt KEIN 60-FPS-Preset.** Bitraten für hohe FPS zu niedrig (fhd nur 2,5 Mbit/s @20,
4k nur 4 Mbit/s @30).
- `startScreenShare` (~Z. 453): erstellt `ScreenShareCaptureOptions(maxFrameRate: …)` und
published mit `simulcast: true`, `degradationPreference: DegradationPreference.balanced`.
- **`contentHint` wird NICHT gesetzt** → der Encoder weiß nicht, dass es bewegtes Video ist,
und priorisiert Detail/Auflösung statt Framerate → Ruckeln.
- `_applyFpsToAllSimulcastLayers` (~Z. 485) hebt die FPS nachträglich auf allen Simulcast-Layern an.
UI `lib/features/call_ui/view/quality_menu.dart` (~Z. 21): „SENDEN"-Menü listet sd/hd/fhd/4k.
## Wahrscheinliche Ruckel-Ursachen
1. `maxFramerate` hart auf 20–30 begrenzt (nie 60).
2. Zu niedrige Bitrate für hohe FPS → Encoder verwirft/verwischt Frames.
3. `contentHint` nicht auf `motion` → Encoder opfert FPS für Detail.
4. `degradationPreference: balanced` → unter Last sinkt eher die FPS als die Auflösung.
## Aufgabe
1. **Neue High-FPS-Presets** für die Send-Qualität, z. B.:
- `hd60` → 720p, `VideoEncoding(maxBitrate ~4–5 Mbit/s, maxFramerate 60)`
- `fhd60` → 1080p, `VideoEncoding(maxBitrate ~6–8 Mbit/s, maxFramerate 60)`
Sowohl in `qualityPresets` (Auflösung) als auch `_screenShareEncodings` (Bitrate/FPS) eintragen
und im „SENDEN"-Menü in `quality_menu.dart` als „HD 60fps" / „Full HD 60fps" ergänzen.
2. **`contentHint = 'motion'`** auf dem Screenshare-Track setzen (flutter_webrtc/LiveKit-Feld) –
wichtigster Einzelhebel gegen Ruckeln bei Bewegung.
3. Für die 60-FPS-Modi **`degradationPreference: DegradationPreference.maintainFramerate`**
statt `balanced` (unter Last lieber Auflösung senken als FPS).
4. Sicherstellen, dass 60 wirklich durchgereicht wird – auch in `ScreenShareCaptureOptions.maxFrameRate`
und `_applyFpsToAllSimulcastLayers`.
5. Für den flüssigen 60-FPS-Modus **`simulcast: false` erwägen** (volle Bitrate auf einen Layer),
A/B gegen simulcast=true testen.
## Testen (mit den Test-Accounts)
- Streamen als pyramidtest1, zuschauen als pyramidtest2 (zwei Logins/Fenster).
- Prüfen, dass `sender.parameters.encodings` real `maxFramerate == 60` und die hohe `maxBitrate` tragen.
- Subjektiv: flüssiger? Bei Bewegung kein Ruckeln?
- EHRLICH protokollieren, was wirklich getestet wurde. Echtes flüssiges 60-FPS-Empfinden ist ggf.
nur mit echtem Gerät/Netz voll beurteilbar → dann klar als UNGETESTET markieren.
## Nicht vergessen
`flutter analyze` sauber, kleiner Commit + Push, PROGRESS.md + ROADMAP (M4) pflegen.
+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`).
+19 -19
View File
@@ -70,7 +70,7 @@ Continuwuity hat einen speziellen Admin-Raum. Als Server-Admin kannst du dort Be
# Als curl (Admin-Token erforderlich):
# Admin-Room-ID herausfinden:
curl -s "https://steggi-matrix.work/_matrix/client/v3/directory/room/%23admins%3Asteggi-matrix.work" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
-H "Authorization: Bearer <ADMIN_TOKEN>"
# → {"room_id": "!adminRoomId:steggi-matrix.work", ...}
```
@@ -84,22 +84,22 @@ curl -s "https://steggi-matrix.work/_matrix/client/v3/directory/room/%23admins%3
# User zu Server-Admin machen
curl -X POST "https://steggi-matrix.work/_conduwuit/admin/v1/make_user_admin" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
-H "Authorization: Bearer <ADMIN_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"user_id": "@todesneutron:steggi-matrix.work"}'
# Server-Status abfragen
curl "https://steggi-matrix.work/_conduwuit/server/version" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
-H "Authorization: Bearer <ADMIN_TOKEN>"
# Alternativ — Standard Matrix-Whoami (prüft ob Token gültig):
curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
-H "Authorization: Bearer <ADMIN_TOKEN>"
# → {"user_id": "@todesneutron:steggi-matrix.work", "is_guest": false}
# Prüfen ob User Server-Admin ist (Conduwuit-spezifisch):
curl "https://steggi-matrix.work/_conduwuit/admin/v1/users/@todesneutron:steggi-matrix.work" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
-H "Authorization: Bearer <ADMIN_TOKEN>"
```
---
@@ -112,7 +112,7 @@ Als Server-Admin kannst du via Matrix Client API Power Levels in jedem Raum setz
```bash
# Schritt 1: Aktuelles Power-Level-Event lesen
ROOM_ID="!raumId:steggi-matrix.work"
TOKEN="J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
TOKEN="<ADMIN_TOKEN>"
SERVER="https://steggi-matrix.work"
curl "${SERVER}/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.power_levels/" \
@@ -172,7 +172,7 @@ await client.setRoomStateWithKey(
```bash
# Testen ob restricted join rules funktionieren:
ROOM_ID="!testRaum:steggi-matrix.work"
TOKEN="J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
TOKEN="<ADMIN_TOKEN>"
SPACE_ID="!spaceId:steggi-matrix.work"
curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.join_rules/" \
@@ -230,7 +230,7 @@ try {
```bash
# 1. Prüfen: Welchen Power Level hat mein User in diesem Raum?
ROOM_ID="!raumId:steggi-matrix.work"
TOKEN="J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
TOKEN="<ADMIN_TOKEN>"
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.power_levels/" \
-H "Authorization: Bearer ${TOKEN}"
@@ -269,7 +269,7 @@ curl "https://steggi-matrix.work/_matrix/client/v3/capabilities" \
```bash
# Prüfen ob User Server-Admin ist:
curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
-H "Authorization: Bearer <ADMIN_TOKEN>"
# Auf Pi (SSH): User-Datenbank direkt abfragen (Conduwuit/Continuwuity nutzt RocksDB)
# Alternativ: Über Admin-Room-Befehl
@@ -278,7 +278,7 @@ curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \
# Über HTTP (Conduwuit Admin-Endpoint):
curl -X POST "https://steggi-matrix.work/_conduwuit/admin/v1/make_user_admin" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
-H "Authorization: Bearer <ADMIN_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"user_id": "@todesneutron:steggi-matrix.work"}'
```
@@ -292,19 +292,19 @@ Da kein Synapse-Admin-API existiert, nutze die Standard-Matrix-Client-API mit Ad
```bash
# Raum beitreten (als Admin, auch wenn invite-only):
curl -X POST "https://steggi-matrix.work/_matrix/client/v3/join/!roomId%3Asteggi-matrix.work" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
-H "Authorization: Bearer <ADMIN_TOKEN>" \
-H "Content-Type: application/json" \
-d '{}'
# User in Raum einladen:
curl -X POST "https://steggi-matrix.work/_matrix/client/v3/rooms/!roomId%3Asteggi-matrix.work/invite" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
-H "Authorization: Bearer <ADMIN_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"user_id": "@user:steggi-matrix.work"}'
# Space-Child-Relation setzen (Channel einem Space hinzufügen):
curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/!spaceId%3Asteggi-matrix.work/state/m.space.child/!channelId%3Asteggi-matrix.work/" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
-H "Authorization: Bearer <ADMIN_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"via": ["steggi-matrix.work"],
@@ -313,7 +313,7 @@ curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/!spaceId%3Astegg
# Space-Parent-Relation setzen (im Channel-Raum):
curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/!channelId%3Asteggi-matrix.work/state/m.space.parent/!spaceId%3Asteggi-matrix.work/" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
-H "Authorization: Bearer <ADMIN_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"via": ["steggi-matrix.work"],
@@ -322,11 +322,11 @@ curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/!channelId%3Aste
# Alle Mitglieder eines Raums auflisten:
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/!roomId%3Asteggi-matrix.work/members" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
-H "Authorization: Bearer <ADMIN_TOKEN>"
# Raum verlassen (als User, nicht löschen):
curl -X POST "https://steggi-matrix.work/_matrix/client/v3/rooms/!roomId%3Asteggi-matrix.work/leave" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
-H "Authorization: Bearer <ADMIN_TOKEN>" \
-H "Content-Type: application/json" \
-d '{}'
```
@@ -419,15 +419,15 @@ Wenn du M_FORBIDDEN bekommst:
```bash
# 1. Token gültig?
curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
-H "Authorization: Bearer <ADMIN_TOKEN>"
# 2. PL im betroffenen Raum prüfen:
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/!ROOM_ID/state/m.room.power_levels/" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
-H "Authorization: Bearer <ADMIN_TOKEN>"
# 3. Mitglied des Raums?
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/!ROOM_ID/state/m.room.member/%40todesneutron%3Asteggi-matrix.work/" \
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
-H "Authorization: Bearer <ADMIN_TOKEN>"
# 4. Was genau schlägt fehl? (Antwort-Body lesen!)
# M_FORBIDDEN + "Power level too low" → PL erhöhen
@@ -0,0 +1,250 @@
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;
/// 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);
await _pumpFor(tester, const Duration(seconds: 6));
await _scrollToNewest(tester);
await _shot(tester, '02_chat');
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));
},
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_riverpod/flutter_riverpod.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/router.dart';
import 'package:pyramid/core/settings_prefs.dart';
@@ -52,7 +53,9 @@ class _PyramidAppState extends ConsumerState<PyramidApp>
Future<void> _loadPersistedPrefs() async {
final saved = await loadThemePrefs();
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(radiusProvider.notifier).state = saved.radius;
ref.read(motionProvider.notifier).state = saved.motion;
@@ -75,7 +78,8 @@ class _PyramidAppState extends ConsumerState<PyramidApp>
}
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 radius = ref.watch(radiusProvider);
final density = ref.watch(densityProvider);
@@ -83,17 +87,31 @@ class _PyramidAppState extends ConsumerState<PyramidApp>
final accent = accentPresets[accentIdx].color;
return PyramidTheme(
isDark: isDark,
variant: variant,
accent: accent,
radius: radius,
density: density,
motion: motion,
child: MaterialApp.router(
title: 'Pyramid',
theme: buildPyramidThemeData(false),
darkTheme: buildPyramidThemeData(true),
themeMode: isDark ? ThemeMode.dark : ThemeMode.light,
theme: buildPyramidThemeData(ThemeVariant.light),
darkTheme: buildPyramidThemeData(
variant.isDark ? variant : ThemeVariant.soft),
themeMode: variant.isDark ? ThemeMode.dark : ThemeMode.light,
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 [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
+484
View File
@@ -0,0 +1,484 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:path/path.dart' as p;
import 'package:sqflite/sqflite.dart' as sqflite_native;
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:sqlcipher_flutter_libs/sqlcipher_flutter_libs.dart';
import 'package:sqlite3/open.dart';
import 'package:pyramid/core/app_dirs.dart';
import 'package:pyramid/core/auth_log.dart';
/// Läuft im DB-Isolate der FFI-Factory (muss deshalb top-level sein):
/// Auf Android liefert das sqlcipher_flutter_libs-Plugin die Bibliothek als
/// `libsqlcipher.so` – dem sqlite3-Paket muss man das dort sagen. Auf
/// Windows/Linux bündelt dasselbe Plugin SQLCipher bereits UNTER DEM
/// STANDARDNAMEN (`sqlite3.dll` neben der exe, verifiziert im Build-Output),
/// der Default-Loader findet sie also ohne Override.
void _sqlcipherFfiInit() {
open.overrideFor(OperatingSystem.android, openCipherOnAndroid);
}
/// Gemeinsamer Öffner für die `pyramid.sqlite` – der eine Storage-Baustein,
/// hinter dem Factory-Wahl, Verschlüsselung und Migration stecken (Plan:
/// `docs/SQLCIPHER_MIGRATION.md`, Modularitäts-Leitsatz aus CLAUDE.md).
///
/// Kapselt an EINER Stelle, was vorher in `matrix_client.dart` und
/// `background_push.dart` dupliziert war:
/// * Factory-Wahl pro Plattform (FFI + SQLCipher wo verfügbar),
/// * Schlüsselbeschaffung: 32 zufällige Bytes, hex-kodiert im
/// Plattform-Keystore (`flutter_secure_storage`, Eintrag `db_cipher_key`),
/// * einmalige Migration bestehender Klartext-DBs nach SQLCipher,
/// * die PRAGMAs (WAL, busy_timeout).
///
/// Oberste Regel (CLAUDE.md „Kein Datenverlust"): KEIN Codepfad darf eine
/// bestehende DB beschädigen oder still eine leere neue anlegen. Im Zweifel
/// läuft die App unverschlüsselt weiter und der Fehler steht im auth_log.
class AppDatabase {
AppDatabase._();
static const _cipherKeyStorageKey = 'db_cipher_key';
/// Klartext-Backup `*.premigration` nach so vielen Tagen automatisch
/// aufräumen (Kompromiss aus dem Migrationsplan: Rollback-Fenster vs.
/// dauerhaft herumliegende Klartext-Kopie).
static const _premigrationMaxAge = Duration(days: 7);
/// Plattformen, auf denen sqlcipher_flutter_libs die Cipher-Bibliothek
/// mitliefert. iOS/macOS bleiben bewusst beim nativen sqflite-Plugin
/// (Klartext): der SQLCipher-Pod kollidiert dort mit Paketen, die das
/// System-sqlite3 linken (z. B. firebase_messaging, siehe Paket-README),
/// und iOS ist kein Release-Ziel von Pyramid.
static bool get _isCipherPlatform =>
!kIsWeb && (Platform.isAndroid || Platform.isWindows || Platform.isLinux);
static Future<String> _dbPath() async {
final appDir = await appSupportDir();
return p.join(appDir.path, 'pyramid.sqlite');
}
// ── Öffnen: Haupt-App ────────────────────────────────────────────────────
/// Öffnet die Datenbank für die Haupt-App. Nur dieser Pfad migriert eine
/// bestehende Klartext-DB (das Push-Isolate migriert NIE selbst).
static Future<Database> open() async {
final dbPath = await _dbPath();
if (!_isCipherPlatform) {
// iOS/macOS: unverändert das native sqflite-Plugin, Klartext.
final db = await sqflite_native.databaseFactory.openDatabase(dbPath);
await _applyPragmas(db);
return db;
}
if (Platform.isAndroid) {
// Lade-Workaround für Android 6 (Paket-README) – im Main-Isolate
// ausführen, BEVOR irgendein Isolate sqlite benutzt.
try {
await applyWorkaroundToOpenSqlCipherOnOldAndroidVersions();
} catch (_) {}
}
final factory = createDatabaseFactoryFfi(ffiInit: _sqlcipherFfiInit);
await _recoverFromInterruptedSwap(dbPath);
await _clearStaleMarker(dbPath);
final dbFile = File(dbPath);
final exists = await dbFile.exists();
final isPlaintext = exists && await isPlaintextSqlite(dbFile);
if (!await _cipherAvailable(factory)) {
// Sicherheitsnetz: sollte auf Cipher-Plattformen nie passieren
// (Bibliothek wird als Plugin mitgebaut). Eine VERSCHLÜSSELTE DB ohne
// Cipher-Lib ist ein harter Fehler – niemals still neu anlegen.
if (exists && !isPlaintext) {
throw StateError(
'pyramid.sqlite ist verschlüsselt, aber die SQLCipher-Bibliothek '
'fehlt in diesem Build. Kein Zugriff möglich – Daten bleiben '
'unangetastet. Bitte App-Build prüfen (sqlcipher_flutter_libs).',
);
}
await AuthLog.write(
'[DB] SQLCipher nicht verfügbar – Datenbank bleibt unverschlüsselt.',
);
final db = await factory.openDatabase(dbPath);
await _applyPragmas(db);
return db;
}
if (exists && !isPlaintext) {
// Bereits verschlüsselt: der Schlüssel MUSS im Keystore liegen.
final key = await _readStoredKey();
if (key == null || key.isEmpty) {
throw StateError(
'pyramid.sqlite ist verschlüsselt, aber im Schlüsselspeicher fehlt '
'"$_cipherKeyStorageKey". Es wird KEINE neue Datenbank angelegt, '
'damit keine Daten verloren gehen. Schlüsselspeicher prüfen '
'(flutter_secure_storage) oder Backup einspielen.',
);
}
final db = await _openWithKey(factory, dbPath, key);
await _applyPragmas(db);
await _cleanupOldBackup(dbPath);
return db;
}
final key = await _getOrCreateKey();
if (exists && isPlaintext) {
final migrated = await migratePlaintextToEncrypted(
factory: factory,
dbPath: dbPath,
hexKey: key,
);
if (!migrated) {
// Migration fehlgeschlagen → Klartext-DB ist unversehrt, App läuft
// wie bisher weiter (Fehlerdetails stehen im auth_log).
final db = await factory.openDatabase(dbPath);
await _applyPragmas(db);
return db;
}
}
// Frische Installation (legt verschlüsselt neu an) oder frisch migriert.
final db = await _openWithKey(factory, dbPath, key);
await _applyPragmas(db);
return db;
}
// ── Öffnen: Android-Push-Isolate ─────────────────────────────────────────
/// Öffnet die Datenbank für das Push-/Hintergrund-Isolate. Gibt `null`
/// zurück, wenn gerade kein sicherer Zugriff möglich ist (Migration läuft,
/// DB fehlt, Schlüssel fehlt) – die Notification zeigt dann einmalig den
/// nativen „Neue Nachricht"-Platzhalter, das ist der akzeptierte Preis.
/// Dieser Pfad migriert NIE und legt NIE eine neue Datei an.
static Future<Database?> openForPush() async {
final dbPath = await _dbPath();
if (await File('$dbPath.migrating').exists()) {
debugPrint('[DB] Migration läuft – Push-Zugriff übersprungen.');
return null;
}
final dbFile = File(dbPath);
if (!await dbFile.exists()) return null;
if (!_isCipherPlatform) {
final db = await sqflite_native.databaseFactory.openDatabase(dbPath);
await _applyPragmas(db);
return db;
}
// noIsolate: databaseFactoryFfi würde ein Unter-Isolate spawnen – genau
// die Fallenklasse, an der im Firebase-Background-Isolate schon
// NativeImplementationsIsolate scheiterte (siehe background_push.dart).
final factory =
createDatabaseFactoryFfi(ffiInit: _sqlcipherFfiInit, noIsolate: true);
if (await isPlaintextSqlite(dbFile)) {
// Noch nicht migriert (macht beim nächsten Start die Haupt-App).
final db = await factory.openDatabase(dbPath);
await _applyPragmas(db);
return db;
}
final key = await _readStoredKey();
if (key == null || key.isEmpty) {
// Bekanntes Risiko aus dem Plan: secure_storage aus dem
// Background-Isolate. Niemals raten oder neu anlegen – nur aussteigen.
debugPrint('[DB] Kein Cipher-Schlüssel im Push-Isolate lesbar.');
return null;
}
final db = await _openWithKey(factory, dbPath, key);
await _applyPragmas(db);
return db;
}
// ── Migration Klartext → SQLCipher ───────────────────────────────────────
/// Wandelt eine bestehende Klartext-DB in eine SQLCipher-DB um – der heikle
/// Teil des Plans. Reihenfolge exakt wie in `docs/SQLCIPHER_MIGRATION.md`:
/// Marker → WAL-Checkpoint → Byte-Backup → Export in NEUE Datei →
/// Verifikation (integrity_check + Zeilenzahlen ALLER Tabellen) → atomarer
/// Tausch. Schlägt irgendetwas fehl, bleibt die Klartext-DB unangetastet
/// und die Funktion gibt `false` zurück.
@visibleForTesting
static Future<bool> migratePlaintextToEncrypted({
required DatabaseFactory factory,
required String dbPath,
required String hexKey,
}) async {
final marker = File('$dbPath.migrating');
final encPath = '$dbPath.enc';
final backupPath = '$dbPath.premigration';
try {
// Marker zuerst: hält das Push-Isolate fern (Race aus dem Plan).
await marker.writeAsString(DateTime.now().toIso8601String());
// 1. WAL eindampfen – sonst fehlen im Export Daten, die noch in
// pyramid.sqlite-wal liegen.
var db = await factory.openDatabase(dbPath);
try {
await db.rawQuery('PRAGMA wal_checkpoint(TRUNCATE)');
} finally {
await db.close();
}
// 2. Byte-Backup (Kopie, kein rename) – bleibt bis zum verifizierten
// Erfolg + Aufräumfrist liegen.
await File(dbPath).copy(backupPath);
// 3. Export in NEUE Datei; das Original wird nie beschrieben.
final encFile = File(encPath);
if (await encFile.exists()) await encFile.delete();
final Map<String, int> sourceCounts;
db = await factory.openDatabase(dbPath);
try {
sourceCounts = await _tableRowCounts(db);
await db.rawQuery(
'ATTACH DATABASE \'${_escapeSqlString(encPath)}\' AS enc '
'KEY "x\'$hexKey\'"',
);
await db.rawQuery("SELECT sqlcipher_export('enc')");
await db.rawQuery('DETACH DATABASE enc');
} finally {
await db.close();
}
// 4. Verifikation der NEUEN Datei, bevor irgendetwas getauscht wird.
final encDb = await _openWithKey(factory, encPath, hexKey);
try {
final integrity = await encDb.rawQuery('PRAGMA integrity_check');
final integrityOk = integrity.length == 1 &&
integrity.first.values.first.toString() == 'ok';
if (!integrityOk) {
throw StateError('integrity_check fehlgeschlagen: $integrity');
}
final encCounts = await _tableRowCounts(encDb);
for (final entry in sourceCounts.entries) {
if (encCounts[entry.key] != entry.value) {
throw StateError(
'Zeilenzahl weicht ab in "${entry.key}": '
'${entry.value} (Original) != ${encCounts[entry.key]} (Export)',
);
}
}
} finally {
await encDb.close();
}
// 5. Tausch. rename auf derselben Partition ist atomar; für das
// Zeitfenster zwischen delete und rename existiert das Byte-Backup
// (Wiederanlauf repariert das, siehe _recoverFromInterruptedSwap).
for (final suffix in ['-wal', '-shm']) {
final sidecar = File('$dbPath$suffix');
if (await sidecar.exists()) await sidecar.delete();
}
await File(dbPath).delete();
await File(encPath).rename(dbPath);
await AuthLog.write(
'[DB] Klartext-DB erfolgreich nach SQLCipher migriert '
'(${sourceCounts.length} Tabellen verifiziert).',
);
return true;
} catch (e) {
// Aufräumen: halbfertigen Export löschen, Original bleibt maßgeblich.
try {
final encFile = File(encPath);
if (await encFile.exists()) await encFile.delete();
} catch (_) {}
await AuthLog.write(
'[DB] SQLCipher-Migration fehlgeschlagen – Klartext-DB bleibt '
'unangetastet: $e',
);
return false;
} finally {
try {
if (await marker.exists()) await marker.delete();
} catch (_) {}
}
}
/// Absturz exakt zwischen „Original gelöscht" und „Export umbenannt":
/// dann fehlt `pyramid.sqlite`, aber das Byte-Backup existiert. Backup
/// zurückkopieren (die Migration läuft danach einfach erneut). Ein evtl.
/// liegengebliebener Export ist nicht als vollständig beweisbar → löschen.
static Future<void> _recoverFromInterruptedSwap(String dbPath) async {
final backup = File('$dbPath.premigration');
if (await File(dbPath).exists() || !await backup.exists()) return;
try {
final enc = File('$dbPath.enc');
if (await enc.exists()) await enc.delete();
await backup.copy(dbPath);
await AuthLog.write(
'[DB] Unterbrochene Migration erkannt – Klartext-Backup '
'wiederhergestellt, Migration läuft erneut.',
);
} catch (e) {
await AuthLog.write('[DB] Wiederherstellung aus .premigration fehlgeschlagen: $e');
}
}
/// Ein Marker ohne laufende Migration ist ein Absturz-Überbleibsel – beim
/// Start der Haupt-App (es gibt nur eine) gefahrlos entfernbar.
static Future<void> _clearStaleMarker(String dbPath) async {
try {
final marker = File('$dbPath.migrating');
if (await marker.exists()) {
await marker.delete();
await AuthLog.write('[DB] Verwaisten Migrations-Marker entfernt.');
}
} catch (_) {}
}
static Future<void> _cleanupOldBackup(String dbPath) async {
try {
final backup = File('$dbPath.premigration');
if (!await backup.exists()) return;
final age = DateTime.now().difference((await backup.stat()).modified);
if (age > _premigrationMaxAge) {
await backup.delete();
await AuthLog.write(
'[DB] Klartext-Backup .premigration nach '
'${_premigrationMaxAge.inDays} Tagen gelöscht.',
);
}
} catch (_) {}
}
// ── Bausteine ────────────────────────────────────────────────────────────
/// Klartext-SQLite-Dateien beginnen mit dem 16-Byte-Header
/// `SQLite format 3\0`; SQLCipher-Dateien haben keinen lesbaren Header.
/// Leere/winzige Dateien zählen als Klartext (die Migration exportiert sie
/// dann schlicht leer).
@visibleForTesting
static Future<bool> isPlaintextSqlite(File file) async {
final raf = await file.open();
try {
final header = await raf.read(16);
if (header.length < 16) return true;
// 16-Byte-Magic: "SQLite format 3" gefolgt von einem NUL-Byte.
const magic = 'SQLite format 3';
for (var i = 0; i < magic.length; i++) {
if (header[i] != magic.codeUnitAt(i)) return false;
}
return header[15] == 0x00;
} finally {
await raf.close();
}
}
/// Öffnet eine Datei mit Schlüssel. `PRAGMA key` MUSS das allererste
/// Statement der Verbindung sein → onConfigure. Der sqlite_master-Zugriff
/// danach lässt einen falschen/fehlenden Schlüssel sofort hier scheitern
/// statt irgendwann später mitten im Betrieb.
static Future<Database> _openWithKey(
DatabaseFactory factory,
String path,
String hexKey,
) async {
final db = await factory.openDatabase(
path,
options: OpenDatabaseOptions(
onConfigure: (db) async {
await db.rawQuery('PRAGMA key = "x\'$hexKey\'"');
},
),
);
try {
await db.rawQuery('SELECT count(*) FROM sqlite_master');
} catch (e) {
await db.close();
rethrow;
}
return db;
}
@visibleForTesting
static Future<Database> openEncrypted(
DatabaseFactory factory,
String path,
String hexKey,
) =>
_openWithKey(factory, path, hexKey);
/// `PRAGMA cipher_version` liefert bei normalem sqlite3 KEINE Zeile – der
/// vom Paket-README empfohlene Wächter dagegen, versehentlich unverschlüsselt
/// weiterzulaufen, weil die falsche Bibliothek geladen wurde.
static Future<bool> _cipherAvailable(DatabaseFactory factory) async {
final db = await factory.openDatabase(inMemoryDatabasePath);
try {
final rows = await db.rawQuery('PRAGMA cipher_version');
if (rows.isEmpty) return false;
final value = rows.first.values.first;
return value != null && value.toString().isNotEmpty;
} finally {
await db.close();
}
}
/// Wie bisher in matrix_client.dart/background_push.dart: WAL gegen
/// "database is locked" zwischen Haupt-App und Push-Isolate.
static Future<void> _applyPragmas(Database db) async {
try {
await db.execute('PRAGMA journal_mode=WAL');
await db.execute('PRAGMA busy_timeout = 5000');
} catch (e) {
if (kDebugMode) debugPrint('Could not set PRAGMAs: $e');
}
}
static Future<String?> _readStoredKey() =>
const FlutterSecureStorage().read(key: _cipherKeyStorageKey);
/// Erzeugt beim allerersten Mal 32 kryptografisch zufällige Bytes und legt
/// sie hex-kodiert im Keystore ab. Der Read-Back danach ist Pflicht: würde
/// das Schreiben still scheitern und wir trotzdem damit verschlüsseln,
/// wäre die DB beim nächsten Start unlesbar (Totalverlust).
static Future<String> _getOrCreateKey() async {
const storage = FlutterSecureStorage();
final existing = await storage.read(key: _cipherKeyStorageKey);
if (existing != null && existing.isNotEmpty) return existing;
final rnd = Random.secure();
final key = List.generate(
32,
(_) => rnd.nextInt(256).toRadixString(16).padLeft(2, '0'),
).join();
await storage.write(key: _cipherKeyStorageKey, value: key);
final readBack = await storage.read(key: _cipherKeyStorageKey);
if (readBack != key) {
throw StateError(
'Schlüsselspeicher-Verifikation fehlgeschlagen: '
'"$_cipherKeyStorageKey" konnte nicht zuverlässig abgelegt werden. '
'Es wird NICHT verschlüsselt (sonst wäre die DB später unlesbar).',
);
}
return key;
}
static Future<Map<String, int>> _tableRowCounts(Database db) async {
final tables = await db.rawQuery(
"SELECT name FROM sqlite_master "
"WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
);
final result = <String, int>{};
for (final row in tables) {
final name = row['name'] as String;
final count = await db.rawQuery('SELECT count(*) AS c FROM "$name"');
result[name] = (count.first['c'] as num).toInt();
}
return result;
}
/// SQL-String-Literal: einfache Anführungszeichen verdoppeln (der Pfad
/// kommt aus dem App-Verzeichnis, aber sicher ist sicher).
static String _escapeSqlString(String value) => value.replaceAll("'", "''");
}
+31
View File
@@ -0,0 +1,31 @@
import 'dart:io';
import 'package:path_provider/path_provider.dart';
/// Basisverzeichnis für alle lokalen App-Daten (pyramid.sqlite, auth_log.txt,
/// Media-Cache).
///
/// Normalfall: das Application-Support-Verzeichnis der Plattform – exakt wie
/// bisher, es ändert sich NICHTS.
///
/// Nur auf dem Desktop kann die Umgebungsvariable `PYRAMID_PROFILE_DIR` ein
/// abweichendes Verzeichnis erzwingen. Zweck: Testläufe mit den
/// Autopilot-Testkonten in einem eigenen Profil, parallel zur echten
/// Session desselben Windows-Nutzers (die echte Session sieht die Variable
/// nie und bleibt unberührt). Bewusst NICHT umgeleitet werden
/// shared_preferences und flutter_secure_storage – deren Plugins lösen ihre
/// Pfade selbst auf; ein Testprofil teilt sich also den DB-Schlüssel
/// (`db_cipher_key`) und die UI-Prefs mit dem Hauptprofil. Für Tests ist das
/// unkritisch (der Schlüssel wird nur GELESEN, nie überschrieben), echte
/// Zweitkonten bräuchten mehr.
Future<Directory> appSupportDir() async {
final override = Platform.isWindows || Platform.isLinux || Platform.isMacOS
? Platform.environment['PYRAMID_PROFILE_DIR']
: null;
if (override != null && override.trim().isNotEmpty) {
final dir = Directory(override.trim());
await dir.create(recursive: true);
return dir;
}
return getApplicationSupportDirectory();
}
+12 -13
View File
@@ -1,15 +1,21 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/settings_prefs.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/livekit_call_manager.dart';
import 'package:pyramid/core/voip_manager.dart';
import 'package:pyramid/features/rooms/rooms_provider.dart';
// ─── 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
const accentPresets = [
@@ -64,16 +70,9 @@ enum ModalKind { none, settings, search }
final activeModalProvider = StateProvider<ModalKind>((ref) => ModalKind.none);
// ─── Call state ───────────────────────────────────────────────────────────────
final callStateProvider = ChangeNotifierProvider<LiveKitCallManager>((ref) {
return LiveKitCallManager.instance;
});
final voipStateProvider = ChangeNotifierProvider<PyramidVoipManager>((ref) {
return PyramidVoipManager.instance;
});
final incomingCallProvider = StateProvider<CallSession?>((ref) => null);
// Die Call-Provider leben seit dem M2-Call-Pilot in ihren Modulen:
// `callSignalingProvider` (features/call_signaling/call_signaling_service.dart)
// und `voiceChannelProvider` (features/voice_channel/voice_channel_service.dart).
// ─── View mode (chat / voice) ─────────────────────────────────────────────────
+73
View File
@@ -0,0 +1,73 @@
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 (Bernds Wunsch 2026-10-07: ≈ 16 statt 14 px), am PC wie bisher.
double defaultTextScale({required bool mobile}) => mobile ? 1.15 : 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);
}
+51
View File
@@ -0,0 +1,51 @@
import 'dart:async';
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:pyramid/core/app_dirs.dart';
/// Schreibt Login-/Logout-relevante Ereignisse in eine Datei, die einen
/// App-Neustart übersteht. Zweck: den Uta-Random-Logout-Bug nachvollziehen
/// können, auch wenn die App zwischen dem Logout und dem nächsten Blick ins
/// Log neu gestartet wurde (siehe ROADMAP M1).
class AuthLog {
static const _maxLines = 500;
static File? _file;
static Future<File> _ensureFile() async {
final existing = _file;
if (existing != null) return existing;
final dir = await appSupportDir();
final file = File(p.join(dir.path, 'auth_log.txt'));
return _file = file;
}
static Future<void> write(String message) async {
try {
final file = await _ensureFile();
await file.writeAsString(
'${DateTime.now().toIso8601String()} $message\n',
mode: FileMode.append,
flush: true,
);
final lines = await file.readAsLines();
if (lines.length > _maxLines) {
await file.writeAsString(
'${lines.sublist(lines.length - _maxLines).join('\n')}\n',
);
}
} catch (_) {
// Logging darf selbst nie einen Fehlerpfad (oder gar einen Logout) auslösen.
}
}
static Future<String> read() async {
try {
final file = await _ensureFile();
if (!await file.exists()) return '(noch keine Einträge)';
return await file.readAsString();
} catch (e) {
return 'Log konnte nicht gelesen werden: $e';
}
}
}
+59 -45
View File
@@ -7,10 +7,10 @@ import 'package:flutter/widgets.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod;
import 'package:matrix/matrix.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:pyramid/core/app_database.dart';
import 'package:pyramid/core/notification_content.dart';
import 'package:pyramid/core/soft_logout_guard.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:sqflite/sqflite.dart' as sqflite_native;
/// Port name used to locate the main isolate from background isolates.
/// Registered by notification_service.dart when the app starts.
@@ -65,6 +65,11 @@ void notificationEngineMain() {
args['room_id'] as String?,
args['text'] as String?,
);
case 'markRead':
return await _bgMarkRead(
args['room_id'] as String?,
args['event_id'] as String?,
);
}
} catch (e) {
debugPrint('[NOTIF-BGENGINE] handler error: $e');
@@ -89,26 +94,27 @@ Future<bool> _bgDecryptAndShow(String? roomId, String? eventId, int? notifId) as
)
.timeout(const Duration(seconds: 20));
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 sender = room?.unsafeGetUserFromMemoryOrFallback(event.senderId).displayName ??
event.senderId.split(':').first.replaceFirst('@', '');
final title = (room?.isDirectChat ?? true)
? sender
: '$sender · ${room?.getLocalizedDisplayname() ?? ''}';
final prefs = await SharedPreferences.getInstance();
final content = MessageNotificationContent.fromEvent(
event,
showPreview: prefs.getBool('notif_preview') ?? true,
);
// 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
// notification via NotificationHelper — that keeps the reliable
// MainActivity-based reply action (the flutter_local_notifications reply
// path doesn't fire dependably when the app is killed).
await const MethodChannel(_kBgEngineChannel).invokeMethod('showDecrypted', {
'title': title,
'body': _buildBody(event),
'room_id': roomId,
'notif_id': notifId ?? _stableId(roomId),
});
// ReplyReceiver-based reply action (the flutter_local_notifications reply
// path doesn't fire dependably when the app is killed). Erst der Text,
// dann lautlos Avatar/Bild (deliverMessageNotification).
await deliverMessageNotification(
event,
content,
notifId: notifId ?? _stableId(roomId),
send: (a) => const MethodChannel(_kBgEngineChannel)
.invokeMethod('showDecrypted', a),
);
return true;
} catch (e) {
debugPrint('[NOTIF-BGENGINE] decryptAndShow error: $e');
@@ -153,6 +159,27 @@ 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 room
.setReadMarker(eventId, mRead: eventId)
.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
/// bootstrap the background engine. Call once at app startup.
Future<void> registerBgEngineHandle() async {
@@ -181,18 +208,13 @@ Future<Client?> _buildClient() async {
await vod.init();
} catch (_) {}
final appDir = await getApplicationSupportDirectory();
final dbPath = p.join(appDir.path, 'pyramid.sqlite');
final db = await sqflite_native.databaseFactory
.openDatabase(dbPath)
.timeout(const Duration(seconds: 5));
// Prevent "database is locked" if the main process still has the DB open.
try {
await db.execute('PRAGMA busy_timeout = 5000');
await db.execute('PRAGMA journal_mode = WAL');
} catch (_) {}
// Gemeinsame Storage-Fassade: erkennt Klartext vs. SQLCipher am
// Datei-Header, migriert aber NIE selbst (docs/SQLCIPHER_MIGRATION.md).
// null = gerade kein sicherer Zugriff (Migration läuft / DB oder
// Schlüssel fehlt) → Notification behält den nativen Platzhalter.
final db =
await AppDatabase.openForPush().timeout(const Duration(seconds: 10));
if (db == null) return null;
final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db);
@@ -201,6 +223,12 @@ Future<Client?> _buildClient() async {
database: sdkDb,
nativeImplementations: NativeImplementationsDummy(),
logLevel: Level.error,
// Kritisch: getEventByPushNotification() ruft als Erstes
// ensureNotSoftLoggedOut() auf. Ohne eigenen Handler würde ein
// fehlgeschlagener Token-Refresh AUS DEM HINTERGRUND-ISOLATE heraus
// logout()+clear() auf der geteilten pyramid.sqlite auslösen und die
// Session der gesamten App zerstören. Siehe soft_logout_guard.dart.
onSoftLogout: guardedSoftLogoutRefresh,
);
await client
@@ -217,20 +245,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 ───────────────────────────────────────────────
// Called in a fresh Dart isolate when the user presses Reply or Gelesen.
// Must return quickly — Android keeps the notification's "sending" spinner
+26 -31
View File
@@ -1,4 +1,4 @@
import 'dart:io';
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -6,38 +6,17 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod;
import 'package:matrix/encryption/utils/key_verification.dart';
import 'package:matrix/matrix.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart' as sqflite_native;
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:pyramid/core/voip_manager.dart';
import 'package:pyramid/core/app_database.dart';
import 'package:pyramid/core/auth_log.dart';
import 'package:pyramid/core/soft_logout_guard.dart';
// Kompositions-Punkt: Einzige Stelle außerhalb des call_signaling-Moduls,
// die die Implementierung (statt der Fassade) kennen darf – sie erzeugt sie.
import 'package:pyramid/features/call_signaling/voip_manager.dart';
final matrixClientProvider = FutureProvider<Client>((ref) async {
final DatabaseFactory factory;
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
// Use the native sqflite plugin — Android/iOS have system SQLite built-in,
// no .so loading required.
factory = sqflite_native.databaseFactory;
} else if (!kIsWeb) {
sqfliteFfiInit();
factory = databaseFactoryFfi;
} else {
throw UnsupportedError('Web not supported');
}
final appDir = await getApplicationSupportDirectory();
final dbPath = p.join(appDir.path, 'pyramid.sqlite');
final db = await factory.openDatabase(dbPath);
// Optimization for Windows/FFI to prevent "database is locked" errors
try {
await db.execute('PRAGMA journal_mode=WAL');
await db.execute('PRAGMA busy_timeout = 5000');
} catch (e) {
if (kDebugMode) debugPrint('Could not set PRAGMAs: $e');
}
// Factory-Wahl, Verschlüsselung (SQLCipher), Migration und PRAGMAs stecken
// komplett in der Storage-Fassade — siehe docs/SQLCIPHER_MIGRATION.md.
final db = await AppDatabase.open();
final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db);
@@ -57,12 +36,28 @@ final matrixClientProvider = FutureProvider<Client>((ref) async {
KeyVerificationMethod.qrShow,
},
logLevel: kReleaseMode ? Level.warning : Level.verbose,
// Ohne eigenen Handler eskaliert das SDK jeden fehlgeschlagenen
// Token-Refresh (auch bloße Netzwerkfehler!) zu logout()+clear() —
// Session und Krypto-Schlüssel wären weg. Siehe soft_logout_guard.dart.
onSoftLogout: guardedSoftLogoutRefresh,
nativeImplementations: NativeImplementationsIsolate(
compute,
vodozemacInit: () => vod.init(),
),
);
// Persistiert Warnungen/Fehler des SDK sowie jeden Login-Status-Wechsel,
// damit ein unerwarteter Logout (z. B. Soft-Logout vom Server) auch nach
// einem App-Neustart noch nachvollziehbar ist (ROADMAP M1, Uta-Logout-Bug).
Logs().onLog = (event) {
if (event.level.index <= Level.warning.index) {
unawaited(AuthLog.write('[SDK ${event.level.name}] ${event.toFormattedString()}'));
}
};
client.onLoginStateChanged.stream.listen((state) {
unawaited(AuthLog.write('LoginState -> ${state.name}'));
});
await client.init(
waitForFirstSync: false,
waitUntilLoadCompletedLoaded: false,
+2 -2
View File
@@ -4,7 +4,7 @@ import 'dart:io';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:path_provider/path_provider.dart';
import 'package:pyramid/core/app_dirs.dart';
/// Shared, bounded LRU cache for media bytes (chat images, document previews,
/// avatars). Eviction is driven by a total byte budget plus a hard entry cap,
@@ -89,7 +89,7 @@ class MediaCache {
}
Future<Directory> _initDiskDir() async {
final support = await getApplicationSupportDirectory();
final support = await appSupportDir();
final dir = Directory(
'${support.path}${Platform.pathSeparator}media_cache');
await dir.create(recursive: true);
+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,
),
);
}
+66 -77
View File
@@ -12,6 +12,7 @@ import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/background_push.dart';
import 'package:pyramid/core/fcm_push_service.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/notification_content.dart';
import 'package:pyramid/core/settings_prefs.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:windows_taskbar/windows_taskbar.dart';
@@ -197,6 +198,21 @@ Future<void> _initAndroid(WidgetRef ref) async {
if (!share.isEmpty) {
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);
await client
.getRoomById(roomId)
?.setReadMarker(eventId, mRead: eventId);
debugPrint('[NOTIF-FG] markReadFromNotification: $roomId bis $eventId');
} catch (e) {
debugPrint('[NOTIF-FG] markReadFromNotification error: $e');
}
}
} else if (call.method == 'decryptAndUpdateNotification') {
// Called by PushService.kt after showing a "Neue Nachricht" placeholder.
// We decrypt the event here (the Matrix client has the keys) and overwrite
@@ -458,57 +474,37 @@ Future<void> _decryptAndUpdateNotification(
final client = await ref
.read(matrixClientProvider.future)
.timeout(const Duration(seconds: 5));
var room = client.getRoomById(roomId);
if (room == null) {
debugPrint('[NOTIF] decryptAndUpdate: room $roomId not found');
if (eventId == null) return;
// 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;
}
// The event might not have synced yet — wait for the next sync (max 12 s).
// We use lastEvent as a proxy: after sync it will be the event we want.
Event? event = room.lastEvent;
final bool alreadyHave =
eventId == null || event?.eventId == eventId;
if (!alreadyHave || event == null) {
debugPrint('[NOTIF] decryptAndUpdate: waiting for sync to deliver $eventId');
await client.onSync.stream.first.timeout(const Duration(seconds: 12));
room = client.getRoomById(roomId);
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"');
// Same eventId → NotificationHelper ersetzt den „Neue Nachricht“-
// Platzhalter im Verlauf, statt eine zweite Nachricht anzuhängen.
await deliverMessageNotification(
event,
content,
notifId: notifId ?? _stableRoomId(roomId),
send: (a) => _kInstallChannel.invokeMethod('showNativeNotification', a),
);
debugPrint('[NOTIF] decryptAndUpdate: updated notification for $eventId');
} catch (e) {
debugPrint('[NOTIF] decryptAndUpdate error: $e');
}
@@ -516,27 +512,30 @@ Future<void> _decryptAndUpdateNotification(
// ─── Show notification ────────────────────────────────────────────────────────
Future<void> showMessageNotification({
required String roomId,
String? roomName,
required String senderName,
required String body,
}) async {
final title = roomName != null ? '$senderName · $roomName' : senderName;
Future<void> showMessageNotification(
Event event,
MessageNotificationContent content,
) async {
final roomId = content.roomId;
if (Platform.isAndroid && _androidInitialized) {
// Use native Kotlin notification so the reply PendingIntent targets
// ReplyReceiver directly — the app never opens when the user replies.
try {
await _kInstallChannel.invokeMethod('showNativeNotification', {
'title': title,
'body': body,
'roomId': roomId,
'notifId': _stableRoomId(roomId),
});
await deliverMessageNotification(
event,
content,
notifId: _stableRoomId(roomId),
send: (a) => _kInstallChannel.invokeMethod('showNativeNotification', a),
);
} catch (e) {
debugPrint('[NOTIF] showNativeNotification failed: $e');
}
} 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.
_windowsNotifs.remove(roomId)?.close();
@@ -624,23 +623,13 @@ void _watchEvents(Ref ref, Client client) {
if (!mentionedIds.contains(myId) && !bodyMentionsUs) return;
}
// ── Build notification body ───────────────────────────────────────────
final rawBody = event.body;
final showPreview = ref.read(notifPreviewProvider);
final notifBody = showPreview
? (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,
// ── Build notification content ────────────────────────────────────────
final content = MessageNotificationContent.fromEvent(
event,
showPreview: ref.read(notifPreviewProvider),
);
if (content == null) return;
showMessageNotification(event, content);
});
}
+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();
}
+102 -9
View File
@@ -2,11 +2,16 @@ import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/theme.dart';
// ── Generic SharedPreferences-backed bool notifier ──────────────────────────
class BoolPref extends StateNotifier<bool> {
final String _key;
// Guards against the load race: if set()/toggle() ran before _load's read
// lands, the stale persisted value must not overwrite the user's choice.
bool _userSet = false;
BoolPref(this._key, bool defaultValue) : super(defaultValue) {
_load(defaultValue);
@@ -14,10 +19,11 @@ class BoolPref extends StateNotifier<bool> {
Future<void> _load(bool def) async {
final p = await SharedPreferences.getInstance();
if (mounted) state = p.getBool(_key) ?? def;
if (mounted && !_userSet) state = p.getBool(_key) ?? def;
}
Future<void> set(bool v) async {
_userSet = true;
state = v;
final p = await SharedPreferences.getInstance();
await p.setBool(_key, v);
@@ -30,9 +36,10 @@ class BoolPref extends StateNotifier<bool> {
class StringSetPref extends StateNotifier<Set<String>> {
final String _key;
late final Future<void> _loaded;
StringSetPref(this._key) : super({}) {
_load();
_loaded = _load();
}
Future<void> _load() async {
@@ -42,14 +49,18 @@ class StringSetPref extends StateNotifier<Set<String>> {
}
Future<void> add(String v) async {
if (state.contains(v)) return;
// Read-modify-write: ohne abgeschlossenes Laden würde hier der halbleere
// Start-State zurückgeschrieben und persistierte Einträge gingen verloren.
await _loaded;
if (!mounted || state.contains(v)) return;
state = {...state, v};
final p = await SharedPreferences.getInstance();
await p.setStringList(_key, state.toList());
}
Future<void> remove(String v) async {
if (!state.contains(v)) return;
await _loaded;
if (!mounted || !state.contains(v)) return;
state = {...state}..remove(v);
final p = await SharedPreferences.getInstance();
await p.setStringList(_key, state.toList());
@@ -81,29 +92,111 @@ final privacyReadReceiptsProvider =
final privacyTypingProvider =
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('privacy_typing', true));
// ── Voice preferences ────────────────────────────────────────────────────────
// Geschrieben von der Settings-Voice-Sektion, gelesen von den Call-Modulen
// (call_signaling, voice_channel). Die historischen voice_*-Keys bleiben
// unverändert, damit bestehende Nutzer-Einstellungen erhalten bleiben.
class VoicePrefs {
VoicePrefs._();
static const _kMic = 'voice_mic_device';
static const _kSpeaker = 'voice_speaker_device';
static const _kCamera = 'voice_camera_device';
static const _kOutputVolume = 'voice_output_volume';
static Future<double> loadOutputVolume() async {
final p = await SharedPreferences.getInstance();
return p.getDouble(_kOutputVolume) ?? 1.0;
}
static Future<void> saveOutputVolume(double v) async {
final p = await SharedPreferences.getInstance();
await p.setDouble(_kOutputVolume, v);
}
/// Gewählte Geräte-IDs; null = kein Pref gesetzt (System-Standard nutzen).
static Future<({String? mic, String? speaker, String? camera})>
loadDevices() async {
final p = await SharedPreferences.getInstance();
return (
mic: p.getString(_kMic),
speaker: p.getString(_kSpeaker),
camera: p.getString(_kCamera),
);
}
static Future<void> saveMic(String? deviceId) => _saveDevice(_kMic, deviceId);
static Future<void> saveSpeaker(String? deviceId) =>
_saveDevice(_kSpeaker, deviceId);
static Future<void> saveCamera(String? deviceId) =>
_saveDevice(_kCamera, deviceId);
static Future<void> _saveDevice(String key, String? deviceId) async {
final p = await SharedPreferences.getInstance();
if (deviceId == null || deviceId.isEmpty) {
await p.remove(key);
} else {
await p.setString(key, deviceId);
}
}
}
// ── Theme persistence helpers ────────────────────────────────────────────────
Future<void> saveThemePrefs({
bool? isDark,
ThemeVariant? variant,
int? accentIdx,
double? radius,
double? motion,
double? textScale,
bool resetTextScale = false,
MessageStyle? messageStyle,
}) async {
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 (radius != null) await p.setDouble('theme_radius', radius);
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})>
loadThemePrefs() async {
Future<
({
ThemeVariant variant,
int accentIdx,
double radius,
double motion,
double? textScale,
MessageStyle messageStyle,
})> loadThemePrefs() async {
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 (
isDark: p.getBool('theme_dark') ?? true,
variant: variant,
accentIdx: p.getInt('theme_accent') ?? 0,
radius: p.getDouble('theme_radius') ?? 12.0,
motion: p.getDouble('theme_motion') ?? 1.0,
textScale: p.getDouble('ui_text_scale'),
messageStyle: MessageStyle.fromName(p.getString('chat_message_style')),
);
}
+41
View File
@@ -0,0 +1,41 @@
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/auth_log.dart';
/// Wendet einen Soft-Logout durch Token-Refresh ab, ohne JE in einen
/// destruktiven Logout zu eskalieren.
///
/// Hintergrund: Ohne gesetztes `Client.onSoftLogout` behandelt das matrix-SDK
/// einen fehlgeschlagenen Refresh so: catch → `logout()` → `finally clear()` —
/// die lokale Session inkl. Krypto-Schlüssel wird also selbst dann zerstört,
/// wenn der Refresh nur an einem transienten Netzwerkfehler scheiterte
/// (Gerät offline im Moment des Token-Ablaufs) oder am Rotations-Race mit dem
/// Push-Hintergrund-Isolate (beide teilen sich die DB; refreshAccessToken()
/// liest den Refresh-Token bei jedem Versuch frisch aus der DB, deshalb heilt
/// ein Retry dieses Race). Wirft dieser Handler nicht, ruft das SDK auch kein
/// `logout()` auf (Client._handleSoftLogout, matrix 6.2.0).
///
/// Regel aus CLAUDE.md: Fehlerpfade dürfen NIE in einem stillen Logout enden.
/// Schlägt der Refresh dauerhaft fehl, bleibt der Client im Zustand
/// soft-logged-out; der nächste Sync/API-Aufruf stößt den Refresh erneut an.
Future<void> guardedSoftLogoutRefresh(Client client) async {
for (var attempt = 1; attempt <= 3; attempt++) {
try {
await client.refreshAccessToken();
await AuthLog.write(
'Soft-Logout durch Token-Refresh abgewendet (Versuch $attempt)',
);
return;
} catch (e) {
await AuthLog.write(
'Soft-Logout: Token-Refresh fehlgeschlagen (Versuch $attempt/3): $e',
);
if (attempt < 3) {
await Future.delayed(Duration(seconds: 2 * attempt));
}
}
}
await AuthLog.write(
'Soft-Logout: Refresh dauerhaft fehlgeschlagen – Session wird NICHT '
'zerstört, nächster Sync/API-Aufruf versucht es erneut.',
);
}
+123 -21
View File
@@ -1,6 +1,103 @@
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 {
const PyramidColors._();
@@ -49,7 +146,7 @@ class PyramidColors {
}
class PyramidTheme extends InheritedWidget {
final bool isDark;
final ThemeVariant variant;
final Color accent;
final double radius;
final double density;
@@ -58,7 +155,7 @@ class PyramidTheme extends InheritedWidget {
const PyramidTheme({
super.key,
required super.child,
this.isDark = true,
this.variant = ThemeVariant.soft,
this.accent = PyramidColors.accentAmber,
this.radius = 12,
this.density = 1,
@@ -69,17 +166,20 @@ class PyramidTheme extends InheritedWidget {
return context.dependOnInheritedWidgetOfExactType<PyramidTheme>()!;
}
Color get bg0 => isDark ? PyramidColors.bg0 : PyramidColors.bg0Light;
Color get bg1 => isDark ? PyramidColors.bg1 : PyramidColors.bg1Light;
Color get bg2 => isDark ? PyramidColors.bg2 : PyramidColors.bg2Light;
Color get bg3 => isDark ? PyramidColors.bg3 : PyramidColors.bg3Light;
Color get bgHover => isDark ? PyramidColors.bgHover : PyramidColors.bgHoverLight;
Color get bgActive => isDark ? PyramidColors.bgActive : PyramidColors.bgActiveLight;
Color get border => isDark ? PyramidColors.border : PyramidColors.borderLight;
Color get borderStrong => isDark ? PyramidColors.borderStrong : PyramidColors.borderStrongLight;
Color get fg => isDark ? PyramidColors.fg : PyramidColors.fgLight;
Color get fgMuted => isDark ? PyramidColors.fgMuted : PyramidColors.fgMutedLight;
Color get fgDim => isDark ? PyramidColors.fgDim : PyramidColors.fgDimLight;
bool get isDark => variant.isDark;
PyramidPalette get palette => PyramidPalette.of(variant);
Color get bg0 => palette.bg0;
Color get bg1 => palette.bg1;
Color get bg2 => palette.bg2;
Color get bg3 => palette.bg3;
Color get bgHover => palette.bgHover;
Color get bgActive => palette.bgActive;
Color get border => palette.border;
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 online => PyramidColors.online;
Color get away => PyramidColors.away;
@@ -104,7 +204,7 @@ class PyramidTheme extends InheritedWidget {
@override
bool updateShouldNotify(PyramidTheme oldWidget) =>
isDark != oldWidget.isDark ||
variant != oldWidget.variant ||
accent != oldWidget.accent ||
radius != oldWidget.radius ||
density != oldWidget.density ||
@@ -114,9 +214,11 @@ class PyramidTheme extends InheritedWidget {
// Inter ist als Asset gebündelt (pubspec `fonts:`) — kein Laufzeit-Download.
TextStyle _base(Color color) => TextStyle(fontFamily: 'Inter', color: color);
ThemeData buildPyramidThemeData(bool isDark) {
final fg = isDark ? PyramidColors.fg : PyramidColors.fgLight;
final bg1 = isDark ? PyramidColors.bg1 : PyramidColors.bg1Light;
ThemeData buildPyramidThemeData(ThemeVariant variant) {
final palette = PyramidPalette.of(variant);
final isDark = variant.isDark;
final fg = palette.fg;
final bg1 = palette.bg1;
final accent = PyramidColors.accentAmber;
return ThemeData(
@@ -137,11 +239,11 @@ ThemeData buildPyramidThemeData(bool isDark) {
),
textTheme: TextTheme(
bodyMedium: _base(fg).copyWith(fontSize: 14),
bodySmall: _base(fg).copyWith(fontSize: 12, color: isDark ? PyramidColors.fgMuted : PyramidColors.fgMutedLight),
labelSmall: _base(fg).copyWith(fontSize: 11, color: isDark ? PyramidColors.fgDim : PyramidColors.fgDimLight),
bodySmall: _base(fg).copyWith(fontSize: 12, color: palette.fgMuted),
labelSmall: _base(fg).copyWith(fontSize: 11, color: palette.fgDim),
),
dividerTheme: DividerThemeData(
color: isDark ? PyramidColors.border : PyramidColors.borderLight,
color: palette.border,
thickness: 1,
space: 1,
),
+9
View File
@@ -3,6 +3,15 @@ import 'package:matrix/encryption.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/theme.dart';
/// Ob Cross-Signing/Key-Backup fehlt oder nicht entsperrt ist — wenn ja, würde
/// ein Logout den Zugriff auf ältere verschlüsselte Nachrichten kosten.
Future<bool> isKeyBackupMissing(Client client) async {
final enc = client.encryption;
if (enc == null) return true;
if (!enc.crossSigning.enabled) return true;
return !(await enc.keyManager.isCached().catchError((_) => false));
}
Future<void> showBootstrapDialog(
BuildContext context,
Client client, {
+12 -1
View File
@@ -76,6 +76,12 @@ class LoginNotifier extends StateNotifier<LoginFormState> {
identifier: mx.AuthenticationUserIdentifier(user: username),
password: password,
initialDeviceDisplayName: deviceName ?? 'Pyramid',
// Ohne Refresh-Token eskaliert das SDK jeden Soft-Logout (z. B. nach
// Ablauf des Access-Tokens) sofort zu einem echten, destruktiven
// Logout, weil `refreshAccessToken()` sonst mangels Refresh-Token
// fehlschlägt (siehe matrix-Paket Client._handleSoftLogout). Das ist
// der wahrscheinlichste Auslöser des Uta-Random-Logout-Bugs.
refreshToken: true,
);
if (Platform.isAndroid) unawaited(initFcm(client));
@@ -104,6 +110,11 @@ final isLoggedInProvider = StreamProvider<bool>((ref) async* {
final client = await ref.watch(matrixClientProvider.future);
yield client.isLogged();
await for (final loginState in client.onLoginStateChanged.stream) {
yield loginState == mx.LoginState.loggedIn;
// softLoggedOut ist KEIN Logout: Die Session existiert lokal weiter, das
// SDK versucht gerade einen stillen Token-Refresh. Würde hier `false`
// geliefert, spränge die UI bei jedem Routine-Refresh kurz auf den
// Login-Screen – für Nutzer nicht von einem „Random Logout" zu
// unterscheiden. Nur ein echtes loggedOut zählt.
yield loginState != mx.LoginState.loggedOut;
}
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,67 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'package:matrix/matrix.dart';
import 'package:pyramid/features/call_signaling/voip_manager.dart';
/// Öffentliche Schnittstelle des Moduls `call_signaling` – natives
/// Matrix-1:1-VoIP (`m.call.*`-Events, direkte WebRTC-Peer-Connection,
/// kein SFU). Schnitt nach `docs/M2_MODULE_SCHNITT.md`.
///
/// Andere Module (UI, Settings, Shell) konsumieren AUSSCHLIESSLICH diese
/// Schnittstelle über [callSignalingProvider] – nie `voip_manager.dart`
/// direkt. Einzige Ausnahme ist der Kompositions-Punkt in
/// `matrix_client.dart`, der die Implementierung konstruiert.
///
/// Bewusste Abweichungen vom Entwurf im Doc (Verhaltensgleichheit vor
/// Schönheit, heiliger Call-Pfad):
/// * ChangeNotifier statt Streams – die gesamte UI hängt heute an
/// `ref.watch(...)` mit notifyListeners; ein Stream-Umbau wäre eine
/// Verhaltensänderung ohne Not.
/// * [currentCall] bleibt als matrix-SDK-`CallSession` sichtbar (die UI
/// braucht state/room/answer/reject), ebenso die WebRTC-Renderer –
/// das Doc sieht Letzteres ausdrücklich vor.
abstract class CallSignalingService extends ChangeNotifier {
/// Der laufende 1:1-Call (null = keiner). UI liest state/room und
/// beantwortet eingehende Calls über [safeAction] + answer()/reject().
CallSession? get currentCall;
rtc.RTCVideoRenderer get localRenderer;
rtc.RTCVideoRenderer get remoteRenderer;
bool get isMicMuted;
bool get isCameraMuted;
bool get isScreensharing;
bool get isRemoteScreensharing;
bool get isDeafened;
/// Sende-Qualität ('sd' | 'hd' | 'fhd' | '4k').
String get currentQualityKey;
Future<void> startCall(String roomId, {bool video});
Future<void> hangup();
Future<void> toggleMic();
Future<void> toggleCamera();
Future<void> toggleDeafen();
/// Auf Windows öffnet ein [context] den Quellen-Picker fürs Screensharing.
Future<void> toggleScreenSharing([BuildContext? context]);
Future<void> changeQuality(String key);
/// Wendet die in den Einstellungen gewählte Ausgabelautstärke auf den
/// Remote-Stream an. Ohne [value] wird der Pref-Wert gelesen.
Future<void> applyOutputVolume([double? value]);
/// Führt eine Call-Aktion (answer/reject/…) fehlertolerant aus und stößt
/// danach die interne Zustands-/Renderer-Aktualisierung an.
Future<void> safeAction(Future<dynamic> Function() action);
}
/// Einziger Zugriffsweg der UI auf das 1:1-VoIP-Modul. Die Instanz wird beim
/// Client-Aufbau in `matrix_client.dart` erzeugt (existiert also, sobald die
/// eingeloggte Shell steht).
final callSignalingProvider =
ChangeNotifierProvider<CallSignalingService>((ref) {
return PyramidVoipManager.instance;
});
@@ -3,20 +3,28 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'package:matrix/matrix.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:webrtc_interface/webrtc_interface.dart';
import 'package:pyramid/core/ice_servers.dart';
import 'package:pyramid/core/settings_prefs.dart';
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
import 'package:pyramid/widgets/screen_share_picker.dart';
class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
/// Implementierung von [CallSignalingService] (Modul `call_signaling`).
/// Wird ausschließlich vom Kompositions-Punkt (`matrix_client.dart`)
/// konstruiert; alle anderen gehen über `callSignalingProvider`.
class PyramidVoipManager extends CallSignalingService
implements WebRTCDelegate {
static PyramidVoipManager? _instance;
static PyramidVoipManager get instance => _instance!;
final Client client;
late final VoIP voip;
@override
CallSession? currentCall;
@override
final rtc.RTCVideoRenderer localRenderer = rtc.RTCVideoRenderer();
@override
final rtc.RTCVideoRenderer remoteRenderer = rtc.RTCVideoRenderer();
bool _renderersInitialized = false;
@@ -29,11 +37,17 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
String? _selectedSourceId;
String _currentQualityKey = 'hd';
@override
bool get isMicMuted => _isMicMuted;
@override
bool get isCameraMuted => _isCameraMuted;
@override
bool get isScreensharing => _isScreensharing;
@override
bool get isRemoteScreensharing => _isRemoteScreensharing;
@override
bool get isDeafened => _isDeafened;
@override
String get currentQualityKey => _currentQualityKey;
PyramidVoipManager(this.client) {
@@ -253,12 +267,12 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
/// Wendet die in den Einstellungen gewählte Ausgabelautstärke auf den
/// Remote-Audio-Stream an. Ohne [value] wird der Pref-Wert gelesen.
@override
Future<void> applyOutputVolume([double? value]) async {
var v = value;
if (v == null) {
try {
final prefs = await SharedPreferences.getInstance();
v = prefs.getDouble('voice_output_volume') ?? 1.0;
v = await VoicePrefs.loadOutputVolume();
} catch (_) {
v = 1.0;
}
@@ -295,6 +309,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
@override
Future<void> registerListeners(CallSession session) async {}
@override
Future<void> safeAction(Future<dynamic> Function() action) async {
try {
await action();
@@ -306,6 +321,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
_updateRenderers();
}
@override
Future<void> toggleMic() async {
if (currentCall == null) return;
_isMicMuted = !_isMicMuted;
@@ -318,6 +334,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
}
}
@override
Future<void> toggleCamera() async {
if (currentCall == null) return;
_isCameraMuted = !_isCameraMuted;
@@ -337,6 +354,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
await _updateRenderers();
}
@override
Future<void> toggleScreenSharing([BuildContext? context]) async {
if (currentCall == null) return;
final call = currentCall!;
@@ -359,11 +377,13 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
});
}
@override
Future<void> toggleDeafen() async {
_isDeafened = !_isDeafened;
notifyListeners();
}
@override
Future<void> hangup() async {
if (currentCall == null) return;
await safeAction(() async {
@@ -372,6 +392,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
});
}
@override
Future<void> startCall(String roomId, {bool video = false}) async {
final room = client.getRoomById(roomId);
if (room == null) return;
@@ -394,6 +415,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
notifyListeners();
}
@override
Future<void> changeQuality(String key) async {
_currentQualityKey = key;
if (currentCall == null) {
@@ -444,10 +466,10 @@ class MediaDevicesWrapper extends MediaDevices {
// In den Einstellungen gewählte Geräte (voice_*_device Prefs) anwenden —
// nur wenn kein Screenshare-Source gesetzt ist (sonst nicht anfassen).
try {
final prefs = await SharedPreferences.getInstance();
final mic = prefs.getString('voice_mic_device');
final cam = prefs.getString('voice_camera_device');
final speaker = prefs.getString('voice_speaker_device');
final devicePrefs = await VoicePrefs.loadDevices();
final mic = devicePrefs.mic;
final cam = devicePrefs.camera;
final speaker = devicePrefs.speaker;
if (mic != null &&
improvedConstraints['audio'] != null &&
improvedConstraints['audio'] != false) {
@@ -528,7 +550,10 @@ class MediaDevicesWrapper extends MediaDevices {
set ondevicechange(Function(dynamic event)? callback) => _delegate.ondevicechange = callback;
@override
Function(dynamic event)? get ondevicechange => _delegate.ondevicechange;
// Erzwungener Override der abstrakten MediaDevices-Klasse, die getSources()
// selbst als deprecated markiert (Ersatz: enumerateDevices()) aber weiterhin verlangt.
@override
// ignore: deprecated_member_use
Future<List<dynamic>> getSources() => _delegate.getSources();
@override
Future<MediaDeviceInfo> selectAudioOutput([AudioOutputOptions? options]) => _delegate.selectAudioOutput(options);
@@ -3,14 +3,14 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/core/livekit_call_manager.dart';
import 'package:pyramid/core/voip_manager.dart';
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
import 'package:pyramid/widgets/screen_share_picker.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'package:livekit_client/livekit_client.dart';
class MiniCallWidget extends ConsumerStatefulWidget {
final dynamic call; // Can be LiveKitCallManager or PyramidVoipManager
final dynamic call; // CallSignalingService (1:1) oder VoiceChannelService
const MiniCallWidget({super.key, required this.call});
@override
@@ -45,7 +45,7 @@ class _MiniCallWidgetState extends ConsumerState<MiniCallWidget> {
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
final isVoip = widget.call is PyramidVoipManager;
final isVoip = widget.call is CallSignalingService;
final call = widget.call;
final String displayName = isVoip
@@ -95,7 +95,7 @@ class _MiniCallWidgetState extends ConsumerState<MiniCallWidget> {
isStreaming ? call.localRenderer : call.remoteRenderer,
objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
)
: _LiveKitPreview(manager: call as LiveKitCallManager),
: _LiveKitPreview(manager: call as VoiceChannelService),
),
// Top bar of preview
@@ -259,9 +259,9 @@ class _MiniCallWidgetState extends ConsumerState<MiniCallWidget> {
pt: pt,
onTap: () async {
if (isVoip) {
(call as PyramidVoipManager).toggleScreenSharing(context);
(call as CallSignalingService).toggleScreenSharing(context);
} else {
final lk = call as LiveKitCallManager;
final lk = call as VoiceChannelService;
if (lk.isScreenSharing) {
await lk.stopScreenShare();
} else {
@@ -351,7 +351,7 @@ class _ControlBtnState extends State<_ControlBtn> {
}
class _LiveKitPreview extends StatelessWidget {
final LiveKitCallManager manager;
final VoiceChannelService manager;
const _LiveKitPreview({required this.manager});
@override
@@ -0,0 +1,173 @@
part of '../voice_channel.dart';
class _BigAvatar extends StatelessWidget {
final _ParticipantData data;
final PyramidTheme pt;
const _BigAvatar({required this.data, required this.pt});
@override
Widget build(BuildContext context) {
return Center(
child: Container(
width: 100, height: 100,
decoration: BoxDecoration(
gradient: LinearGradient(colors: [data.avatarColor, data.avatarColor.withAlpha(150)], begin: Alignment.topLeft, end: Alignment.bottomRight),
shape: BoxShape.circle,
),
child: Center(child: Text(data.name.isNotEmpty ? data.name[0].toUpperCase() : '?', style: const TextStyle(color: Colors.white, fontSize: 40, fontWeight: FontWeight.bold))),
),
);
}
}
class _SmallAvatar extends StatelessWidget {
final _ParticipantData data;
final PyramidTheme pt;
const _SmallAvatar({required this.data, required this.pt});
@override
Widget build(BuildContext context) {
return Center(
child: Container(
width: 44, height: 44,
decoration: BoxDecoration(
gradient: LinearGradient(colors: [data.avatarColor, data.avatarColor.withAlpha(150)], begin: Alignment.topLeft, end: Alignment.bottomRight),
shape: BoxShape.circle,
),
child: Center(child: Text(data.name.isNotEmpty ? data.name[0].toUpperCase() : '?', style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold))),
),
);
}
}
class _CallControls extends StatelessWidget {
final bool isMuted;
final bool isDeafened;
final bool isCameraOff;
final bool isScreenSharing;
final VoidCallback onToggleMic;
final VoidCallback onToggleDeafen;
final VoidCallback onToggleCam;
final VoidCallback onToggleScreen;
final VoidCallback onHangUp;
final PyramidTheme pt;
const _CallControls({
required this.isMuted,
required this.isDeafened,
required this.isCameraOff,
required this.isScreenSharing,
required this.onToggleMic,
required this.onToggleDeafen,
required this.onToggleCam,
required this.onToggleScreen,
required this.onHangUp,
required this.pt,
});
@override
Widget build(BuildContext context) {
return SafeArea(
top: false,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
decoration: BoxDecoration(color: pt.bg1, border: Border(top: BorderSide(color: pt.border))),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_VoiceCtrlBtn(icon: isMuted ? Icons.mic_off_rounded : Icons.mic_rounded, danger: isMuted, pt: pt, onTap: onToggleMic),
const SizedBox(width: 16),
_VoiceCtrlBtn(icon: isDeafened ? Icons.headset_off_rounded : Icons.headphones_rounded, danger: isDeafened, pt: pt, onTap: onToggleDeafen),
const SizedBox(width: 16),
_VoiceCtrlBtn(icon: isCameraOff ? Icons.videocam_off_rounded : Icons.videocam_rounded, active: !isCameraOff, pt: pt, onTap: onToggleCam),
const SizedBox(width: 16),
_VoiceCtrlBtn(icon: Icons.screen_share_rounded, active: isScreenSharing, pt: pt, onTap: onToggleScreen),
const SizedBox(width: 24),
Container(width: 1, height: 32, color: pt.border),
const SizedBox(width: 24),
_VoiceCtrlBtn(icon: Icons.call_end_rounded, isDanger: true, pt: pt, onTap: onHangUp),
],
),
),
);
}
}
class _NoCall extends StatelessWidget {
final PyramidTheme pt;
final WidgetRef ref;
const _NoCall({required this.pt, required this.ref});
@override
Widget build(BuildContext context) {
return Container(
color: pt.bg1,
child: Column(
children: [
Container(
height: 52,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: pt.border))),
child: Row(
children: [
PyrIconBtn(
icon: const Icon(Icons.menu_rounded),
tooltip: 'Toggle sidebar',
onPressed: () => ref.read(railCollapsedProvider.notifier).update((s) => !s),
),
const SizedBox(width: 8),
Icon(Icons.headphones_rounded, size: 16, color: pt.fgMuted),
const SizedBox(width: 8),
Text('No active call', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)),
],
),
),
Expanded(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('𐃏', style: TextStyle(fontSize: 48, color: pt.fgDim.withAlpha(100))),
const SizedBox(height: 12),
Text('Join a voice room to start a call', style: TextStyle(color: pt.fgMuted, fontSize: 14)),
],
),
),
),
],
),
);
}
}
class _FullscreenVideoPage extends StatelessWidget {
final _ParticipantData data;
const _FullscreenVideoPage({required this.data});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(children: [
Positioned.fill(
child: data.videoWidget != null
? _MirrorWrapper(
isLocal: data.id == 'local' || (data.originalParticipant is LocalParticipant),
isScreenShare: data.isScreenShare,
child: data.videoWidget!,
)
: _BigAvatar(data: data, pt: PyramidTheme.of(context)),
),
Positioned(
top: 0, right: 0,
child: SafeArea(
child: IconButton(
icon: const Icon(Icons.fullscreen_exit_rounded, color: Colors.white, size: 28),
onPressed: () => Navigator.of(context).pop(),
),
),
),
]),
);
}
}
+214
View File
@@ -0,0 +1,214 @@
part of '../voice_channel.dart';
class _UnifiedCallView extends StatefulWidget {
final List<_ParticipantData> participants;
final bool isLiveKit;
final String displayName;
final bool isConnecting;
final VoidCallback onHangUp;
final VoidCallback onMinimize;
final VoidCallback onToggleMic;
final VoidCallback onToggleDeafen;
final VoidCallback onToggleCam;
final VoidCallback onToggleScreen;
final bool isMuted;
final bool isDeafened;
final bool isCameraOff;
final bool isScreenSharing;
final Function(Offset)? onQualityMenu;
final PyramidTheme pt;
const _UnifiedCallView({
required this.participants,
required this.isLiveKit,
required this.displayName,
required this.isConnecting,
required this.onHangUp,
required this.onMinimize,
required this.onToggleMic,
required this.onToggleDeafen,
required this.onToggleCam,
required this.onToggleScreen,
required this.isMuted,
required this.isDeafened,
required this.isCameraOff,
required this.isScreenSharing,
this.onQualityMenu,
required this.pt,
});
@override
State<_UnifiedCallView> createState() => _UnifiedCallViewState();
}
class _UnifiedCallViewState extends State<_UnifiedCallView> {
String? _heroId;
bool _isGridView = false;
bool _stripCollapsed = false;
@override
void initState() {
super.initState();
_autoSelectHero();
}
@override
void didUpdateWidget(_UnifiedCallView oldWidget) {
super.didUpdateWidget(oldWidget);
_autoSelectHero();
}
void _autoSelectHero() {
if (_isGridView) return;
final screenshare = widget.participants.firstWhereOrNull((p) => p.isScreenShare);
if (screenshare != null && (_heroId == null || !widget.participants.any((p) => p.id == _heroId && p.hasVideo))) {
_heroId = screenshare.id;
} else if (_heroId == null && widget.participants.isNotEmpty) {
final firstVideo = widget.participants.firstWhereOrNull((p) => p.hasVideo);
if (firstVideo != null) _heroId = firstVideo.id;
}
}
List<_ParticipantData> _sorted(List<_ParticipantData> all) {
int rank(_ParticipantData p) {
if (p.isScreenShare) return 0;
if (p.hasVideo) return 1;
if (p.isSpeaking) return 2;
return 3;
}
return [...all]..sort((a, b) => rank(a).compareTo(rank(b)));
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final all = _sorted(widget.participants);
final hero = all.firstWhereOrNull((p) => p.id == _heroId) ?? (all.isNotEmpty ? all.first : null);
if (widget.isConnecting) {
return Scaffold(
backgroundColor: pt.bg1,
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const PyramidLoader(size: 80),
const SizedBox(height: 20),
Text('Verbinde…',
style: TextStyle(color: pt.fgMuted, fontSize: 14)),
const SizedBox(height: 16),
TextButton.icon(
style: TextButton.styleFrom(foregroundColor: pt.danger),
onPressed: widget.onHangUp,
icon: const Icon(Icons.call_end_rounded, size: 16),
label: const Text('Abbrechen'),
),
],
),
),
);
}
return Scaffold(
backgroundColor: pt.bg1,
body: Column(
children: [
_CallHeader(
displayName: widget.displayName,
isGridView: _isGridView,
onToggleView: () => setState(() => _isGridView = !_isGridView),
onMinimize: widget.onMinimize,
pt: pt,
),
Expanded(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: _isGridView || hero == null
? _ParticipantGrid(participants: all, pt: pt, onSelect: (p) => setState(() { _heroId = p.id; _isGridView = false; }))
: _HeroLayout(
hero: hero,
others: all.where((p) => p.id != hero.id).toList(),
stripCollapsed: _stripCollapsed,
onToggleStrip: () => setState(() => _stripCollapsed = !_stripCollapsed),
onSetStripCollapsed: (v) => setState(() => _stripCollapsed = v),
onSelectOther: (p) => setState(() => _heroId = p.id),
onQualityMenu: widget.onQualityMenu,
pt: pt,
),
),
),
_CallControls(
isMuted: widget.isMuted,
isDeafened: widget.isDeafened,
isCameraOff: widget.isCameraOff,
isScreenSharing: widget.isScreenSharing,
onToggleMic: widget.onToggleMic,
onToggleDeafen: widget.onToggleDeafen,
onToggleCam: widget.onToggleCam,
onToggleScreen: widget.onToggleScreen,
onHangUp: widget.onHangUp,
pt: pt,
),
],
),
);
}
}
class _CallHeader extends StatelessWidget {
final String displayName;
final bool isGridView;
final VoidCallback onToggleView;
final VoidCallback onMinimize;
final PyramidTheme pt;
const _CallHeader({required this.displayName, required this.isGridView, required this.onToggleView, required this.onMinimize, required this.pt});
@override
Widget build(BuildContext context) {
return Container(
height: 52,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: pt.border))),
child: Row(
children: [
Icon(Icons.headphones_rounded, size: 16, color: pt.fgMuted),
const SizedBox(width: 8),
Text(displayName, style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)),
const SizedBox(width: 8),
_LiveBadge(pt: pt),
const Spacer(),
PyrIconBtn(
icon: Icon(isGridView ? Icons.grid_view_rounded : Icons.featured_video_rounded, color: isGridView ? pt.accent : pt.fg),
tooltip: isGridView ? 'Hero mode' : 'Grid mode',
onPressed: onToggleView,
),
const SizedBox(width: 4),
PyrIconBtn(
icon: Icon(Icons.close_fullscreen_rounded, color: pt.fg),
tooltip: 'Minimieren',
onPressed: onMinimize,
),
],
),
);
}
}
class _LiveBadge extends StatelessWidget {
final PyramidTheme pt;
const _LiveBadge({required this.pt});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(999), color: pt.online.withAlpha(30)),
child: Row(children: [
Container(width: 6, height: 6, decoration: BoxDecoration(color: pt.online, shape: BoxShape.circle)),
const SizedBox(width: 4),
Text('Live', style: TextStyle(color: pt.online, fontSize: 11, fontWeight: FontWeight.w600)),
]),
);
}
}
@@ -0,0 +1,424 @@
part of '../voice_channel.dart';
class _ParticipantGrid extends StatelessWidget {
final List<_ParticipantData> participants;
final PyramidTheme pt;
final Function(_ParticipantData) onSelect;
const _ParticipantGrid({required this.participants, required this.pt, required this.onSelect});
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, box) {
final cols = box.maxWidth > 900 ? 4 : (box.maxWidth > 600 ? 3 : 2);
const spacing = 12.0;
final tileWidth = (box.maxWidth - spacing * (cols - 1) - 24) / cols;
return SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: Wrap(
spacing: spacing,
runSpacing: spacing,
children: participants.map((p) => SizedBox(
width: tileWidth,
height: tileWidth / p.aspectRatio,
child: _ParticipantTile(data: p, pt: pt, onTap: () => onSelect(p)),
)).toList(),
),
);
});
}
}
class _HeroLayout extends StatelessWidget {
final _ParticipantData hero;
final List<_ParticipantData> others;
final bool stripCollapsed;
final VoidCallback onToggleStrip;
final Function(bool) onSetStripCollapsed;
final Function(_ParticipantData) onSelectOther;
final Function(Offset)? onQualityMenu;
final PyramidTheme pt;
const _HeroLayout({
required this.hero,
required this.others,
required this.stripCollapsed,
required this.onToggleStrip,
required this.onSetStripCollapsed,
required this.onSelectOther,
this.onQualityMenu,
required this.pt,
});
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, box) {
final isLandscape = box.maxWidth > box.maxHeight && box.maxWidth > 600;
final heroView = _HeroSection(
data: hero,
onQualityMenu: onQualityMenu,
pt: pt,
);
final strip = _ParticipantStrip(
participants: others,
isVertical: isLandscape,
onSelect: onSelectOther,
pt: pt,
);
if (isLandscape) {
return Row(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.all(12),
child: GestureDetector(
onHorizontalDragEnd: (details) {
if (details.primaryVelocity! < -300) onSetStripCollapsed(false);
if (details.primaryVelocity! > 300) onSetStripCollapsed(true);
},
child: heroView,
),
),
),
if (!stripCollapsed)
SizedBox(width: 200, child: Padding(padding: const EdgeInsets.only(right: 12, top: 12, bottom: 12), child: strip)),
_CollapseHandle(isVertical: true, collapsed: stripCollapsed, onToggle: onToggleStrip, pt: pt),
],
);
} else {
return Column(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.all(12),
child: GestureDetector(
onVerticalDragEnd: (details) {
if (details.primaryVelocity! < -300) onSetStripCollapsed(false);
if (details.primaryVelocity! > 300) onSetStripCollapsed(true);
},
child: heroView,
),
),
),
_CollapseHandle(isVertical: false, collapsed: stripCollapsed, onToggle: onToggleStrip, pt: pt),
if (!stripCollapsed)
SizedBox(height: 120, child: Padding(padding: const EdgeInsets.only(bottom: 12, left: 12, right: 12), child: strip)),
],
);
}
});
}
}
class _HeroSection extends StatefulWidget {
final _ParticipantData data;
final Function(Offset)? onQualityMenu;
final PyramidTheme pt;
const _HeroSection({required this.data, this.onQualityMenu, required this.pt});
@override
State<_HeroSection> createState() => _HeroSectionState();
}
class _HeroSectionState extends State<_HeroSection> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final pt = widget.pt;
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: () => setState(() => _hovered = !_hovered),
child: Center(
child: ClipRRect(
borderRadius: BorderRadius.circular(pt.rLg),
child: Container(
color: Colors.black,
child: Stack(
children: [
Positioned.fill(
child: widget.data.videoWidget != null
? _MirrorWrapper(
isLocal: widget.data.id == 'local' || (widget.data.originalParticipant is LocalParticipant),
isScreenShare: widget.data.isScreenShare,
child: widget.data.videoWidget!,
)
: _BigAvatar(data: widget.data, pt: pt),
),
AnimatedPositioned(
duration: const Duration(milliseconds: 200),
top: _hovered ? 0 : -60, left: 0, right: 0,
child: _HeroTopBar(data: widget.data, pt: pt),
),
AnimatedPositioned(
duration: const Duration(milliseconds: 200),
bottom: _hovered ? 0 : -60, left: 0, right: 0,
child: _HeroBottomBar(
data: widget.data,
onQualityMenu: widget.onQualityMenu,
pt: pt,
),
),
],
),
),
),
),
),
);
}
}
class _HeroTopBar extends StatelessWidget {
final _ParticipantData data;
final PyramidTheme pt;
const _HeroTopBar({required this.data, required this.pt});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: const BoxDecoration(
gradient: LinearGradient(begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Colors.black54, Colors.transparent]),
),
child: Row(
children: [
if (data.isScreenShare) _LivePill(pt: pt),
const SizedBox(width: 8),
Text(
data.isScreenShare ? "${data.name} präsentiert" : data.name,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 14),
),
],
),
);
}
}
class _HeroBottomBar extends StatelessWidget {
final _ParticipantData data;
final Function(Offset)? onQualityMenu;
final PyramidTheme pt;
const _HeroBottomBar({required this.data, this.onQualityMenu, required this.pt});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: const BoxDecoration(
gradient: LinearGradient(begin: Alignment.bottomCenter, end: Alignment.topCenter, colors: [Colors.black54, Colors.transparent]),
),
child: Row(
children: [
const Spacer(),
if (onQualityMenu != null)
_HeroActionBtn(icon: Icons.settings_rounded, onTapDown: (d) => onQualityMenu!(d.globalPosition)),
const SizedBox(width: 8),
_HeroActionBtn(
icon: Icons.fullscreen_rounded,
onTap: () => Navigator.of(context).push(MaterialPageRoute(
fullscreenDialog: true,
builder: (_) => _FullscreenVideoPage(data: data),
)),
),
],
),
);
}
}
class _HeroActionBtn extends StatelessWidget {
final IconData icon;
final VoidCallback? onTap;
final Function(TapDownDetails)? onTapDown;
const _HeroActionBtn({required this.icon, this.onTap, this.onTapDown});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
onTapDown: onTapDown,
child: Container(
width: 36, height: 36,
decoration: BoxDecoration(color: Colors.white24, borderRadius: BorderRadius.circular(8)),
child: Icon(icon, color: Colors.white, size: 20),
),
);
}
}
class _LivePill extends StatelessWidget {
final PyramidTheme pt;
const _LivePill({required this.pt});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(color: const Color(0xFFEF4444), borderRadius: BorderRadius.circular(4)),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Container(width: 4, height: 4, decoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle)),
const SizedBox(width: 4),
const Text('LIVE', style: TextStyle(color: Colors.white, fontSize: 9, fontWeight: FontWeight.w800, letterSpacing: 0.5)),
]),
);
}
}
class _ParticipantStrip extends StatelessWidget {
final List<_ParticipantData> participants;
final bool isVertical;
final Function(_ParticipantData) onSelect;
final PyramidTheme pt;
const _ParticipantStrip({required this.participants, required this.isVertical, required this.onSelect, required this.pt});
@override
Widget build(BuildContext context) {
return ListView.separated(
scrollDirection: isVertical ? Axis.vertical : Axis.horizontal,
itemCount: participants.length,
separatorBuilder: (_, _) => const SizedBox(width: 12, height: 12),
itemBuilder: (context, i) => SizedBox(
width: isVertical ? double.infinity : 101 * participants[i].aspectRatio,
height: 101,
child: _ParticipantTile(data: participants[i], pt: pt, onTap: () => onSelect(participants[i])),
),
);
}
}
class _ParticipantTile extends StatefulWidget {
final _ParticipantData data;
final PyramidTheme pt;
final VoidCallback onTap;
const _ParticipantTile({required this.data, required this.pt, required this.onTap});
@override
State<_ParticipantTile> createState() => _ParticipantTileState();
}
class _ParticipantTileState extends State<_ParticipantTile> with SingleTickerProviderStateMixin {
late AnimationController _speakingCtrl;
@override
void initState() {
super.initState();
_speakingCtrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 800))..repeat(reverse: true);
}
@override
void dispose() {
_speakingCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final data = widget.data;
return GestureDetector(
onTap: widget.onTap,
child: AnimatedBuilder(
animation: _speakingCtrl,
builder: (context, _) {
final isSpeaking = data.isSpeaking;
final pulse = isSpeaking ? _speakingCtrl.value : 0.0;
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
color: pt.bg2,
borderRadius: BorderRadius.circular(pt.rBase),
border: Border.all(
color: isSpeaking
? pt.accent.withAlpha((150 + pulse * 105).toInt())
: pt.border,
width: isSpeaking ? 2 : 1,
),
boxShadow: isSpeaking
? [BoxShadow(color: pt.accent.withAlpha((20 + pulse * 40).toInt()), blurRadius: 4 + pulse * 8, spreadRadius: pulse * 2)]
: null,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(pt.rBase - 1),
child: Stack(
children: [
Positioned.fill(
child: data.videoWidget != null
? _MirrorWrapper(
isLocal: data.id == 'local' || (data.originalParticipant is LocalParticipant),
isScreenShare: data.isScreenShare,
child: data.videoWidget!,
)
: _SmallAvatar(data: data, pt: pt),
),
Positioned(
left: 6, bottom: 6, right: 6,
child: Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(color: Colors.black54, borderRadius: BorderRadius.circular(4)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (data.isMuted) ...[Icon(Icons.mic_off_rounded, size: 10, color: pt.danger), const SizedBox(width: 4)],
Flexible(child: Text(data.name, style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w500), overflow: TextOverflow.ellipsis)),
],
),
),
],
),
),
if (data.isScreenShare) Positioned(top: 6, left: 6, child: _LivePill(pt: pt)),
],
),
),
);
},
),
);
}
}
class _CollapseHandle extends StatelessWidget {
final bool isVertical;
final bool collapsed;
final VoidCallback onToggle;
final PyramidTheme pt;
const _CollapseHandle({required this.isVertical, required this.collapsed, required this.onToggle, required this.pt});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onToggle,
child: Container(
width: isVertical ? 24 : double.infinity,
height: isVertical ? double.infinity : 24,
color: Colors.transparent,
child: Center(
child: Icon(
isVertical
? (collapsed ? Icons.chevron_left_rounded : Icons.chevron_right_rounded)
: (collapsed ? Icons.keyboard_arrow_up_rounded : Icons.keyboard_arrow_down_rounded),
color: pt.fgDim, size: 20,
),
),
),
);
}
}
+133
View File
@@ -0,0 +1,133 @@
part of '../voice_channel.dart';
void _handleLiveKitScreenShare(BuildContext context, VoiceChannelService call) async {
if (call.isScreenSharing) {
await call.stopScreenShare();
} else {
final source = await ScreenSharePicker.show(context);
if (source != null) await call.startScreenShare(source.id);
}
}
void _showLiveKitQualityMenu(BuildContext context, VoiceChannelService call, Offset pos) {
final pt = PyramidTheme.of(context);
showMenu(
context: context,
position: RelativeRect.fromLTRB(pos.dx - 210, pos.dy - 350, pos.dx, pos.dy),
color: pt.bg2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(pt.rBase)),
items: <PopupMenuEntry>[
const PopupMenuItem(enabled: false, height: 28, child: Text('SENDEN', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Colors.grey))),
for (final q in [
('sd', 'SD', '360p'),
('hd', 'HD', '720p'),
('hd60', 'HD 60fps', '720p·60'),
('fhd', 'Full HD', '1080p'),
('fhd60', 'Full HD 60fps', '1080p·60'),
('4k', '4K', '2160p'),
])
PopupMenuItem(onTap: () => call.changeQuality(q.$1), child: _QualityItem(label: q.$2, hint: q.$3, active: call.currentQualityKey == q.$1, pt: pt)),
const PopupMenuDivider(),
const PopupMenuItem(enabled: false, height: 28, child: Text('EMPFANGEN', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Colors.grey))),
for (final q in [('auto', 'Auto', ''), ('high', 'Hoch', 'FHD'), ('medium', 'Mittel', 'HD'), ('low', 'Niedrig', 'SD')])
PopupMenuItem(onTap: () => call.setSubscribeQuality(q.$1), child: _QualityItem(label: q.$2, hint: q.$3, active: call.subscribeQualityKey == q.$1, pt: pt)),
],
);
}
void _showVoipQualityMenu(BuildContext context, CallSignalingService voip, Offset pos) {
final pt = PyramidTheme.of(context);
showMenu(
context: context,
position: RelativeRect.fromLTRB(pos.dx - 210, pos.dy - 200, pos.dx, pos.dy),
color: pt.bg2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(pt.rBase)),
items: <PopupMenuEntry>[
const PopupMenuItem(enabled: false, height: 28, child: Text('SENDEN', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Colors.grey))),
for (final q in [('sd', 'SD', '360p'), ('hd', 'HD', '720p'), ('fhd', 'Full HD', '1080p'), ('4k', '4K', '2160p')])
PopupMenuItem(onTap: () => voip.changeQuality(q.$1), child: _QualityItem(label: q.$2, hint: q.$3, active: voip.currentQualityKey == q.$1, pt: pt)),
],
);
}
class _QualityItem extends StatelessWidget {
final String label;
final String? hint;
final bool active;
final PyramidTheme pt;
const _QualityItem({required this.label, required this.active, required this.pt, this.hint});
@override
Widget build(BuildContext context) {
return Row(children: [
if (active) Icon(Icons.check_rounded, size: 16, color: pt.accent) else const SizedBox(width: 16),
const SizedBox(width: 8),
Text(label, style: TextStyle(color: active ? pt.accent : pt.fg, fontSize: 13, fontWeight: active ? FontWeight.bold : FontWeight.normal)),
if (hint != null && hint!.isNotEmpty) ...[const Spacer(), Text(hint!, style: TextStyle(color: pt.fgMuted, fontSize: 11))],
]);
}
}
class _VoiceCtrlBtn extends StatefulWidget {
final IconData icon;
final PyramidTheme pt;
final bool active;
final bool danger;
final bool isDanger;
final VoidCallback? onTap;
const _VoiceCtrlBtn({required this.icon, required this.pt, this.onTap, this.active = false, this.danger = false, this.isDanger = false});
@override
State<_VoiceCtrlBtn> createState() => _VoiceCtrlBtnState();
}
class _VoiceCtrlBtnState extends State<_VoiceCtrlBtn> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final pt = widget.pt;
Color bg;
Color iconColor;
if (widget.isDanger) { bg = pt.danger; iconColor = Colors.white; }
else if (widget.danger) { bg = pt.danger.withAlpha(30); iconColor = pt.danger; }
else if (widget.active) { bg = pt.accent.withAlpha(30); iconColor = pt.accent; }
else { bg = _hovered ? pt.bgHover : pt.bg3; iconColor = pt.fg; }
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: 52, height: 52,
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(26)),
child: Center(child: Icon(widget.icon, size: 20, color: iconColor)),
),
),
);
}
}
class _MirrorWrapper extends StatelessWidget {
final Widget child;
final bool isLocal;
final bool isScreenShare;
const _MirrorWrapper({required this.child, required this.isLocal, required this.isScreenShare});
@override
Widget build(BuildContext context) {
if (isLocal && !isScreenShare) {
return Transform(
alignment: Alignment.center,
transform: Matrix4.identity()..rotateY(3.14159),
child: child,
);
}
return child;
}
}
@@ -0,0 +1,178 @@
part of '../voice_channel.dart';
// Unified participant data to handle both LiveKit and VoIP
class _ParticipantData {
final String id;
final String name;
final Widget? videoWidget;
final bool isSpeaking;
final bool isMuted;
final bool hasVideo;
final bool isScreenShare;
final Color avatarColor;
final dynamic originalParticipant;
final double aspectRatio;
const _ParticipantData({
required this.id,
required this.name,
this.videoWidget,
this.isSpeaking = false,
this.isMuted = false,
this.hasVideo = false,
this.isScreenShare = false,
this.avatarColor = Colors.grey,
this.originalParticipant,
this.aspectRatio = 16.0 / 9.0,
});
}
class VoiceChannelView extends ConsumerWidget {
const VoiceChannelView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pt = PyramidTheme.of(context);
final call = ref.watch(voiceChannelProvider);
final voip = ref.watch(callSignalingProvider);
if (voip.currentCall != null && voip.currentCall!.state != CallState.kEnded) {
return _UnifiedCallView(
participants: _getVoipParticipants(voip),
isLiveKit: false,
displayName: voip.currentCall!.room.getLocalizedDisplayname(),
isConnecting: false,
onHangUp: () {
voip.hangup();
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
},
onMinimize: () =>
ref.read(viewModeProvider.notifier).state = ViewMode.chat,
onToggleMic: voip.toggleMic,
onToggleDeafen: voip.toggleDeafen,
onToggleCam: voip.toggleCamera,
onToggleScreen: () => voip.toggleScreenSharing(context),
isMuted: voip.isMicMuted,
isDeafened: voip.isDeafened,
isCameraOff: voip.isCameraMuted,
isScreenSharing: voip.isScreensharing,
onQualityMenu: (pos) => _showVoipQualityMenu(context, voip, pos),
pt: pt,
);
}
if (!call.isActive) {
return _NoCall(pt: pt, ref: ref);
}
return _UnifiedCallView(
participants: _getLiveKitParticipants(call),
isLiveKit: true,
displayName: call.displayName,
isConnecting: call.isConnecting,
onHangUp: () {
call.hangUp();
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
},
onMinimize: () =>
ref.read(viewModeProvider.notifier).state = ViewMode.chat,
onToggleMic: call.toggleMute,
onToggleDeafen: call.toggleDeafen,
onToggleCam: call.toggleCamera,
onToggleScreen: () => _handleLiveKitScreenShare(context, call),
isMuted: call.isMuted,
isDeafened: call.isDeafened,
isCameraOff: call.isCameraOff,
isScreenSharing: call.isScreenSharing,
onQualityMenu: (pos) => _showLiveKitQualityMenu(context, call, pos),
pt: pt,
);
}
List<_ParticipantData> _getLiveKitParticipants(VoiceChannelService call) {
final room = call.room;
if (room == null) return [];
final all = <Participant>[
if (room.localParticipant != null) room.localParticipant!,
...room.remoteParticipants.values,
];
return all.mapIndexed((i, p) {
final screenPub = p.videoTrackPublications.firstWhereOrNull((v) => v.source == TrackSource.screenShareVideo);
final camPub = p.videoTrackPublications.firstWhereOrNull((v) => v.source == TrackSource.camera);
final videoTrack = (screenPub?.track ?? camPub?.track) as VideoTrack?;
final isScreen = screenPub != null;
double ar = 16.0 / 9.0;
if (videoTrack != null) {
try {
final dim = (videoTrack as dynamic).currentDimensions ?? (videoTrack as dynamic).dimensions;
if (dim != null) {
final w = (dim.width as num?)?.toDouble() ?? 0;
final h = (dim.height as num?)?.toDouble() ?? 0;
if (w > 0 && h > 0) ar = w / h;
}
} catch (_) {}
}
return _ParticipantData(
id: p.identity,
name: p.identity == room.localParticipant?.identity ? 'Du' : p.identity,
videoWidget: videoTrack != null ? VideoTrackRenderer(videoTrack, fit: VideoViewFit.contain) : null,
isSpeaking: p.isSpeaking,
isMuted: !p.isMicrophoneEnabled(),
hasVideo: videoTrack != null,
isScreenShare: isScreen,
avatarColor: _avatarColors[i % _avatarColors.length],
originalParticipant: p,
aspectRatio: ar,
);
}).toList();
}
List<_ParticipantData> _getVoipParticipants(CallSignalingService voip) {
final participants = <_ParticipantData>[];
double ar(rtc.RTCVideoRenderer r) {
final w = r.videoWidth.toDouble();
final h = r.videoHeight.toDouble();
return (w > 0 && h > 0) ? w / h : 16.0 / 9.0;
}
// Remote
final isRemoteScreen = voip.isRemoteScreensharing;
final hasRemoteVideo = voip.remoteRenderer.srcObject != null && voip.remoteRenderer.srcObject!.getVideoTracks().isNotEmpty;
participants.add(_ParticipantData(
id: 'remote',
name: voip.currentCall?.room.getLocalizedDisplayname() ?? 'Remote',
videoWidget: hasRemoteVideo ? rtc.RTCVideoView(voip.remoteRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null,
isMuted: false,
hasVideo: hasRemoteVideo,
isScreenShare: isRemoteScreen,
avatarColor: _avatarColors[1],
aspectRatio: hasRemoteVideo ? ar(voip.remoteRenderer) : 16.0 / 9.0,
));
// Local
final isLocalScreen = voip.isScreensharing;
final hasLocalVideo = voip.localRenderer.srcObject != null && voip.localRenderer.srcObject!.getVideoTracks().isNotEmpty;
participants.add(_ParticipantData(
id: 'local',
name: 'Du',
videoWidget: hasLocalVideo ? rtc.RTCVideoView(voip.localRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null,
isMuted: voip.isMicMuted,
hasVideo: hasLocalVideo,
isScreenShare: isLocalScreen,
avatarColor: _avatarColors[0],
aspectRatio: hasLocalVideo ? ar(voip.localRenderer) : 16.0 / 9.0,
));
return participants;
}
static const _avatarColors = [
Color(0xFFFF6B9D), Color(0xFF06B6D4), Color(0xFFA855F7),
Color(0xFF10B981), Color(0xFFF59E0B), Color(0xFFEF4444),
Color(0xFF3B82F6), Color(0xFF84CC16),
];
}
+19
View File
@@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:collection/collection.dart';
import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/widgets/screen_share_picker.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'package:pyramid/widgets/hover_region.dart';
import 'package:pyramid/widgets/pyramid_loader.dart';
part 'view/voice_channel_core.dart';
part 'view/call_view.dart';
part 'view/participant_layout.dart';
part 'view/avatars_controls.dart';
part 'view/quality_menu.dart';
+1 -1
View File
@@ -184,7 +184,7 @@ class _AttachmentDialogState extends State<AttachmentDialog> {
Switch.adaptive(
value: !_compress,
onChanged: (v) => setState(() => _compress = !v),
activeColor: pt.accent,
activeThumbColor: pt.accent,
),
],
),
+2 -2
View File
@@ -303,8 +303,8 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
final roomName = widget.room.getLocalizedDisplayname();
final placeholder = widget.room.isDirectChat
? 'Message…'
: 'Message #$roomName';
? 'Nachricht…'
: 'Nachricht an #$roomName';
return SafeArea(
child: Column(
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,322 @@
part of '../document_viewer.dart';
class _ArchiveViewer extends StatefulWidget {
final Uint8List bytes;
final String filename;
final PyramidTheme pt;
const _ArchiveViewer({required this.bytes, required this.filename, required this.pt});
@override
State<_ArchiveViewer> createState() => _ArchiveViewerState();
}
class _ArchiveViewerState extends State<_ArchiveViewer> {
List<_ArchiveEntry>? _entries;
String? _error;
@override
void initState() {
super.initState();
_decode();
}
void _decode() {
try {
final ext = widget.filename.contains('.') ? widget.filename.split('.').last.toLowerCase() : '';
Archive archive;
if (ext == 'tar') {
archive = TarDecoder().decodeBytes(widget.bytes);
} else if (ext == 'gz' || ext == 'tgz') {
final inner = GZipDecoder().decodeBytes(widget.bytes);
archive = TarDecoder().decodeBytes(inner);
} else {
archive = ZipDecoder().decodeBytes(widget.bytes);
}
final entries = archive.files.map((f) => _ArchiveEntry(name: f.name, size: f.size, isDir: !f.isFile)).toList()
..sort((a, b) {
if (a.isDir != b.isDir) return a.isDir ? -1 : 1;
return a.name.compareTo(b.name);
});
setState(() => _entries = entries);
} catch (e) {
setState(() => _error = e.toString());
}
}
String _fmtSize(int b) {
if (b < 1024) return '$b B';
if (b < 1024 * 1024) return '${(b / 1024).toStringAsFixed(1)} KB';
return '${(b / (1024 * 1024)).toStringAsFixed(1)} MB';
}
@override
Widget build(BuildContext context) {
if (_error != null) return _ErrorView(message: _error!, pt: widget.pt);
if (_entries == null) return Center(child: CircularProgressIndicator(strokeWidth: 2, color: widget.pt.accent));
final pt = widget.pt;
final entries = _entries!;
final totalBytes = entries.fold<int>(0, (s, e) => s + (e.isDir ? 0 : e.size));
return Column(
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: pt.bg1,
child: Row(
children: [
Icon(Icons.folder_zip_outlined, size: 15, color: pt.fgDim),
const SizedBox(width: 8),
Text('${entries.length} Einträge · ${_fmtSize(totalBytes)} unkomprimiert', style: TextStyle(color: pt.fgDim, fontSize: 12)),
],
),
),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: entries.length,
itemBuilder: (ctx, i) {
final e = entries[i];
return ListTile(
dense: true,
visualDensity: VisualDensity.compact,
leading: Icon(e.isDir ? Icons.folder_outlined : _fileIcon(e.name), size: 18, color: e.isDir ? pt.accent : pt.fgDim),
title: Text(e.name, style: TextStyle(color: pt.fg, fontSize: 13), overflow: TextOverflow.ellipsis),
trailing: e.isDir ? null : Text(_fmtSize(e.size), style: TextStyle(color: pt.fgDim, fontSize: 11)),
);
},
),
),
],
);
}
IconData _fileIcon(String name) {
final ext = name.contains('.') ? name.split('.').last.toLowerCase() : '';
return switch (ext) {
'dart' || 'py' || 'js' || 'ts' || 'json' || 'xml' || 'yaml' || 'toml' => Icons.code_rounded,
'png' || 'jpg' || 'jpeg' || 'gif' || 'webp' || 'svg' => Icons.image_outlined,
'mp4' || 'mov' || 'avi' || 'mkv' => Icons.videocam_outlined,
'mp3' || 'ogg' || 'flac' || 'wav' => Icons.music_note_outlined,
'pdf' => Icons.picture_as_pdf_outlined,
'zip' || 'tar' || 'gz' || '7z' || 'rar' => Icons.folder_zip_outlined,
'md' || 'markdown' => Icons.article_outlined,
_ => Icons.insert_drive_file_outlined,
};
}
}
class _ArchiveEntry {
final String name;
final int size;
final bool isDir;
const _ArchiveEntry({required this.name, required this.size, required this.isDir});
}
// Extracts the JPEG thumbnail stored in PSD Image Resource 1036.
Uint8List? _extractPsdThumbnail(Uint8List bytes) {
try {
if (bytes.length < 34) return null;
final data = ByteData.sublistView(bytes);
// Verify '8BPS' signature
if (bytes[0] != 0x38 || bytes[1] != 0x42 || bytes[2] != 0x50 || bytes[3] != 0x53) return null;
// PSD is big-endian throughout.
// Color Mode Data section length at offset 26.
final cmdLen = data.getUint32(26, Endian.big);
// Image Resources section starts after 26-byte header + 4-byte length field + cmdLen bytes.
final irPos = 30 + cmdLen;
if (irPos + 4 > bytes.length) return null;
final irLen = data.getUint32(irPos, Endian.big);
int pos = irPos + 4;
final irEnd = pos + irLen;
while (pos + 10 <= irEnd && pos + 10 <= bytes.length) {
// Signature: '8BIM'
if (bytes[pos] != 0x38 || bytes[pos+1] != 0x42 || bytes[pos+2] != 0x49 || bytes[pos+3] != 0x4D) break;
pos += 4;
final resourceId = data.getUint16(pos, Endian.big);
pos += 2;
// Pascal string: 1-byte length + string, total padded to even.
final nameLen = bytes[pos];
pos += (nameLen + 2) & ~1;
if (pos + 4 > bytes.length) break;
final dataSize = data.getUint32(pos, Endian.big);
pos += 4;
// Resource 1036 (Photoshop 5+) or 1033 (older) = Thumbnail
if ((resourceId == 0x040C || resourceId == 0x0409) && dataSize >= 28) {
final format = data.getUint32(pos, Endian.big);
if (format == 1) { // kJpegRGB
// Use dataSize-28 for the JPEG extent (more reliable than the
// compressedSize field at pos+20, which varies across PS versions).
final jpegStart = pos + 28;
final jpegEnd = (pos + dataSize).clamp(jpegStart, bytes.length);
if (jpegEnd > jpegStart) {
final thumb = Uint8List.sublistView(bytes, jpegStart, jpegEnd);
// Validate JPEG magic bytes (FF D8)
if (thumb.length >= 2 && thumb[0] == 0xFF && thumb[1] == 0xD8) {
return thumb;
}
// Some files need a forward scan for the actual JPEG start
for (var i = 0; i < thumb.length - 1; i++) {
if (thumb[i] == 0xFF && thumb[i + 1] == 0xD8) {
return Uint8List.sublistView(thumb, i);
}
}
}
}
return null;
}
// Advance past this resource's data (padded to even).
final advance = (dataSize + 1) & ~1;
if (advance == 0 && dataSize == 0) { pos += 0; } else { pos += advance; }
}
return null;
} catch (_) {
return null;
}
}
class _PsdThumbViewer extends StatelessWidget {
final Uint8List bytes;
final String filename;
final PyramidTheme pt;
const _PsdThumbViewer({required this.bytes, required this.filename, required this.pt});
@override
Widget build(BuildContext context) {
final thumb = _extractPsdThumbnail(bytes);
if (thumb == null) {
return _ProprietaryViewer(filename: filename, bytes: bytes, pt: pt);
}
return Column(
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
color: pt.bg2,
child: Row(
children: [
Icon(Icons.info_outline_rounded, size: 14, color: pt.fgDim),
const SizedBox(width: 8),
Text('Eingebettete Vorschau (Vollbild nicht verfügbar)', style: TextStyle(color: pt.fgDim, fontSize: 12)),
],
),
),
Expanded(
child: InteractiveViewer(
minScale: 0.5,
maxScale: 10,
child: Padding(
padding: const EdgeInsets.all(24),
child: Image.memory(thumb, fit: BoxFit.contain),
),
),
),
],
);
}
}
// ZIP-preview viewer (Sketch, XD).
// These formats are ZIP archives that embed a preview PNG.
class _ZipPreviewViewer extends StatefulWidget {
final Uint8List bytes;
final String filename;
final PyramidTheme pt;
const _ZipPreviewViewer({required this.bytes, required this.filename, required this.pt});
@override
State<_ZipPreviewViewer> createState() => _ZipPreviewViewerState();
}
class _ZipPreviewViewerState extends State<_ZipPreviewViewer> {
Uint8List? _preview;
bool _loaded = false;
bool _isImage = false;
@override
void initState() {
super.initState();
_extract();
}
void _extract() {
try {
final archive = ZipDecoder().decodeBytes(widget.bytes);
// Priority search list
const candidates = [
'previews/preview.png', 'preview.png', 'thumbnail.png',
'thumbnails/thumbnail.png', 'preview.jpg', 'thumbnail.jpg',
];
ArchiveFile? found;
for (final path in candidates) {
found = archive.findFile(path);
if (found != null && found.isFile) break;
}
// Fallback: any file with preview/thumbnail in the name
if (found == null) {
for (final f in archive.files) {
if (!f.isFile) continue;
final n = f.name.toLowerCase();
if ((n.contains('preview') || n.contains('thumb')) &&
(n.endsWith('.png') || n.endsWith('.jpg') || n.endsWith('.jpeg'))) {
found = f;
break;
}
}
}
if (found != null) {
setState(() {
_preview = Uint8List.fromList(found!.content as List<int>);
_isImage = true;
_loaded = true;
});
} else {
setState(() => _loaded = true);
}
} catch (_) {
setState(() => _loaded = true);
}
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
if (!_loaded) {
return Center(child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent));
}
if (_isImage && _preview != null) {
return Column(
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
color: pt.bg2,
child: Row(
children: [
Icon(Icons.info_outline_rounded, size: 14, color: pt.fgDim),
const SizedBox(width: 8),
Text('Eingebettete Vorschau', style: TextStyle(color: pt.fgDim, fontSize: 12)),
],
),
),
Expanded(
child: InteractiveViewer(
minScale: 0.5,
maxScale: 10,
child: Padding(
padding: const EdgeInsets.all(24),
child: Image.memory(_preview!, fit: BoxFit.contain),
),
),
),
],
);
}
// No preview image found — fall back to archive listing
return _ArchiveViewer(bytes: widget.bytes, filename: widget.filename, pt: pt);
}
}
@@ -0,0 +1,297 @@
part of '../document_viewer.dart';
enum _DocType { pdf, svg, text, code, markdown, office, archive, psdThumb, zipPreview, proprietary, unknown }
_DocType _detectType(String filename) {
final ext = filename.contains('.') ? filename.split('.').last.toLowerCase() : '';
return switch (ext) {
// AI/EPS files embed a full PDF when saved with PDF compatibility
'pdf' || 'ai' || 'eps' => _DocType.pdf,
'svg' => _DocType.svg,
'md' || 'markdown' || 'rst' => _DocType.markdown,
'zip' || 'tar' || 'gz' || 'tgz' || 'tar.gz' => _DocType.archive,
'txt' || 'log' || 'csv' || 'tsv' ||
'rtf' || 'tex' || 'srt' || 'vtt' || 'nfo' => _DocType.text,
// ODF formats are ZIP+XML, same extraction approach as OOXML
'docx' || 'xlsx' || 'pptx' ||
'odt' || 'odp' || 'ods' => _DocType.office,
'dart' || 'py' || 'js' || 'mjs' || 'cjs' || 'ts' || 'jsx' || 'tsx' ||
'json' || 'jsonl' || 'ndjson' || 'yaml' || 'yml' || 'xml' || 'plist' ||
'html' || 'htm' || 'css' || 'scss' || 'sass' || 'less' ||
'vue' || 'svelte' || 'astro' ||
'sh' || 'bash' || 'zsh' || 'fish' || 'ps' || 'ps1' || 'bat' || 'cmd' ||
'kt' || 'kts' || 'gradle' || 'java' || 'swift' || 'go' || 'rs' ||
'cpp' || 'cc' || 'cxx' || 'c' || 'h' || 'hpp' || 'hxx' || 'm' || 'mm' ||
'cs' || 'rb' || 'php' || 'r' || 'lua' || 'pl' || 'pm' || 'scala' ||
'clj' || 'ex' || 'exs' || 'erl' || 'hs' || 'jl' || 'nim' || 'zig' ||
'toml' || 'ini' || 'conf' || 'cfg' || 'env' || 'editorconfig' ||
'properties' || 'diff' || 'patch' || 'gitignore' || 'dockerfile' ||
'sql' || 'graphql' || 'gql' || 'proto' => _DocType.code,
// PSD/PSB embed a JPEG thumbnail in Image Resource 1036
'psd' || 'psb' => _DocType.psdThumb,
// Sketch and XD are ZIP archives containing a preview PNG
'sketch' || 'xd' => _DocType.zipPreview,
// Truly proprietary — no extractable preview
'afphoto' || 'afdesign' || 'afpub' ||
'indd' || 'inx' || 'fig' ||
'doc' || 'ppt' || 'xls' => _DocType.proprietary,
_ => _DocType.unknown,
};
}
class DocumentViewer extends StatelessWidget {
final String filename;
final Uint8List bytes;
final String senderName;
final int initialPage;
const DocumentViewer({
super.key,
required this.filename,
required this.bytes,
required this.senderName,
this.initialPage = 1,
});
static bool supportsPreview(String filename) =>
_detectType(filename) != _DocType.unknown;
/// True for formats rendered inline in chat (pdf/svg types, including ai/eps).
static bool isInlinePreviewable(String filename) {
final t = _detectType(filename);
return t == _DocType.pdf || t == _DocType.svg;
}
/// True for formats that show a card inline in chat (needs download-on-tap).
static bool showsInlineCard(String filename) {
final t = _detectType(filename);
return t == _DocType.proprietary || t == _DocType.psdThumb || t == _DocType.zipPreview;
}
/// Kept for compatibility — same as showsInlineCard.
static bool isProprietaryFormat(String filename) => showsInlineCard(filename);
static Future<void> show(
BuildContext context, {
required String filename,
required Uint8List bytes,
required String senderName,
int initialPage = 1,
}) {
return showGeneralDialog(
context: context,
barrierDismissible: true,
barrierLabel: 'Schließen',
barrierColor: Colors.black.withAlpha(178),
transitionDuration: const Duration(milliseconds: 180),
transitionBuilder: (ctx, anim, a, child) =>
FadeTransition(opacity: anim, child: child),
pageBuilder: (ctx, a, b) => DocumentViewer(
filename: filename,
bytes: bytes,
senderName: senderName,
initialPage: initialPage,
),
);
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
final type = _detectType(filename);
final size = MediaQuery.sizeOf(context);
final maxW = (size.width - 48).clamp(300.0, 1100.0);
final maxH = (size.height - 48).clamp(300.0, 900.0);
return Material(
type: MaterialType.transparency,
child: Center(
child: Container(
width: maxW,
height: maxH,
decoration: BoxDecoration(
color: pt.bg0,
borderRadius: BorderRadius.circular(pt.rLg),
border: Border.all(color: pt.border),
boxShadow: [BoxShadow(color: Colors.black.withAlpha(80), blurRadius: 32, offset: const Offset(0, 8))],
),
clipBehavior: Clip.hardEdge,
child: Column(
children: [
_DocHeader(filename: filename, senderName: senderName, pt: pt),
Expanded(
child: _DocContent(
filename: filename,
bytes: bytes,
type: type,
pt: pt,
initialPage: initialPage,
),
),
],
),
),
),
);
}
}
class _DocHeader extends StatelessWidget {
final String filename;
final String senderName;
final PyramidTheme pt;
const _DocHeader({required this.filename, required this.senderName, required this.pt});
@override
Widget build(BuildContext context) {
final ext = filename.contains('.') ? filename.split('.').last.toUpperCase() : '';
return Container(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 12),
decoration: BoxDecoration(
color: pt.bg1,
border: Border(bottom: BorderSide(color: pt.border)),
),
child: Row(
children: [
_ExtBadge(ext: ext, pt: pt),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(filename, style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600), overflow: TextOverflow.ellipsis),
Text(senderName, style: TextStyle(color: pt.fgDim, fontSize: 12)),
],
),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: Icon(Icons.close_rounded, color: pt.fgMuted, size: 20),
tooltip: 'Schließen',
),
],
),
);
}
}
class _ExtBadge extends StatelessWidget {
final String ext;
final PyramidTheme pt;
const _ExtBadge({required this.ext, required this.pt});
@override
Widget build(BuildContext context) {
final label = ext.length > 4 ? ext.substring(0, 4) : ext;
return Container(
width: 44, height: 44,
decoration: BoxDecoration(color: pt.accentSoft, borderRadius: BorderRadius.circular(pt.rSm)),
child: Center(
child: Text(label, style: TextStyle(color: pt.accent, fontSize: label.length > 3 ? 10 : 12, fontWeight: FontWeight.w700)),
),
);
}
}
class _DocContent extends StatelessWidget {
final String filename;
final Uint8List bytes;
final _DocType type;
final PyramidTheme pt;
final int initialPage;
const _DocContent({required this.filename, required this.bytes, required this.type, required this.pt, required this.initialPage});
@override
Widget build(BuildContext context) {
return switch (type) {
_DocType.pdf => _PdfFullViewer(bytes: bytes, filename: filename, pt: pt, initialPage: initialPage),
_DocType.svg => _SvgViewer(bytes: bytes, pt: pt),
_DocType.markdown => _MarkdownViewer(bytes: bytes, pt: pt),
_DocType.text => _TextViewer(bytes: bytes, pt: pt, mono: false),
_DocType.code => _TextViewer(bytes: bytes, pt: pt, mono: true),
_DocType.office => _OfficeTextViewer(bytes: bytes, filename: filename, pt: pt),
_DocType.archive => _ArchiveViewer(bytes: bytes, filename: filename, pt: pt),
_DocType.psdThumb => _PsdThumbViewer(bytes: bytes, filename: filename, pt: pt),
_DocType.zipPreview => _ZipPreviewViewer(bytes: bytes, filename: filename, pt: pt),
_DocType.proprietary => _ProprietaryViewer(filename: filename, bytes: bytes, pt: pt),
_DocType.unknown => _UnsupportedViewer(pt: pt),
};
}
}
class _ErrorView extends StatelessWidget {
final String message;
final PyramidTheme pt;
const _ErrorView({required this.message, required this.pt});
@override
Widget build(BuildContext context) => Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline_rounded, size: 32, color: pt.danger),
const SizedBox(height: 8),
Text('Fehler beim Laden', style: TextStyle(color: pt.danger, fontSize: 14, fontWeight: FontWeight.w600)),
const SizedBox(height: 6),
Text(message, style: TextStyle(color: pt.fgMuted, fontSize: 12), textAlign: TextAlign.center),
],
),
),
);
}
class _UnsupportedViewer extends StatelessWidget {
final PyramidTheme pt;
const _UnsupportedViewer({required this.pt});
@override
Widget build(BuildContext context) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.preview_outlined, size: 48, color: pt.fgDim),
const SizedBox(height: 12),
Text('Vorschau nicht verfügbar', style: TextStyle(color: pt.fgDim, fontSize: 15)),
const SizedBox(height: 4),
Text('Proprietäres Format — bitte herunterladen.', style: TextStyle(color: pt.fgMuted, fontSize: 12)),
],
),
);
}
class _FileFallback extends StatelessWidget {
final String filename;
final PyramidTheme pt;
final VoidCallback? onDownload;
const _FileFallback({required this.filename, required this.pt, this.onDownload});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(top: 4, bottom: 4),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
constraints: const BoxConstraints(maxWidth: 300),
decoration: BoxDecoration(
color: pt.bg3,
borderRadius: BorderRadius.circular(pt.rBase),
border: Border.all(color: pt.border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 36, height: 36,
decoration: BoxDecoration(color: pt.accentSoft, borderRadius: BorderRadius.circular(pt.rSm)),
child: Icon(Icons.picture_as_pdf_outlined, size: 20, color: pt.accent),
),
const SizedBox(width: 10),
Expanded(
child: Text(filename, style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500), maxLines: 2, overflow: TextOverflow.ellipsis),
),
if (onDownload != null)
IconButton(
onPressed: onDownload,
icon: Icon(Icons.download_rounded, size: 20, color: pt.fgMuted),
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
padding: EdgeInsets.zero,
),
],
),
);
}
}
@@ -0,0 +1,71 @@
part of '../document_viewer.dart';
class _ProprietaryViewer extends StatelessWidget {
final String filename;
final Uint8List bytes;
final PyramidTheme pt;
const _ProprietaryViewer({required this.filename, required this.bytes, required this.pt});
String _fmtSize(int b) {
if (b < 1024) return '$b B';
if (b < 1024 * 1024) return '${(b / 1024).toStringAsFixed(1)} KB';
return '${(b / (1024 * 1024)).toStringAsFixed(1)} MB';
}
IconData _iconFor(String ext) => switch (ext) {
'psd' || 'psb' => Icons.photo_camera_outlined,
'ai' || 'eps' => Icons.gesture_outlined,
'indd' || 'inx' => Icons.menu_book_outlined,
'afphoto' || 'afdesign' || 'afpub' => Icons.palette_outlined,
'sketch' || 'fig' || 'xd' => Icons.design_services_outlined,
'doc' => Icons.description_outlined,
'ppt' => Icons.slideshow_outlined,
'xls' => Icons.table_chart_outlined,
_ => Icons.insert_drive_file_outlined,
};
@override
Widget build(BuildContext context) {
final ext = filename.contains('.') ? filename.split('.').last.toLowerCase() : '';
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 80, height: 80,
decoration: BoxDecoration(color: pt.accentSoft, borderRadius: BorderRadius.circular(pt.rBase)),
child: Icon(_iconFor(ext), size: 40, color: pt.accent),
),
const SizedBox(height: 16),
Text(
filename,
style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600),
textAlign: TextAlign.center,
),
const SizedBox(height: 6),
Text(_fmtSize(bytes.length), style: TextStyle(color: pt.fgDim, fontSize: 13)),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: pt.bg2,
borderRadius: BorderRadius.circular(pt.rSm),
border: Border.all(color: pt.border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.info_outline_rounded, size: 16, color: pt.fgDim),
const SizedBox(width: 8),
Text(
'Format kann nicht nativ dargestellt werden',
style: TextStyle(color: pt.fgDim, fontSize: 13),
),
],
),
),
],
),
);
}
}
@@ -0,0 +1,814 @@
part of '../document_viewer.dart';
// Static render cache.
// Survives widget dispose/recreate (e.g. scroll in chat list).
// Key: filename + file-size-bytes + page + render-dimensions.
// Evicts the oldest entry (FIFO) when the limit is reached.
class _RenderCache {
static const _max = 80;
static final _data = <String, Uint8List>{};
static String _k(String name, int sz, int page, int w, int h) =>
'$name\x00$sz\x00$page\x00${w}x$h';
static Uint8List? get(String name, int sz, int page, int w, int h) =>
_data[_k(name, sz, page, w, h)];
static void put(String name, int sz, int page, int w, int h, Uint8List img) {
if (_data.length >= _max) _data.remove(_data.keys.first);
_data[_k(name, sz, page, w, h)] = img;
}
}
Future<void> _saveBytes(BuildContext context, Uint8List bytes, String filename) async {
try {
Directory? dir;
if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) {
dir = await getDownloadsDirectory();
}
dir ??= await getTemporaryDirectory();
final ext = filename.contains('.') ? filename.split('.').last : 'bin';
final name = 'pyramid_${DateTime.now().millisecondsSinceEpoch}.$ext';
final file = File('${dir.path}/$name');
await file.writeAsBytes(bytes);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Gespeichert: ${file.path}'), duration: const Duration(seconds: 4)),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Fehler: $e')));
}
}
}
// Inline chat preview.
// Used by _DocPreviewContent in message_group.dart.
// Shows the first page of a PDF as an image with page navigation arrows.
// Tap → opens the full DocumentViewer overlay.
class PdfInlinePreview extends StatefulWidget {
final Uint8List bytes;
final String filename;
final PyramidTheme pt;
final String senderName;
final VoidCallback? onDownload;
const PdfInlinePreview({
super.key,
required this.bytes,
required this.filename,
required this.pt,
required this.senderName,
this.onDownload,
});
@override
State<PdfInlinePreview> createState() => _PdfInlinePreviewState();
}
class _PdfInlinePreviewState extends State<PdfInlinePreview> {
PdfDocument? _doc;
int _currentPage = 1;
int _pageCount = 0;
double? _pageAspect; // width/height
final Map<int, Uint8List?> _cache = {};
bool _loading = true;
String? _error;
bool _rendering = false;
static const _previewW = 280.0;
static const _renderScale = 2.0;
@override
void initState() {
super.initState();
_init();
}
Future<void> _init() async {
try {
final doc = await PdfDocument.openData(widget.bytes);
if (!mounted) { await doc.close(); return; }
_doc = doc;
_pageCount = doc.pagesCount;
setState(() => _loading = false);
await _ensurePage(1);
} catch (e) {
if (mounted) setState(() { _loading = false; _error = e.toString().split('\n').first; });
}
}
Future<void> _ensurePage(int n) async {
if (_cache.containsKey(n) || _rendering) return;
_rendering = true;
try {
final page = await _doc!.getPage(n);
if (mounted && _pageAspect == null) {
setState(() => _pageAspect = page.width / page.height);
}
final rw = (page.width * _renderScale).round();
final rh = (page.height * _renderScale).round();
final hit = _RenderCache.get(widget.filename, widget.bytes.length, n, rw, rh);
if (hit != null) {
await page.close();
if (mounted) setState(() => _cache[n] = hit);
return;
}
final img = await page.render(
width: page.width * _renderScale,
height: page.height * _renderScale,
format: PdfPageImageFormat.jpeg,
backgroundColor: '#ffffff',
);
await page.close();
if (img?.bytes != null) {
_RenderCache.put(widget.filename, widget.bytes.length, n, rw, rh, img!.bytes);
}
if (mounted) setState(() => _cache[n] = img?.bytes);
} catch (_) {
_cache[n] = null;
} finally {
_rendering = false;
}
}
void _goTo(int page) {
if (page < 1 || page > _pageCount) return;
setState(() => _currentPage = page);
_ensurePage(page);
_ensurePage((page + 1).clamp(1, _pageCount));
}
void _openFull() {
DocumentViewer.show(
context,
filename: widget.filename,
bytes: widget.bytes,
senderName: widget.senderName,
initialPage: _currentPage,
);
}
@override
void dispose() {
_doc?.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final radius = BorderRadius.circular(pt.rBase);
if (_loading) {
return Container(
width: _previewW,
// Match the typical loaded A4 height so the preview doesn't jump (and
// shift the scroll position) once the first page finishes rendering.
height: (_previewW / 0.707).clamp(160.0, 400.0),
margin: const EdgeInsets.only(top: 4, bottom: 4),
decoration: BoxDecoration(
color: pt.bg2,
borderRadius: radius,
border: Border.all(color: pt.border),
),
child: Center(child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)),
);
}
if (_error != null) {
return _FileFallback(filename: widget.filename, pt: pt, onDownload: widget.onDownload);
}
final pageBytes = _cache[_currentPage];
final aspect = _pageAspect ?? 0.707; // A4 fallback
final imgH = (_previewW / aspect).clamp(160.0, 400.0);
return Listener(
onPointerSignal: (event) {
if (event is PointerScrollEvent) {
GestureBinding.instance.pointerSignalResolver.register(event, (resolved) {
final dy = (resolved as PointerScrollEvent).scrollDelta.dy;
if (dy > 0) { _goTo(_currentPage + 1); }
else if (dy < 0) { _goTo(_currentPage - 1); }
});
}
},
child: GestureDetector(
onTap: _openFull,
onHorizontalDragEnd: (d) {
if ((d.primaryVelocity ?? 0) < -200) _goTo(_currentPage + 1);
if ((d.primaryVelocity ?? 0) > 200) _goTo(_currentPage - 1);
},
child: Container(
margin: const EdgeInsets.only(top: 4, bottom: 4),
width: _previewW,
decoration: BoxDecoration(
borderRadius: radius,
border: Border.all(color: pt.border),
),
clipBehavior: Clip.hardEdge,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Page image
Container(
width: _previewW,
height: imgH,
color: Colors.white,
child: pageBytes != null
? Image.memory(pageBytes, width: _previewW, height: imgH, fit: BoxFit.cover, alignment: Alignment.topCenter)
: Center(child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)),
),
// Footer
Container(
color: pt.bg2,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
child: Row(
children: [
Icon(Icons.picture_as_pdf_outlined, size: 14, color: pt.fgDim),
const SizedBox(width: 6),
Expanded(
child: Text(
widget.filename,
style: TextStyle(color: pt.fg, fontSize: 12, fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis,
),
),
if (_pageCount > 1) ...[
_NavArrow(
icon: Icons.chevron_left_rounded,
enabled: _currentPage > 1,
pt: pt,
onTap: () => _goTo(_currentPage - 1),
),
Text(
'$_currentPage/$_pageCount',
style: TextStyle(color: pt.fgMuted, fontSize: 11, fontFeatures: const [FontFeature.tabularFigures()]),
),
_NavArrow(
icon: Icons.chevron_right_rounded,
enabled: _currentPage < _pageCount,
pt: pt,
onTap: () => _goTo(_currentPage + 1),
),
],
if (widget.onDownload != null) ...[
const SizedBox(width: 4),
GestureDetector(
onTap: widget.onDownload,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.all(2),
child: Icon(Icons.download_rounded, size: 16, color: pt.fgMuted),
),
),
],
],
),
),
],
),
),
),
);
}
}
class _NavArrow extends StatelessWidget {
final IconData icon;
final bool enabled;
final PyramidTheme pt;
final VoidCallback onTap;
const _NavArrow({required this.icon, required this.enabled, required this.pt, required this.onTap});
@override
Widget build(BuildContext context) => GestureDetector(
onTap: enabled ? onTap : null,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Icon(icon, size: 18, color: enabled ? pt.fg : pt.fgDim),
),
);
}
class _PdfFullViewer extends StatefulWidget {
final Uint8List bytes;
final String filename;
final PyramidTheme pt;
final int initialPage;
const _PdfFullViewer({required this.bytes, required this.filename, required this.pt, required this.initialPage});
@override
State<_PdfFullViewer> createState() => _PdfFullViewerState();
}
class _PdfFullViewerState extends State<_PdfFullViewer> {
late final PdfController _ctrl;
PdfDocument? _thumbDoc;
final Map<int, Uint8List?> _thumbs = {};
int _pageCount = 0;
int _currentPage = 1;
bool _animating = false;
@override
void initState() {
super.initState();
_currentPage = widget.initialPage;
_ctrl = PdfController(
document: PdfDocument.openData(widget.bytes),
initialPage: widget.initialPage,
);
_ctrl.pageListenable.addListener(_onPage);
_initThumbs();
}
Future<void> _initThumbs() async {
final doc = await PdfDocument.openData(widget.bytes);
if (!mounted) { await doc.close(); return; }
_thumbDoc = doc;
if (!mounted) { await doc.close(); _thumbDoc = null; return; }
setState(() => _pageCount = doc.pagesCount);
for (var i = 1; i <= doc.pagesCount; i++) {
_renderThumb(i);
}
}
Future<void> _renderThumb(int n) async {
final doc = _thumbDoc;
if (doc == null) return;
try {
final page = await doc.getPage(n);
final rw = (page.width * 0.12).round();
final rh = (page.height * 0.12).round();
final hit = _RenderCache.get(widget.filename, widget.bytes.length, n, rw, rh);
if (hit != null) {
await page.close();
if (mounted) setState(() => _thumbs[n] = hit);
return;
}
final img = await page.render(
width: page.width * 0.12,
height: page.height * 0.12,
format: PdfPageImageFormat.jpeg,
backgroundColor: '#ffffff',
);
await page.close();
if (img?.bytes != null) {
_RenderCache.put(widget.filename, widget.bytes.length, n, rw, rh, img!.bytes);
}
if (mounted) setState(() => _thumbs[n] = img?.bytes);
} catch (_) {
_thumbs[n] = null;
}
}
void _onPage() => setState(() => _currentPage = _ctrl.page);
void _onScrollPage(int delta) {
if (_animating) return;
final pageCount = _ctrl.pagesCount ?? _pageCount;
if (pageCount == 0) return;
final target = (_currentPage + delta).clamp(1, pageCount);
if (target == _currentPage) return;
_animating = true;
_ctrl.animateToPage(
target,
duration: const Duration(milliseconds: 280),
curve: Curves.easeInOut,
).then((_) { if (mounted) setState(() => _animating = false); });
}
@override
void dispose() {
_ctrl.pageListenable.removeListener(_onPage);
_ctrl.dispose();
_thumbDoc?.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
return Column(
children: [
Expanded(
child: Stack(
children: [
Listener(
onPointerSignal: (event) {
if (event is PointerScrollEvent) {
if (event.scrollDelta.dy > 0) { _onScrollPage(1); }
else if (event.scrollDelta.dy < 0) { _onScrollPage(-1); }
}
},
child: PdfView(
controller: _ctrl,
scrollDirection: Axis.vertical,
physics: const NeverScrollableScrollPhysics(),
backgroundDecoration: BoxDecoration(color: pt.bg0),
onPageChanged: (p) => setState(() => _currentPage = p),
// Render at high resolution in PNG: sharper text/line-art and no
// JPEG chroma artifacts (matters most for AI/EPS/vector content),
// and gives headroom for the InteractiveViewer zoom.
renderer: (page) => page.render(
width: page.width * 4,
height: page.height * 4,
format: PdfPageImageFormat.png,
backgroundColor: '#ffffff',
),
builders: PdfViewBuilders<DefaultBuilderOptions>(
options: const DefaultBuilderOptions(),
documentLoaderBuilder: (ctx) => Center(
child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent),
),
errorBuilder: (ctx, error) => _ErrorView(message: error.toString(), pt: pt),
),
),
),
// Launch the immersive fullscreen page viewer.
Positioned(
top: 8,
right: 8,
child: Material(
color: pt.bg0.withAlpha(200),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rSm),
side: BorderSide(color: pt.border),
),
child: IconButton(
tooltip: 'Vollbild',
icon: Icon(Icons.fullscreen_rounded, size: 22, color: pt.fg),
onPressed: () => _PdfFullscreenViewer.show(
context,
bytes: widget.bytes,
filename: widget.filename,
initialPage: _currentPage,
),
),
),
),
],
),
),
if (_pageCount > 1) _ThumbnailStrip(
pageCount: _pageCount,
currentPage: _currentPage,
thumbs: _thumbs,
pt: pt,
onTap: (p) {
_ctrl.jumpToPage(p);
setState(() => _currentPage = p);
},
),
],
);
}
}
// Immersive fullscreen PDF viewer.
// Launched from the enlarged preview. Per-page high-res render, pan via drag,
// zoom via +/− buttons (20%/click) and Ctrl+wheel, page nav via wheel/arrows.
class _PdfFullscreenViewer extends StatefulWidget {
final Uint8List bytes;
final String filename;
final int initialPage;
const _PdfFullscreenViewer({required this.bytes, required this.filename, required this.initialPage});
static Future<void> show(BuildContext context, {
required Uint8List bytes,
required String filename,
required int initialPage,
}) {
return Navigator.of(context, rootNavigator: true).push(PageRouteBuilder(
opaque: true,
barrierColor: Colors.black,
transitionDuration: const Duration(milliseconds: 180),
pageBuilder: (_, _, _) =>
_PdfFullscreenViewer(bytes: bytes, filename: filename, initialPage: initialPage),
transitionsBuilder: (_, anim, _, child) => FadeTransition(opacity: anim, child: child),
));
}
@override
State<_PdfFullscreenViewer> createState() => _PdfFullscreenViewerState();
}
class _PdfFullscreenViewerState extends State<_PdfFullscreenViewer> {
PdfDocument? _doc;
final _tc = TransformationController();
final _viewerKey = GlobalKey();
int _page = 1;
int _count = 0;
Uint8List? _img;
bool _loading = true;
bool _rendering = false;
bool _uiVisible = true;
bool get _isMobile => Platform.isAndroid || Platform.isIOS;
static const _minScale = 1.0;
static const _maxScale = 8.0;
static const _renderScale = 5.0; // high-res page render for crisp zoom
@override
void initState() {
super.initState();
_page = widget.initialPage;
_open();
}
Future<void> _open() async {
try {
final doc = await PdfDocument.openData(widget.bytes);
if (!mounted) { await doc.close(); return; }
_doc = doc;
_count = doc.pagesCount;
await _render(_page);
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _render(int n) async {
final doc = _doc;
if (doc == null || _rendering) return;
_rendering = true;
if (mounted) setState(() => _loading = true);
try {
final page = await doc.getPage(n);
final rw = (page.width * _renderScale).round();
final rh = (page.height * _renderScale).round();
var bytes = _RenderCache.get(widget.filename, widget.bytes.length, n, rw, rh);
if (bytes == null) {
final img = await page.render(
width: page.width * _renderScale,
height: page.height * _renderScale,
format: PdfPageImageFormat.png,
backgroundColor: '#ffffff',
);
bytes = img?.bytes;
if (bytes != null) {
_RenderCache.put(widget.filename, widget.bytes.length, n, rw, rh, bytes);
}
}
await page.close();
if (mounted) setState(() { _img = bytes; _loading = false; });
} catch (_) {
if (mounted) setState(() => _loading = false);
} finally {
_rendering = false;
}
}
void _goto(int n) {
final t = n.clamp(1, _count);
if (t == _page) return;
_tc.value = Matrix4.identity(); // reset zoom on page change
setState(() => _page = t);
_render(t);
}
// factor 1.2 = +20%, 1/1.2 = −20%
void _zoom(double factor) {
final size = _viewerKey.currentContext?.size;
if (size == null) return;
final current = _tc.value.getMaxScaleOnAxis();
final target = (current * factor).clamp(_minScale, _maxScale);
final applied = target / current;
if ((applied - 1).abs() < 0.001) return;
final cx = size.width / 2, cy = size.height / 2;
final m = _tc.value.clone()
..translateByDouble(cx, cy, 0, 1)
..scaleByDouble(applied, applied, applied, 1)
..translateByDouble(-cx, -cy, 0, 1);
setState(() => _tc.value = m);
}
@override
void dispose() {
_doc?.close();
_tc.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Stack(
children: [
Positioned.fill(
child: GestureDetector(
// Single tap on the page (not on a control) toggles the UI.
behavior: HitTestBehavior.translucent,
onTap: () => setState(() => _uiVisible = !_uiVisible),
child: Listener(
onPointerSignal: (e) {
if (e is! PointerScrollEvent) return;
final ctrl = HardwareKeyboard.instance.isControlPressed ||
HardwareKeyboard.instance.isMetaPressed;
if (ctrl) {
_zoom(e.scrollDelta.dy < 0 ? 1.2 : 1 / 1.2);
} else {
_goto(e.scrollDelta.dy > 0 ? _page + 1 : _page - 1);
}
},
child: _img == null
? Center(
child: _loading
? CircularProgressIndicator(color: pt.accent)
: const Icon(Icons.error_outline_rounded, color: Colors.white54, size: 40),
)
: InteractiveViewer(
key: _viewerKey,
transformationController: _tc,
minScale: _minScale,
maxScale: _maxScale,
// Pinch-zoom on mobile only. On desktop the built-in
// scroll-wheel zoom is disabled so plain scrolling
// pages instead; zoom is Ctrl+wheel and the +/− buttons.
scaleEnabled: _isMobile,
child: Center(
child: Image.memory(_img!, fit: BoxFit.contain, gaplessPlayback: true),
),
),
),
),
),
// Right-side control column
if (_uiVisible)
Positioned(
right: 10,
top: 0,
bottom: 0,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_FsBtn(icon: Icons.add_rounded, tooltip: 'Vergrößern', pt: pt, onTap: () => _zoom(1.2)),
const SizedBox(height: 8),
_FsBtn(icon: Icons.remove_rounded, tooltip: 'Verkleinern', pt: pt, onTap: () => _zoom(1 / 1.2)),
const SizedBox(height: 8),
_FsBtn(icon: Icons.download_rounded, tooltip: 'Herunterladen', pt: pt,
onTap: () => _saveBytes(context, widget.bytes, widget.filename)),
const SizedBox(height: 8),
_FsBtn(icon: Icons.fullscreen_exit_rounded, tooltip: 'Vollbild beenden', pt: pt,
onTap: () => Navigator.of(context).maybePop()),
],
),
),
),
// Bottom-center page navigation
if (_uiVisible && _count > 1)
Positioned(
bottom: 16,
left: 0,
right: 0,
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
decoration: BoxDecoration(
color: Colors.black.withAlpha(160),
borderRadius: BorderRadius.circular(24),
border: Border.all(color: Colors.white24),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_FsBtn(icon: Icons.chevron_left_rounded, tooltip: 'Vorherige', pt: pt,
enabled: _page > 1, onTap: () => _goto(_page - 1), flat: true),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Text('$_page / $_count',
style: const TextStyle(color: Colors.white, fontSize: 13,
fontFeatures: [FontFeature.tabularFigures()])),
),
_FsBtn(icon: Icons.chevron_right_rounded, tooltip: 'Nächste', pt: pt,
enabled: _page < _count, onTap: () => _goto(_page + 1), flat: true),
],
),
),
),
),
],
),
),
);
}
}
class _FsBtn extends StatelessWidget {
final IconData icon;
final String tooltip;
final PyramidTheme pt;
final VoidCallback onTap;
final bool enabled;
final bool flat;
const _FsBtn({required this.icon, required this.tooltip, required this.pt,
required this.onTap, this.enabled = true, this.flat = false});
@override
Widget build(BuildContext context) {
final btn = Material(
color: flat ? Colors.transparent : Colors.black.withAlpha(160),
shape: flat
? const CircleBorder()
: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10), side: const BorderSide(color: Colors.white24)),
child: IconButton(
tooltip: tooltip,
icon: Icon(icon, color: enabled ? Colors.white : Colors.white30, size: 22),
onPressed: enabled ? onTap : null,
),
);
return btn;
}
}
class _ThumbnailStrip extends StatefulWidget {
final int pageCount;
final int currentPage;
final Map<int, Uint8List?> thumbs;
final PyramidTheme pt;
final ValueChanged<int> onTap;
const _ThumbnailStrip({required this.pageCount, required this.currentPage, required this.thumbs, required this.pt, required this.onTap});
@override
State<_ThumbnailStrip> createState() => _ThumbnailStripState();
}
class _ThumbnailStripState extends State<_ThumbnailStrip> {
late final ScrollController _sc;
@override
void initState() {
super.initState();
_sc = ScrollController();
}
@override
void didUpdateWidget(_ThumbnailStrip old) {
super.didUpdateWidget(old);
if (old.currentPage != widget.currentPage) {
_scrollToPage(widget.currentPage);
}
}
void _scrollToPage(int page) {
const thumbW = 60.0 + 8.0; // thumb + margin
final target = (page - 1) * thumbW - 80;
if (_sc.hasClients) {
_sc.animateTo(
target.clamp(0.0, _sc.position.maxScrollExtent),
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
}
@override
void dispose() {
_sc.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
return Container(
height: 88,
decoration: BoxDecoration(
color: pt.bg1,
border: Border(top: BorderSide(color: pt.border)),
),
child: ListView.builder(
controller: _sc,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
itemCount: widget.pageCount,
itemBuilder: (ctx, i) {
final page = i + 1;
final isCurrent = page == widget.currentPage;
final bytes = widget.thumbs[page];
return GestureDetector(
onTap: () => widget.onTap(page),
child: Container(
width: 52,
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: isCurrent ? pt.accent : pt.border,
width: isCurrent ? 2 : 1,
),
),
clipBehavior: Clip.hardEdge,
child: bytes != null
? Image.memory(bytes, fit: BoxFit.cover)
: Center(
child: Text('$page', style: TextStyle(color: pt.fgDim, fontSize: 10)),
),
),
);
},
),
);
}
}
@@ -0,0 +1,201 @@
part of '../document_viewer.dart';
class _SvgViewer extends StatelessWidget {
final Uint8List bytes;
final PyramidTheme pt;
const _SvgViewer({required this.bytes, required this.pt});
@override
Widget build(BuildContext context) {
return Center(
child: InteractiveViewer(
minScale: 0.5,
maxScale: 10,
child: Padding(
padding: const EdgeInsets.all(24),
child: SvgPicture.memory(bytes),
),
),
);
}
}
class _MarkdownViewer extends StatelessWidget {
final Uint8List bytes;
final PyramidTheme pt;
const _MarkdownViewer({required this.bytes, required this.pt});
@override
Widget build(BuildContext context) {
final String text;
try {
text = utf8.decode(bytes, allowMalformed: true);
} catch (e) {
return _ErrorView(message: e.toString(), pt: pt);
}
return Markdown(
data: text,
selectable: true,
padding: const EdgeInsets.all(24),
styleSheet: MarkdownStyleSheet(
p: TextStyle(color: pt.fg, fontSize: 14, height: 1.65),
h1: TextStyle(color: pt.fg, fontSize: 22, fontWeight: FontWeight.w700),
h2: TextStyle(color: pt.fg, fontSize: 18, fontWeight: FontWeight.w600),
h3: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w600),
h4: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600),
code: TextStyle(color: pt.accent, fontFamily: 'monospace', fontSize: 13, backgroundColor: pt.bg2),
codeblockDecoration: BoxDecoration(
color: pt.bg2,
borderRadius: BorderRadius.circular(pt.rSm),
border: Border.all(color: pt.border),
),
blockquoteDecoration: BoxDecoration(
border: Border(left: BorderSide(color: pt.accent, width: 3)),
color: pt.accentSoft,
),
blockquotePadding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
a: TextStyle(color: pt.accent, decoration: TextDecoration.underline, decorationColor: pt.accent),
strong: TextStyle(color: pt.fg, fontWeight: FontWeight.w600),
em: TextStyle(color: pt.fg, fontStyle: FontStyle.italic),
listBullet: TextStyle(color: pt.fgMuted),
horizontalRuleDecoration: BoxDecoration(border: Border(top: BorderSide(color: pt.border))),
),
onTapLink: (text, href, title) {
if (href != null) launchUrl(Uri.parse(href), mode: LaunchMode.externalApplication);
},
);
}
}
class _TextViewer extends StatelessWidget {
final Uint8List bytes;
final PyramidTheme pt;
final bool mono;
static const _maxBytes = 512 * 1024;
const _TextViewer({required this.bytes, required this.pt, required this.mono});
@override
Widget build(BuildContext context) {
final bool truncated = bytes.length > _maxBytes;
final slice = truncated ? bytes.sublist(0, _maxBytes) : bytes;
final String text;
try {
text = utf8.decode(slice, allowMalformed: true);
} catch (e) {
return _ErrorView(message: e.toString(), pt: pt);
}
return Column(
children: [
if (truncated)
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
color: pt.bg2,
child: Text('Zeige erste ${_maxBytes ~/ 1024} KB', style: TextStyle(color: pt.fgDim, fontSize: 11)),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: SelectableText(
text,
style: mono
? TextStyle(color: pt.fg, fontFamily: 'monospace', fontSize: 13, height: 1.5)
: TextStyle(color: pt.fg, fontSize: 14, height: 1.65),
),
),
),
],
);
}
}
class _OfficeTextViewer extends StatefulWidget {
final Uint8List bytes;
final String filename;
final PyramidTheme pt;
const _OfficeTextViewer({required this.bytes, required this.filename, required this.pt});
@override
State<_OfficeTextViewer> createState() => _OfficeTextViewerState();
}
class _OfficeTextViewerState extends State<_OfficeTextViewer> {
String? _text;
String? _error;
@override
void initState() {
super.initState();
_extract();
}
void _extract() {
try {
final archive = ZipDecoder().decodeBytes(widget.bytes);
final ext = widget.filename.contains('.') ? widget.filename.split('.').last.toLowerCase() : '';
final xmlFiles = <ArchiveFile>[];
for (final f in archive.files) {
if (!f.isFile) continue;
final n = f.name.toLowerCase();
if (ext == 'docx' && n == 'word/document.xml') { xmlFiles.add(f); break; }
if (ext == 'xlsx' && n.startsWith('xl/worksheets/') && n.endsWith('.xml')) xmlFiles.add(f);
if (ext == 'pptx' && n.startsWith('ppt/slides/slide') && n.endsWith('.xml')) xmlFiles.add(f);
// ODF formats (Writer/Impress/Calc) all use content.xml
if ((ext == 'odt' || ext == 'odp' || ext == 'ods') && n == 'content.xml') { xmlFiles.add(f); break; }
}
final buf = StringBuffer();
for (final f in xmlFiles) {
final xml = utf8.decode(f.content as List<int>, allowMalformed: true);
// Strip XML tags, collapse whitespace
final stripped = xml
.replaceAll(RegExp(r'<[^>]+>'), ' ')
.replaceAll(RegExp(r'&amp;'), '&')
.replaceAll(RegExp(r'&lt;'), '<')
.replaceAll(RegExp(r'&gt;'), '>')
.replaceAll(RegExp(r'&nbsp;'), ' ')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
buf.writeln(stripped);
buf.writeln();
}
_text = buf.toString().trim().isEmpty
? '(Kein lesbarer Text gefunden)'
: buf.toString().trim();
} catch (e) {
_error = e.toString().split('\n').first;
}
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
if (_error != null) return _ErrorView(message: _error!, pt: pt);
return Column(
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: pt.bg2,
child: Row(
children: [
Icon(Icons.info_outline_rounded, size: 14, color: pt.fgDim),
const SizedBox(width: 8),
Text('Textvorschau (Formatierung nicht verfügbar)', style: TextStyle(color: pt.fgDim, fontSize: 12)),
],
),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: SelectableText(
_text ?? '',
style: TextStyle(color: pt.fg, fontSize: 14, height: 1.65),
),
),
),
],
);
}
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -628,7 +628,7 @@ class _SectionLabel extends StatelessWidget {
return Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
child: Text(title,
style: TextStyle(color: PyramidColors.fgDim,
style: TextStyle(color: PyramidTheme.of(context).fgDim,
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 0.6)),
+41 -11
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'dart:typed_data';
@@ -266,7 +267,7 @@ class _AArtwork extends StatelessWidget {
borderRadius: BorderRadius.circular(size * 0.3),
gradient: LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [c1, c2]),
),
child: Icon(Icons.music_note, color: Colors.white.withOpacity(0.85), size: size * 0.48),
child: Icon(Icons.music_note, color: Colors.white.withValues(alpha: 0.85), size: size * 0.48),
);
}
}
@@ -419,6 +420,15 @@ class _ChunkyBarsPainter extends CustomPainter {
//
// 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 {
final Uint8List bytes;
final String filename;
@@ -441,6 +451,10 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
late final Player _player;
late final VideoController _controller;
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;
String _quality = 'Auto';
@@ -452,9 +466,19 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
super.initState();
_player = Player();
_controller = VideoController(_player);
_subs.add(_player.stream.width.listen((_) => _updateAspect()));
_subs.add(_player.stream.height.listen((_) => _updateAspect()));
_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 {
final ext = widget.filename.contains('.') ? widget.filename.split('.').last : 'mp4';
final dir = await getTemporaryDirectory();
@@ -466,6 +490,9 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
@override
void dispose() {
for (final s in _subs) {
s.cancel();
}
_player.dispose();
super.dispose();
}
@@ -506,7 +533,10 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
return Container(
margin: const EdgeInsets.only(top: 4, bottom: 4),
constraints: const BoxConstraints(maxWidth: 420),
constraints: BoxConstraints(
maxWidth: 420,
maxHeight: inlineMediaMaxHeight(context),
),
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(pt.rLg),
@@ -514,7 +544,7 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
),
clipBehavior: Clip.hardEdge,
child: AspectRatio(
aspectRatio: 16 / 9,
aspectRatio: _aspect ?? 16 / 9,
child: StreamBuilder<bool>(
stream: _player.stream.playing,
builder: (_, playSnap) => StreamBuilder<Duration>(
@@ -603,9 +633,9 @@ class _VCenterPlay extends StatelessWidget {
Widget build(BuildContext context) => Container(
width: 56, height: 56,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.10),
color: Colors.white.withValues(alpha: 0.10),
shape: BoxShape.circle,
border: Border.all(color: Colors.white.withOpacity(0.18)),
border: Border.all(color: Colors.white.withValues(alpha: 0.18)),
),
child: const Icon(Icons.play_arrow, color: Colors.white, size: 26),
);
@@ -682,14 +712,14 @@ class _VScrubber extends StatelessWidget {
milliseconds: (dur.inMilliseconds * (d.localPosition.dx / c.maxWidth).clamp(0.0, 1.0)).round())),
child: SizedBox(height: 16, child: Stack(alignment: Alignment.centerLeft, children: [
Container(height: 3, decoration: BoxDecoration(
color: Colors.white.withOpacity(0.20), borderRadius: BorderRadius.circular(2))),
color: Colors.white.withValues(alpha: 0.20), borderRadius: BorderRadius.circular(2))),
FractionallySizedBox(widthFactor: progress, child: Container(height: 3,
decoration: BoxDecoration(color: accent, borderRadius: BorderRadius.circular(2)))),
Positioned(
left: c.maxWidth * progress.clamp(0.0, 1.0) - 5,
child: Container(width: 10, height: 10,
decoration: BoxDecoration(color: accent, shape: BoxShape.circle,
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.4), blurRadius: 0, spreadRadius: 2)])),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.4), blurRadius: 0, spreadRadius: 2)])),
),
])),
));
@@ -722,7 +752,7 @@ class _VOverlayBtn extends StatelessWidget {
onTap: onTap,
borderRadius: BorderRadius.circular(6),
child: SizedBox(width: 32, height: 32,
child: Icon(icon, size: 17, color: Colors.white.withOpacity(0.85))),
child: Icon(icon, size: 17, color: Colors.white.withValues(alpha: 0.85))),
);
}
@@ -750,7 +780,7 @@ class _VSettingsButton extends StatelessWidget {
color: const Color(0xF014141A),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: Colors.white.withOpacity(0.1)),
side: BorderSide(color: Colors.white.withValues(alpha: 0.1)),
),
itemBuilder: (_) => [PopupMenuItem<void>(
enabled: false, padding: EdgeInsets.zero,
@@ -761,7 +791,7 @@ class _VSettingsButton extends StatelessWidget {
),
)],
child: SizedBox(width: 32, height: 32,
child: Icon(Icons.settings_outlined, size: 16, color: Colors.white.withOpacity(0.85))),
child: Icon(Icons.settings_outlined, size: 16, color: Colors.white.withValues(alpha: 0.85))),
);
}
@@ -812,7 +842,7 @@ class _VSettingsMenuState extends State<_VSettingsMenu> {
]),
),
),
Container(height: 1, color: Colors.white.withOpacity(0.08)),
Container(height: 1, color: Colors.white.withValues(alpha: 0.08)),
if (isSpeed)
...widget.speeds.map((s) {
final lbl = '${s == s.toInt() ? s.toInt() : s}x';
+183 -51
View File
@@ -8,7 +8,14 @@ import 'package:pyramid/features/chat/media_player.dart';
import 'package:pyramid/utils/gif_favorite_service.dart';
/// 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 {
final String senderName;
final Uint8List? bytes;
@@ -40,7 +47,9 @@ class MediaViewer extends StatefulWidget {
context: context,
barrierDismissible: true,
barrierLabel: 'Schließen',
barrierColor: Colors.black.withAlpha(178),
// Den Hintergrund zeichnet der Viewer selbst (damit er beim Wischen
// ausblenden kann).
barrierColor: Colors.transparent,
transitionDuration: const Duration(milliseconds: 180),
transitionBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
@@ -59,21 +68,127 @@ class MediaViewer extends StatefulWidget {
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 _contentKey = GlobalKey();
late final AnimationController _zoomAnim;
Animation<Matrix4>? _zoomTween;
bool _controlsVisible = true;
bool _saving = 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
void dispose() {
_zoomAnim.dispose();
_tc.removeListener(_onTransform);
_tc.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 _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 {
final event = widget.event;
@@ -127,9 +242,16 @@ class _MediaViewerState extends State<MediaViewer> {
final pt = PyramidTheme.of(context);
final isVideo = widget.isVideo && widget.bytes != null;
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(
opacity: _controlsVisible ? 1 : 0,
opacity: _controlsVisible && _dragOffset == 0 ? 1 : 0,
duration: const Duration(milliseconds: 180),
child: IgnorePointer(
ignoring: !_controlsVisible,
@@ -146,7 +268,7 @@ class _MediaViewerState extends State<MediaViewer> {
child: Row(
children: [
IconButton(
onPressed: () => Navigator.of(context).pop(),
onPressed: _close,
icon: const Icon(Icons.close_rounded, color: Colors.white),
tooltip: 'Schließen',
),
@@ -181,7 +303,7 @@ class _MediaViewerState extends State<MediaViewer> {
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Icon(Icons.download_rounded, color: Colors.white),
),
if (!isVideo)
if (!isVideo && _zoomed)
IconButton(
onPressed: _resetZoom,
tooltip: 'Zoom zurücksetzen',
@@ -194,61 +316,71 @@ class _MediaViewerState extends State<MediaViewer> {
),
);
// Content area — constrained so the transparent Material outside it lets
// 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);
final Widget content;
if (isVideo) {
return Material(
type: MaterialType.transparency,
content = GestureDetector(
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: SizedBox(
width: contentMaxW,
height: contentMaxH,
child: Stack(
fit: StackFit.expand,
children: [
FullscreenVideoPlayer(bytes: widget.bytes!, filename: widget.filename),
Positioned(top: 0, left: 0, right: 0, child: header),
],
),
key: _contentKey,
// Nur das Bild selbst fängt Tipps ab; daneben greift das Schließen.
child: GestureDetector(
onTap: _toggle,
onDoubleTapDown: (d) => _doubleTapGlobal = d.globalPosition,
onDoubleTap: _toggleZoom,
child: img,
),
),
);
}
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(
type: MaterialType.transparency,
child: Center(
child: SizedBox(
width: contentMaxW,
height: contentMaxH,
child: GestureDetector(
onTap: _toggle,
child: Stack(
fit: StackFit.expand,
children: [
InteractiveViewer(
transformationController: _tc,
minScale: 0.5,
maxScale: 10.0,
child: Center(child: img),
),
Positioned(top: 0, left: 0, right: 0, child: header),
],
child: Stack(
fit: StackFit.expand,
children: [
// Hintergrund + „daneben tippen schließt“.
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _close,
child: ColoredBox(color: Colors.black.withValues(alpha: bgAlpha)),
),
Padding(
padding: EdgeInsets.all(margin),
child: Transform.translate(
offset: Offset(0, _dragOffset),
child: GestureDetector(
// Tipps auf die leere Fläche um das Bild (innerhalb des
// Inhaltsbereichs) schließen ebenfalls.
behavior: HitTestBehavior.translucent,
onTap: _close,
child: content,
),
),
),
),
Positioned(top: 0, left: 0, right: 0, child: header),
],
),
);
}
@@ -0,0 +1,282 @@
part of '../message_group.dart';
class _HoverActions extends StatefulWidget {
final PyramidTheme pt;
final Event event;
final List<Event> events;
final Room? room;
final String currentUserId;
final VoidCallback? onReply;
final ValueChanged<bool>? onOverlayStateChanged;
const _HoverActions({
required this.pt,
required this.event,
required this.events,
required this.currentUserId,
this.room,
this.onReply,
this.onOverlayStateChanged,
});
@override
State<_HoverActions> createState() => _HoverActionsState();
}
class _HoverActionsState extends State<_HoverActions> {
OverlayEntry? _reactionOverlay;
List<String> _quickReactions = ReactionService.quickReactions;
@override
void initState() {
super.initState();
ReactionService.load().then((_) {
if (mounted) setState(() => _quickReactions = ReactionService.quickReactions);
});
}
void _closeReactionOverlay() {
_reactionOverlay?.remove();
_reactionOverlay = null;
widget.onOverlayStateChanged?.call(false);
}
void _toggleReactionPicker() {
if (_reactionOverlay != null) {
_closeReactionOverlay();
return;
}
final box = context.findRenderObject() as RenderBox?;
final offset = box?.localToGlobal(Offset.zero) ?? Offset.zero;
_reactionOverlay = OverlayEntry(builder: (ctx) {
final screenSize = MediaQuery.sizeOf(ctx);
const pickerW = 340.0;
const pickerH = 380.0;
double left = (offset.dx - pickerW / 2).clamp(8.0, screenSize.width - pickerW - 8);
double top = (offset.dy - pickerH - 8).clamp(8.0, screenSize.height - pickerH - 8);
return Material(
type: MaterialType.transparency,
child: Stack(children: [
GestureDetector(
onTap: _closeReactionOverlay,
behavior: HitTestBehavior.translucent,
child: const SizedBox.expand(),
),
Positioned(
left: left,
top: top,
child: EmojiPicker(
onEmojiSelected: (emoji) {
_closeReactionOverlay();
widget.room?.sendReaction(widget.event.eventId, emoji)
.catchError((_) => '');
ReactionService.recordUsed(emoji).then((_) {
if (mounted) setState(() => _quickReactions = ReactionService.quickReactions);
});
},
onClose: _closeReactionOverlay,
),
),
]),
);
});
widget.onOverlayStateChanged?.call(true);
Overlay.of(context).insert(_reactionOverlay!);
}
void _showMoreMenu() {
final box = context.findRenderObject() as RenderBox?;
final offset = box?.localToGlobal(Offset.zero) ?? Offset.zero;
final size = box?.size ?? Size.zero;
final isOwnMessage = widget.event.senderId == widget.currentUserId;
final text = widget.event.body;
// Find any image or sticker event in the group that can be saved as a sticker.
final imageEvent = widget.events.firstWhereOrNull(
(e) => e.messageType == 'm.image' || e.type == EventTypes.Sticker,
);
// Capture messenger synchronously before showMenu's async gap so the
// snackbar can be shown even if the widget is unmounted by then.
final messenger = ScaffoldMessenger.maybeOf(context);
showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
offset.dx, offset.dy - size.height,
offset.dx + size.width, offset.dy,
),
items: [
PopupMenuItem(value: 'copy', child: Row(children: [
Icon(Icons.copy_rounded, size: 16, color: widget.pt.fgMuted),
const SizedBox(width: 10),
Text('Kopieren', style: TextStyle(color: widget.pt.fg, fontSize: 14)),
])),
if (imageEvent != null)
PopupMenuItem(value: 'save_sticker', child: Row(children: [
Icon(Icons.sticky_note_2_outlined, size: 16, color: widget.pt.fgMuted),
const SizedBox(width: 10),
Text('Als Sticker speichern', style: TextStyle(color: widget.pt.fg, fontSize: 14)),
])),
if (isOwnMessage)
PopupMenuItem(value: 'delete', child: Row(children: [
Icon(Icons.delete_outline_rounded, size: 16, color: widget.pt.danger),
const SizedBox(width: 10),
Text('Löschen', style: TextStyle(color: widget.pt.danger, fontSize: 14)),
])),
PopupMenuItem(value: 'pin', child: Row(children: [
Icon(Icons.push_pin_outlined, size: 16, color: widget.pt.fgMuted),
const SizedBox(width: 10),
Text('Anpinnen', style: TextStyle(color: widget.pt.fg, fontSize: 14)),
])),
],
).then((value) async {
if (value == 'copy') {
Clipboard.setData(ClipboardData(text: text));
} else if (value == 'save_sticker' && imageEvent != null) {
final ok = await GifFavoriteService.addImageAsStickerFavorite(imageEvent);
messenger?.showSnackBar(SnackBar(
content: Text(ok ? 'Als Sticker gespeichert' : 'Fehler beim Speichern'),
duration: const Duration(seconds: 2),
));
} else if (value == 'delete') {
widget.event.redactEvent(reason: 'Vom Nutzer gelöscht')
.catchError((_) => null);
} else if (value == 'pin') {
final room = widget.room;
if (room == null) return;
final pinned = List<String>.from(
room.getState(EventTypes.RoomPinnedEvents)?.content['pinned'] as List? ?? [],
);
final eventId = widget.event.eventId;
if (!pinned.contains(eventId)) pinned.add(eventId);
room.setPinnedEvents(pinned).catchError((_) => '');
}
});
}
@override
void dispose() {
if (_reactionOverlay != null) {
_reactionOverlay!.remove();
_reactionOverlay = null;
widget.onOverlayStateChanged?.call(false);
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
return Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: pt.bg0,
borderRadius: BorderRadius.circular(pt.rBase),
border: Border.all(color: pt.border),
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(100),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Row(
children: [
// Quick reactions
..._quickReactions.map((emoji) => _QuickReactionBtn(
emoji: emoji,
pt: pt,
onTap: () {
widget.room?.sendReaction(widget.event.eventId, emoji)
.catchError((_) => '');
ReactionService.recordUsed(emoji).then((_) {
if (mounted) setState(() => _quickReactions = ReactionService.quickReactions);
});
},
)),
Container(width: 1, height: 18, color: pt.border, margin: const EdgeInsets.symmetric(horizontal: 2)),
_ActionBtn(
icon: Icons.add_reaction_outlined,
tooltip: 'Reaktion',
pt: pt,
accent: true,
onTap: _toggleReactionPicker,
),
_ActionBtn(
icon: Icons.reply_rounded,
tooltip: 'Antworten',
pt: pt,
onTap: widget.onReply,
),
_ActionBtn(
icon: Icons.more_horiz_rounded,
tooltip: 'Mehr',
pt: pt,
onTap: _showMoreMenu,
),
],
),
);
}
}
class _ActionBtn extends StatefulWidget {
final IconData icon;
final String tooltip;
final PyramidTheme pt;
final bool accent;
final VoidCallback? onTap;
const _ActionBtn({
required this.icon,
required this.tooltip,
required this.pt,
this.accent = false,
this.onTap,
});
@override
State<_ActionBtn> createState() => _ActionBtnState();
}
class _ActionBtnState extends State<_ActionBtn> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final color = _hovered
? (widget.accent ? widget.pt.accent : widget.pt.fg)
: widget.pt.fgMuted;
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: Tooltip(
message: widget.tooltip,
waitDuration: const Duration(milliseconds: 400),
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
width: 32,
height: 32,
decoration: BoxDecoration(
color: _hovered ? widget.pt.bgHover : Colors.transparent,
borderRadius: BorderRadius.circular(widget.pt.rSm),
),
child: Center(
child: Icon(widget.icon, size: 16, color: color),
),
),
),
),
);
}
}
@@ -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(pt: pt));
}
}
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,
),
),
);
}
@@ -0,0 +1,473 @@
part of '../message_group.dart';
String _formatMessageTime(DateTime t) {
final h = t.hour.toString().padLeft(2, '0');
final m = t.minute.toString().padLeft(2, '0');
return '$h:$m';
}
// Wraps a group of consecutive messages from the same sender
class MessageGroup extends StatefulWidget {
final List<Event> events;
final String currentUserId;
final Room? room;
final Timeline? timeline;
final VoidCallback? onReply;
final Set<String> selectedIds;
final ValueChanged<String>? onToggleSelect;
final bool showStatusIndicator;
/// Event IDs that another member has read up to (their last-read position).
/// A ✓✓ is rendered at any of this group's events contained here.
final Set<String> readEventIds;
/// When non-null and pointing to a non-last event in this group, an inline
/// "Neue Nachrichten" divider is rendered after that event's row.
final String? fullyReadId;
/// Jump-to-message (from search): the row matching [jumpEventId] gets
/// [jumpKey] attached (for ensureVisible) and [highlightEventId] is tinted.
final String? jumpEventId;
final GlobalKey? jumpKey;
final String? highlightEventId;
final bool highlightOn;
const MessageGroup({
super.key,
required this.events,
required this.currentUserId,
this.room,
this.timeline,
this.onReply,
this.selectedIds = const {},
this.onToggleSelect,
this.showStatusIndicator = false,
this.readEventIds = const {},
this.fullyReadId,
this.jumpEventId,
this.jumpKey,
this.highlightEventId,
this.highlightOn = false,
});
@override
State<MessageGroup> createState() => _MessageGroupState();
}
class _MessageGroupState extends State<MessageGroup> {
// Per-message hover state (desktop): which event row the pointer is over, and
// which row currently has its reaction-picker overlay open.
String? _hoveredEventId;
String? _overlayEventId;
double _swipeOffset = 0;
static const _swipeThreshold = 56.0;
bool _swipeTriggered = false;
bool get _isMobile => Platform.isAndroid || Platform.isIOS;
@override
Widget build(BuildContext 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 sender = firstEvent.senderFromMemoryOrFallback;
final name = sender.calcDisplayname();
final initial = name.isNotEmpty ? name[0].toUpperCase() : '?';
final color = _colorFromId(firstEvent.senderId);
final time = _formatMessageTime(firstEvent.originServerTs);
final avatarUri = sender.avatarUrl;
// Build a FLAT column of rows — each message at the same indentation level.
// First event row: avatar + name + time + content
// Continuation rows: timestamp-slot + content
// This prevents the nested-Row double-indent bug.
final rows = <Widget>[];
// ── First event row ──────────────────────────────────
// Each message row is INDIVIDUALLY selectable (see _selectableRow) so two
// consecutive messages from the same sender aren't selected as one block.
rows.add(_selectableRow(firstEvent, pt, Padding(
padding: EdgeInsets.only(top: pt.msgGap / 2, left: 20, right: 20, bottom: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Avatar
SizedBox(
width: 38,
child: Column(children: [
const SizedBox(height: 2),
_Avatar(initial: initial, color: color, pt: pt, size: 38, avatarUri: avatarUri),
]),
),
const SizedBox(width: 14),
// Name + time + message
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Text(name, style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600)),
const SizedBox(width: 8),
Text(time, style: TextStyle(color: pt.fgDim, fontSize: 11)),
]),
const SizedBox(height: 2),
_MessageBody(
event: firstEvent,
pt: pt,
timeline: widget.timeline,
room: widget.room,
currentUserId: widget.currentUserId,
),
],
),
),
],
),
)));
if (widget.readEventIds.contains(firstEvent.eventId)) rows.add(_ReadMarker(pt: pt));
// Inline unread divider after the first event when it's the fullyRead
// marker and there are more events in this group.
if (widget.fullyReadId == firstEvent.eventId && widget.events.length > 1) {
rows.add(_InlineUnreadDivider(pt: pt));
}
// ── Continuation event rows (siblings, same indentation) ──
for (var j = 1; j < widget.events.length; j++) {
final event = widget.events[j];
rows.add(_selectableRow(event, pt, Padding(
padding: const EdgeInsets.only(top: 8, left: 20, right: 20),
child: _ContinuationMessage(
event: event,
pt: pt,
timeline: widget.timeline,
room: widget.room,
currentUserId: widget.currentUserId,
),
)));
if (widget.readEventIds.contains(event.eventId)) rows.add(_ReadMarker(pt: pt));
// Inline divider after this event if it's the fullyRead and not the last.
if (widget.fullyReadId == event.eventId && j < widget.events.length - 1) {
rows.add(_InlineUnreadDivider(pt: pt));
}
}
// Sending/sent indicator on the newest own message — only when no read ✓✓
// is already shown for it (the ✓✓ implies it was delivered + read).
if (widget.showStatusIndicator &&
firstEvent.senderId == widget.currentUserId &&
widget.room != null &&
!widget.readEventIds.contains(widget.events.last.eventId)) {
rows.add(Padding(
padding: const EdgeInsets.only(right: 24, bottom: 1),
child: Align(
alignment: Alignment.centerRight,
child: _MessageStatus(
lastEvent: widget.events.last,
pt: pt,
),
),
));
}
rows.add(const SizedBox(height: 2));
// Hover highlight + actions are now per-row (see _selectableRow), so the
// group is just the column of rows.
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: rows,
);
}
// Handy: nach links wischen = antworten (beide Darstellungen).
Widget _withSwipeToReply(PyramidTheme pt, Widget content) {
if (!_isMobile) return content;
return GestureDetector(
onHorizontalDragUpdate: (d) {
if (d.delta.dx > 0) return; // Only allow right-to-left swipe for reply
setState(() {
_swipeOffset = (_swipeOffset + d.delta.dx).clamp(-_swipeThreshold, 0.0);
if (_swipeOffset <= -_swipeThreshold && !_swipeTriggered) {
_swipeTriggered = true;
HapticFeedback.mediumImpact();
}
});
},
onHorizontalDragEnd: (_) {
if (_swipeTriggered) widget.onReply?.call();
setState(() { _swipeOffset = 0; _swipeTriggered = false; });
},
child: Stack(
children: [
Transform.translate(
offset: Offset(_swipeOffset, 0),
child: content,
),
if (_swipeOffset < -8)
Positioned(
right: 12,
top: 0,
bottom: 0,
child: Center(
child: AnimatedOpacity(
opacity: (-_swipeOffset / _swipeThreshold).clamp(0.0, 1.0),
duration: const Duration(milliseconds: 60),
child: Icon(Icons.reply_rounded, size: 20, color: pt.fgMuted),
),
),
),
],
),
);
}
// Wraps a single message row so it can be selected independently of the rest
// of the sender's group. Long-press enters selection mode; in selection mode a
// full-row tap overlay toggles this message's selection.
Widget _selectableRow(Event event, PyramidTheme pt, Widget child) {
final selected = widget.selectedIds.contains(event.eventId);
final inSel = widget.selectedIds.isNotEmpty;
final showActions = !_isMobile && !inSel &&
(_hoveredEventId == event.eventId || _overlayEventId == event.eventId);
final isJumpTarget = widget.jumpEventId == event.eventId;
final isHighlighted = widget.highlightEventId == event.eventId;
Widget row = GestureDetector(
key: isJumpTarget ? widget.jumpKey : null,
onLongPress: () => widget.onToggleSelect?.call(event.eventId),
child: Stack(
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 100),
color: selected
? pt.accent.withAlpha(40)
: (showActions ? pt.bg2 : Colors.transparent),
child: child,
),
// Jump-from-search highlight — fades out via AnimatedOpacity.
if (isHighlighted)
Positioned.fill(
child: IgnorePointer(
child: AnimatedOpacity(
opacity: widget.highlightOn ? 1.0 : 0.0,
duration: const Duration(milliseconds: 900),
curve: Curves.easeOut,
child: Container(color: pt.accent.withAlpha(48)),
),
),
),
if (selected)
Positioned(
left: 0,
top: 0,
bottom: 0,
child: Container(width: 3, color: pt.accent),
),
if (showActions)
Positioned(
top: 0,
right: 32,
child: _HoverActions(
pt: pt,
event: event,
events: [event],
room: widget.room,
currentUserId: widget.currentUserId,
onReply: widget.onReply,
onOverlayStateChanged: (active) => setState(
() => _overlayEventId = active ? event.eventId : null),
),
),
if (inSel)
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => widget.onToggleSelect?.call(event.eventId),
),
),
],
),
);
if (_isMobile) return row;
// Desktop: per-row hover highlight + actions.
return MouseRegion(
onEnter: (_) => setState(() => _hoveredEventId = event.eventId),
onExit: (_) => setState(() {
if (_hoveredEventId == event.eventId) _hoveredEventId = null;
}),
child: row,
);
}
Color _colorFromId(String id) {
const colors = [
Color(0xFF06B6D4), Color(0xFFA855F7), Color(0xFF10B981),
Color(0xFFF43F5E), Color(0xFF3B82F6), Color(0xFFF59E0B),
Color(0xFFFF6B9D),
];
if (id.isEmpty) return colors[0];
return colors[id.codeUnitAt(0) % colors.length];
}
}
// Shown under a message whose send failed (e.g. offline) — keeps the message
// visible with retry / delete instead of letting it silently disappear.
class _SendFailedRow extends StatelessWidget {
final Event event;
final PyramidTheme pt;
const _SendFailedRow({required this.event, required this.pt});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 3, bottom: 1),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline_rounded, size: 13, color: pt.danger),
const SizedBox(width: 4),
Text('Nicht gesendet', style: TextStyle(color: pt.danger, fontSize: 12)),
const SizedBox(width: 10),
_link('Wiederholen', pt, () async {
try { await event.sendAgain(); } catch (_) {}
}),
const SizedBox(width: 10),
_link('Löschen', pt, () async {
try { await event.cancelSend(); } catch (_) {}
}),
],
),
);
}
Widget _link(String label, PyramidTheme pt, VoidCallback onTap) => GestureDetector(
onTap: onTap,
child: Text(
label,
style: TextStyle(
color: pt.accent,
fontSize: 12,
decoration: TextDecoration.underline,
decorationColor: pt.accent,
),
),
);
}
// Single message (first in group) with full avatar + name shown
class _MessageBody extends StatelessWidget {
final Event event;
final PyramidTheme pt;
final Timeline? timeline;
final Room? room;
final String currentUserId;
const _MessageBody({
required this.event,
required this.pt,
this.timeline,
this.room,
required this.currentUserId,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_MessageContent(event: event, pt: pt, timeline: timeline),
if (event.status.isError) _SendFailedRow(event: event, pt: pt),
if (timeline != null) _ReactionsRow(event: event, timeline: timeline!, room: room, currentUserId: currentUserId, pt: pt),
],
);
}
}
// Continuation message — no avatar, just indented text with timestamp on hover
class _ContinuationMessage extends StatefulWidget {
final Event event;
final PyramidTheme pt;
final Timeline? timeline;
final Room? room;
final String currentUserId;
const _ContinuationMessage({
required this.event,
required this.pt,
this.timeline,
this.room,
required this.currentUserId,
});
@override
State<_ContinuationMessage> createState() => _ContinuationMessageState();
}
class _ContinuationMessageState extends State<_ContinuationMessage> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Timestamp on hover (in avatar column space)
SizedBox(
width: 38,
child: Padding(
padding: const EdgeInsets.only(top: 4),
child: AnimatedOpacity(
opacity: _hovered ? 1 : 0,
duration: const Duration(milliseconds: 120),
child: Text(
_formatMessageTime(widget.event.originServerTs),
textAlign: TextAlign.center,
style: TextStyle(
color: widget.pt.fgDim,
fontSize: 10,
),
),
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_MessageContent(event: widget.event, pt: widget.pt, timeline: widget.timeline),
if (widget.event.status.isError) _SendFailedRow(event: widget.event, pt: widget.pt),
if (widget.timeline != null)
_ReactionsRow(
event: widget.event,
timeline: widget.timeline!,
room: widget.room,
currentUserId: widget.currentUserId,
pt: widget.pt,
),
],
),
),
],
),
);
}
}
@@ -0,0 +1,237 @@
part of '../message_group.dart';
class _LinkPreviewData {
final String? title;
final String? description;
final String? imageMxc;
const _LinkPreviewData({this.title, this.description, this.imageMxc});
bool get isEmpty =>
(title == null || title!.isEmpty) &&
(description == null || description!.isEmpty) &&
imageMxc == null;
}
class _LinkPreview extends ConsumerStatefulWidget {
final String url;
final PyramidTheme pt;
const _LinkPreview({required this.url, required this.pt});
// url → preview (null = fetched but nothing usable). Avoids refetching on
// every rebuild/scroll.
static final Map<String, _LinkPreviewData?> _cache = {};
@override
ConsumerState<_LinkPreview> createState() => _LinkPreviewState();
}
class _LinkPreviewState extends ConsumerState<_LinkPreview> {
_LinkPreviewData? _data;
bool _done = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
if (_LinkPreview._cache.containsKey(widget.url)) {
setState(() { _data = _LinkPreview._cache[widget.url]; _done = true; });
return;
}
try {
final client = await ref.read(matrixClientProvider.future);
final hs = client.homeserver.toString();
final enc = Uri.encodeQueryComponent(widget.url);
final headers = {'authorization': 'Bearer ${client.accessToken}'};
var res = await client.httpClient.get(
Uri.parse('$hs/_matrix/client/v1/media/preview_url?url=$enc'),
headers: headers,
);
if (res.statusCode == 404) {
// Legacy fallback for older servers.
res = await client.httpClient.get(
Uri.parse('$hs/_matrix/media/v3/preview_url?url=$enc'),
headers: headers,
);
}
_LinkPreviewData? data;
if (res.statusCode == 200) {
final json = jsonDecode(utf8.decode(res.bodyBytes)) as Map<String, dynamic>;
data = _LinkPreviewData(
title: (json['og:title'] as String?)?.trim(),
description: (json['og:description'] as String?)?.trim(),
imageMxc: json['og:image'] as String?,
);
if (data.isEmpty) data = null;
}
_LinkPreview._cache[widget.url] = data;
if (mounted) setState(() { _data = data; _done = true; });
} catch (_) {
_LinkPreview._cache[widget.url] = null;
if (mounted) setState(() { _data = null; _done = true; });
}
}
@override
Widget build(BuildContext context) {
final data = _data;
if (!_done || data == null) return const SizedBox.shrink();
final pt = widget.pt;
final client = ref.watch(matrixClientProvider).valueOrNull;
return Padding(
padding: const EdgeInsets.only(top: 6, bottom: 2),
child: GestureDetector(
onTap: () => launchUrl(Uri.parse(widget.url), mode: LaunchMode.externalApplication),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 380),
child: ClipRRect(
borderRadius: BorderRadius.circular(pt.rBase),
child: Container(
decoration: BoxDecoration(
color: pt.bg2,
border: Border(left: BorderSide(color: pt.accent, width: 3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (data.imageMxc != null && client != null)
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 180),
child: SizedBox(
width: double.infinity,
child: MxcImage(
mxcUri: data.imageMxc!,
client: client,
fit: BoxFit.cover,
placeholder: const SizedBox.shrink(),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(10, 8, 10, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (data.title != null && data.title!.isNotEmpty)
Text(
data.title!,
style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w600),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (data.description != null && data.description!.isNotEmpty) ...[
const SizedBox(height: 3),
Text(
data.description!,
style: TextStyle(color: pt.fgMuted, fontSize: 12, height: 1.35),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
],
),
),
),
),
),
);
}
}
class _ReplyPreview extends StatelessWidget {
final Event replyEvent;
final PyramidTheme pt;
const _ReplyPreview({required this.replyEvent, required this.pt});
@override
Widget build(BuildContext context) {
final senderName = replyEvent.senderFromMemoryOrFallback.calcDisplayname();
// Media-aware preview: show an icon + label instead of a raw filename,
// matching WhatsApp/Discord. Falls back to stripped text for m.text.
final (IconData? icon, String body) = _previewFor(replyEvent);
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: IntrinsicHeight(
child: ClipRRect(
borderRadius: BorderRadius.circular(pt.rSm),
child: Container(
color: pt.accentSoft,
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Accent bar flush with the left edge of the tinted card.
Container(width: 3, color: pt.accent),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 5, 10, 5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(senderName, style: TextStyle(color: pt.accent, fontSize: 12, fontWeight: FontWeight.w600)),
Row(
children: [
if (icon != null) ...[
Icon(icon, size: 13, color: pt.fgMuted),
const SizedBox(width: 4),
],
Expanded(
child: Text(
body,
style: TextStyle(color: pt.fgMuted, fontSize: 12),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
),
),
),
],
),
),
),
),
);
}
(IconData?, String) _previewFor(Event e) {
switch (e.messageType) {
case 'm.image':
return (Icons.image_outlined, 'Bild');
case 'm.video':
return (Icons.videocam_outlined, 'Video');
case 'm.audio':
return (Icons.mic_none_rounded, 'Sprachnachricht');
case 'm.file':
return (Icons.insert_drive_file_outlined, e.body.isNotEmpty ? e.body : 'Datei');
}
if (e.type == EventTypes.Sticker) return (Icons.emoji_emotions_outlined, 'Sticker');
// Plain text: strip nested reply fallback, collapse to first line.
String body = e.body.replaceAll('\r\n', '\n');
if (body.startsWith('> ')) {
final lines = body.split('\n');
var i = 0;
while (i < lines.length && (lines[i].startsWith('> ') || lines[i].trimRight() == '>')) {
i++;
}
if (i < lines.length && lines[i].trim().isEmpty) i++;
if (i < lines.length) body = lines.skip(i).join(' ').trim();
}
return (null, body.split('\n').first);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,167 @@
part of '../message_group.dart';
class _ReactionsRow extends StatelessWidget {
final Event event;
final Timeline timeline;
final Room? room;
final String currentUserId;
final PyramidTheme pt;
const _ReactionsRow({
required this.event,
required this.timeline,
required this.currentUserId,
required this.pt,
this.room,
});
@override
Widget build(BuildContext context) {
final reactionEvents = event.aggregatedEvents(timeline, RelationshipTypes.reaction);
if (reactionEvents.isEmpty) return const SizedBox.shrink();
// Group by emoji key → list of senders
final Map<String, List<String>> grouped = {};
for (final r in reactionEvents) {
final key = r.content.tryGetMap<String, dynamic>('m.relates_to')?['key'] as String?;
if (key == null || key.isEmpty) continue;
(grouped[key] ??= []).add(r.senderId);
}
if (grouped.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 4, bottom: 2),
child: Wrap(
spacing: 4,
runSpacing: 4,
children: grouped.entries.map((e) {
final myReaction = e.value.contains(currentUserId);
return _ReactionChip(
emoji: e.key,
count: e.value.length,
mine: myReaction,
pt: pt,
onTap: () {
if (myReaction) {
// Find own reaction event to redact
for (final r in reactionEvents) {
final key = r.content.tryGetMap<String, dynamic>('m.relates_to')?['key'] as String?;
if (key == e.key && r.senderId == currentUserId) {
r.redactEvent().catchError((_) => null);
break;
}
}
} else {
room?.sendReaction(event.eventId, e.key).catchError((_) => '');
ReactionService.recordUsed(e.key);
}
},
);
}).toList(),
),
);
}
}
class _ReactionChip extends StatefulWidget {
final String emoji;
final int count;
final bool mine;
final PyramidTheme pt;
final VoidCallback onTap;
const _ReactionChip({
required this.emoji,
required this.count,
required this.mine,
required this.pt,
required this.onTap,
});
@override
State<_ReactionChip> createState() => _ReactionChipState();
}
class _ReactionChipState extends State<_ReactionChip> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final pt = widget.pt;
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
decoration: BoxDecoration(
color: widget.mine
? pt.accentSoft
: (_hovered ? pt.bgHover : pt.bg2),
borderRadius: BorderRadius.circular(pt.rBase),
border: Border.all(
color: widget.mine ? pt.accent : (_hovered ? pt.borderStrong : pt.border),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(widget.emoji, style: const TextStyle(fontSize: 13)),
const SizedBox(width: 4),
Text(
'${widget.count}',
style: TextStyle(
color: widget.mine ? pt.accent : pt.fgMuted,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
);
}
}
class _QuickReactionBtn extends StatefulWidget {
final String emoji;
final PyramidTheme pt;
final VoidCallback? onTap;
const _QuickReactionBtn({required this.emoji, required this.pt, this.onTap});
@override
State<_QuickReactionBtn> createState() => _QuickReactionBtnState();
}
class _QuickReactionBtnState extends State<_QuickReactionBtn> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
width: 28,
height: 28,
decoration: BoxDecoration(
color: _hovered ? widget.pt.bgHover : Colors.transparent,
borderRadius: BorderRadius.circular(widget.pt.rSm),
),
child: Center(
child: Text(widget.emoji, style: const TextStyle(fontSize: 15)),
),
),
),
);
}
}
@@ -0,0 +1,209 @@
part of '../message_group.dart';
class _Avatar extends ConsumerStatefulWidget {
final String initial;
final Color color;
final PyramidTheme pt;
final double size;
final Uri? avatarUri;
const _Avatar({
required this.initial,
required this.color,
required this.pt,
required this.size,
this.avatarUri,
});
@override
ConsumerState<_Avatar> createState() => _AvatarState();
}
class _AvatarState extends ConsumerState<_Avatar> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final client = ref.watch(matrixClientProvider).valueOrNull;
final radius = BorderRadius.circular(widget.size / 2);
Widget child;
if (client != null && widget.avatarUri != null) {
child = MxcAvatar(
mxcUri: widget.avatarUri,
client: client,
size: widget.size,
borderRadius: radius,
placeholder: (_) => _InitialBox(
initial: widget.initial,
color: widget.color,
size: widget.size,
radius: radius,
),
);
} else {
child = _InitialBox(
initial: widget.initial,
color: widget.color,
size: widget.size,
radius: radius,
);
}
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutBack,
transform: _hovered
? Matrix4.diagonal3Values(1.06, 1.06, 1.0)
: Matrix4.identity(),
transformAlignment: Alignment.center,
width: widget.size,
height: widget.size,
child: child,
),
);
}
}
class _InitialBox extends StatelessWidget {
final String initial;
final Color color;
final double size;
final BorderRadius radius;
const _InitialBox({required this.initial, required this.color, required this.size, required this.radius});
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(color: color, borderRadius: radius),
child: Center(
child: Text(initial, style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600)),
),
);
}
}
// Inline "Neue Nachrichten" divider rendered inside a MessageGroup when the
// fullyRead event is in the middle of the group (same sender, <5 min window).
class _InlineUnreadDivider extends StatelessWidget {
final PyramidTheme pt;
const _InlineUnreadDivider({required this.pt});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(0, 8, 0, 4),
child: Row(
children: [
Expanded(child: Divider(color: pt.accent.withAlpha(160), height: 1)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Text(
'Neue Nachrichten',
style: TextStyle(color: pt.accent, fontSize: 11, fontWeight: FontWeight.w600),
),
),
Expanded(child: Divider(color: pt.accent.withAlpha(160), height: 1)),
],
),
);
}
}
// Date divider between message groups
class DateDivider extends StatelessWidget {
final DateTime date;
const DateDivider({super.key, required this.date});
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Row(
children: [
Expanded(child: Divider(color: pt.border, height: 1)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(
_formatDate(date),
style: TextStyle(
color: pt.fgDim,
fontSize: 11,
fontWeight: FontWeight.w600,
letterSpacing: 0.06 * 11,
),
),
),
Expanded(child: Divider(color: pt.border, height: 1)),
],
),
);
}
String _formatDate(DateTime d) {
final now = DateTime.now();
if (d.year == now.year && d.month == now.month && d.day == now.day) {
return 'Heute · ${d.day}. ${_monthName(d.month)}';
}
final yesterday = now.subtract(const Duration(days: 1));
if (d.year == yesterday.year && d.month == yesterday.month && d.day == yesterday.day) {
return 'Gestern';
}
return '${d.day}. ${_monthName(d.month)} ${d.year}';
}
String _monthName(int m) => const [
'', 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'
][m];
}
class _MessageStatus extends StatelessWidget {
final Event lastEvent;
final PyramidTheme pt;
const _MessageStatus({required this.lastEvent, required this.pt});
@override
Widget build(BuildContext context) {
final status = lastEvent.status;
if (status == EventStatus.error) {
return Icon(Icons.error_outline_rounded, size: 12, color: pt.danger);
}
if (status == EventStatus.sending) {
return Icon(Icons.access_time_rounded, size: 12, color: pt.fgDim);
}
// Sent (delivered). "Read" is shown by the per-event ✓✓ marker.
return Icon(Icons.done_rounded, size: 13, color: pt.fgDim);
}
}
// Read receipt: a ✓✓ shown at the partner's last-read message (any sender),
// so it moves next to their own message when they reply.
class _ReadMarker extends StatelessWidget {
final PyramidTheme pt;
const _ReadMarker({required this.pt});
// Goldener Doppelhaken — bewusst nicht die Akzentfarbe, damit "gelesen"
// unabhängig vom Theme sofort erkennbar ist.
static const _gold = Color(0xFFE6B422);
@override
Widget build(BuildContext context) {
return const Padding(
padding: EdgeInsets.only(right: 24, top: 1, bottom: 1),
child: Align(
alignment: Alignment.centerRight,
child: Icon(Icons.done_all_rounded, size: 13, color: _gold),
),
);
}
}
+394
View File
@@ -0,0 +1,394 @@
part of '../message_group.dart';
class _MessageContent extends StatelessWidget {
final Event event;
final PyramidTheme pt;
final Timeline? timeline;
const _MessageContent({required this.event, required this.pt, this.timeline});
// Resolves edits: returns the display event (last edit) for this event.
Event get _displayEvent =>
timeline != null ? event.getDisplayEvent(timeline!) : event;
String? _getReplyId() {
final relatesTo = event.content.tryGetMap<String, dynamic>('m.relates_to');
final replyMap = relatesTo?.tryGetMap<String, dynamic>('m.in_reply_to');
return replyMap?['event_id'] as String?;
}
String _stripReplyFallback(String body) {
final norm = body.replaceAll('\r\n', '\n');
if (!norm.startsWith('> ') && !norm.startsWith('>\n')) return norm;
final lines = norm.split('\n');
var i = 0;
while (i < lines.length && (lines[i].startsWith('> ') || lines[i].trimRight() == '>')) {
i++;
}
if (i < lines.length && lines[i].trim().isEmpty) i++;
if (i >= lines.length) return norm;
return lines.skip(i).join('\n').trim();
}
Event? _findReplyEvent(String replyId) {
if (timeline == null) return null;
try {
return timeline!.events.firstWhere((e) => e.eventId == replyId);
} catch (_) {
return null;
}
}
@override
Widget build(BuildContext context) {
final displayEvent = _displayEvent;
final isEdited = displayEvent.eventId != event.eventId;
// Still encrypted (decryption pending or failed)
if (displayEvent.type == EventTypes.Encrypted) {
return Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 4,
children: [
Icon(Icons.lock_outline_rounded, size: 13, color: pt.fgDim),
Text(
'Nachricht wird entschlüsselt…',
style: TextStyle(color: pt.fgDim, fontSize: 13, fontStyle: FontStyle.italic),
),
if (timeline != null)
Builder(
builder: (ctx) => GestureDetector(
onTap: () {
timeline!.requestKeys();
ScaffoldMessenger.of(ctx).showSnackBar(
const SnackBar(
content: Text('Schlüssel werden von anderen Geräten angefordert…'),
duration: Duration(seconds: 3),
),
);
},
child: Text(
'Schlüssel anfordern',
style: TextStyle(
color: pt.accent,
fontSize: 12,
decoration: TextDecoration.underline,
decorationColor: pt.accent,
),
),
),
),
],
),
);
}
if (displayEvent.type == EventTypes.Sticker) {
return _MediaContent(event: displayEvent, pt: pt);
}
final msgType = displayEvent.messageType;
if (msgType == 'm.image') {
final ext = displayEvent.body.contains('.')
? displayEvent.body.split('.').last.toLowerCase()
: '';
const kImageExts = {
'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp',
'tiff', 'tif', 'heic', 'heif', 'avif', 'ico', 'jfif',
};
// SVG kann Image.memory nicht dekodieren — über den SVG-Renderer anzeigen.
if (ext == 'svg' ||
displayEvent.content
.tryGetMap<String, Object?>('info')?['mimetype'] ==
'image/svg+xml') {
return _DocPreviewContent(event: displayEvent, pt: pt);
}
if (ext.isNotEmpty && !kImageExts.contains(ext)) {
return _FileContent(event: displayEvent, pt: pt, icon: Icons.insert_drive_file_rounded);
}
return _ImageContent(event: displayEvent, pt: pt);
}
if (msgType == 'm.audio') {
return _AudioContent(event: displayEvent, pt: pt);
}
if (msgType == 'm.video') {
return _VideoContent(event: displayEvent, pt: pt);
}
if (msgType == 'm.file') {
if (DocumentViewer.isInlinePreviewable(displayEvent.body)) {
return _DocPreviewContent(event: displayEvent, pt: pt);
}
if (DocumentViewer.showsInlineCard(displayEvent.body)) {
return _ProprietaryInlineCard(event: displayEvent, pt: pt);
}
return _FileContent(event: displayEvent, pt: pt, icon: Icons.insert_drive_file_rounded);
}
final replyId = _getReplyId();
final replyEvent = replyId != null ? _findReplyEvent(replyId) : null;
final plainBody = replyId != null
? _stripReplyFallback(displayEvent.body)
: displayEvent.body;
// Use formatted_body (HTML) if the event has one, otherwise plain text.
final format = displayEvent.content.tryGet<String>('format');
// Strip the Matrix reply fallback <mx-reply>…</mx-reply> block — we render
// our own quote card (_ReplyPreview) above, so the inline "In reply to @user"
// would be redundant and ugly.
final formattedBody = displayEvent.formattedText.replaceAll(
RegExp(r'<mx-reply>.*?</mx-reply>', caseSensitive: false, dotAll: true),
'',
).trim();
final hasHtml = format == 'org.matrix.custom.html' && formattedBody.isNotEmpty;
final firstUrl = _firstUrl(plainBody);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (replyEvent != null) _ReplyPreview(replyEvent: replyEvent, pt: pt),
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: hasHtml
? _MatrixHtmlText(
html: formattedBody,
baseStyle: TextStyle(color: pt.fg, fontSize: 14, height: 1.55),
linkColor: pt.accent,
)
: _PlainLinkText(
text: plainBody,
baseStyle: TextStyle(color: pt.fg, fontSize: 14, height: 1.55),
linkColor: pt.accent,
),
),
if (isEdited)
Text(
'(bearbeitet)',
style: TextStyle(color: pt.fgDim, fontSize: 11, fontStyle: FontStyle.italic),
),
if (firstUrl != null) _LinkPreview(url: firstUrl, pt: pt),
],
);
}
// Extracts the first http(s) URL in the text, for link-preview fetching.
static final RegExp _urlRegex = RegExp(r'https?://[^\s<>()\[\]]+', caseSensitive: false);
String? _firstUrl(String text) {
final m = _urlRegex.firstMatch(text);
if (m == null) return null;
var url = m.group(0)!;
// Trim common trailing punctuation that isn't part of the URL.
url = url.replaceAll(RegExp(r'[.,;:!?]+$'), '');
return url;
}
}
// Handles the subset of HTML that Matrix clients emit in formatted_body:
// <b>/<strong> → bold
// <i>/<em> → italic
// <del>/<s> → strikethrough
// <u> → underline
// <code> → monospace, slightly different background
// <a href> → tappable link (url_launcher)
// <br> → newline
// HTML entities → decoded
// Anything else → text content preserved, tags stripped
//
// Note: <pre> and <blockquote> are simplified (rendered as indented code/text).
class _MatrixHtmlText extends StatelessWidget {
final String html;
final TextStyle baseStyle;
final Color linkColor;
const _MatrixHtmlText({
required this.html,
required this.baseStyle,
required this.linkColor,
});
@override
Widget build(BuildContext context) {
final spans = _parseHtml(html, baseStyle, linkColor);
return SelectionArea(
child: Text.rich(TextSpan(children: spans, style: baseStyle)),
);
}
List<InlineSpan> _parseHtml(String html, TextStyle base, Color linkColor) {
// Normalise line endings and collapse whitespace around block elements.
var src = html
.replaceAll('\r\n', '\n')
.replaceAll(RegExp(r'<br\s*/?>', caseSensitive: false), '\n')
.replaceAll(RegExp(r'</(p|div|blockquote|pre|li)>', caseSensitive: false), '\n')
.replaceAll(RegExp(r'<(p|div|li)[^>]*>', caseSensitive: false), '')
.replaceAll(RegExp(r'<blockquote[^>]*>', caseSensitive: false), ' ')
.replaceAll(RegExp(r'<pre[^>]*>', caseSensitive: false), '');
final spans = <InlineSpan>[];
// Simple recursive stack-based parser.
// We tokenise into (tag | text) chunks, then apply style based on open tags.
final tagRe = RegExp(r'<(/?)(\w+)([^>]*)>', caseSensitive: false);
var bold = 0;
var italic = 0;
var strike = 0;
var underline = 0;
var code = 0;
String? pendingHref;
int pos = 0;
for (final m in tagRe.allMatches(src)) {
// Text before this tag.
if (m.start > pos) {
final text = _decodeEntities(src.substring(pos, m.start));
if (text.isNotEmpty) {
spans.add(_buildSpan(
text, base, bold > 0, italic > 0, strike > 0, underline > 0,
code > 0, pendingHref, linkColor,
));
if (pendingHref != null) pendingHref = null;
}
}
pos = m.end;
final closing = m.group(1) == '/';
final tag = m.group(2)!.toLowerCase();
final attrs = m.group(3) ?? '';
if (closing) {
switch (tag) {
case 'b': case 'strong': bold = (bold - 1).clamp(0, 99); break;
case 'i': case 'em': italic = (italic - 1).clamp(0, 99); break;
case 'del': case 's': case 'strike': strike = (strike - 1).clamp(0, 99); break;
case 'u': underline = (underline - 1).clamp(0, 99); break;
case 'code': code = (code - 1).clamp(0, 99); break;
case 'a': pendingHref = null; break;
}
} else {
switch (tag) {
case 'b': case 'strong': bold++; break;
case 'i': case 'em': italic++; break;
case 'del': case 's': case 'strike': strike++; break;
case 'u': underline++; break;
case 'code': code++; break;
case 'a':
final hrefMatch = RegExp("""href=["']([^"']+)["']""", caseSensitive: false)
.firstMatch(attrs);
pendingHref = hrefMatch?.group(1);
break;
}
}
}
// Remaining text after last tag.
if (pos < src.length) {
final text = _decodeEntities(src.substring(pos));
if (text.isNotEmpty) {
spans.add(_buildSpan(
text, base, bold > 0, italic > 0, strike > 0, underline > 0,
code > 0, pendingHref, linkColor,
));
}
}
return spans;
}
InlineSpan _buildSpan(
String text,
TextStyle base,
bool bold, bool italic, bool strike, bool underline, bool code,
String? href, Color linkColor,
) {
var style = base.copyWith(
fontWeight: bold ? FontWeight.bold : null,
fontStyle: italic ? FontStyle.italic : null,
decoration: TextDecoration.combine([
if (strike) TextDecoration.lineThrough,
if (underline) TextDecoration.underline,
]),
fontFamily: code ? 'monospace' : null,
fontSize: code ? (base.fontSize ?? 14) * 0.9 : null,
);
if (href != null) {
style = style.copyWith(
color: linkColor,
decoration: TextDecoration.underline,
decorationColor: linkColor,
);
return TextSpan(
text: text,
style: style,
recognizer: TapGestureRecognizer()
..onTap = () => launchUrl(Uri.parse(href), mode: LaunchMode.externalApplication),
);
}
return TextSpan(text: text, style: style);
}
static String _decodeEntities(String s) => s
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'")
.replaceAll('&apos;', "'")
.replaceAll('&nbsp;', ' ');
}
class _PlainLinkText extends StatelessWidget {
final String text;
final TextStyle baseStyle;
final Color linkColor;
const _PlainLinkText({
required this.text,
required this.baseStyle,
required this.linkColor,
});
static final _urlRe = RegExp(
r'https?://[^\s\]\)>]+',
caseSensitive: false,
);
@override
Widget build(BuildContext context) {
final matches = _urlRe.allMatches(text).toList();
if (matches.isEmpty) {
return SelectableText(text, style: baseStyle);
}
final spans = <InlineSpan>[];
int pos = 0;
for (final m in matches) {
if (m.start > pos) {
spans.add(TextSpan(text: text.substring(pos, m.start)));
}
final url = m.group(0)!;
spans.add(TextSpan(
text: url,
style: baseStyle.copyWith(
color: linkColor,
decoration: TextDecoration.underline,
decorationColor: linkColor,
),
recognizer: TapGestureRecognizer()
..onTap = () => launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication),
));
pos = m.end;
}
if (pos < text.length) {
spans.add(TextSpan(text: text.substring(pos)));
}
return SelectionArea(
child: Text.rich(TextSpan(children: spans, style: baseStyle)),
);
}
}
-178
View File
@@ -1,178 +0,0 @@
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
class MessageBubble extends StatelessWidget {
final Event event;
final bool isOwn;
final Event? replyEvent;
const MessageBubble({
super.key,
required this.event,
required this.isOwn,
this.replyEvent,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final bgColor = isOwn ? scheme.primaryContainer : scheme.surfaceContainerHigh;
final textColor = isOwn ? scheme.onPrimaryContainer : scheme.onSurface;
return Align(
alignment: isOwn ? Alignment.centerRight : Alignment.centerLeft,
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: MediaQuery.sizeOf(context).width * 0.75,
),
child: Container(
margin: EdgeInsets.only(
left: isOwn ? 48 : 8,
right: isOwn ? 8 : 48,
top: 2,
bottom: 2,
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16),
topRight: const Radius.circular(16),
bottomLeft: Radius.circular(isOwn ? 16 : 4),
bottomRight: Radius.circular(isOwn ? 4 : 16),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isOwn) ...[
Text(
event.senderFromMemoryOrFallback.calcDisplayname(),
style: theme.textTheme.labelSmall?.copyWith(
color: scheme.primary,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 2),
],
if (replyEvent != null) _ReplyPreview(event: replyEvent!),
_MessageContent(event: event, textColor: textColor),
const SizedBox(height: 2),
Align(
alignment: Alignment.bottomRight,
child: Text(
_formatTime(event.originServerTs),
style: theme.textTheme.labelSmall?.copyWith(
color: textColor.withAlpha(160),
fontSize: 10,
),
),
),
],
),
),
),
);
}
String _formatTime(DateTime t) =>
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
}
class _MessageContent extends StatelessWidget {
final Event event;
final Color textColor;
const _MessageContent({required this.event, required this.textColor});
@override
Widget build(BuildContext context) {
final body = event.body;
return Text(
body,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: textColor),
);
}
}
class _ReplyPreview extends StatelessWidget {
final Event event;
const _ReplyPreview({required this.event});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
margin: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
border: Border(
left: BorderSide(color: theme.colorScheme.primary, width: 3),
),
color: theme.colorScheme.surface.withAlpha(120),
borderRadius: const BorderRadius.only(
topRight: Radius.circular(4),
bottomRight: Radius.circular(4),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
event.senderFromMemoryOrFallback.calcDisplayname(),
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
Text(
event.body,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall,
),
],
),
);
}
}
class DateSeparator extends StatelessWidget {
final DateTime date;
const DateSeparator({super.key, required this.date});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
const Expanded(child: Divider()),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(
_formatDate(date),
style: Theme.of(context).textTheme.labelSmall,
),
),
const Expanded(child: Divider()),
],
),
);
}
String _formatDate(DateTime d) {
final now = DateTime.now();
if (d.year == now.year && d.month == now.month && d.day == now.day) {
return 'Heute';
}
final yesterday = now.subtract(const Duration(days: 1));
if (d.year == yesterday.year &&
d.month == yesterday.month &&
d.day == yesterday.day) {
return 'Gestern';
}
return '${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}';
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,80 @@
part of '../chat_view.dart';
// Shows a slim banner when the client loses its connection to the homeserver.
// Debounced so brief transient sync errors don't flicker a banner.
class _ConnectionBanner extends ConsumerStatefulWidget {
const _ConnectionBanner();
@override
ConsumerState<_ConnectionBanner> createState() => _ConnectionBannerState();
}
class _ConnectionBannerState extends ConsumerState<_ConnectionBanner> {
StreamSubscription? _sub;
Timer? _debounce;
bool _lost = false;
@override
void initState() {
super.initState();
_attach();
}
Future<void> _attach() async {
try {
final client = await ref.read(matrixClientProvider.future);
_sub = client.onSyncStatus.stream.listen((s) {
if (s.status == SyncStatus.error) {
// Only surface after the error persists a few seconds.
_debounce ??= Timer(const Duration(seconds: 4), () {
_debounce = null;
if (mounted) setState(() => _lost = true);
});
} else if (s.status == SyncStatus.finished ||
s.status == SyncStatus.processing) {
_debounce?.cancel();
_debounce = null;
if (_lost && mounted) setState(() => _lost = false);
}
});
} catch (_) {}
}
@override
void dispose() {
_sub?.cancel();
_debounce?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
return AnimatedSize(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
alignment: Alignment.topCenter,
child: _lost
? Container(
width: double.infinity,
color: pt.danger.withAlpha(36),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.cloud_off_rounded, size: 14, color: pt.danger),
const SizedBox(width: 8),
Flexible(
child: Text(
'Keine Verbindung zum Server – Wiederverbindung läuft…',
style: TextStyle(color: pt.danger, fontSize: 12, fontWeight: FontWeight.w500),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
)
: const SizedBox.shrink(),
);
}
}
+735
View File
@@ -0,0 +1,735 @@
part of '../chat_view.dart';
class _ChatHeader extends ConsumerWidget {
final Room? room;
final String roomId;
final PyramidTheme pt;
final bool membersOpen;
final bool pinnedOpen;
final VoidCallback onToggleMembers;
final VoidCallback onOpenSearch;
final VoidCallback onTogglePinned;
const _ChatHeader({
required this.room,
required this.roomId,
required this.pt,
required this.membersOpen,
required this.pinnedOpen,
required this.onToggleMembers,
required this.onOpenSearch,
required this.onTogglePinned,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final name = room?.getLocalizedDisplayname() ?? roomId;
final isDm = room?.isDirectChat ?? false;
final isEncrypted = room?.encrypted ?? false;
final topic = room?.topic ?? (isDm ? '' : 'Welcome to #$name');
final client = ref.watch(matrixClientProvider).valueOrNull;
final size = MediaQuery.sizeOf(context);
final isMobile = size.width < 900 || size.height < 500;
return Container(
height: 52,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: pt.bg1,
border: Border(bottom: BorderSide(color: pt.border)),
),
child: Row(
children: [
// Back button on mobile
if (isMobile) ...[
GestureDetector(
onTap: () => ref.read(activeRoomIdProvider.notifier).state = null,
child: Icon(Icons.arrow_back_rounded, size: 22, color: pt.fg),
),
const SizedBox(width: 12),
],
// Room icon / avatar
if (isDm && client != null) ...[
_DmHeaderAvatar(room: room, name: name, client: client),
] else if (!isDm) ...[
Icon(
isEncrypted ? Icons.lock_outline_rounded : Icons.tag_rounded,
size: 16,
color: pt.fgDim,
),
],
const SizedBox(width: 8),
// Name + presence for DMs
if (isDm)
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: TextStyle(
color: pt.fg,
fontSize: 15,
fontWeight: FontWeight.w600),
overflow: TextOverflow.ellipsis,
),
if (room != null && client != null)
_PresenceLine(
userId: room!.directChatMatrixID,
client: client,
pt: pt,
),
],
),
)
else
Text(
name,
style: TextStyle(
color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600),
),
const SizedBox(width: 12),
if (!isDm && topic.isNotEmpty) ...[
Container(width: 1, height: 18, color: pt.border),
const SizedBox(width: 12),
Expanded(
child: Text(
topic,
style: TextStyle(color: pt.fgMuted, fontSize: 13),
overflow: TextOverflow.ellipsis,
),
),
] else if (!isDm)
const Spacer(),
// Right actions
Row(
children: [
if (isDm) ...[
PyrIconBtn(
size: 28,
icon: const Icon(Icons.phone_rounded),
tooltip: 'Voice call',
onPressed: () => _startCall(ref, room, cam: false),
),
PyrIconBtn(
size: 28,
icon: const Icon(Icons.videocam_rounded),
tooltip: 'Video call',
onPressed: () => _startCall(ref, room, cam: true),
),
if (size.width > 700)
Container(
width: 1,
height: 20,
margin: const EdgeInsets.symmetric(horizontal: 4),
color: pt.border,
),
],
if (size.width > 800) ...[
PyrIconBtn(
size: 28,
icon: const Icon(Icons.inbox_rounded),
tooltip: 'Threads',
onPressed: () {},
),
PyrIconBtn(
size: 28,
icon: const Icon(Icons.push_pin_rounded),
tooltip: 'Angepinnte Nachrichten',
active: pinnedOpen,
onPressed: onTogglePinned,
),
],
PyrIconBtn(
size: 28,
icon: const Icon(Icons.search_rounded),
tooltip: 'Suchen',
onPressed: onOpenSearch,
),
PyrIconBtn(
size: 28,
icon: const Icon(Icons.group_rounded),
tooltip: 'Mitglieder',
active: membersOpen,
onPressed: onToggleMembers,
),
],
),
],
),
);
}
void _startCall(WidgetRef ref, Room? room, {required bool cam}) async {
if (room == null) return;
if (room.isDirectChat) {
// Use native Matrix VoIP for 1:1 DMs
ref.read(viewModeProvider.notifier).state = ViewMode.voice;
await ref.read(callSignalingProvider).startCall(room.id, video: cam);
return;
}
// Use LiveKit for group voice rooms
ref.read(viewModeProvider.notifier).state = ViewMode.voice;
await ref.read(voiceChannelProvider).startCall(
roomName: roomId,
roomDisplayName: room.getLocalizedDisplayname(),
identity: 'user_${DateTime.now().millisecondsSinceEpoch}',
audioOnly: !cam,
);
}
}
/// DM-Header, der sich per Klick aufklappt: Das Banner blendet als Hintergrund
/// ein, Avatar und Name skalieren mit, Handle + Bio erscheinen darunter
/// (Design nach Nutzer-Mockup, Twitter/Discord-artig).
class _DmExpandingHeader extends ConsumerStatefulWidget {
final Room room;
final String roomId;
final PyramidTheme pt;
final bool expanded;
final VoidCallback onToggle;
final bool membersOpen;
final bool pinnedOpen;
final VoidCallback onToggleMembers;
final VoidCallback onOpenSearch;
final VoidCallback onTogglePinned;
const _DmExpandingHeader({
super.key,
required this.room,
required this.roomId,
required this.pt,
required this.expanded,
required this.onToggle,
required this.membersOpen,
required this.pinnedOpen,
required this.onToggleMembers,
required this.onOpenSearch,
required this.onTogglePinned,
});
@override
ConsumerState<_DmExpandingHeader> createState() =>
_DmExpandingHeaderState();
}
class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
static const _animDuration = Duration(milliseconds: 280);
static const _animCurve = Curves.easeOutCubic;
Profile? _profile;
String? _bannerMxcUri;
String? _statusMsg;
bool _online = false;
String? get _partnerId => widget.room.directChatMatrixID;
@override
void initState() {
super.initState();
// Direkt laden, damit das Banner beim ersten Aufklappen schon da ist.
_load();
}
@override
void didUpdateWidget(_DmExpandingHeader old) {
super.didUpdateWidget(old);
if (old.room.id != widget.room.id) {
_profile = null;
_bannerMxcUri = null;
_statusMsg = null;
_online = false;
_load();
}
}
Future<void> _load() async {
final userId = _partnerId;
if (userId == null) return;
final client = widget.room.client;
// Race-Guard: Beim schnellen DM-Wechsel dürfen verspätete Antworten des
// VORHERIGEN Partners das Profil des aktuellen nicht überschreiben.
bool stillCurrent() => mounted && _partnerId == userId;
try {
final p = await client.getProfileFromUserId(userId);
if (stillCurrent()) setState(() => _profile = p);
} catch (_) {}
try {
final p = await client.fetchCurrentPresence(userId);
final last = p.lastActiveTimestamp;
final fresh = p.currentlyActive == true ||
(last != null &&
DateTime.now().difference(last) < const Duration(minutes: 5));
if (stillCurrent()) {
setState(() {
_online = p.presence == PresenceType.online && fresh;
_statusMsg = p.statusMsg;
});
}
} catch (_) {}
// Banner aus dem Profil-Custom-Field (io.pyramid.profile.banner_url).
try {
final apiUri = client.homeserver!.replace(
path: '/_matrix/client/v3/profile/${Uri.encodeComponent(userId)}',
);
final resp = await client.httpClient.get(
apiUri,
headers: {'Authorization': 'Bearer ${client.accessToken}'},
);
if (resp.statusCode == 200) {
final data = jsonDecode(resp.body) as Map<String, dynamic>;
final url = data['io.pyramid.profile.banner_url'] as String?;
if (url != null && stillCurrent()) {
setState(() => _bannerMxcUri = url);
}
}
} catch (_) {}
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final expanded = widget.expanded;
final size = MediaQuery.sizeOf(context);
final isMobile = size.width < 900 || size.height < 500;
final client = ref.watch(matrixClientProvider).valueOrNull;
final userId = _partnerId ?? '';
final member = widget.room.unsafeGetUserFromMemoryOrFallback(userId);
final name = _profile?.displayName ??
member.displayName ??
widget.room.getLocalizedDisplayname();
final avatarUrl =
_profile?.avatarUrl ?? member.avatarUrl ?? widget.room.avatar;
final initial = name.isNotEmpty ? name[0].toUpperCase() : '?';
// Im Querformat (wenig Höhe) kompakter aufklappen.
final expandedHeight = size.height < 500 ? 140.0 : 190.0;
final avatarSize = expanded
? (size.height < 500 ? 72.0 : 96.0)
: 32.0;
return AnimatedContainer(
duration: _animDuration,
curve: _animCurve,
height: expanded ? expandedHeight : 52,
decoration: BoxDecoration(
color: pt.bg1,
border: Border(bottom: BorderSide(color: pt.border)),
),
clipBehavior: Clip.hardEdge,
child: Stack(
fit: StackFit.expand,
children: [
// Banner als Hintergrund — blendet beim Aufklappen ein.
if (_bannerMxcUri != null && client != null)
Positioned.fill(
child: AnimatedOpacity(
opacity: expanded ? 1 : 0,
duration: _animDuration,
curve: Curves.easeOut,
child: MxcImage(
mxcUri: _bannerMxcUri!,
client: client,
fit: BoxFit.cover,
placeholder: const SizedBox.shrink(),
),
),
),
// Scrim: links dunkel, damit Avatar/Text lesbar bleiben (Mockup).
if (_bannerMxcUri != null)
Positioned.fill(
child: AnimatedOpacity(
opacity: expanded ? 1 : 0,
duration: _animDuration,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
pt.bg1,
pt.bg1.withAlpha(210),
pt.bg1.withAlpha(0),
],
stops: const [0.0, 0.32, 0.72],
),
),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
if (isMobile) ...[
GestureDetector(
onTap: () =>
ref.read(activeRoomIdProvider.notifier).state = null,
child:
Icon(Icons.arrow_back_rounded, size: 22, color: pt.fg),
),
const SizedBox(width: 12),
],
// Klickbarer Profilbereich: Avatar + Name + Details
Expanded(
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.onToggle,
child: Row(
children: [
// Avatar — skaliert mit (rund, wie im Mockup)
AnimatedContainer(
duration: _animDuration,
curve: _animCurve,
width: avatarSize,
height: avatarSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: pt.bg3,
),
clipBehavior: Clip.antiAlias,
child: avatarUrl != null && client != null
? MxcImage(
mxcUri: avatarUrl.toString(),
client: client,
fit: BoxFit.cover,
placeholder: _initialBox(initial, pt),
)
: _initialBox(initial, pt),
),
AnimatedContainer(
duration: _animDuration,
curve: _animCurve,
width: expanded ? 20 : 10,
),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Name skaliert mit; Status blendet daneben ein
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
child: AnimatedDefaultTextStyle(
duration: _animDuration,
curve: _animCurve,
style: TextStyle(
color: pt.fg,
fontSize: expanded ? 24 : 15,
fontWeight: expanded
? FontWeight.w700
: FontWeight.w600,
),
child: Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
AnimatedOpacity(
opacity: expanded ? 1 : 0,
duration: _animDuration,
child: Padding(
padding:
const EdgeInsets.only(left: 8),
child: Text(
_online ? 'online' : 'offline',
style: TextStyle(
color: _online
? pt.online
: pt.fgMuted,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
// 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,
),
],
],
),
),
],
),
),
],
),
),
),
),
const SizedBox(width: 12),
// Aktionen — bleiben immer sichtbar
Row(
children: [
PyrIconBtn(
size: 28,
icon: const Icon(Icons.phone_rounded),
tooltip: 'Voice call',
onPressed: () => _startCall(cam: false),
),
PyrIconBtn(
size: 28,
icon: const Icon(Icons.videocam_rounded),
tooltip: 'Video call',
onPressed: () => _startCall(cam: true),
),
if (size.width > 700)
Container(
width: 1,
height: 20,
margin: const EdgeInsets.symmetric(horizontal: 4),
color: pt.border,
),
if (size.width > 800)
PyrIconBtn(
size: 28,
icon: const Icon(Icons.push_pin_rounded),
tooltip: 'Angepinnte Nachrichten',
active: widget.pinnedOpen,
onPressed: widget.onTogglePinned,
),
PyrIconBtn(
size: 28,
icon: const Icon(Icons.search_rounded),
tooltip: 'Suchen',
onPressed: widget.onOpenSearch,
),
PyrIconBtn(
size: 28,
icon: const Icon(Icons.group_rounded),
tooltip: 'Mitglieder',
active: widget.membersOpen,
onPressed: widget.onToggleMembers,
),
],
),
],
),
),
],
),
);
}
Widget _initialBox(String initial, PyramidTheme pt) => Center(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Padding(
padding: const EdgeInsets.all(6),
child: Text(
initial,
style: TextStyle(
color: pt.fg, fontSize: 28, fontWeight: FontWeight.w700),
),
),
),
);
void _startCall({required bool cam}) async {
ref.read(viewModeProvider.notifier).state = ViewMode.voice;
await ref.read(callSignalingProvider).startCall(widget.room.id, video: cam);
}
}
class _DmHeaderAvatar extends StatelessWidget {
final Room? room;
final String name;
final Client client;
const _DmHeaderAvatar({required this.room, required this.name, required this.client});
@override
Widget build(BuildContext context) {
final initial = name.isNotEmpty ? name[0].toUpperCase() : '?';
const size = 32.0;
return MxcAvatar(
mxcUri: room?.avatar,
client: client,
size: size,
borderRadius: BorderRadius.circular(size / 2),
placeholder: (_) => _InitialCircle(initial: initial, size: size),
);
}
}
class _InitialCircle extends StatelessWidget {
final String initial;
final double size;
const _InitialCircle({required this.initial, required this.size});
@override
Widget build(BuildContext context) {
const color = Color(0xFF06B6D4);
return Container(
width: size,
height: size,
decoration: const BoxDecoration(color: color, shape: BoxShape.circle),
child: Center(
child: Text(initial,
style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600)),
),
);
}
}
class _PresenceLine extends StatefulWidget {
final String? userId;
final Client client;
final PyramidTheme pt;
const _PresenceLine({required this.userId, required this.client, required this.pt});
@override
State<_PresenceLine> createState() => _PresenceLineState();
}
class _PresenceLineState extends State<_PresenceLine> {
CachedPresence? _presence;
StreamSubscription<CachedPresence>? _sub;
// Debounce timer: delay "online" updates 4s to filter out background-sync
// false positives (push notifications briefly wake the recipient's client).
Timer? _onlineDebounce;
@override
void initState() {
super.initState();
_load(widget.userId);
}
@override
void didUpdateWidget(_PresenceLine oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.userId != widget.userId) {
_sub?.cancel();
_sub = null;
_onlineDebounce?.cancel();
setState(() => _presence = null);
_load(widget.userId);
}
}
@override
void dispose() {
_sub?.cancel();
_onlineDebounce?.cancel();
super.dispose();
}
// "online" only when the server's presence enum says online AND the last
// activity is recent. Using currentlyActive (the old logic) reported online
// long after the user left, because that flag lingers.
static bool _isOnline(CachedPresence p) {
if (p.presence != PresenceType.online) return false;
final last = p.lastActiveTimestamp;
return last == null || DateTime.now().difference(last).inSeconds < 90;
}
void _applyPresence(CachedPresence p) {
_onlineDebounce?.cancel();
if (_isOnline(p)) {
// Wait 4 seconds before committing "online" to filter single-sync spikes
// (a backgrounded client briefly reports online when a push wakes it).
_onlineDebounce = Timer(const Duration(seconds: 4), () {
if (mounted) setState(() => _presence = p);
});
} else {
if (mounted) setState(() => _presence = p);
}
}
Future<void> _load(String? userId) async {
if (userId == null) return;
// Never show our own presence in a DM header
if (userId == widget.client.userID) return;
try {
final p = await widget.client.fetchCurrentPresence(userId);
if (mounted) _applyPresence(p);
} catch (_) {}
_sub = widget.client.onPresenceChanged.stream
.where((p) => p.userid == userId)
.listen((p) => _applyPresence(p));
}
@override
Widget build(BuildContext context) {
final p = _presence;
if (p == null) return const SizedBox.shrink();
String text;
Color color = widget.pt.fgDim;
if (_isOnline(p)) {
text = 'online';
color = const Color(0xFF3BA55D);
} else if (p.lastActiveTimestamp != null) {
text = 'zuletzt online ${_relativeTime(p.lastActiveTimestamp!)}';
} else {
return const SizedBox.shrink();
}
return Text(
text,
style: TextStyle(color: color, fontSize: 11),
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
}
String _relativeTime(DateTime t) {
final diff = DateTime.now().difference(t);
if (diff.inMinutes < 1) return 'gerade eben';
if (diff.inMinutes < 60) return 'vor ${diff.inMinutes}min';
if (diff.inHours < 24) return 'vor ${diff.inHours}h';
if (diff.inDays < 7) return 'vor ${diff.inDays}d';
if (diff.inDays < 365) return 'vor ${(diff.inDays / 7).floor()}w';
return 'vor ${(diff.inDays / 365).floor()}j';
}
}
@@ -0,0 +1,490 @@
part of '../chat_view.dart';
class _MessageList extends StatelessWidget {
final Timeline timeline;
final ScrollController scrollCtrl;
final String currentUserId;
final PyramidTheme pt;
final ValueChanged<Event> onReply;
final Set<String> selectedIds;
final ValueChanged<String> onToggleSelect;
final String? sessionFullyReadId;
final bool sessionHasUnread;
final bool unreadFading;
/// Attached to the group-level _UnreadDivider so ensureVisible can scroll to it.
final GlobalKey? unreadDividerKey;
/// Jump-to-message target (from search): scroll to + highlight this event.
final String? jumpEventId;
final GlobalKey? jumpKey;
final String? highlightEventId;
final bool highlightOn;
const _MessageList({
required this.timeline,
required this.scrollCtrl,
required this.currentUserId,
required this.pt,
required this.onReply,
required this.selectedIds,
required this.onToggleSelect,
this.sessionFullyReadId,
this.sessionHasUnread = false,
this.unreadFading = false,
this.unreadDividerKey,
this.jumpEventId,
this.jumpKey,
this.highlightEventId,
this.highlightOn = false,
});
@override
Widget build(BuildContext context) {
// Dedupe by eventId — during concurrent forward/backward pagination the
// timeline can transiently contain the same event twice, which would
// produce duplicate ListView keys and crash the sliver (indexOf assertion).
final seenIds = <String>{};
final events = timeline.events
.where(_isVisible)
.where((e) => seenIds.add(e.eventId))
.toList();
if (events.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.forum_outlined, size: 48, color: pt.fgDim),
const SizedBox(height: 16),
Text(
'No messages yet',
style: TextStyle(color: pt.fgMuted, fontSize: 14),
),
],
),
);
}
final groups = _buildGroups(events);
final hasUnread = sessionHasUnread;
// Resolve the effective fullyReadId: if the captured ID points to a
// non-visible event (reaction, edit, state) walk the timeline forward
// (toward older events) until we find the nearest visible one.
String? fullyReadId = sessionFullyReadId;
if (fullyReadId != null) {
final inGroups = groups.any((g) => g.any((e) => e.eventId == fullyReadId));
if (!inGroups) {
final tlEvents = timeline.events; // newest-first
final idx = tlEvents.indexWhere((e) => e.eventId == fullyReadId);
if (idx >= 0) {
fullyReadId = null;
for (int i = idx; i < tlEvents.length; i++) {
if (_isVisible(tlEvents[i])) {
fullyReadId = tlEvents[i].eventId;
break;
}
}
} else {
fullyReadId = null; // event not even in loaded timeline
}
}
}
// Read-receipt display — matching Element behaviour:
// • The newest unread own group shows ✓ (sent but not read)
// • The newest own group AT or BEFORE the partner's receipt shows ✓✓
// • If those are the same group → just ✓✓
final room = timeline.room;
// Read receipts (Element-style):
// • ✓✓ at the single newest event another member has read (any sender) —
// moves next to the partner's own reply when they reply/read everything.
// • grey ✓ only on my newest message while it's still unread by them.
// timeline.events is newest-first → lower index = newer.
//
// Receipts können je nach Client in `global` ODER `mainThread` stehen
// (threaded read receipts) — beide zusammenführen, neuere ts gewinnt.
final mergedReceipts = <String, LatestReceiptStateData>{};
for (final source in [
room.receiptState.global.otherUsers,
room.receiptState.mainThread?.otherUsers ?? const {},
]) {
source.forEach((userId, data) {
final existing = mergedReceipts[userId];
if (existing == null || data.ts > existing.ts) {
mergedReceipts[userId] = data;
}
});
}
int readIndex = -1;
int latestReceiptTs = 0;
mergedReceipts.forEach((userId, data) {
if (userId == currentUserId) return;
if (data.ts > latestReceiptTs) latestReceiptTs = data.ts;
var ri = timeline.events.indexWhere((e) => e.eventId == data.eventId);
// Receipt kann auf ein unsichtbares Event zeigen (Reaktion, Edit) —
// zum nächstälteren sichtbaren Event vorrücken, sonst geht der Marker
// verloren.
while (ri >= 0 &&
ri < timeline.events.length &&
!_isVisible(timeline.events[ri])) {
ri++;
}
if (ri >= timeline.events.length) ri = -1;
if (ri >= 0 && (readIndex < 0 || ri < readIndex)) readIndex = ri;
});
final readEventIds =
readIndex >= 0 ? {timeline.events[readIndex].eventId} : <String>{};
// Newest own visible message, and whether it's still unread (newer than the
// partner's read position, or no read position yet).
final newestOwnIndex =
timeline.events.indexWhere((e) => _isVisible(e) && e.senderId == currentUserId);
var ownUnread = newestOwnIndex >= 0 && (readIndex < 0 || newestOwnIndex < readIndex);
// Fallback: Das Receipt-Event liegt außerhalb des geladenen Fensters
// (z.B. sehr alte Nachricht) — wenn der Receipt-Zeitstempel jünger ist als
// meine neueste Nachricht, gilt sie trotzdem als gelesen.
if (ownUnread &&
readIndex < 0 &&
latestReceiptTs >=
timeline.events[newestOwnIndex].originServerTs.millisecondsSinceEpoch) {
readEventIds.add(timeline.events[newestOwnIndex].eventId);
ownUnread = false;
}
final lastOwnGroupKey = (ownUnread)
? () {
for (final g in groups) {
if (g.first.senderId == currentUserId) return 'g:${g.first.eventId}';
}
return null;
}()
: null;
// Flatten groups + date dividers + unread divider into independent list items.
// itemFullyReadIds[i] is non-null only for a group item that contains the
// fullyRead event at a non-last position — MessageGroup renders an inline
// divider after that event.
final itemKeys = <String>[];
final itemGroups = <List<Event>?>[];
final itemDates = <DateTime?>[];
final itemUnread = <bool>[];
final itemFullyReadIds = <String?>[];
bool unreadInserted = false;
for (var i = 0; i < groups.length; i++) {
final group = groups[i];
// Use the newest event (group.last after reversal) for date comparison.
final groupNewest = group.last.originServerTs;
final nextGroupNewest = i < groups.length - 1 ? groups[i + 1].last.originServerTs : null;
final isLastOfDay = nextGroupNewest == null || !_sameDay(groupNewest, nextGroupNewest);
// Check if this group contains the effective fullyRead event.
final groupContainsFullyRead = fullyReadId != null &&
group.any((e) => e.eventId == fullyReadId);
if (!unreadInserted && hasUnread && groupContainsFullyRead) {
if (group.last.eventId == fullyReadId) {
// fullyRead is the NEWEST event in the group → the whole group is
// read. Insert a group-level divider between this group and the
// newer (lower-index) groups.
itemKeys.add('unread');
itemGroups.add(null);
itemDates.add(null);
itemUnread.add(true);
itemFullyReadIds.add(null);
}
// Whether group-level or inline, we've now handled the marker.
unreadInserted = true;
}
// Key based on oldest event (group.first) — stable identifier.
itemKeys.add('g:${group.first.eventId}');
itemGroups.add(group);
itemDates.add(null);
itemUnread.add(false);
// Pass fullyReadId to the group only when the event is mid-group
// (not the last event) so MessageGroup can render an inline divider.
// groupContainsFullyRead already guarantees fullyReadId != null.
final passedFullyReadId =
(groupContainsFullyRead && group.last.eventId != fullyReadId)
? fullyReadId
: null;
itemFullyReadIds.add(passedFullyReadId);
if (isLastOfDay) {
final d = groupNewest;
itemKeys.add('d:${d.year}-${d.month}-${d.day}');
itemGroups.add(null);
itemDates.add(d);
itemUnread.add(false);
itemFullyReadIds.add(null);
}
}
// Fallback: fullyRead event is older than all loaded events — all visible
// messages are unread. Insert divider above the oldest loaded group (top of list).
if (!unreadInserted && hasUnread) {
itemKeys.add('unread');
itemGroups.add(null);
itemDates.add(null);
itemUnread.add(true);
itemFullyReadIds.add(null);
}
final keyIndex = <String, int>{
for (var i = 0; i < itemKeys.length; i++) itemKeys[i]: i,
};
return ListView.builder(
controller: scrollCtrl,
reverse: true,
physics: const ClampingScrollPhysics(),
padding: const EdgeInsets.only(bottom: 8),
scrollCacheExtent: const ScrollCacheExtent.pixels(2500),
itemCount: itemKeys.length,
findChildIndexCallback: (Key key) {
// Only return an index when the key still maps to that exact slot —
// returning a stale/duplicate index trips the sliver indexOf assertion.
if (key is ValueKey<String>) {
final idx = keyIndex[key.value];
if (idx != null && idx < itemKeys.length && itemKeys[idx] == key.value) {
return idx;
}
}
return null;
},
itemBuilder: (context, i) {
final key = ValueKey(itemKeys[i]);
if (itemUnread[i]) {
// Wrap in SizedBox so the list can use the ValueKey for optimisation
// while _UnreadDivider carries the GlobalKey for ensureVisible.
return SizedBox(
key: key,
child: _UnreadDivider(key: unreadDividerKey, pt: pt, fading: unreadFading),
);
}
final date = itemDates[i];
if (date != null) return DateDivider(key: key, date: date);
final group = itemGroups[i]!;
final groupKey = itemKeys[i];
// Show the sending/sent indicator on the newest own group.
final showStatus = groupKey == lastOwnGroupKey;
return MessageGroup(
key: key,
events: group,
currentUserId: currentUserId,
room: room,
timeline: timeline,
onReply: () => onReply(group.last),
selectedIds: selectedIds,
onToggleSelect: onToggleSelect,
showStatusIndicator: showStatus,
readEventIds: readEventIds,
fullyReadId: itemFullyReadIds[i],
jumpEventId: jumpEventId,
jumpKey: jumpKey,
highlightEventId: highlightEventId,
highlightOn: highlightOn,
);
},
);
}
List<List<Event>> _buildGroups(List<Event> events) {
final groups = <List<Event>>[];
List<Event>? current;
for (final event in events) {
if (current == null ||
current.first.senderId != event.senderId ||
event.originServerTs.difference(current.last.originServerTs).abs() >
const Duration(minutes: 5)) {
current = [event];
groups.add(current);
} else {
current.add(event);
}
}
// The timeline is newest-first, so each group is [newest, ..., oldest].
// Reverse each group so messages render oldest-to-newest (top to bottom).
return groups.map((g) => g.reversed.toList()).toList();
}
bool _sameDay(DateTime a, DateTime b) =>
a.year == b.year && a.month == b.month && a.day == b.day;
static bool _isVisible(Event e) {
// NOTE: error-status events are kept visible so a failed send shows a
// "not sent" state with retry/delete instead of silently disappearing.
if (!{
EventTypes.Message,
EventTypes.Sticker,
EventTypes.Encrypted,
}.contains(e.type)) { return false; }
if ({
RelationshipTypes.edit,
RelationshipTypes.reaction,
}.contains(e.relationshipType)) { return false; }
if (e.type == EventTypes.Redaction) { return false; }
return true;
}
}
class _SelectionHeader extends StatelessWidget {
final PyramidTheme pt;
final int count;
final Set<String> selectedIds;
final Room? room;
final Timeline? timeline;
final String currentUserId;
final VoidCallback onClear;
final VoidCallback onReply;
const _SelectionHeader({
required this.pt,
required this.count,
required this.selectedIds,
required this.room,
required this.timeline,
required this.currentUserId,
required this.onClear,
required this.onReply,
});
List<Event> _resolveEvents() {
if (timeline == null) return [];
return selectedIds
.map((id) {
try { return timeline!.events.firstWhere((e) => e.eventId == id); } catch (_) { return null; }
})
.whereType<Event>()
.toList();
}
void _copyAll() {
final texts = _resolveEvents().map((e) => e.body).join('\n');
Clipboard.setData(ClipboardData(text: texts));
}
void _deleteAll(BuildContext context) {
for (final e in _resolveEvents()) {
if (e.senderId == currentUserId) {
e.redactEvent(reason: 'Vom Nutzer gelöscht').catchError((_) => null);
}
}
onClear();
}
void _pinAll() {
if (room == null) return;
final pinned = List<String>.from(
room!.getState(EventTypes.RoomPinnedEvents)?.content['pinned'] as List? ?? [],
);
for (final e in _resolveEvents()) {
if (!pinned.contains(e.eventId)) pinned.add(e.eventId);
}
room!.setPinnedEvents(pinned).catchError((_) => '');
onClear();
}
@override
Widget build(BuildContext context) {
final events = _resolveEvents();
final canDelete = events.isNotEmpty && events.every((e) => e.senderId == currentUserId);
final isTextOnly = events.every((e) => e.messageType == 'm.text');
return Container(
height: 52,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: pt.accentSoft,
border: Border(bottom: BorderSide(color: pt.accent.withAlpha(80))),
),
child: Row(
children: [
IconButton(
onPressed: onClear,
icon: Icon(Icons.close_rounded, size: 20, color: pt.accent),
tooltip: 'Auswahl aufheben',
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
padding: EdgeInsets.zero,
),
const SizedBox(width: 4),
Text(
'$count ausgewählt',
style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600),
),
const Spacer(),
if (count == 1)
_SelBtn(icon: Icons.reply_rounded, tooltip: 'Antworten', pt: pt, onTap: onReply),
if (isTextOnly)
_SelBtn(icon: Icons.copy_rounded, tooltip: 'Kopieren', pt: pt, onTap: _copyAll),
_SelBtn(icon: Icons.push_pin_outlined, tooltip: 'Anpinnen', pt: pt, onTap: _pinAll),
if (canDelete)
_SelBtn(
icon: Icons.delete_outline_rounded,
tooltip: 'Löschen',
pt: pt,
color: pt.danger,
onTap: () => _deleteAll(context),
),
],
),
);
}
}
class _SelBtn extends StatelessWidget {
final IconData icon;
final String tooltip;
final PyramidTheme pt;
final Color? color;
final VoidCallback onTap;
const _SelBtn({required this.icon, required this.tooltip, required this.pt, required this.onTap, this.color});
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: onTap,
icon: Icon(icon, size: 20, color: color ?? pt.fg),
tooltip: tooltip,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
padding: EdgeInsets.zero,
);
}
}
class _UnreadDivider extends StatelessWidget {
final PyramidTheme pt;
final bool fading;
const _UnreadDivider({super.key, required this.pt, this.fading = false});
@override
Widget build(BuildContext context) {
return AnimatedOpacity(
opacity: fading ? 0.0 : 1.0,
duration: const Duration(milliseconds: 700),
curve: Curves.easeOut,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 4),
child: Row(
children: [
Expanded(child: Divider(color: pt.accent.withAlpha(160), height: 1)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Text(
'Neue Nachrichten',
style: TextStyle(color: pt.accent, fontSize: 11, fontWeight: FontWeight.w600),
),
),
Expanded(child: Divider(color: pt.accent.withAlpha(160), height: 1)),
],
),
),
);
}
}
+131
View File
@@ -0,0 +1,131 @@
part of '../chat_view.dart';
class _DragOverlay extends StatefulWidget {
final PyramidTheme pt;
final bool active;
const _DragOverlay({required this.pt, required this.active});
@override
State<_DragOverlay> createState() => _DragOverlayState();
}
class _DragOverlayState extends State<_DragOverlay>
with SingleTickerProviderStateMixin {
// Sanftes Auf-und-ab des Icons, solange das Overlay aktiv ist.
late final AnimationController _bob = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
);
@override
void initState() {
super.initState();
if (widget.active) _bob.repeat(reverse: true);
}
@override
void didUpdateWidget(_DragOverlay old) {
super.didUpdateWidget(old);
if (widget.active && !old.active) {
_bob.repeat(reverse: true);
} else if (!widget.active && old.active) {
_bob.stop();
}
}
@override
void dispose() {
_bob.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
return Container(
color: pt.bg0.withAlpha(200),
child: Center(
child: AnimatedScale(
scale: widget.active ? 1.0 : 0.88,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutBack,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 32),
decoration: BoxDecoration(
color: pt.bg2,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: pt.accent, width: 2),
boxShadow: [
BoxShadow(
color: pt.accent.withAlpha(60),
blurRadius: 32,
spreadRadius: 4,
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedBuilder(
animation: _bob,
builder: (_, child) => Transform.translate(
offset: Offset(
0, -7 * Curves.easeInOut.transform(_bob.value)),
child: child,
),
child:
Icon(Icons.upload_file_rounded, size: 56, color: pt.accent),
),
const SizedBox(height: 16),
Text(
'Datei hier ablegen',
style: TextStyle(
color: pt.fg,
fontSize: 20,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
'Loslassen zum Hochladen',
style: TextStyle(color: pt.fgMuted, fontSize: 14),
),
],
),
),
),
),
);
}
}
// Placeholder shown when no room is selected
class NoChatSelected extends StatelessWidget {
const NoChatSelected({super.key});
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
return Container(
color: pt.bg1,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.forum_outlined, size: 64, color: pt.fgDim),
const SizedBox(height: 16),
Text(
'Wähle einen Chat aus',
style: TextStyle(color: pt.fgMuted, fontSize: 16),
),
const SizedBox(height: 8),
Text(
'Choose from the sidebar on the left',
style: TextStyle(color: pt.fgDim, fontSize: 13),
),
],
),
),
);
}
}
+136
View File
@@ -0,0 +1,136 @@
part of '../chat_view.dart';
class _TypingIndicator extends StatefulWidget {
final Room room;
final PyramidTheme pt;
const _TypingIndicator({required this.room, required this.pt});
@override
State<_TypingIndicator> createState() => _TypingIndicatorState();
}
class _TypingIndicatorState extends State<_TypingIndicator> {
StreamSubscription? _sub;
List<User> _typing = [];
StreamSubscription _listenTyping(Room room) {
// client.onSync fires after every sync — cheap to filter for typing events.
return room.client.onSync.stream.listen((_) {
if (!mounted) return;
final typing = room.typingUsers
.where((u) => u.id != room.client.userID)
.toList();
if (typing.length != _typing.length ||
!typing.every((u) => _typing.any((t) => t.id == u.id))) {
setState(() => _typing = typing);
}
});
}
@override
void initState() {
super.initState();
_sub = _listenTyping(widget.room);
}
@override
void didUpdateWidget(_TypingIndicator oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.room.id != widget.room.id) {
_sub?.cancel();
setState(() => _typing = []);
_sub = _listenTyping(widget.room);
}
}
@override
void dispose() {
_sub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (_typing.isEmpty) return const SizedBox.shrink();
final pt = widget.pt;
final names = _typing.map((u) => u.calcDisplayname()).toList();
final label = names.length == 1
? '${names[0]} tippt…'
: names.length == 2
? '${names[0]} und ${names[1]} tippen…'
: '${names[0]} und ${names.length - 1} weitere tippen…';
return Padding(
padding: const EdgeInsets.fromLTRB(20, 2, 20, 0),
child: Row(
children: [
_TypingDots(color: pt.fgDim),
const SizedBox(width: 8),
Flexible(
child: Text(
label,
style: TextStyle(color: pt.fgDim, fontSize: 12, fontStyle: FontStyle.italic),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}
class _TypingDots extends StatefulWidget {
final Color color;
const _TypingDots({required this.color});
@override
State<_TypingDots> createState() => _TypingDotsState();
}
class _TypingDotsState extends State<_TypingDots>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
)..repeat();
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _ctrl,
builder: (context, child) {
return Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(3, (i) {
// Each dot peaks at a different phase.
final phase = ((_ctrl.value * 3) - i).clamp(0.0, 1.0);
final opacity = (phase < 0.5 ? phase * 2 : (1 - phase) * 2).clamp(0.3, 1.0);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 1),
child: Opacity(
opacity: opacity,
child: Container(
width: 4,
height: 4,
decoration: BoxDecoration(color: widget.color, shape: BoxShape.circle),
),
),
);
}),
);
},
);
}
}
+521
View File
@@ -0,0 +1,521 @@
part of '../chat_view.dart';
class ChatView extends ConsumerStatefulWidget {
final String roomId;
const ChatView({super.key, required this.roomId});
@override
ConsumerState<ChatView> createState() => _ChatViewState();
}
class _ChatViewState extends ConsumerState<ChatView> {
final _scrollCtrl = ScrollController();
Event? _replyTo;
bool _loadingHistory = false;
bool _historyExhausted = false;
bool _draggingOver = false;
bool _pinnedOpen = false;
bool _profileExpanded = false;
bool _showScrollDown = false;
final Set<String> _selectedIds = {};
// Unread state captured at session open (before marking as read)
String? _sessionFullyReadId;
bool _sessionHasUnread = false;
bool _sessionCaptured = false;
bool _didScrollToUnread = false;
bool _loadingFuture = false;
// Fade-out of the "Neue Nachrichten" divider once the user reaches the bottom.
bool _unreadFading = false;
Timer? _unreadFadeTimer;
// Jump-to-message (from search): scroll to + briefly highlight a target event.
final _jumpKey = GlobalKey();
String? _jumpEventId;
String? _highlightEventId;
bool _highlightOn = false;
bool _jumpInProgress = false;
Timer? _highlightTimer;
void _startJump(String eventId) {
_highlightTimer?.cancel();
setState(() {
_jumpEventId = eventId;
_highlightEventId = eventId;
_highlightOn = true;
_jumpInProgress = true;
});
// Consume the request so it doesn't re-fire.
ref.read(pendingJumpEventProvider.notifier).state = null;
WidgetsBinding.instance.addPostFrameCallback((_) => _attemptJump(0));
}
void _attemptJump(int attempt) {
if (!mounted || _jumpEventId == null) return;
final ctx = _jumpKey.currentContext;
if (ctx != null) {
Scrollable.ensureVisible(
ctx,
alignment: 0.4,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
_jumpInProgress = false;
// Hold the highlight briefly, then fade it out smoothly.
_highlightTimer?.cancel();
_highlightTimer = Timer(const Duration(milliseconds: 1300), () {
if (!mounted) return;
setState(() => _highlightOn = false); // triggers the AnimatedOpacity fade
_highlightTimer = Timer(const Duration(milliseconds: 950), () {
if (mounted) setState(() { _highlightEventId = null; _jumpEventId = null; });
});
});
return;
}
if (attempt >= 40) {
// Couldn't reach the event — give up gracefully (room is at least open).
setState(() { _jumpInProgress = false; _highlightEventId = null; _jumpEventId = null; _highlightOn = false; });
return;
}
// Not built yet — load older history toward the target every few tries.
final timeline = ref.read(timelineProvider(widget.roomId)).valueOrNull;
if (timeline != null && timeline.canRequestHistory && attempt % 3 == 0) {
timeline.requestHistory(historyCount: 100).catchError((_) {});
}
WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 120), () => _attemptJump(attempt + 1));
});
}
// GlobalKey for the "Neue Nachrichten" divider — used by ensureVisible to
// scroll precisely to the divider on chat open.
final _unreadDividerKey = GlobalKey();
bool get _inSelectionMode => _selectedIds.isNotEmpty;
void _toggleSelect(String eventId) {
setState(() {
if (_selectedIds.contains(eventId)) {
_selectedIds.remove(eventId);
} else {
_selectedIds.add(eventId);
}
});
}
void _clearSelection() => setState(() => _selectedIds.clear());
@override
void initState() {
super.initState();
_scrollCtrl.addListener(_onScroll);
}
@override
void didUpdateWidget(ChatView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.roomId != widget.roomId) {
_profileExpanded = false;
_sessionFullyReadId = null;
_sessionHasUnread = false;
_sessionCaptured = false;
_didScrollToUnread = false;
_loadingFuture = false;
_historyExhausted = false;
_unreadFading = false;
_unreadFadeTimer?.cancel();
_jumpEventId = null;
_highlightEventId = null;
_highlightOn = false;
_jumpInProgress = false;
_highlightTimer?.cancel();
// The GlobalKey stays alive — it'll simply have no context until the
// new room's divider (if any) is built.
}
}
@override
void dispose() {
_unreadFadeTimer?.cancel();
_highlightTimer?.cancel();
_scrollCtrl.dispose();
super.dispose();
}
void _captureAndMarkRead(Timeline timeline) {
if (!_sessionCaptured) {
final room = timeline.room;
final fullyRead = room.fullyRead; // "" if never set
final newFullyReadId = fullyRead.isNotEmpty ? fullyRead : null;
// Show the divider only if there's a genuinely unread VISIBLE message
// FROM SOMEONE ELSE after the read marker. Own messages and hidden events
// (reactions, receipts, edits) don't count — otherwise the divider ends
// up below all messages (e.g. right after one's own latest message).
final me = room.client.userID;
final events = timeline.events; // newest-first
final frIndex =
fullyRead.isEmpty ? -1 : events.indexWhere((e) => e.eventId == fullyRead);
// Indices [0, upTo) are newer than the read marker.
final upTo = frIndex >= 0 ? frIndex : events.length;
var newHasUnread = false;
for (var i = 0; i < upTo; i++) {
final e = events[i];
if (_MessageList._isVisible(e) && e.senderId != me) {
newHasUnread = true;
break;
}
}
_sessionCaptured = true;
// setState so _MessageList rebuilds with the captured unread marker.
if (mounted) {
setState(() {
_sessionFullyReadId = newFullyReadId;
_sessionHasUnread = newHasUnread;
});
if (newHasUnread) {
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToUnread(timeline));
}
}
}
_markRead(timeline);
}
void _scrollToUnread(Timeline timeline) {
if (_didScrollToUnread) return;
_attemptScrollToUnread(0);
}
// The "Neue Nachrichten" divider may not be laid out yet on first open (lazy
// reverse ListView only builds visible + cacheExtent items). Retry until its
// context exists, nudging the scroll upward periodically so the list builds
// toward the divider when there are many unread messages.
void _attemptScrollToUnread(int attempt) {
if (_didScrollToUnread || !mounted) return;
final ctx = _unreadDividerKey.currentContext;
if (ctx != null) {
_didScrollToUnread = true;
// alignment 0.85: in a reverse list this lands the divider near the top
// of the viewport with a little read context above it and the unread
// messages filling the screen below.
Scrollable.ensureVisible(
ctx,
alignment: 0.85,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
);
return;
}
if (attempt >= 24) {
_didScrollToUnread = true; // give up — stay at the newest messages
return;
}
if (attempt > 0 && attempt % 4 == 0 && _scrollCtrl.hasClients) {
final pos = _scrollCtrl.position;
final target = (pos.pixels + 1500).clamp(0.0, pos.maxScrollExtent);
if (target > pos.pixels) _scrollCtrl.jumpTo(target);
}
WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 70),
() => _attemptScrollToUnread(attempt + 1));
});
}
void _markRead([Timeline? tl]) {
// Cancel any pending notification for this room (user is now reading it).
cancelNotificationForRoom(widget.roomId);
final sendReceipts = ref.read(privacyReadReceiptsProvider);
if (!sendReceipts) return;
final room = ref.read(roomProvider(widget.roomId));
if (room == null) return;
final timeline = tl ?? ref.read(timelineProvider(widget.roomId)).valueOrNull;
final latest = timeline?.events.firstOrNull;
if (latest != null && latest.status.isSynced) {
room.setReadMarker(latest.eventId, mRead: latest.eventId)
.catchError((_) {});
} else {
room.markUnread(false).catchError((_) {});
}
}
Future<void> _onScroll() async {
if (!_scrollCtrl.hasClients) return;
final pixels = _scrollCtrl.position.pixels;
final maxExtent = _scrollCtrl.position.maxScrollExtent;
if (pixels < 60) {
_markRead(null);
// Reached the newest messages → the "Neue Nachrichten" divider is no
// longer useful. Fade it out, then remove it.
if (_sessionHasUnread && !_unreadFading) {
setState(() => _unreadFading = true);
_unreadFadeTimer?.cancel();
_unreadFadeTimer = Timer(const Duration(milliseconds: 900), () {
if (mounted) setState(() { _sessionHasUnread = false; _unreadFading = false; });
});
}
}
final shouldShow = pixels > 400;
if (shouldShow != _showScrollDown) {
setState(() => _showScrollDown = shouldShow);
}
final timeline = ref.read(timelineProvider(widget.roomId)).valueOrNull;
if (timeline == null) return;
// Backward pagination — older events near the top. We keep requesting until
// a request genuinely returns nothing new (real start of room), tracked via
// _historyExhausted, so we neither stop early nor hammer the server.
if (!_loadingHistory &&
!_historyExhausted &&
pixels >= maxExtent - 600) {
_loadingHistory = true;
final oldestBefore = timeline.events.isNotEmpty ? timeline.events.last.eventId : null;
final lenBefore = timeline.events.length;
try {
await timeline.requestHistory(historyCount: 60);
} catch (_) {}
final oldestAfter = timeline.events.isNotEmpty ? timeline.events.last.eventId : null;
// Nothing older arrived → we've reached the beginning of the room.
if (timeline.events.length == lenBefore && oldestBefore == oldestAfter) {
_historyExhausted = true;
}
await Future.delayed(const Duration(milliseconds: 200));
_loadingHistory = false;
}
// Forward pagination — only relevant after jumping to a historical anchor
// (e.g. from search). At the live edge canRequestFuture is false, so this
// won't fire during normal reading and can't pull the view back down.
if (!_loadingFuture &&
timeline.canRequestFuture &&
pixels < 200) {
_loadingFuture = true;
try {
await timeline.requestFuture(historyCount: 60);
} catch (_) {}
await Future.delayed(const Duration(milliseconds: 250));
_loadingFuture = false;
}
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
final room = ref.watch(roomProvider(widget.roomId));
final timelineAsync = ref.watch(timelineProvider(widget.roomId));
final membersOpen = ref.watch(membersPanelOpenProvider);
// A search result requested a jump to a specific message. Using watch (not
// listen) so it also fires when this ChatView is freshly mounted for a
// DIFFERENT room — listen would miss a value set before registration.
final pendingJump = ref.watch(pendingJumpEventProvider);
if (pendingJump != null && !_jumpInProgress && pendingJump != _jumpEventId) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted &&
!_jumpInProgress &&
ref.read(pendingJumpEventProvider) == pendingJump) {
_startJump(pendingJump);
}
});
}
final isDesktop = Platform.isWindows || Platform.isMacOS || Platform.isLinux;
final body = Stack(
fit: StackFit.expand,
children: [
Row(
children: [
Expanded(
child: Container(
color: pt.bg1,
child: Column(
children: [
if (_inSelectionMode)
_SelectionHeader(
pt: pt,
count: _selectedIds.length,
selectedIds: Set.from(_selectedIds),
room: room,
timeline: ref.read(timelineProvider(widget.roomId)).valueOrNull,
currentUserId: room?.client.userID ?? '',
onClear: _clearSelection,
onReply: () {
final tl = ref.read(timelineProvider(widget.roomId)).valueOrNull;
if (tl == null) return;
try {
final event = tl.events.firstWhere((e) => e.eventId == _selectedIds.first);
setState(() { _replyTo = event; _selectedIds.clear(); });
} catch (_) {}
},
)
else if (room != null && room.isDirectChat)
// DM: aufklappbarer Banner-Header — Avatar und Name
// skalieren mit, Banner und Bio blenden ein.
// ValueKey: pro Raum ein frischer State, damit beim
// schnellen Kanalwechsel keinerlei Daten (Avatar,
// Banner, laufende Futures) vom Vorgänger übrigbleiben.
_DmExpandingHeader(
key: ValueKey('dmheader:${room.id}'),
room: room,
roomId: widget.roomId,
pt: pt,
expanded: _profileExpanded,
onToggle: () => setState(
() => _profileExpanded = !_profileExpanded),
membersOpen: membersOpen,
pinnedOpen: _pinnedOpen,
onToggleMembers: () => ref
.read(membersPanelOpenProvider.notifier)
.update((s) => !s),
onOpenSearch: () =>
ref.read(activeModalProvider.notifier).state =
ModalKind.search,
onTogglePinned: () =>
setState(() => _pinnedOpen = !_pinnedOpen),
)
else
_ChatHeader(
room: room,
roomId: widget.roomId,
pt: pt,
membersOpen: membersOpen,
pinnedOpen: _pinnedOpen,
onToggleMembers: () => ref
.read(membersPanelOpenProvider.notifier)
.update((s) => !s),
onOpenSearch: () =>
ref.read(activeModalProvider.notifier).state =
ModalKind.search,
onTogglePinned: () =>
setState(() => _pinnedOpen = !_pinnedOpen),
),
const _ConnectionBanner(),
Expanded(
child: Stack(
children: [
timelineAsync.when(
loading: () => Center(
child: CircularProgressIndicator(
strokeWidth: 2,
color: pt.accent,
),
),
error: (e, _) => Center(
child: Text(
'Could not load messages',
style: TextStyle(color: pt.fgDim, fontSize: 14),
),
),
data: (timeline) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => _captureAndMarkRead(timeline));
return _MessageList(
timeline: timeline,
scrollCtrl: _scrollCtrl,
currentUserId: room?.client.userID ?? '',
pt: pt,
onReply: (event) => setState(() => _replyTo = event),
selectedIds: _selectedIds,
onToggleSelect: _toggleSelect,
sessionFullyReadId: _sessionFullyReadId,
sessionHasUnread: _sessionHasUnread,
unreadFading: _unreadFading,
unreadDividerKey: _sessionHasUnread ? _unreadDividerKey : null,
jumpEventId: _jumpEventId,
jumpKey: _jumpKey,
highlightEventId: _highlightEventId,
highlightOn: _highlightOn,
);
},
),
if (_showScrollDown)
Positioned(
right: 16,
bottom: 12,
child: AnimatedOpacity(
opacity: _showScrollDown ? 1 : 0,
duration: const Duration(milliseconds: 150),
child: GestureDetector(
onTap: () => _scrollCtrl.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
),
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: pt.bg0,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: pt.border),
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(80),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Icon(Icons.keyboard_arrow_down_rounded, size: 20, color: pt.fgMuted),
),
),
),
),
],
),
),
if (room != null) _TypingIndicator(room: room, pt: pt),
if (room != null)
ChatComposer(
room: room,
replyTo: _replyTo,
onClearReply: () => setState(() => _replyTo = null),
),
],
),
),
),
if (_pinnedOpen)
PinnedMessagesPanel(
roomId: widget.roomId,
onClose: () => setState(() => _pinnedOpen = false),
),
],
),
// Drag-and-drop overlay — bleibt gemountet und blendet animiert
// ein/aus, damit der Übergang flüssig statt abrupt ist.
Positioned.fill(
child: IgnorePointer(
child: AnimatedOpacity(
opacity: _draggingOver ? 1 : 0,
duration: const Duration(milliseconds: 160),
curve: Curves.easeOutCubic,
child: _DragOverlay(pt: pt, active: _draggingOver),
),
),
),
],
);
if (!isDesktop) return body;
return DropTarget(
onDragEntered: (_) => setState(() => _draggingOver = true),
onDragExited: (_) => setState(() => _draggingOver = false),
onDragDone: (details) async {
setState(() => _draggingOver = false);
if (room == null) return;
final files = details.files.map((xf) => File(xf.path)).toList();
if (files.isEmpty) return;
if (!mounted) return;
await AttachmentDialog.show(context, files, room, replyTo: _replyTo);
if (mounted) setState(() => _replyTo = null);
},
child: body,
);
}
}
+1 -1
View File
@@ -43,7 +43,7 @@ class MembersPanel extends ConsumerWidget {
Icon(Icons.group_rounded, size: 16, color: pt.fg),
const SizedBox(width: 8),
Text(
'Members',
'Mitglieder',
style: TextStyle(
color: pt.fg,
fontSize: 14,
+503
View File
@@ -0,0 +1,503 @@
part of '../rooms_panel.dart';
enum _SpaceMenuItem {
newDm, createGroup, createRoom, joinRoom, discoverRooms,
editSpace, adminSpace, leaveSpace,
}
class _Header extends ConsumerWidget {
final PyramidTheme pt;
final String? spaceId;
const _Header({required this.pt, required this.spaceId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isRealSpace = spaceId != null && spaceId != 'dms' && spaceId != 'rooms';
final size = MediaQuery.sizeOf(context);
final isMobile = size.width < 900 || size.height < 500;
final spaces = ref.watch(spacesProvider).valueOrNull ?? [];
Room? activeSpace;
if (isRealSpace) {
try {
activeSpace = spaces.firstWhere((s) => s.id == spaceId);
} catch (_) {}
}
final client = ref.watch(matrixClientProvider).valueOrNull;
final isAdmin =
activeSpace != null && activeSpace.ownPowerLevel >= 50;
final name = isRealSpace
? (activeSpace?.getLocalizedDisplayname() ?? 'Space')
: (spaceId == 'dms' ? 'Chats' : 'Räume');
void handleMenu(_SpaceMenuItem item) {
switch (item) {
case _SpaceMenuItem.newDm:
showDialog(context: context, builder: (_) => const NewDmDialog());
case _SpaceMenuItem.createGroup:
showDialog(
context: context, builder: (_) => const CreateJoinDialog());
case _SpaceMenuItem.createRoom:
showDialog(
context: context, builder: (_) => const CreateJoinDialog());
case _SpaceMenuItem.joinRoom:
showDialog(
context: context,
builder: (_) =>
const CreateJoinDialog(initialTab: CreateJoinTab.join));
case _SpaceMenuItem.discoverRooms:
showDialog(
context: context,
builder: (_) =>
const CreateJoinDialog(initialTab: CreateJoinTab.discover));
case _SpaceMenuItem.editSpace:
if (activeSpace != null) {
showDialog(
context: context,
builder: (_) => SpaceEditDialog(space: activeSpace!));
}
case _SpaceMenuItem.adminSpace:
if (activeSpace != null) {
showDialog(
context: context,
builder: (_) => SpaceAdminDialog(space: activeSpace!));
}
case _SpaceMenuItem.leaveSpace:
if (activeSpace != null) {
final space = activeSpace;
showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: pt.bg2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rXl),
side: BorderSide(color: pt.border)),
title: Text('Space verlassen?',
style: TextStyle(
color: pt.fg,
fontSize: 16,
fontWeight: FontWeight.w700)),
content: Text(
'Du verlässt "${space.getLocalizedDisplayname()}".',
style: TextStyle(color: pt.fgMuted, fontSize: 13)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text('Abbrechen',
style: TextStyle(color: pt.fgMuted))),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: pt.danger,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8))),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Verlassen'),
),
],
),
).then((confirmed) {
if (confirmed == true) {
space.leave().then((_) {
ref.read(activeSpaceIdProvider.notifier).state = 'rooms';
});
}
});
}
}
}
List<PopupMenuEntry<_SpaceMenuItem>> buildMenuItems() {
if (spaceId == 'dms' || spaceId == null) {
return [
PopupMenuItem(
value: _SpaceMenuItem.newDm,
child: _MenuRow(
Icons.chat_bubble_outline_rounded, 'Neue Unterhaltung', pt)),
PopupMenuItem(
value: _SpaceMenuItem.createGroup,
child: _MenuRow(Icons.group_add_rounded, 'Gruppe erstellen', pt)),
];
} else if (spaceId == 'rooms') {
return [
PopupMenuItem(
value: _SpaceMenuItem.createRoom,
child: _MenuRow(Icons.add_rounded, 'Raum erstellen', pt)),
PopupMenuItem(
value: _SpaceMenuItem.joinRoom,
child: _MenuRow(Icons.login_rounded, 'Beitreten', pt)),
PopupMenuItem(
value: _SpaceMenuItem.discoverRooms,
child: _MenuRow(Icons.explore_rounded, 'Entdecken', pt)),
];
} else {
return [
if (isAdmin) ...[
PopupMenuItem(
value: _SpaceMenuItem.adminSpace,
child: _MenuRow(
Icons.admin_panel_settings_rounded, 'Space verwalten', pt)),
PopupMenuItem(
value: _SpaceMenuItem.editSpace,
child: _MenuRow(Icons.edit_outlined, 'Name/Beschreibung', pt)),
const PopupMenuDivider(),
],
PopupMenuItem(
value: _SpaceMenuItem.leaveSpace,
child: _MenuRow(Icons.exit_to_app_rounded, 'Space verlassen', pt,
danger: true)),
];
}
}
// Check for banner — space banner for real spaces, server banner otherwise.
final bannerOverrides = ref.watch(spaceBannerOverrideProvider);
final serverBanners = ref.watch(serverBannerProvider).valueOrNull ?? {};
String? bannerUrl;
if (isRealSpace && activeSpace != null) {
bannerUrl = bannerOverrides.containsKey(activeSpace.id)
? bannerOverrides[activeSpace.id]
: activeSpace.getState(_kSpaceBannerType)?.content['url'] as String?;
} else if (client != null) {
bannerUrl = serverBanners[client.homeserver.toString()];
}
final hasBanner = bannerUrl != null && client != null;
Widget controlsRow = Row(
children: [
if (isMobile)
PyrIconBtn(
icon: const Icon(Icons.menu_rounded),
tooltip: 'Spaces',
onPressed: () => Scaffold.of(context).openDrawer(),
)
else ...[
PyrIconBtn(
icon: const Icon(Icons.menu_rounded),
tooltip: 'Toggle sidebar',
onPressed: () =>
ref.read(railCollapsedProvider.notifier).update((s) => !s),
),
const SizedBox(width: 4),
],
// Space avatar (only when no banner)
if (isRealSpace && !hasBanner && client != null && activeSpace != null) ...[
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: pt.accent.withAlpha(40),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: activeSpace.avatar != null
? MxcAvatar(
mxcUri: activeSpace.avatar,
client: client,
size: 28,
borderRadius: BorderRadius.circular(8),
placeholder: (_) => Center(
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
style: TextStyle(
color: pt.accent,
fontSize: 13,
fontWeight: FontWeight.w700),
),
),
)
: Center(
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
style: TextStyle(
color: pt.accent,
fontSize: 13,
fontWeight: FontWeight.w700),
),
),
),
),
const SizedBox(width: 8),
],
Expanded(
child: Text(
name,
style: TextStyle(
color: hasBanner ? Colors.white : pt.fg,
fontSize: 15,
fontWeight: isRealSpace ? FontWeight.w700 : FontWeight.w600,
shadows: hasBanner
? [const Shadow(blurRadius: 4, color: Colors.black45)]
: null,
),
overflow: TextOverflow.ellipsis,
),
),
if (isRealSpace && isAdmin)
PyrIconBtn(
icon: Icon(Icons.admin_panel_settings_rounded,
color: hasBanner ? Colors.white70 : null),
tooltip: 'Space verwalten',
onPressed: () => handleMenu(_SpaceMenuItem.adminSpace),
),
if (!isRealSpace)
_NotificationsButton(pt: pt, hasBanner: hasBanner),
PopupMenuButton<_SpaceMenuItem>(
tooltip: 'Erstellen',
color: pt.bg2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: pt.border)),
itemBuilder: (_) => buildMenuItems(),
onSelected: handleMenu,
child: Padding(
padding: const EdgeInsets.all(8),
child: Icon(Icons.add_rounded,
color: hasBanner ? Colors.white70 : pt.fgDim, size: 18),
),
),
],
);
if (hasBanner) {
return SizedBox(
height: 110,
child: Stack(
fit: StackFit.expand,
children: [
// Banner image
MxcImage(
mxcUri: bannerUrl,
client: client,
fit: BoxFit.cover,
placeholder: Container(color: pt.bg0),
),
// Gradient overlay — bottom-heavy so text is readable
DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: [0.0, 0.4, 1.0],
colors: [
Color(0x00000000),
Color(0x55000000),
Color(0xCC000000),
],
),
),
),
// Controls at the bottom
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
child: controlsRow,
),
),
// Bottom border
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(height: 1, color: pt.border.withAlpha(80)),
),
],
),
);
}
return Container(
height: isRealSpace ? 56 : 52,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: isRealSpace ? pt.bg0 : pt.bg1,
border: Border(bottom: BorderSide(color: pt.border)),
),
child: controlsRow,
);
}
}
class _MenuRow extends StatelessWidget {
final IconData icon;
final String label;
final PyramidTheme pt;
final bool danger;
const _MenuRow(this.icon, this.label, this.pt, {this.danger = false});
@override
Widget build(BuildContext context) => Row(children: [
Icon(icon, size: 14, color: danger ? pt.danger : pt.fgMuted),
const SizedBox(width: 8),
Text(label,
style: TextStyle(
color: danger ? pt.danger : pt.fg, fontSize: 13)),
]);
}
/// Glocke im Header: Popup mit allen Räumen, die ungelesene Nachrichten oder
/// Mentions haben. Tippen öffnet den jeweiligen Raum.
class _NotificationsButton extends ConsumerWidget {
final PyramidTheme pt;
final bool hasBanner;
const _NotificationsButton({required this.pt, required this.hasBanner});
@override
Widget build(BuildContext context, WidgetRef ref) {
final rooms = ref.watch(roomListProvider).valueOrNull ?? [];
final unread = rooms
.where((r) =>
r.membership == Membership.join &&
(r.notificationCount > 0 || r.highlightCount > 0))
.toList()
..sort((a, b) {
final hl = b.highlightCount.compareTo(a.highlightCount);
if (hl != 0) return hl;
return b.notificationCount.compareTo(a.notificationCount);
});
return PopupMenuButton<String>(
tooltip: 'Benachrichtigungen',
color: pt.bg2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: pt.border)),
offset: const Offset(0, 36),
onSelected: (roomId) {
ref.read(activeRoomIdProvider.notifier).state = roomId;
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
},
itemBuilder: (_) {
if (unread.isEmpty) {
return [
PopupMenuItem<String>(
enabled: false,
child: Text('Keine neuen Benachrichtigungen',
style: TextStyle(color: pt.fgDim, fontSize: 13)),
),
];
}
return unread
.map((r) => PopupMenuItem<String>(
value: r.id,
child: Row(
children: [
Icon(
r.highlightCount > 0
? Icons.alternate_email_rounded
: (r.isDirectChat
? Icons.chat_bubble_outline_rounded
: Icons.tag_rounded),
size: 14,
color:
r.highlightCount > 0 ? pt.danger : pt.fgMuted,
),
const SizedBox(width: 8),
Expanded(
child: Text(
r.getLocalizedDisplayname(),
style: TextStyle(color: pt.fg, fontSize: 13),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: r.highlightCount > 0 ? pt.danger : pt.accent,
borderRadius: BorderRadius.circular(999),
),
child: Text(
'${r.notificationCount > 0 ? r.notificationCount : r.highlightCount}',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w700),
),
),
],
),
))
.toList();
},
child: Padding(
padding: const EdgeInsets.all(8),
child: Stack(
clipBehavior: Clip.none,
children: [
Icon(Icons.notifications_none_rounded,
size: 18, color: hasBanner ? Colors.white70 : pt.fgDim),
if (unread.isNotEmpty)
Positioned(
top: -1,
right: -1,
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: pt.danger,
shape: BoxShape.circle,
border: Border.all(color: pt.bg1, width: 1.5),
),
),
),
],
),
),
);
}
}
class _FilterInput extends StatelessWidget {
final TextEditingController controller;
final PyramidTheme pt;
final ValueChanged<String> onChanged;
const _FilterInput(
{required this.controller, required this.pt, required this.onChanged});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
child: Stack(
alignment: Alignment.centerLeft,
children: [
Padding(
padding: const EdgeInsets.only(left: 8),
child: Icon(Icons.search_rounded, size: 14, color: pt.fgDim),
),
TextField(
controller: controller,
onChanged: onChanged,
style: TextStyle(color: pt.fg, fontSize: 13),
decoration: InputDecoration(
hintText: 'Chats durchsuchen',
hintStyle: TextStyle(color: pt.fgDim, fontSize: 13),
contentPadding: const EdgeInsets.fromLTRB(28, 7, 10, 7),
filled: true,
fillColor: pt.bg2,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(pt.rSm),
borderSide: BorderSide.none),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(pt.rSm),
borderSide: const BorderSide(color: Colors.transparent)),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(pt.rSm),
borderSide: BorderSide(color: pt.accent, width: 1)),
isDense: true,
),
cursorColor: pt.accent,
),
],
),
);
}
}
@@ -0,0 +1,113 @@
part of '../rooms_panel.dart';
class _InviteItem extends ConsumerStatefulWidget {
final Room room;
final PyramidTheme pt;
const _InviteItem({required this.room, required this.pt});
@override
ConsumerState<_InviteItem> createState() => _InviteItemState();
}
class _InviteItemState extends ConsumerState<_InviteItem> {
bool _accepting = false;
bool _declining = false;
Future<void> _accept() async {
setState(() => _accepting = true);
try {
await widget.room.join();
if (mounted) {
ref.read(activeRoomIdProvider.notifier).state = widget.room.id;
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
}
} catch (_) {
if (mounted) setState(() => _accepting = false);
}
}
Future<void> _decline() async {
setState(() => _declining = true);
try {
await widget.room.leave();
} catch (_) {
if (mounted) setState(() => _declining = false);
}
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final name = widget.room.getLocalizedDisplayname();
final isDm = widget.room.isDirectChat;
return Container(
margin: const EdgeInsets.symmetric(vertical: 1),
padding: const EdgeInsets.fromLTRB(10, 8, 10, 8),
decoration: BoxDecoration(
color: pt.accentSoft,
borderRadius: BorderRadius.circular(pt.rSm),
border: Border.all(color: pt.accent.withAlpha(50)),
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Icon(isDm ? Icons.person_rounded : Icons.tag_rounded,
size: 12, color: pt.accent),
const SizedBox(width: 6),
Expanded(
child: Text(name,
style: TextStyle(
color: pt.fg,
fontSize: 13,
fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis),
),
]),
const SizedBox(height: 6),
Row(children: [
Expanded(
child: OutlinedButton(
style: OutlinedButton.styleFrom(
foregroundColor: pt.danger,
side: BorderSide(color: pt.danger.withAlpha(80)),
padding: const EdgeInsets.symmetric(vertical: 4),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6)),
),
onPressed: (_declining || _accepting) ? null : _decline,
child: _declining
? const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator.adaptive(strokeWidth: 1.5))
: const Text('Ablehnen', style: TextStyle(fontSize: 12)),
),
),
const SizedBox(width: 6),
Expanded(
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: pt.accent,
foregroundColor: pt.accentFg,
elevation: 0,
padding: const EdgeInsets.symmetric(vertical: 4),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6)),
),
onPressed: (_accepting || _declining) ? null : _accept,
child: _accepting
? const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator.adaptive(strokeWidth: 1.5))
: const Text('Annehmen', style: TextStyle(fontSize: 12)),
),
),
]),
]),
);
}
}
+656
View File
@@ -0,0 +1,656 @@
part of '../rooms_panel.dart';
class _DraggableRoomItem extends StatefulWidget {
final Room room;
final int index;
final bool isActive;
final bool isInVoice;
final PyramidTheme pt;
final VoidCallback onTap;
const _DraggableRoomItem({
super.key,
required this.room,
required this.index,
required this.isActive,
this.isInVoice = false,
required this.pt,
required this.onTap,
});
@override
State<_DraggableRoomItem> createState() => _DraggableRoomItemState();
}
class _DraggableRoomItemState extends State<_DraggableRoomItem> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: Row(children: [
ReorderableDragStartListener(
index: widget.index,
child: MouseRegion(
cursor: SystemMouseCursors.grab,
child: AnimatedOpacity(
opacity: _hovered ? 1.0 : 0.0,
duration: const Duration(milliseconds: 120),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 3),
child: Icon(Icons.drag_indicator_rounded,
size: 12, color: widget.pt.fgDim),
),
),
),
),
Expanded(
child: _RoomItem(
room: widget.room,
isActive: widget.isActive,
isInVoice: widget.isInVoice,
pt: widget.pt,
onTap: widget.onTap,
),
),
]),
);
}
}
class _RoomsList extends ConsumerWidget {
final List<Room> rooms;
final String filter;
final String? spaceId;
final Map<String, bool> collapsed;
final PyramidTheme pt;
final ValueChanged<String> onToggleSection;
const _RoomsList({
required this.rooms,
required this.filter,
required this.spaceId,
required this.collapsed,
required this.pt,
required this.onToggleSection,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final activeRoomId = ref.watch(activeRoomIdProvider);
final isDms = spaceId == 'dms';
final invites = rooms
.where((r) =>
r.membership == Membership.invite &&
isDms == r.isDirectChat)
.toList();
final filtered = rooms.where((r) {
if (r.membership == Membership.invite) return false;
if (isDms != r.isDirectChat) return false;
if (filter.isEmpty) return true;
return r.getLocalizedDisplayname()
.toLowerCase()
.contains(filter.toLowerCase());
}).toList();
if (filtered.isEmpty && invites.isEmpty) {
return Center(
child:
Text(isDms ? 'Noch keine Chats' : 'Keine Räume', style: TextStyle(color: pt.fgDim, fontSize: 13)));
}
final sectionTitle = isDms ? 'Direktnachrichten' : 'Räume';
final sectionId = isDms ? 'dms' : 'rooms';
final isCollapsed = collapsed[sectionId] ?? false;
return ListView(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 12),
children: [
if (invites.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 8, 2),
child: Text('EINLADUNGEN',
style: TextStyle(
color: pt.accent,
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 0.6)),
),
...invites.map((r) => Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: _InviteItem(room: r, pt: pt))),
const SizedBox(height: 4),
],
_SectionHeader(
title: sectionTitle,
count: filtered.length,
isCollapsed: isCollapsed,
pt: pt,
onToggle: () => onToggleSection(sectionId),
onAddTap: isDms
? () => showDialog(
context: context, builder: (_) => const NewDmDialog())
: () => showDialog(
context: context, builder: (_) => const CreateJoinDialog()),
),
if (!isCollapsed)
...filtered.map((room) {
final isVoice = _isVoiceRoom(room);
return _RoomItem(
room: room,
isActive: isVoice ? false : activeRoomId == room.id,
isInVoice:
isVoice && ref.read(activeVoiceRoomIdProvider) == room.id,
pt: pt,
// Voice-Räume treten dem Kanal bei (wie in der Space-Liste) –
// vorher öffnete der Klick hier nur die Chat-Ansicht, ein
// Standalone-Voice-Channel war per Klick gar nicht beitretbar.
onTap: () {
if (isVoice) {
_toggleVoiceChannel(ref, room);
} else {
ref.read(activeRoomIdProvider.notifier).state = room.id;
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
}
},
);
}),
],
);
}
}
/// Beitreten/Verlassen eines Voice-Channels aus der Raumliste – exakt das
/// Verhalten von `joinVoiceChannel` in rooms_space_list.dart (Toggle beim
/// aktiven Kanal, Wechsel legt vorher auf).
Future<void> _toggleVoiceChannel(WidgetRef ref, Room room) async {
final call = ref.read(voiceChannelProvider);
final activeVoice = ref.read(activeVoiceRoomIdProvider);
if (call.isVoiceChannel && activeVoice == room.id) {
ref.read(activeVoiceRoomIdProvider.notifier).state = null;
await call.hangUp();
return;
}
if (call.isActive) await call.hangUp();
ref.read(activeVoiceRoomIdProvider.notifier).state = room.id;
await call.startCall(
roomName: room.id,
roomDisplayName: room.getLocalizedDisplayname(),
identity: room.client.userID ?? '',
audioOnly: true,
voiceChannel: true,
matrixClient: room.client,
matrixRoomId: room.id,
);
}
class _SectionHeader extends StatefulWidget {
final String title;
final int count;
final bool isCollapsed;
final PyramidTheme pt;
final VoidCallback onToggle;
final VoidCallback? onAddTap;
const _SectionHeader({
required this.title,
required this.count,
required this.isCollapsed,
required this.pt,
required this.onToggle,
this.onAddTap,
});
@override
State<_SectionHeader> createState() => _SectionHeaderState();
}
class _SectionHeaderState extends State<_SectionHeader> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final pt = widget.pt;
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: Container(
padding: const EdgeInsets.fromLTRB(8, 6, 8, 4),
child: Row(children: [
Expanded(
child: GestureDetector(
onTap: widget.onToggle,
child: Row(children: [
AnimatedRotation(
turns: widget.isCollapsed ? -0.25 : 0,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutBack,
child: Icon(Icons.keyboard_arrow_down_rounded,
size: 14,
color: _hovered ? pt.fgMuted : pt.fgDim),
),
const SizedBox(width: 4),
Text(widget.title.toUpperCase(),
style: TextStyle(
color: _hovered ? pt.fgMuted : pt.fgDim,
fontSize: 11,
fontWeight: FontWeight.w600,
letterSpacing: 0.04 * 11)),
const Spacer(),
Text('${widget.count}',
style: TextStyle(color: pt.fgDim, fontSize: 11)),
]),
),
),
if (_hovered && widget.onAddTap != null) ...[
const SizedBox(width: 4),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.onAddTap,
child: SizedBox(
width: 18,
height: 18,
child: Center(
child: Icon(Icons.add, size: 12, color: pt.fgMuted)),
),
),
] else
const SizedBox(width: 22),
]),
),
);
}
}
class _RoomItem extends ConsumerStatefulWidget {
final Room room;
final bool isActive;
final bool isInVoice;
final PyramidTheme pt;
final VoidCallback onTap;
const _RoomItem({
super.key,
required this.room,
required this.isActive,
this.isInVoice = false,
required this.pt,
required this.onTap,
});
@override
ConsumerState<_RoomItem> createState() => _RoomItemState();
}
class _RoomItemState extends ConsumerState<_RoomItem> {
bool _hovered = false;
Future<void> _leaveRoom() async {
final pt = widget.pt;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: pt.bg2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rXl),
side: BorderSide(color: pt.border)),
title: Text('Raum verlassen?',
style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)),
content: Text(
'"${widget.room.getLocalizedDisplayname()}" verlassen?',
style: TextStyle(color: pt.fgMuted, fontSize: 13)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: pt.danger,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8))),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Verlassen'),
),
],
),
);
if (confirmed != true || !mounted) return;
try {
final id = widget.room.id;
await widget.room.leave();
if (mounted && ref.read(activeRoomIdProvider) == id) {
ref.read(activeRoomIdProvider.notifier).state = null;
}
} catch (_) {}
}
Future<void> _leaveDm() async {
final pt = widget.pt;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: pt.bg2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rXl),
side: BorderSide(color: pt.border)),
title: Text('Chat verlassen?',
style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)),
content: Text(
'Unterhaltung mit "${widget.room.getLocalizedDisplayname()}" schließen?',
style: TextStyle(color: pt.fgMuted, fontSize: 13)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: pt.danger,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8))),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Verlassen'),
),
],
),
);
if (confirmed != true || !mounted) return;
try {
final id = widget.room.id;
await widget.room.leave();
if (mounted) {
if (ref.read(activeRoomIdProvider) == id) {
ref.read(activeRoomIdProvider.notifier).state = null;
}
}
} catch (_) {}
}
Future<void> _showRoomSettings() async {
await showDialog(
context: context,
builder: (_) => RoomSettingsDialog(room: widget.room),
);
}
Future<void> _inviteUser() async {
final pt = widget.pt;
final ctrl = TextEditingController();
final userId = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: pt.bg2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rXl),
side: BorderSide(color: pt.border)),
title: Text('Nutzer einladen',
style: TextStyle(
color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)),
content: TextField(
controller: ctrl,
autofocus: true,
style: TextStyle(color: pt.fg, fontSize: 13),
decoration: InputDecoration(
hintText: '@nutzer:server',
hintStyle: TextStyle(color: pt.fgDim, fontSize: 12),
filled: true,
fillColor: pt.bg3,
isDense: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: pt.border)),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: pt.border)),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: pt.accent)),
),
cursorColor: pt.accent,
onSubmitted: (v) => Navigator.pop(ctx, v.trim()),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))),
TextButton(
onPressed: () => Navigator.pop(ctx, ctrl.text.trim()),
child: Text('Einladen', style: TextStyle(color: pt.accent))),
],
),
);
ctrl.dispose();
if (userId == null || userId.isEmpty || !mounted) return;
try {
await widget.room.invite(userId);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('Fehler: ${e.toString().split('\n').first}'),
backgroundColor: widget.pt.danger,
));
}
}
}
/// Touch-friendly action menu — opened via long-press (the hover menu is not
/// reachable on touch devices).
void _showContextMenu() {
final pt = widget.pt;
final room = widget.room;
final isDm = room.isDirectChat;
Widget action(IconData icon, String label, VoidCallback onTap,
{bool danger = false}) {
return ListTile(
leading: Icon(icon, size: 20, color: danger ? pt.danger : pt.fgMuted),
title: Text(label,
style: TextStyle(
color: danger ? pt.danger : pt.fg, fontSize: 14)),
onTap: () {
Navigator.pop(context);
onTap();
},
);
}
showModalBottomSheet(
context: context,
backgroundColor: pt.bg2,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Row(children: [
Expanded(
child: Text(room.getLocalizedDisplayname(),
style: TextStyle(
color: pt.fg,
fontSize: 15,
fontWeight: FontWeight.w700),
overflow: TextOverflow.ellipsis),
),
]),
),
Divider(height: 1, color: pt.border),
if (!isDm) ...[
action(Icons.person_add_outlined, 'Nutzer einladen', _inviteUser),
action(Icons.settings_rounded, 'Einstellungen', _showRoomSettings),
],
action(
Icons.exit_to_app_rounded,
isDm ? 'Chat verlassen' : 'Raum verlassen',
isDm ? _leaveDm : _leaveRoom,
danger: true,
),
const SizedBox(height: 8),
],
),
),
);
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final room = widget.room;
final active = widget.isActive;
final inVoice = widget.isInVoice;
final name = room.getLocalizedDisplayname();
final unread = room.notificationCount;
final isDm = room.isDirectChat;
final isVoice = _isVoiceRoom(room);
final voiceParticipants = isVoice ? ref.watch(voiceParticipantsProvider(room.id)) : <Map<String, dynamic>>[];
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;
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
onLongPress: _showContextMenu,
child: Stack(
children: [
if (active)
Positioned(
left: 0,
top: 6,
bottom: 6,
child: Container(
width: 3,
decoration: BoxDecoration(
color: pt.accent,
borderRadius: const BorderRadius.only(
topRight: Radius.circular(2),
bottomRight: Radius.circular(2),
),
),
),
),
AnimatedContainer(
duration: const Duration(milliseconds: 150),
curve: Curves.easeOutCubic,
margin: const EdgeInsets.symmetric(vertical: 1),
padding: EdgeInsets.symmetric(
horizontal: pt.rowPadX, vertical: pt.rowPadY),
decoration: BoxDecoration(
color: active
? pt.accentSoft
: inVoice
? const Color(0xFF57F287).withAlpha(25)
: _hovered ? pt.bgHover : Colors.transparent,
borderRadius: BorderRadius.circular(pt.rSm),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
if (isDm) ...[
_DmAvatar(room: room, pt: pt),
] else ...[
SizedBox(
width: 16,
height: 16,
child: Icon(
isVoice ? Icons.mic_rounded : _roomIcon(room),
size: 14,
color: isVoice
? (inVoice ? const Color(0xFF57F287) : _hovered ? const Color(0xFF57F287) : const Color(0xFF57F287).withAlpha(180))
: (active ? pt.accent : _hovered ? pt.fg : pt.fgDim),
),
),
],
const SizedBox(width: 8),
Expanded(
child: Text(
name,
style: TextStyle(
color: textColor,
fontSize: 14,
fontWeight: unread > 0 || active || inVoice
? FontWeight.w600
: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
if (isVoice && voiceParticipants.isNotEmpty)
Padding(
padding: const EdgeInsets.only(right: 4),
child: Text(
'${voiceParticipants.length}',
style: TextStyle(
color: const Color(0xFF57F287).withAlpha(200),
fontSize: 11,
fontWeight: FontWeight.w600),
),
),
if (!isVoice && _hovered) ...[
if (isDm)
_HoverAction(icon: Icons.close, pt: pt, onTap: _leaveDm)
else
_RoomMenuBtn(
pt: pt,
onInvite: _inviteUser,
onSettings: _showRoomSettings,
onLeave: _leaveRoom,
),
] else if (!isVoice && unread > 0) ...[
_UnreadBadge(
count: unread,
isMention: room.highlightCount > 0,
pt: pt),
],
],
),
// Voice participants (Discord-style named rows)
if (isVoice && voiceParticipants.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2, left: 24),
child: _VoiceParticipantList(
participants: voiceParticipants,
client: client,
pt: pt,
),
),
],
),
),
],
),
),
);
}
IconData _roomIcon(Room room) {
if (room.encrypted) return Icons.lock_outline_rounded;
return Icons.tag_rounded;
}
}
@@ -0,0 +1,159 @@
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,
),
),
),
if (unread > 0) ...[
const SizedBox(width: 8),
_UnreadBadge(count: unread, isMention: mention, pt: pt),
],
],
),
],
),
),
],
),
),
);
}
}
/// 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';
}
@@ -0,0 +1,171 @@
part of '../rooms_panel.dart';
const _kVoiceRoomType = 'io.pyramid.voice_channel';
const _kSpaceBannerType = 'io.pyramid.space.banner';
bool _isVoiceRoom(Room room) =>
room.getState(EventTypes.RoomCreate)?.content['type'] == _kVoiceRoomType;
int _compareSpaceChildren(SpaceChild a, SpaceChild b) {
final ao = a.order;
final bo = b.order;
if (ao.isEmpty && bo.isEmpty) return 0;
if (ao.isEmpty) return 1;
if (bo.isEmpty) return -1;
return ao.compareTo(bo);
}
class RoomsPanel extends ConsumerStatefulWidget {
const RoomsPanel({super.key});
@override
ConsumerState<RoomsPanel> createState() => _RoomsPanelState();
}
class _RoomsPanelState extends ConsumerState<RoomsPanel> {
final _filterCtrl = TextEditingController();
String _filter = '';
final Map<String, bool> _collapsed = {};
String? _lastShownCallError;
@override
void dispose() {
_filterCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
// Voice-Zustand mit der UI synchron halten: Bei unerwartetem Abbruch
// oder fehlgeschlagenem Beitritt wird der Service inaktiv, aber
// activeVoiceRoomIdProvider zeigte vorher weiter auf den Kanal – das
// Kanal-Highlight und der eigene (optimistische) Teilnehmer-Eintrag
// blieben hängen. Außerdem den Fehler sichtbar machen (kein stiller
// Fehlerpfad, CLAUDE.md).
ref.listen<VoiceChannelService>(voiceChannelProvider, (_, call) {
if (!call.isActive && ref.read(activeVoiceRoomIdProvider) != null) {
ref.read(activeVoiceRoomIdProvider.notifier).state = null;
}
final err = call.error;
if (err == null) {
_lastShownCallError = null;
} else if (err != _lastShownCallError) {
_lastShownCallError = err;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
SnackBar(content: Text('Voice: $err')),
);
});
}
});
final roomsAsync = ref.watch(roomListProvider);
final activeSpaceId = ref.watch(activeSpaceIdProvider);
final call = ref.watch(voiceChannelProvider);
final voip = ref.watch(callSignalingProvider);
final isRealSpace = activeSpaceId != null &&
activeSpaceId != 'dms' &&
activeSpaceId != 'rooms';
final client = ref.watch(matrixClientProvider).valueOrNull;
final forceJoined = ref.watch(forceJoinedSpacesProvider);
final activeSpaceRoom =
isRealSpace ? client?.getRoomById(activeSpaceId) : null;
final isInvitedSpace = activeSpaceRoom?.membership == Membership.invite &&
!forceJoined.contains(activeSpaceId);
return Container(
color: pt.bg1,
child: Column(
children: [
_Header(pt: pt, spaceId: activeSpaceId),
if (!isInvitedSpace)
_FilterInput(
controller: _filterCtrl,
pt: pt,
onChanged: (v) => setState(() => _filter = v),
),
Expanded(
child: isInvitedSpace
? _SpaceInviteView(space: activeSpaceRoom!, pt: pt)
: isRealSpace
? _SpaceRoomsList(
spaceId: activeSpaceId,
filter: _filter,
collapsed: _collapsed,
pt: pt,
onToggleSection: (id) => setState(() {
_collapsed[id] = !(_collapsed[id] ?? false);
}),
)
: roomsAsync.when(
loading: () => Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: pt.accent),
),
),
error: (e, _) => Center(
child: Text('Error',
style: TextStyle(color: pt.fgDim, fontSize: 12)),
),
data: (rooms) => _RoomsList(
rooms: rooms,
filter: _filter,
spaceId: activeSpaceId,
collapsed: _collapsed,
pt: pt,
onToggleSection: (id) => setState(() {
_collapsed[id] = !(_collapsed[id] ?? false);
}),
),
),
),
// Call-Dock: immer ÜBER dem Konto-Balken, animiert ein-/ausblenden.
// Im Mobil-Querformat (Höhe knapp) liegen Call-Balken und
// Konto-Balken nebeneinander statt übereinander.
Builder(builder: (context) {
final hasVoipCall = voip.currentCall != null &&
voip.currentCall!.state != CallState.kEnded;
final hasCallDock = call.isActive || hasVoipCall;
final isLandscapeCompact =
MediaQuery.sizeOf(context).height < 500;
final callDock = AnimatedSize(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
alignment: Alignment.bottomCenter,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (call.isActive) MiniCallWidget(call: call),
if (hasVoipCall) MiniCallWidget(call: voip),
],
),
);
if (isLandscapeCompact && hasCallDock) {
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(child: callDock),
const Expanded(child: UserPanel()),
],
);
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [callDock, const UserPanel()],
);
}),
],
),
);
}
}
+280
View File
@@ -0,0 +1,280 @@
part of '../rooms_panel.dart';
class _DmAvatar extends ConsumerStatefulWidget {
final Room room;
final PyramidTheme pt;
final double size;
const _DmAvatar({required this.room, required this.pt, this.size = 22});
@override
ConsumerState<_DmAvatar> createState() => _DmAvatarState();
}
class _DmAvatarState extends ConsumerState<_DmAvatar> {
CachedPresence? _presence;
StreamSubscription<CachedPresence>? _sub;
@override
void initState() {
super.initState();
_loadPresence();
}
@override
void dispose() {
_sub?.cancel();
super.dispose();
}
Future<void> _loadPresence() async {
final client = ref.read(matrixClientProvider).valueOrNull;
if (client == null) return;
final userId = widget.room.directChatMatrixID;
if (userId == null) return;
try {
final p = await client.fetchCurrentPresence(userId);
if (mounted) setState(() => _presence = p);
} catch (_) {}
_sub = client.onPresenceChanged.stream
.where((p) => p.userid == userId)
.listen((p) {
if (mounted) setState(() => _presence = p);
});
}
Color? _dotColor() {
final p = _presence;
if (p == null) return null;
// Server-Presence kann veraltet sein (z.B. nie als offline gemeldet).
// Nur als online werten, wenn die letzte Aktivität wirklich frisch ist.
final last = p.lastActiveTimestamp;
final fresh = last != null &&
DateTime.now().difference(last) < const Duration(minutes: 5);
if (p.presence == PresenceType.online &&
(p.currentlyActive == true || fresh)) {
return PyramidColors.online;
}
if (p.presence == PresenceType.unavailable) return PyramidColors.away;
return null;
}
@override
Widget build(BuildContext context) {
final name = widget.room.getLocalizedDisplayname();
final initial = name.isNotEmpty ? name[0].toUpperCase() : '?';
final color = _colorFromName(name);
final client = ref.watch(matrixClientProvider).valueOrNull;
Widget avatar;
if (client != null && widget.room.avatar != null) {
avatar = MxcAvatar(
mxcUri: widget.room.avatar,
client: client,
size: widget.size,
borderRadius: BorderRadius.circular(widget.size / 2),
placeholder: (_) =>
_InitialCircle(initial: initial, color: color, size: widget.size),
);
} else {
avatar = _InitialCircle(initial: initial, color: color, size: widget.size);
}
final dot = _dotColor();
if (dot == null) return avatar;
return Stack(clipBehavior: Clip.none, children: [
avatar,
Positioned(
right: widget.size > 30 ? 0 : -2,
bottom: widget.size > 30 ? 0 : -2,
child: Container(
width: widget.size > 30 ? 13 : 9,
height: widget.size > 30 ? 13 : 9,
decoration: BoxDecoration(
color: dot,
shape: BoxShape.circle,
border: Border.all(color: widget.pt.bg1, width: 1.5),
),
),
),
]);
}
Color _colorFromName(String name) {
const colors = [
Color(0xFF06B6D4), Color(0xFFA855F7), Color(0xFF10B981),
Color(0xFFF43F5E), Color(0xFF3B82F6), Color(0xFFF59E0B),
];
if (name.isEmpty) return colors[0];
return colors[name.codeUnitAt(0) % colors.length];
}
}
class _InitialCircle extends StatelessWidget {
final String initial;
final Color color;
final double size;
const _InitialCircle(
{required this.initial, required this.color, required this.size});
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
child: Center(
child: Text(initial,
style: TextStyle(
color: Colors.white,
fontSize: size * 0.45,
fontWeight: FontWeight.w600))),
);
}
}
class _RoomMenuBtn extends StatefulWidget {
final PyramidTheme pt;
final VoidCallback onInvite;
final VoidCallback onSettings;
final VoidCallback onLeave;
const _RoomMenuBtn({required this.pt, required this.onInvite, required this.onSettings, required this.onLeave});
@override
State<_RoomMenuBtn> createState() => _RoomMenuBtnState();
}
class _RoomMenuBtnState extends State<_RoomMenuBtn> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
return PopupMenuButton<String>(
color: widget.pt.bg2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: widget.pt.border)),
offset: const Offset(-100, 24),
tooltip: '',
onSelected: (v) {
if (v == 'invite') widget.onInvite();
if (v == 'settings') widget.onSettings();
if (v == 'leave') widget.onLeave();
},
itemBuilder: (_) => [
PopupMenuItem(
value: 'invite',
height: 36,
child: Row(children: [
Icon(Icons.person_add_outlined, size: 14, color: widget.pt.fgMuted),
const SizedBox(width: 8),
Text('Nutzer einladen', style: TextStyle(color: widget.pt.fg, fontSize: 13)),
]),
),
PopupMenuItem(
value: 'settings',
height: 36,
child: Row(children: [
Icon(Icons.settings_rounded, size: 14, color: widget.pt.fgMuted),
const SizedBox(width: 8),
Text('Einstellungen', style: TextStyle(color: widget.pt.fg, fontSize: 13)),
]),
),
PopupMenuDivider(height: 1),
PopupMenuItem(
value: 'leave',
height: 36,
child: Row(children: [
Icon(Icons.exit_to_app_rounded, size: 14, color: widget.pt.danger),
const SizedBox(width: 8),
Text('Raum verlassen', style: TextStyle(color: widget.pt.danger, fontSize: 13)),
]),
),
],
child: MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
width: 20,
height: 20,
decoration: BoxDecoration(
color: _hovered ? widget.pt.bgActive : Colors.transparent,
borderRadius: BorderRadius.circular(4),
),
child: Center(
child: Icon(Icons.more_horiz_rounded,
size: 12,
color: _hovered ? widget.pt.fg : widget.pt.fgDim)),
),
),
);
}
}
class _HoverAction extends StatefulWidget {
final IconData icon;
final PyramidTheme pt;
final VoidCallback? onTap;
const _HoverAction({required this.icon, required this.pt, this.onTap});
@override
State<_HoverAction> createState() => _HoverActionState();
}
class _HoverActionState extends State<_HoverAction> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.onTap,
child: MouseRegion(
cursor: widget.onTap != null
? SystemMouseCursors.click
: MouseCursor.defer,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
width: 20,
height: 20,
decoration: BoxDecoration(
color: _hovered ? widget.pt.bgActive : Colors.transparent,
borderRadius: BorderRadius.circular(4),
),
child: Center(
child: Icon(widget.icon,
size: 12,
color: _hovered ? widget.pt.fg : widget.pt.fgDim)),
),
),
);
}
}
class _UnreadBadge extends StatelessWidget {
final int count;
final bool isMention;
final PyramidTheme pt;
const _UnreadBadge(
{required this.count, required this.isMention, required this.pt});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 5),
constraints: const BoxConstraints(minWidth: 18, minHeight: 18),
decoration: BoxDecoration(
color: isMention ? pt.danger : pt.accent,
borderRadius: BorderRadius.circular(999),
),
child: Center(
child: Text(count > 99 ? '99+' : '$count',
style: TextStyle(
color: isMention ? Colors.white : pt.accentFg,
fontSize: 10,
fontWeight: FontWeight.w700))),
);
}
}
@@ -0,0 +1,159 @@
part of '../rooms_panel.dart';
class _SpaceInviteView extends ConsumerStatefulWidget {
final Room space;
final PyramidTheme pt;
const _SpaceInviteView({required this.space, required this.pt});
@override
ConsumerState<_SpaceInviteView> createState() => _SpaceInviteViewState();
}
class _SpaceInviteViewState extends ConsumerState<_SpaceInviteView> {
bool _accepting = false;
bool _declining = false;
Future<void> _accept() async {
setState(() => _accepting = true);
final client = widget.space.client;
final id = widget.space.id;
try {
await client.joinRoomById(id).timeout(const Duration(seconds: 20));
// Server confirmed the join. Switch to the channel list optimistically —
// the local sync membership may lag behind, so don't wait for it.
ref.read(forceJoinedSpacesProvider.notifier).add(id);
if (mounted) setState(() => _accepting = false);
} catch (e) {
if (mounted) {
setState(() => _accepting = false);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(
'Beitritt fehlgeschlagen: ${e.toString().split('\n').first}'),
backgroundColor: widget.pt.danger,
));
}
}
}
Future<void> _decline() async {
setState(() => _declining = true);
try {
await widget.space.leave();
if (mounted) {
ref.read(activeSpaceIdProvider.notifier).state = 'rooms';
}
} catch (_) {
if (mounted) setState(() => _declining = false);
}
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final client = ref.watch(matrixClientProvider).valueOrNull;
final name = widget.space.getLocalizedDisplayname();
final busy = _accepting || _declining;
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: pt.accent.withAlpha(40),
borderRadius: BorderRadius.circular(20),
),
child: client != null && widget.space.avatar != null
? ClipRRect(
borderRadius: BorderRadius.circular(20),
child: MxcAvatar(
mxcUri: widget.space.avatar,
client: client,
size: 72,
borderRadius: BorderRadius.circular(20),
placeholder: (_) => Center(
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
style: TextStyle(
color: pt.accent,
fontSize: 28,
fontWeight: FontWeight.w700),
),
),
),
)
: Center(
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
style: TextStyle(
color: pt.accent,
fontSize: 28,
fontWeight: FontWeight.w700),
),
),
),
const SizedBox(height: 16),
Text('Einladung zum Space',
style: TextStyle(color: pt.fgDim, fontSize: 13)),
const SizedBox(height: 4),
Text(
name,
textAlign: TextAlign.center,
style: TextStyle(
color: pt.fg, fontSize: 18, fontWeight: FontWeight.w700),
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: OutlinedButton(
style: OutlinedButton.styleFrom(
foregroundColor: pt.danger,
side: BorderSide(color: pt.danger.withAlpha(100)),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
),
onPressed: busy ? null : _decline,
child: _declining
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator.adaptive(
strokeWidth: 2))
: const Text('Ablehnen'),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: pt.accent,
foregroundColor: pt.accentFg,
elevation: 0,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
),
onPressed: busy ? null : _accept,
child: _accepting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator.adaptive(
strokeWidth: 2))
: const Text('Beitreten'),
),
),
],
),
],
),
),
);
}
}

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