Compare commits

..

28 Commits

Author SHA1 Message Date
Bernd Steckmeister 87ff15d760 chore: CHANGES.md Hook-Log nachführen
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 17:52:32 +02:00
Bernd Steckmeister 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 <noreply@anthropic.com>
2026-07-03 17:50:58 +02:00
Bernd Steckmeister 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 <noreply@anthropic.com>
2026-07-03 17:46:55 +02:00
Bernd Steckmeister 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 <noreply@anthropic.com>
2026-07-03 17:46:55 +02:00
Bernd Steckmeister 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 <noreply@anthropic.com>
2026-07-03 14:42:15 +02:00
Bernd Steckmeister 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 <noreply@anthropic.com>
2026-07-03 14:37:48 +02:00
Bernd Steckmeister d254a36de7 chore: Autopilot auf Fable 5 umgestellt + Qualitäts-Review-Auftrag in ROADMAP
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPrAGBxBT6GfPXzeWQ4AXb
2026-07-03 14:31:51 +02:00
Bernd Steckmeister 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 <noreply@anthropic.com>
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 Steckmeister 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 <noreply@anthropic.com>
2026-07-03 11:03:31 +02:00
Bernd Steckmeister 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 <noreply@anthropic.com>
2026-07-03 11:01:58 +02:00
Bernd Steckmeister 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 <noreply@anthropic.com>
2026-07-03 11:00:22 +02:00
Bernd Steckmeister 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 <noreply@anthropic.com>
2026-07-03 10:58:46 +02:00
Bernd Steckmeister 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 <noreply@anthropic.com>
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
73 changed files with 18249 additions and 16910 deletions
+1
View File
@@ -1,4 +1,5 @@
{
"model": "claude-fable-5",
"hooks": {
"PostToolUse": [
{
+1
View File
@@ -45,3 +45,4 @@ app.*.map.json
/android/app/release
autopilot.log
.claude/worktrees/
*.bak
+91
View File
@@ -1108,3 +1108,94 @@
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
+868
View File
@@ -13,6 +13,874 @@ Schritt (und beim Abbruch mitten im Schritt den Zwischenstand). Format:
---
## 2026-07-03 M1-Vorbereitung: SQLCipher-Migrationsplan + konsolidierter PC-Testplan
**Erledigt:** Nach Abschluss des Fable-5-Reviews (Abschnitt darunter) die zwei
obersten offenen ROADMAP-Punkte (SQLCipher, Härtetest) so weit gebracht, wie es
auf dem Pi ohne GUI/Testgerät sicher geht beide bleiben offen, aber der
nächste PC-Termin kann jetzt direkt loslegen statt zu planen:
- `docs/SQLCIPHER_MIGRATION.md`: vollständiger Umsetzungsplan, gegen den echten
Code geschrieben (beide DB-Öffnungsstellen, Factory-Wahl pro Plattform).
Kernpunkte: gemeinsame DB-Fassade `app_database.dart` (zahlt aufs
M2-Storage-Modul ein), Android via `databaseFactoryFfi` +
`openCipherOnAndroid`, im Push-Isolate zwingend `databaseFactoryFfiNoIsolate`
(dieselbe Isolate-Falle, an der schon `NativeImplementationsIsolate`
scheiterte), Schlüssel im Android Keystore (`flutter_secure_storage`, schon
Abhängigkeit), Migration per `sqlcipher_export` in NEUE Datei mit
Backup/Verifikation/atomarem Tausch kein Fehlerpfad darf die Klartext-DB
beschädigen. Marker-Datei gegen das Migration/Push-Race. Phase 1 nur
Android (sqlcipher_flutter_libs liefert keine Windows-Binaries).
Tabellenname für die Verifikation gegen matrix-6.2.0 geprüft
(`box_inbound_group_session`).
- `docs/PC_TESTPLAN.md`: ALLE aufgelaufenen UNGETESTET(Pi)-Punkte aus den
bisherigen PROGRESS-Einträgen an einer Stelle konsolidiert (Härtetest/
Krypto-Kreislauf zuerst, dann die 6 Split-Bereiche, Reorder-Off-by-One-Check,
PDF-Zoom, Push-Beobachtung, „Uta einmal neu einloggen"). Bisher waren die
über 7 Einträge verstreut Gefahr, dass der PC-Lauf etwas übersieht.
**Offen/Nächster Schritt:** Unverändert: PC-Termin (jetzt mit fertigem
Testplan + Migrationsplan) oder Bernds Antworten (Call-Pilot, SQLCipher-
Priorität). Auf dem Pi ist damit alles abgearbeitet, was ohne GUI/Entscheidung
sicher geht inklusive des kompletten Qualitäts-Reviews.
**Stolperfallen:** Keine neuen reine Dokumentation, kein Code angefasst.
---
## Fable-5-Review (Qualitäts-Review aller Autopilot-Commits 25ed765..HEAD)
Bernds Auftrag (ROADMAP M0): alle bisherigen Sonnet-5-Commits kritisch und in
der Tiefe nachprüfen. Jeder Punkt hier wird abgehakt, sobald er geprüft ist
**bereits abgehakte Punkte in künftigen Sessions NICHT erneut prüfen.**
Hinweis zur Historie: Eine frühere Fable-5-Session hatte den Review begonnen
und zwei Fix-Commits gepusht (f9979e4, 891f348), wurde aber VOR dem
Protokollieren abgebrochen dieser Abschnitt holt das Protokoll nach und
schließt den Review ab.
- [x] **(a) Die 6 Gott-Datei-Aufteilungen fachlich geprüft Ergebnis: korrekt,
1 Befund bereits gefixt (f9979e4).** Unabhängige Nachprüfung (nicht nur den
alten Blockvergleich wiederholt): eigener Zeilen-Multiset-Vergleich
(Code-Zeilen ohne Kommentare/Imports/part-Direktiven) Original vor dem Split
vs. heutiger Stand für alle 6 Bibliotheken → **keine Code-Zeile verloren,
keine hinzugekommen** (settings_modal 5055, message_group 2409, rooms_panel
2308, space_admin_dialog 2079, chat_view 1813, document_viewer 1543 Zeilen,
jeweils exakt identisch). Alle `part`-Direktiven == Dateien auf Platte,
alle Part-Dateien mit korrektem `part of`. Befund der Vorsession bestätigt:
Beim Split gingen 24 **Erklärkommentar-Blöcke** verloren (u. a. die
Selbst-Aussperr-Warnung über `updatePowerLevelsSafely`, HTML-Subset-Doku,
PDF-Cache-Doku) mit f9979e4 an den richtigen Klassen wiederhergestellt
(Stichprobe geprüft: sitzt korrekt). `flutter analyze`: unverändert 1
bekannter Hinweis (`chat_provider.dart:42`).
- [x] **(b) Uta-Logout-/Push-Fix (63e4919, 9dc83f7) gegen das matrix-SDK 6.2.0
verifiziert Analyse war richtig, aber unvollständig; Lücke mit 891f348
geschlossen (Vorsession), heute vollständig gegenverifiziert:**
- SDK-Quelle bestätigt (client.dart:2374 ff.): Ohne eigenen `onSoftLogout`-
Handler eskaliert JEDER fehlgeschlagene Token-Refresh (auch bloße
Netzwerkfehler) zu `logout()` + `clear()` → Session inkl. Krypto-Schlüssel
weg. Mit `refreshToken: true` (9dc83f7) wären Refreshes Routine geworden
das Restrisiko wäre also GESTIEGEN. Der Guard (`soft_logout_guard.dart`,
891f348) ist die notwendige zweite Hälfte des Fixes.
- Guard-Code geprüft: wirft nie (alle Pfade gefangen; `AuthLog.write` fängt
intern selbst alles wichtig, sonst würde ein Logging-Fehler im
catch-Pfad doch wieder `logout()` auslösen). SDK ruft bei nicht-werfendem
Handler nachweislich nie `logout()` auf.
- „Retry heilt das Token-Rotations-Race mit dem Push-Isolate"-Behauptung
gegen SDK-Quelle geprüft: stimmt `refreshAccessToken()` liest den
Refresh-Token bei jedem Aufruf frisch per `database.getClient()` aus der
geteilten DB.
- Beide `Client(`-Konstruktionen der App (matrix_client.dart,
background_push.dart) haben den Guard keine ungeschützte übrig.
- `isLoggedInProvider`-Änderung (softLoggedOut ≠ ausgeloggt) korrekt:
verhindert Login-Screen-Flackern bei Routine-Refresh; echtes `loggedOut`
führt weiterhin zum Login-Screen.
- **Dokumentiertes Restrisiko (kein Fix möglich/nötig):** Ein hartes
`M_UNKNOWN_TOKEN` OHNE `soft_logout`-Flag führt im SDK weiterhin direkt zu
`clear()`. Das ist ein echter serverseitiger Session-Widerruf (z. B.
Admin-Logout) lokal nicht abwendbar; Schlüssel kommen dann übers
Key-Backup zurück. Kein stiller Fehlerpfad, sondern legitimer Server-Befehl.
- [x] **(c) analyze-/Lint-Fixes (9dc1175, 133a8ac, 99f10c5, 2395db5, 057ac1e,
401bd6a) auf Verhaltensänderungen geprüft alle äquivalent, 1 Kleinigkeit
gefunden (bewusst belassen):**
- `onReorder``onReorderItem` (401bd6a): gegen Flutter-SDK-Quelle
verifiziert der neue Callback korrigiert den Index intern, das entfernte
manuelle `newIdx--` ist korrekt; einzige Aufrufstelle migriert. Kein
Off-by-One beim Raum-Umsortieren.
- `Matrix4.translate/scale``translateByDouble/scaleByDouble` (PDF-Zoom):
mathematisch äquivalent (uniforme Skalierung, w=1).
- `withOpacity``withValues`, `activeColor``activeThumbColor`,
`cacheExtent``scrollCacheExtent`, `PublicRoomsChunk``PublishedRoomsChunk`,
print→debugPrint, `__``_`-Parameter, Curly-Braces: reine Renames/Syntax.
- Toter Code (9dc1175): entfernte Funktionen (`_showNotif`, `_fetchTitle`,
`_get`, `_applyNoiseSuppression`, `destructive`-Param, `_saved`-Feld) per
grep bestätigt nirgends referenziert.
- **Kleinigkeit:** In `mini_call_widget.dart` wurde das ungenutzte
`_hovered`-Feld nicht gelöscht, sondern verdrahtet (Hover-Farbe auf
Call-Buttons) streng genommen eine Mini-Verhaltensänderung in einem
Lint-Commit. Offensichtlich die ursprünglich gemeinte Funktion des Feldes,
rein kosmetisch, kein Risiko → belassen, hier nur dokumentiert.
- [x] **(d) Abgehakte ROADMAP-Punkte gegen Code gegengeprüft alle stimmen:**
`isKeyBackupMissing`-Warnung sitzt nach dem Settings-Split weiterhin in
beiden Logout-Pfaden (settings_about.dart, settings_shared.dart);
`refreshToken: true` in login_notifier.dart:84; Bootstrap-Flows
(`askUnlockSsss`, `_triggerBootstrap`) vorhanden; `features/calls/`-Leiche
existiert nicht; `docs/M2_MODULE_SCHNITT.md` vorhanden; `flutter analyze`
sauber (1 bekannter Hinweis). Nebenbefund gefixt: `docs/PROGRESS.md` war ein
eingefrorener Alt-Stand (2026-06-04) mit Verwechslungsgefahr zum echten
Protokoll im Root → Archiv-Banner eingefügt.
**Fazit:** Die Sonnet-5-Arbeit war handwerklich sauber; die zwei echten Lücken
(verlorene Kommentare, fehlender Soft-Logout-Guard) hatte die abgebrochene
Fable-5-Vorsession bereits gefixt beide Fixes heute unabhängig verifiziert.
Alles weiterhin UNGETESTET (Pi) im Sinne von „nicht in echter UI gesehen";
der ausstehende PC-Praxistest (siehe Einträge unten) bleibt Pflicht.
---
## 2026-07-03 M2: Gott-Datei `document_viewer.dart` aufgeteilt (5 Dateien)
**Erledigt:** Vor dem Weiterarbeiten wie immer Arbeitszyklus durchlaufen
(`PROGRESS.md`/`ROADMAP.md` gelesen, `flutter doctor` geprüft GUI-Lücken
auf dem Pi unverändert). Die Ist-Analyse aus M2 hatte ursprünglich 8 große
Dateien gefunden, aber nur 5 wurden als „Gott-Dateien" tatsächlich
aufgeteilt (`settings_modal.dart`, `message_group.dart`, `rooms_panel.dart`,
`space_admin_dialog.dart`, `chat_view.dart`); `document_viewer.dart` (1773
Zeilen, 31 Klassen/Enums + 3 lose Top-Level-Helfer) war übrig, ist aber
inhaltlich genau derselbe Fall (viele unabhängige Widget-Klassen in einer
Datei) und anders als `voice_channel.dart`/`app_shell.dart` reines
Medien-/PDF-Anzeige-Feature ohne Berührung mit Login/Session/Verschlüsselung
oder der noch unentschiedenen Call-Fassade. Deshalb mit derselben
bewährten Technik aufgeteilt:
- `part`/`part of` wie bei den 5 vorigen Umbauten, per Skript extrahiert
(Klammer-Zählung für Blockgrenzen inkl. der 3 losen Top-Level-Funktionen
`_saveBytes`, `_extractPsdThumbnail`, `_detectType` nicht nur
`class`/`enum`, das wurde diesmal zusätzlich automatisiert gesucht, um
keine lose Funktion zu übersehen).
- 5 neue Dateien unter `lib/features/chat/document/`:
`document_viewer_core.dart` (`DocumentViewer`-Dispatcher, `_DocHeader`,
`_ExtBadge`, `_DocContent`, `_DocType`-Enum + `_detectType`, `_ErrorView`,
`_UnsupportedViewer`, `_FileFallback`), `document_viewer_pdf.dart`
(größter Teil: `_RenderCache`, `PdfInlinePreview`+State für die
Chat-Inline-Vorschau, `_NavArrow`, `_saveBytes`, `_PdfFullViewer`+State,
`_PdfFullscreenViewer`+State, `_FsBtn`, `_ThumbnailStrip`+State),
`document_viewer_text.dart` (SVG/Markdown/Text/Office-Text-Extraktion),
`document_viewer_archive.dart` (ZIP/TAR-Archiv-Browser, PSD-Thumbnail-
Extraktion inkl. `_extractPsdThumbnail`, Sketch/XD-ZIP-Preview),
`document_viewer_misc.dart` (`_ProprietaryViewer`-Infokarte für nicht
darstellbare Formate). `document_viewer.dart` selbst: nur noch
Bibliothekskopf (Imports + 5 `part`-Direktiven, 20 Zeilen).
- Verifikation wie bei den 5 vorigen Umbauten: automatisierte
Klammer-Zählung fand alle 34 Top-Level-Blöcke (31 Klassen/Enums + 3 lose
Funktionen), alle wortwörtlich in den neuen Dateien wiedergefunden,
`class`/`enum`-Anzahl (31) vorher/nachher identisch, `flutter analyze`
unverändert bei 1 bekanntem Hinweis (`chat_provider.dart:42`). Einzige
externe Importstelle (`message_group.dart`) bleibt unverändert gültig, da
`document_viewer.dart` weiterhin der Bibliothekseinstieg ist.
- **Bewusst NICHT angefasst:** `voice_channel.dart` (1123 Zeilen) und
`app_shell.dart` (1073 Zeilen) auch große Dateien mit vielen Klassen,
aber `voice_channel.dart` gehört zur noch unentschiedenen Call-Fassade
(siehe Fragen an Bernd unten) und `app_shell.dart` enthält den
Bootstrap-Trigger (`_triggerBootstrap`) für Cross-Signing/Key-Backup
laut CLAUDE.md heiliger Bereich. Eine reine Datei-Aufteilung wäre zwar
vermutlich genauso risikoarm wie bei den anderen 6, aber ohne GUI-Test auf
dem Pi wollte ich das Risiko nicht zusätzlich zum ohnehin schon
aufgelaufenen UNGETESTET-Stapel erhöhen, bevor nicht wenigstens einmal ein
PC-Durchlauf stattgefunden hat.
**Offen/Nächster Schritt:** UNGETESTET (Pi) siehe ROADMAP-Eintrag oben,
gehört zum selben ausstehenden Windows-Praxistest wie die 5 vorigen
Datei-Aufteilungen (PDF/Text/Archiv/Bild im Chat öffnen, Vollbild-PDF-Viewer
inkl. Thumbnail-Leiste und Speichern-Button prüfen). Danach bleibt exakt
dieselbe Blockade wie im Eintrag direkt darunter: kein M1/M2-Punkt mehr
ohne Bernds Antwort (Call-Pilot, SQLCipher-Priorität) oder PC/GUI-Zugriff
sicher bearbeitbar.
**Stolperfallen:** Anders als bei den 5 vorigen Umbauten gab es diesmal 3
lose Top-Level-Funktionen zusätzlich zu den Klassen/Enums
(`_saveBytes`/`_extractPsdThumbnail`/`_detectType`) eine reine
`grep -E '^(class|enum) '`-Suche hätte sie übersehen und stillschweigend aus
der neuen Datei-Struktur fallen lassen. Deshalb diesmal zusätzlich per
Diff aller durch Klammer-Zählung abgedeckten vs. nicht abgedeckten
Zeilenbereiche geprüft, dass keine „Lücke" zwischen zwei Klassenblöcken
mehr als Kommentar/Leerzeile ist. Für künftige Datei-Aufteilungen (z. B.
`voice_channel.dart`/`app_shell.dart`, falls Bernd grünes Licht gibt): immer
zuerst auf lose Top-Level-Funktionen prüfen, nicht nur auf Klassen/Enums.
---
## 2026-07-03 Session-Check: weiterhin kein sicher bearbeitbarer Punkt auf dem Pi
**Erledigt:** Vor dem Weiterarbeiten Arbeitszyklus komplett durchlaufen: `PROGRESS.md`
+ `ROADMAP.md` gelesen, `flutter analyze` (weiterhin sauber, nur der eine bekannte
Hinweis in `chat_provider.dart:42`), `git status` (sauber, `master` = `origin/master`),
`flutter doctor` erneut geprüft (Android-SDK, Chrome, Linux-Desktop-Toolchain fehlen
weiterhin auf diesem Pi keine GUI möglich). Damit alle offenen Punkte in M1/M2
gegengeprüft:
- M1 „SQLCipher" und „Härtetest dokumentieren": unverändert PC-only (siehe Einträge
weiter unten), keine neue Information seit letzter Session.
- M2 „Call-Schicht entwirren": zum Einstieg `core/voip_manager.dart` (538 Zeilen)
gelesen, um eine Fassade zu entwerfen. Ergebnis: Das ist **kein** Datei-Split wie die
letzten 5 Gott-Datei-Umbauten (dort: reines `part`/`part of`, null Logikänderung,
automatisiert byte-genau verifizierbar). `PyramidVoipManager` ist ein Singleton
(`_instance`), der gleichzeitig `ChangeNotifier` UND die vom matrix-SDK verlangte
`WebRTCDelegate`-Schnittstelle implementiert, mit Timer-basiertem Renderer-Polling,
impliziten Reihenfolgen bei Mute/Unmute/Screenshare und einem `MediaDevicesWrapper`,
der direkt auf private Felder des Managers zugreift. Eine Fassade davor UND die
Umstellung der 7 Importstellen lässt sich nicht mehr rein mechanisch verifizieren
echtes Risiko für Verhaltensänderung an Calls, die echte Nutzer (Uta) im Einsatz
haben. Genau das hatte die vorige Session bereits zweimal als „erste Stelle mit
echtem Verhaltensrisiko in M2" geflaggt und Bernds Entscheidung dazu angefragt
(siehe „Fragen an Bernd" weiter unten) hier bestätigt und bewusst NICHT
eigenmächtig angefasst, weil auf diesem Pi ohnehin kein GUI-Test möglich ist, um
eine Regression aufzufangen.
**Offen/Nächster Schritt:** Es gibt aktuell **keinen** ROADMAP-Punkt in M1/M2, der auf
diesem Pi ohne (a) Bernds Antwort auf die Call-Pilot-Frage oder (b) echten PC/GUI-Zugriff
sicher bearbeitet werden kann. Nächster sinnvoller Schritt ist entweder ein PC-Termin
(Härtetest + SQLCipher-Migrationsplanung + Call-Fassade mit echtem Test) oder Bernds
kurze Antwort auf die unten stehende Frage, damit die nächste Pi-Session direkt starten
kann.
**Stolperfallen:** Keine neuen Bestätigung, dass die Vorsicht der letzten Session
richtig war: „sieht aus wie ein weiterer Datei-Split" ist bei der Call-Schicht trügerisch,
weil Singleton-Lifecycle + SDK-Delegate-Vertrag + Timer-Polling nicht mit einem
Textvergleich absicherbar sind wie bei reinen `part`/`part of`-Umbauten.
**Fragen an Bernd:** Unverändert offen (wiederholt aus den letzten beiden Einträgen,
damit sie nicht übersehen wird):
1. Call-Pilot (Fassade für `voip_manager.dart`/`livekit_call_manager.dart`) jetzt
angehen (nur auf einem PC mit echtem Test), oder erstmal zurückstellen?
2. SQLCipher-Verschlüsselung der lokalen DB: Priorität vor M2, oder reicht die
App-Sandbox als Schutz vorerst?
Ohne eine dieser beiden Antworten bleibt der Pi bei jedem weiteren Durchlauf an
derselben Stelle stehen ein kurzer PC-Termin für Härtetest + Call-Pilot würde
gleich mehrere blockierte Punkte auf einmal lösen.
---
## 2026-07-03 M2: Gott-Datei `chat_view.dart` aufgeteilt (6 Dateien) ROADMAP-Punkt abgehakt
**Erledigt:** Fünfter und letzter Durchgang derselben `part`/`part of`-Technik:
`features/chat/chat_view.dart` (2116 Zeilen, 22 Klassen, keine losen
Top-Level-Helfer) aufgeteilt in 6 Dateien unter `features/chat/view/`:
`chat_view_core.dart` (`ChatView`, `_ChatViewState` der komplette
Nachrichten-Senden/Scroll/Attachment-Zustandsautomat, mit 521 Zeilen
weiterhin der größte Einzelblock, aber nicht weiter unterteilt, da es EINE
zusammenhängende State-Klasse ist, keine künstliche Aufspaltung mitten in
einer Klasse), `chat_header.dart` (`_ChatHeader`, DM-Header inkl.
`_DmExpandingHeader`+State, `_DmHeaderAvatar`, `_InitialCircle`,
`_PresenceLine`+State), `chat_connection.dart` (`_ConnectionBanner`+State,
Offline/Reconnect-Banner), `chat_message_list.dart` (`_MessageList`,
`_SelectionHeader`, `_SelBtn`, `_UnreadDivider`), `chat_typing.dart`
(`_TypingIndicator`+State, `_TypingDots`+State), `chat_misc.dart`
(`_DragOverlay`+State für Datei-Drag&Drop, `NoChatSelected` bleibt
öffentlich, `app_shell.dart` importiert `chat_view.dart` und nutzt es).
`chat_view.dart` selbst: nur noch Bibliothekskopf (33 Zeilen).
Verifikation identisch zu den vier vorigen Umbauten: automatisierte
Klammer-Zählung, alle 22 Blöcke wortwörtlich wiedergefunden,
`class`/`enum`-Anzahl (22) und `Widget build(`-Methoden (15) vorher/nachher
identisch, `flutter analyze` unverändert bei 1 bekanntem Hinweis.
**Damit sind alle 5 in der M2-Ist-Analyse gefundenen Gott-Dateien aufgeteilt**
(`settings_modal.dart`, `message_group.dart`, `rooms_panel.dart`,
`space_admin_dialog.dart`, `chat_view.dart` siehe die vier Einträge
darunter). ROADMAP-Punkt „Toten Code & Duplikate entfernen, Ordnerstruktur
vereinheitlichen" jetzt abgehakt.
**Offen/Nächster Schritt:** **Wichtig, bevor der nächste ROADMAP-Punkt
angefasst wird:** all diese Aufteilungen sind bisher NUR per `flutter
analyze` + automatisiertem Textvergleich geprüft, NICHT in echter UI
gesehen (kein GUI-Toolchain auf dem Pi verfügbar kein Android-SDK, kein
Linux-Desktop-Build, siehe `flutter doctor`). Der nächste PC-/Windows-Lauf
MUSS vor dem nächsten inhaltlichen Schritt einmal durch alle 5 betroffenen
Bereiche klicken (Einstellungen komplett, ein Chat mit allen
Nachrichtentypen, die Raumliste, eine Space-Verwaltung, ein normaler
Chat-Screen mit Header/Tippanzeige/Drag&Drop) das Risiko einer reinen
`part`/`part-of`-Verschiebung ist strukturell gering (Dart-Compiler hätte
jeden fehlenden Import/jede falsche Sichtbarkeit sofort als Analyzer-Fehler
gemeldet, nicht erst zur Laufzeit), aber „gering" ist nicht „null", und
CLAUDE.md verlangt nach jedem Refactoring-Schritt einen echten App-Start.
Danach nächster ROADMAP-Punkt in M2: „Call-Schicht entwirren" dafür
weiterhin Bernds Entscheidung zum Call-Pilot aus „M2: Modul-Schnitt
definiert" ausstehend, UND dieser Punkt braucht ohnehin einen PC/Windows-Test
(Verhaltensänderung an State-Management, nicht nur Datei-Verschiebung).
**Stolperfallen:** Keine neuen. Generelle Lehre aus allen 5 Durchgängen:
Skript-gestützte Klammer-Zählung + automatisierter Verbatim-Abgleich
(„jeder Originalblock muss wortwörtlich in den neuen Dateien auftauchen")
war der entscheidende Sicherheitsnetz-Schritt, um trotz fehlendem GUI-Test
auf dem Pi mit vertretbarem Risiko fortzufahren sollte bei künftigen
reinen Datei-Umbauten (z. B. `voice_channel.dart` beim Call-Pilot) als
Vorgehen wiederverwendet werden.
---
## 2026-07-03 M2: Gott-Datei `space_admin_dialog.dart` aufgeteilt (6 Dateien)
**Erledigt:** Vierter Durchgang derselben `part`/`part of`-Technik:
`features/spaces/space_admin_dialog.dart` (2278 Zeilen, 32 Klassen/Enums +
6 lose Top-Level-Helfer) aufgeteilt in 6 Dateien unter
`features/spaces/admin/`: `space_admin_core.dart` (`SpaceAdminDialog`,
`_SpaceAdminDialogState`, `_DialogHeader` + die breit genutzten Helfer
`_kSpaceBannerEvent`, `updatePowerLevelsSafely` (öffentliche Funktion
schützt vor Selbst-Aussperrung beim Ändern der Power-Levels, genutzt von
Members- UND Permissions-Tab), `_kPowerAdmin`/`_kPowerMod`/`_kPowerMember`,
`_powerLabel`, `_powerColor`), `space_admin_overview.dart` (`_OverviewTab`+State,
`_SpaceVisibilityToggle`+State, `_DangerZone`), `space_admin_members.dart`
(`_MembersTab`+State, `_InviteButton`+State, `_MemberTile`+State,
`_MemberActions`, `_MemberAction`), `space_admin_permissions.dart`
(`_PermissionsTab`+State, `_PermissionRow`), `space_admin_channels.dart`
(`_ChannelsTab`+State, `_ChannelItem`+State, `_CreateChannelDialog`+State,
`_AddExistingDialog`+State + die dort lokal genutzten Helfer `_kSpaceChild`/
`_kSpaceParent`/`_kVoiceChannelType`), `space_admin_shared.dart`
(`_SectionLabel`, `_Field`, `_SearchField`, `_AvatarEditor`, `_InitialAvatar`).
`space_admin_dialog.dart` selbst: nur noch Bibliothekskopf (19 Zeilen).
Auffällig beim Durchlesen (nicht angefasst, nur notiert): `updatePowerLevelsSafely`
ist als einzige Funktion in dieser Datei öffentlich (kein `_`-Präfix), wird
aber nirgends außerhalb von `space_admin_dialog.dart` importiert/aufgerufen
vermutlich für einen mal geplanten, nie gebrauchten externen Aufrufer public
gemacht. Bewusst nicht auf privat zurückgestuft, um diesen Schritt nicht mit
einer inhaltlichen Änderung zu vermischen; wäre ein Kandidat für den nächsten
"toter Code"-Durchgang.
Verifikation wie bei den drei vorigen Umbauten: automatisierte
Klammer-Zählung für Blockgrenzen, alle 32 Blöcke + 4 Helfer-Gruppen
wortwörtlich wiedergefunden, `class`/`enum`-Anzahl (32) und
`Widget build(`-Methoden (20) vorher/nachher identisch, `flutter analyze`
unverändert bei 1 bekanntem Hinweis.
**Offen/Nächster Schritt:** UNGETESTET (Pi). Nächster Windows-Lauf: Space-
Verwaltung öffnen Übersicht (Sichtbarkeit umschalten, Banner ändern,
Danger-Zone-Buttons NUR ansehen, nicht bestätigen), Mitglieder (einladen,
Rolle ändern, kicken/bannen wenn möglich an einem Test-Space, nicht an
Utas echtem), Berechtigungen, Kanäle (erstellen, bestehenden Raum hinzufügen).
Danach `chat_view.dart` (2116 Zeilen) als letzte der 5 in der Ist-Analyse
gefundenen Gott-Dateien damit wäre der komplette „Ordnerstruktur
vereinheitlichen"-Katalog aus M2 abgearbeitet (Häkchen dann setzen).
**Stolperfallen:** Keine neuen.
---
## 2026-07-03 M2: Gott-Datei `rooms_panel.dart` aufgeteilt (8 Dateien)
**Erledigt:** Gleiche `part`/`part of`-Technik ein drittes Mal angewandt:
`features/rooms/rooms_panel.dart` (2555 Zeilen, 37 Klassen/Enums + 4 lose
Top-Level-Helfer) aufgeteilt in 8 Dateien unter `features/rooms/panel/`:
`rooms_panel_core.dart` (`RoomsPanel`, `_RoomsPanelState` + die
Top-Level-Helfer `_kVoiceRoomType`/`_kSpaceBannerType`/`_isVoiceRoom`/
`_compareSpaceChildren`, die von mehreren anderen Teilen genutzt werden
deshalb bewusst NICHT in ein eigenes „shared"-File, sondern beim Hauptwidget
belassen, wo sie ursprünglich standen), `rooms_header.dart` (`_Header`,
`_MenuRow`, `_NotificationsButton`, `_FilterInput`, `_SpaceMenuItem`),
`rooms_space_invite.dart` (`_SpaceInviteView`+State), `rooms_space_list.dart`
(`_SpaceRoomsList`+State, `_JoinableRoom`+Item+State, `_CategoryHeader`+State),
`rooms_list.dart` (`_RoomsList`, `_SectionHeader`+State, `_RoomItem`+State,
`_DraggableRoomItem`+State), `rooms_voice.dart` (`_VoiceParticipantList`,
`_Initials`, `_VoiceBarBtn`+State), `rooms_invite_item.dart`
(`_InviteItem`+State), `rooms_shared.dart` (`_DmAvatar`+State,
`_InitialCircle`, `_RoomMenuBtn`+State, `_HoverAction`+State, `_UnreadBadge`).
`rooms_panel.dart` selbst: nur noch Bibliothekskopf (Imports + 8
`part`-Direktiven, 31 Zeilen).
Verifikation wie bei den beiden vorigen Umbauten: automatisierte
Klammer-Zählung für Blockgrenzen, alle 37 Blöcke + 4 Helfer wortwörtlich in
den neuen Dateien wiedergefunden, Anzahl `class`/`enum` (37) und
`Widget build(`-Methoden (22) vorher/nachher identisch, `flutter analyze`
unverändert bei 1 bekanntem Hinweis. `dart format` wieder bewusst nicht
gelaufen.
**Offen/Nächster Schritt:** UNGETESTET (Pi). Nächster Windows-Lauf: Raumliste
prüfen Space wechseln, Raum beitreten/verlassen, Voice-Channel-Teilnehmerliste
in der Raumliste, Einladung annehmen/ablehnen, Drag&Drop zum Umsortieren,
Ungelesen-Badge, DM-Avatare. Danach `space_admin_dialog.dart` (2278 Zeilen)
oder `chat_view.dart` (2116 Zeilen) nach demselben Muster damit wären alle
in der Ist-Analyse gefundenen Gott-Dateien abgearbeitet.
**Stolperfallen:** Keine neuen.
---
## 2026-07-03 M2: Gott-Datei `message_group.dart` aufgeteilt (7 Dateien)
**Erledigt:** Gleiche Technik wie beim `settings_modal.dart`-Umbau direkt
darunter angewandt: `features/chat/message_group.dart` (2775 Zeilen, 41
Klassen/Enums + 5 lose Top-Level-Hilfsfunktionen) per Dart `part`/`part of`
in 7 thematische Dateien unter `features/chat/message/` aufgeteilt:
`message_group_core.dart` (`MessageGroup`, `_MessageGroupState`,
`_SendFailedRow`, `_MessageBody`, `_ContinuationMessage`(+State), inkl. der in
der letzten Session vereinheitlichten `_formatMessageTime`), `message_text.dart`
(`_MessageContent`, `_MatrixHtmlText`, `_PlainLinkText`),
`message_link_preview.dart` (`_LinkPreview`(+State/Data), `_ReplyPreview`),
`message_media.dart` (alle Medientypen: Bild/Audio/Video/Doc/Datei/proprietäre
Inline-Karten + die zugehörigen Top-Level-Helfer `_reservedImageSize`/
`_classifyMediaError`/`_checkedDownload`/`_mediaFallback` mit 1000 Zeilen
weiterhin der größte Teil, aber thematisch klar abgegrenzt),
`message_reactions.dart`, `message_actions.dart` (Hover-Aktionsleiste),
`message_shared.dart` (`_Avatar`, `DateDivider` bleibt öffentlich, da
`chat_view.dart` es importiert; über `part`/`part of` weiterhin ohne
Umbenennung nutzbar). `message_group.dart` selbst ist jetzt nur noch der
Bibliothekskopf (Imports + 7 `part`-Direktiven, 30 Zeilen).
Verifikation identisch zum Settings-Umbau: automatisierte Klammer-Zählung
für die Blockgrenzen (kein manuelles Abtippen), alle 41 Original-Blöcke +
5 Helfer wortwörtlich in den neuen Dateien wiedergefunden, Anzahl
`class`/`enum` (45) und `Widget build(`-Methoden (27) vorher/nachher
identisch, `flutter analyze` unverändert bei 1 bekanntem Hinweis. `dart format`
diesmal bewusst NICHT ausgeführt (siehe Stolperfalle im Eintrag darunter).
**Offen/Nächster Schritt:** UNGETESTET (Pi). Nächster Windows-Lauf: einen
Chat mit mehreren Nachrichtentypen öffnen (Text mit Link-Vorschau, Bild,
Video, Audio, Datei-Anhang, Reaktionen setzen, Hover-Aktionsleiste,
Datums-Trenner, gescheiterte Nachricht mit Retry) rein mechanische
Verschiebung, aber `message_media.dart` ist der bisher am wenigsten oft
geprüfte Bereich (Download/Entschlüsselungs-Fehlerpfade), daher dort genauer
hinschauen. Danach ggf. `rooms_panel.dart` (2555 Zeilen) oder
`space_admin_dialog.dart` (2278 Zeilen) nach demselben Muster aufteilen.
**Stolperfallen:** Keine neuen siehe `dart format`-Hinweis im
`settings_modal.dart`-Eintrag darunter, hier von vornherein vermieden.
---
## 2026-07-03 M2: Gott-Datei `settings_modal.dart` aufgeteilt (12 Dateien)
**Erledigt:** `widgets/settings_modal.dart` (5541 Zeilen, 50+ Klassen) war laut
Ist-Analyse die größte „Gott-Datei". „Call-Schicht entwirren" (nächster
ROADMAP-Punkt) bleibt weiter blockiert, da es dabei um Verhaltens-/State-
Management-Änderungen an einem CLAUDE.md-„heiligen" Bereich (Calls) geht, die
laut Arbeitsregeln nach jedem Schritt einen echten App-Test brauchen
auf dem Pi ohne GUI-Toolchain (kein Android-SDK, kein Linux-Desktop-Build,
siehe `flutter doctor`) unmöglich zu prüfen. Stattdessen den in
`docs/M2_MODULE_SCHNITT.md` als zweiten (risikoärmeren) Schritt vorgeschlagenen
Umbau vorgezogen: reine Datei-Aufteilung ohne Verhaltensänderung, komplett über
`flutter analyze` prüfbar.
- Technik: Dart `part`/`part of` statt eigenständiger Libraries dadurch
bleiben alle privaten Klassen (`_ProfileSection`, `_SettingsGroup`, …) ohne
Umbenennung nutzbar, und alle Imports bleiben zentral in `settings_modal.dart`
(Part-Dateien brauchen keine eigenen Imports). Kein Klassenname geändert,
kein Zeichen Logik angefasst.
- 12 neue Dateien unter `lib/widgets/settings/`: `settings_shared.dart`
(gemeinsame Low-Level-Widgets wie `_SettingsGroup`/`_Toggle`/`_Divider`),
`settings_profile.dart`, `settings_notifications.dart`,
`settings_appearance.dart`, `settings_voice.dart`, `settings_keybinds.dart`
(Tastaturkürzel-Anzeige, NICHT zu verwechseln mit Verschlüsselungs-Keys),
`settings_privacy.dart`, `settings_sessions.dart`, `settings_verification.dart`
(SAS/QR-Dialog), `settings_encryption.dart` (inkl. der Megolm-Export/Import-
Kryptofunktionen `_runPbkdf2`/`_aesCtr`/`_encryptMegolmKeys`/`_decryptMegolmKeys`,
die bisher lose im Datei-Kopf lagen), `settings_account.dart` (Passwort ändern/
Account löschen), `settings_about.dart`.
- `settings_modal.dart` selbst: von 5541 auf 270 Zeilen geschrumpft enthält
nur noch Imports, die 12 `part`-Direktiven, `SettingsModal` und
`_SettingsModalState` (Navigation/Layout).
- Verifikation (da kein GUI-Test möglich): Skript-gestützte Extraktion per
Klammer-Zählung (kein manuelles Copy-Paste, um Übertragungsfehler
auszuschließen); anschließend automatisiert geprüft, dass alle 61
ursprünglichen Klassen/Enums wortwörtlich (`in`-Substring-Check) in den neuen
Dateien wieder auftauchen; Anzahl `class`/`enum`-Deklarationen (60) und
`Widget build(`-Methoden (40) vorher/nachher identisch; alle Lücken zwischen
den Klassenblöcken im Original einzeln geprüft ausschließlich Leerzeilen
und dekorative Trennkommentare, kein Code verloren.
- `flutter analyze`: weiterhin nur der eine bekannte, zurückgestellte Hinweis
(`chat_provider.dart:42`).
- **Stolperfalle vermieden:** `dart format` versuchte 40 neue Lint-Hinweise
einzuführen (`curly_braces_in_flow_control_structures`) Ursache: die
Originaldatei war selbst nie komplett `dart format`-clean (bestätigt per
`dart format --set-exit-if-changed` auf dem Original-Git-Stand), und Reflow
einzelner langer Einzeiler-`if`s ohne Klammern erzeugte neue Zeilenumbrüche,
die der Linter anders bewertet als die Originalformatierung. Formatierung
komplett verworfen und die Dateien unformatiert aus dem Original übernommen,
um „keine Verhaltensänderung ohne Not" strikt einzuhalten und die
`flutter analyze`-Baseline (1 Hinweis) nicht zu verwässern.
**Offen/Nächster Schritt:** UNGETESTET (Pi) im Sinne von „nicht in echter UI
gesehen". Nächster Windows-Lauf: Einstellungen öffnen, durch alle Reiter
klicken (Profil, Benachrichtigungen, Erscheinungsbild, Voice, Tastaturkürzel,
Privatsphäre, Sessions, Verschlüsselung inkl. SAS/QR-Verifizierung und
Recovery-Key-Anzeige/-Import, Passwort ändern, Account löschen-Dialog öffnen
(ohne zu bestätigen!), Über). Da rein mechanisch verschoben, ist das Risiko
gering, aber Verschlüsselung/Recovery-Key ist laut CLAUDE.md heilig bitte
gezielt den Recovery-Key-Anzeige- und Passphrase-Export/Import-Flow einmal
durchklicken. Danach ROADMAP-Häkchen für „Ordnerstruktur vereinheitlichen"
erst setzen, wenn auch die übrigen Gott-Dateien (`message_group.dart`,
`rooms_panel.dart`, `space_admin_dialog.dart`, `chat_view.dart`,
`document_viewer.dart`) angegangen sind aktuell nur `settings_modal.dart`
erledigt.
**Stolperfallen:** `dart format` NICHT blind nach einem Refactoring laufen
lassen, wenn die Baseline vorher nicht schon formatter-clean war kann neue,
unrelated Lint-Hinweise einführen und die vermeintlich reine Verschiebung mit
Formatierungsrauschen vermischen (siehe oben).
**Fragen an Bernd:** Keine neuen die offene Frage zum Call-Pilot aus dem
Eintrag „M2: Modul-Schnitt definiert" ist weiterhin unbeantwortet, blockiert
aber nicht (siehe dort).
---
## 2026-07-03 M2: Toten Code & Duplikate entfernt (Chat-Timeline)
**Erledigt:** Vorgriff auf den ROADMAP-Punkt „Toten Code & Duplikate entfernen"
(technisch der 5. Punkt in M2, aber risikofrei und ohne GUI testbar im
Gegensatz zum aktuell blockierten „Call-Schicht entwirren", siehe Frage an
Bernd im Eintrag darunter):
- `lib/features/chat/message_bubble.dart` (178 Zeilen) gelöscht vollständig
toter Code, wurde nirgends importiert/instanziiert (durch `message_group.dart`
abgelöst), per `grep` doppelt bestätigt.
- Die zwei identischen `_formatTime`-Methoden in `message_group.dart`
(`_MessageGroupState` Zeile 322, `_ContinuationMessageState` Zeile 475
Vorher-Zeilennummern) zu einer gemeinsamen Top-Level-Funktion
`_formatMessageTime()` zusammengeführt, beide Aufrufstellen umgestellt.
Reine Extraktion, keine Verhaltensänderung (identischer Code vorher/nachher).
`pinned_messages_panel.dart:_formatTime` bewusst NICHT angefasst hat
andere Logik (relatives Datum „vor Xd"), ist kein echtes Duplikat.
- `flutter analyze` weiterhin sauber: nur der eine bekannte, bewusst
zurückgestellte Hinweis (`chat_provider.dart:42`, `Timeline.onUpdate`
deprecated, hängt am E2EE-Redecrypt-Pfad, siehe M0-Eintrag).
**Offen/Nächster Schritt:** UNGETESTET (Pi) im Sinne von „nicht in echter UI
gesehen" kein GUI auf diesem Pi verfügbar. Da es sich aber um reine
Code-Verschiebung ohne Logikänderung handelt (identischer Funktionskörper),
ist das Risiko gering; trotzdem beim nächsten Windows-Lauf kurz einen
Chat mit mehreren aufeinanderfolgenden Nachrichten (Zeit-Anzeige) und eine
Fortsetzungsnachricht (Hover zeigt Uhrzeit) ansehen.
**Stolperfallen:** Keine.
---
## 2026-07-03 M2: Modul-Schnitt definiert (docs/M2_MODULE_SCHNITT.md)
**Erledigt:** Ausgehend von der Ist-Analyse (Eintrag darunter) die konkreten
Modul-Grenzen + öffentliche Schnittstellen entworfen, dokumentiert in
`docs/M2_MODULE_SCHNITT.md`. Reine Planung, kein Code geändert. Wichtigste
Erkenntnis beim genaueren Lesen von `voip_manager.dart` und
`livekit_call_manager.dart`: „Calls" ist architektonisch **kein einzelnes
Feature**, sondern zwei unabhängige Mechanismen, die getrennt bleiben sollten:
- `PyramidVoipManager` = natives Matrix-1:1-VoIP (`m.call.*`, direkte
WebRTC-Peer-Connection, kein SFU)
- `LiveKitCallManager` = SFU-basierte Voice-Channels (LiveKit-Raum, Presence
via Matrix-State-Event, Screensharing, Mehrpersonen) das ist der
Discord-artige Kanal aus M3/M4
Modul-Liste im Dokument: `call_signaling`, `voice_channel`, `call_ui`,
`settings` (aufgesplittet in Sektionsdateien), `verification` (neu, aus
Settings herausgelöst SAS/QR/Recovery-Key), `storage` (Ausbau der
bestehenden `settings_prefs.dart`-Abstraktion zur Pflicht), `auth` (schon
relativ sauber, kein akuter Bedarf), `push` (schon getrennt, eigener
`sqflite`-Zugriff in `background_push.dart` ist bewusst so, kein
Refactoring-Kandidat), `chat_timeline`/`rooms` (niedrigste Priorität, da
strukturell am wenigsten verwoben Hauptproblem dort ist Dateigröße/Duplikate,
nicht Kopplung). Jeweils mit Dart-Interface-Skizze bzw. Datei-Zielliste.
Empfohlene Umsetzungsreihenfolge: 1) Call-Pilot (`call_signaling` +
`voice_channel` + `call_ui`, deckt gleich zwei ROADMAP-Punkte ab), 2)
`settings_modal.dart` aufsplitten (geringes Risiko, reine Datei-Teilung),
3) `verification` herauslösen, 4) `storage`-Fassade schrittweise erzwingen,
5) `chat_timeline`/`rooms` zuletzt.
**Offen/Nächster Schritt:** Wartet auf Bernds Entscheidung, ob der Call-Pilot
wie vorgeschlagen als Nächstes umgesetzt wird (siehe Frage unten). Erst danach
den ROADMAP-Punkt „Call-Schicht entwirren" wirklich anfassen das ist die
erste Stelle mit echtem Verhaltensrisiko in M2 (CLAUDE.md: nach Refactoring-
Schritten immer App starten + Kernflows prüfen, hier zusätzlich brisant, weil
GUI-Test auf dem Pi nicht möglich ist).
**Stolperfallen:** Der erste Ist-Analyse-Durchgang (Subagent) hatte einen
falschen Befund (`rooms_provider.dart`-Import in `app_state.dart` sei toter
Code) beim genaueren Lesen für den Modul-Schnitt widerlegt
(`voiceParticipantsProvider` nutzt `roomListProvider` daraus). Bereits im
Ist-Analyse-Eintrag korrigiert. Lehre: Subagent-Ist-Analysen sind ein guter
Startpunkt, aber vor jedem darauf aufbauenden Schritt einzelne Befunde
gegenlesen, nicht blind übernehmen.
**Fragen an Bernd:**
- Call-Pilot wie in `docs/M2_MODULE_SCHNITT.md` vorgeschlagen (zuerst
`call_signaling`/`voice_channel`/`call_ui` entwirren) als nächster
Umsetzungsschritt in Ordnung, oder lieber zuerst `settings_modal.dart`
aufsplitten (geringeres Risiko, aber deckt keinen ROADMAP-Punkt aus M2
direkt ab)? Ohne GUI-Zugriff auf dem Pi kann der riskantere Call-Umbau
hier ohnehin nicht praktisch getestet werden das spricht eher für
„auf dem PC anfangen", falls Zeitdruck besteht.
---
## 2026-07-03 M2: Ist-Analyse der Architektur geschrieben
**Erledigt:** M1 hat aktuell keinen auf dem Pi bearbeitbaren Punkt mehr offen
(SQLCipher wartet auf Bernds Priorisierung, Härtetest braucht GUI/PC siehe
Fragen an Bernd unten und Eintrag 2026-07-03 „3 schon vorhanden, 1 echte
Lücke"). Deshalb mit M2 weitergemacht, erster Punkt: Ist-Analyse. Per
Code-Lektüre (Zeilenzahlen, Import-Graph, grep) folgende Schwachstellen
gefunden:
- **Gott-Dateien:** `widgets/settings_modal.dart` mit 5541 Zeilen / 50+ Klassen
bündelt mindestens 8 unabhängige Bereiche (Profil, Benachrichtigungen,
Voice/TURN, E2EE-Verifizierung inkl. SAS/QR, Sessions, Account, Erscheinungsbild,
Updates). Weitere große Dateien: `message_group.dart` (2780), `rooms_panel.dart`
(2555), `space_admin_dialog.dart` (2278), `chat_view.dart` (2116),
`document_viewer.dart` (1773), `voice_channel.dart` (1123), `app_shell.dart` (1073).
- **Call-Schicht unsauber verteilt statt hinter einer Schnittstelle:**
`core/voip_manager.dart` (Matrix-VoIP-Signalisierung), `core/livekit_call_manager.dart`
(LiveKit-Transport), `features/call/voice_channel.dart` + `mini_call_widget.dart`
(UI) aber `chat_view.dart`, `matrix_client.dart`, `settings_modal.dart` greifen
alle DIREKT auf die Manager zu statt über eine gemeinsame Call-Fassade. Vier
verschiedene Module importieren `voice_channel.dart` direkt.
Genau das Gegenteil des „austauschbare Bausteine"-Ziels aus CLAUDE.md.
- **State-Management gemischt:** 2 zentrale `ChangeNotifier`-Singletons
(`voip_manager.dart`, `livekit_call_manager.dart`) statt Riverpod-`Notifier`,
eingebunden über `app_state.dart` (`callStateProvider`, `voipStateProvider`).
Migration sinnvoll, aber riskant wegen breiter Call-Sites und enger
SDK-Callback-Kopplung (LiveKit-`Room`-Events, Matrix-`WebRTCDelegate`) sollte
zusammen mit der Call-Fassade angegangen werden, nicht isoliert.
- **`core/` ist nicht rein generisch:** importiert Feature-Code direkt
(`app.dart`/`router.dart``features/auth/*`, `matrix_client.dart` instanziiert
`PyramidVoipManager` direkt). `app_state.dart` selbst ist ein Sammelbecken für
Theme-, Call-, Voice-, Share-Target- und Spaces-Provider aus praktisch allen
Features zentraler Kopplungspunkt, der jedem Modulschnitt im Weg steht.
(Korrektur beim genaueren Hinsehen für den Modul-Schnitt-Punkt: der
`rooms_provider.dart`-Import dort ist entgegen der ersten Vermutung KEIN
toter Altlast-Import `voiceParticipantsProvider` nutzt `roomListProvider`
daraus. Zeigt: Subagent-Befunde vor dem Weiterbauen selbst nachprüfen.)
- **Keine gemeinsame Storage-Schnittstelle:** `SharedPreferences.getInstance()`
wird an 12 Stellen direkt aufgerufen, obwohl `core/settings_prefs.dart` bereits
eine `StateNotifier`-Abstraktion (`BoolPref`, `StringSetPref`) anbietet wird
meist umgangen. `sqflite` wird zusätzlich unabhängig in `background_push.dart`
nochmal geöffnet (eigene DB-Instanz fürs Background-Isolate). `media_cache.dart`
ist dagegen sauber gekapselt gutes Vorbild für die anderen Speicherzugriffe.
- **Toter Code bestätigt:** `features/chat/message_bubble.dart` (178 Zeilen,
Klasse `MessageBubble`) wird nirgends instanziiert von `message_group.dart`
abgelöst, kann beim Refactoring entfernt werden.
- **Duplikate:** `_formatTime`/`_formatDate` unabhängig implementiert in
`pinned_messages_panel.dart:206`, `message_group.dart:322` UND `:475` (zwei Mal
im selben File!), sowie im toten `message_bubble.dart:80/165`.
**Modul-Kandidaten für den nächsten Punkt („Modul-Schnitt definieren"):**
1. `call_signaling` (Matrix-VoIP) `core/voip_manager.dart`
2. `call_transport` (LiveKit) `core/livekit_call_manager.dart`
3. `call_ui` (nur UI, konsumiert 1+2 über Interface) `features/call/*`
4. `settings/*` aufsplitten: profile, encryption_verification, sessions,
notifications, appearance, account aus `widgets/settings_modal.dart`
5. `verification` als eigenständiges Feature (SAS/QR/Recovery-Key), nicht
Teil von Settings wiederverwendbar (z. B. künftig beim Login)
6. `storage` gemeinsame Fassade, `settings_prefs.dart` als Pflichtzugang
statt 12 Direktzugriffsstellen
7. `app_state.dart` auflösen in feature-lokale Provider-Dateien
8. `chat_timeline` `message_group.dart` + `pinned_messages_panel.dart`,
dabei toten Code (`message_bubble.dart`) entfernen, `_formatTime`/`_formatDate`
in `chat/format_utils.dart` zusammenführen
**Offen/Nächster Schritt:** M2, zweiter Punkt „Modul-Schnitt definieren":
aus der obigen Kandidatenliste konkrete Modul-Interfaces (Dart-Abstract-Classes
o. ä.) entwerfen, dann EIN Modul zuerst umbauen (Vorschlag: `call_*`, da am
klarsten abgegrenzt und am dringendsten laut CLAUDE.md-Leitprinzip) als Pilot,
bevor der Rest folgt. Kein Codeumbau in diesem Schritt, nur Schnittstellen-Design.
**Stolperfallen:** Keine reine Lesearbeit, kein Risiko für Bestandsdaten.
Analyse wurde von einem Explore-Subagent erstellt und stichprobenartig
plausibilisiert (Zeilenzahlen/Imports selbst nachgezählt), nicht jede
Einzelbehauptung im Detail nachverifiziert.
**Fragen an Bernd:**
- SQLCipher-Priorität weiterhin offen (siehe Eintrag „3 schon vorhanden, 1 echte
Lücke" weiter unten) blockiert nicht, aber ohne Antwort bleibt der M1-Punkt
bis zum nächsten PC-Praxistest liegen.
- Für M2: Soll ich die Call-Schicht als erstes Pilot-Modul umbauen (mein
Vorschlag oben), oder hast du eine andere Präferenz (z. B. erst die
`settings_modal.dart` aufsplitten, weil die am unübersichtlichsten ist)?
---
## 2026-07-03 M1: Restliche Punkte geprüft 3 schon vorhanden, 1 echte Lücke gefunden
**Erledigt:**
- **Logout-Warnung bei fehlendem Key-Backup** (Commit b5762ea): neue Funktion
`isKeyBackupMissing(client)` in `bootstrap_dialog.dart` (prüft Cross-Signing +
`keyManager.isCached()`). Beide Logout-Dialoge in `settings_modal.dart` zeigen
jetzt eine rote Klartext-Warnung statt des neutralen Textes, wenn kein Backup
eingerichtet ist.
- **Drei ROADMAP-Punkte waren bereits umgesetzt** (nur nicht abgehakt) durch
Code-Lektüre verifiziert, nicht neu gebaut:
- Key-Backup einrichten/wiederherstellen: `bootstrap_dialog.dart` deckt beides
über den `Bootstrap`-Zustandsautomaten des matrix-SDK ab (`askNewSsss` für
Neueinrichtung, `askUnlockSsss` fragt auf einem neuen Gerät nach dem
Recovery-Key). Wird automatisch getriggert, wenn Cross-Signing fehlt
(`app_shell.dart:_triggerBootstrap`).
- Geräte-/Sessionverwaltung: `_SessionsSection` in `settings_modal.dart`
Liste, Umbenennen, SAS/QR-Geräteverifizierung, Einzel- und
Massen-Abmeldung (mit Passwort-Reauth bei UIA-Anforderung).
- **Echte Sicherheitslücke gefunden:** `sqlcipher_flutter_libs` ist eine
Abhängigkeit in `pubspec.yaml`, wird aber **nirgends im Code benutzt**.
`matrix_client.dart` (Haupt-App) und `background_push.dart` (Push-Hintergrund)
öffnen `pyramid.sqlite` beide über den normalen, unverschlüsselten
`sqflite`/`sqflite_common_ffi`-Factory. Die Krypto-DB (Access-Token,
gepickelter Olm-Account, Megolm-Sessions, komplette Nachrichtenhistorie)
liegt also **im Klartext** auf der Platte nur durch Androids App-Sandbox
geschützt, nicht durch eine eigene Verschlüsselung.
**Offen/Nächster Schritt:** SQLCipher-Anbindung NICHT blind auf dem Pi
umgesetzt siehe Stolperfalle unten. Braucht einen eigenen, vorsichtig
geplanten PC-Termin: Backup der Test-DB, Migrationscode (bestehende
Klartext-DB einmalig nach SQLCipher überführen), Test auf einem Gerät mit
echten Daten, erst dann Rollout.
**Fragen an Bernd:**
- Priorität der SQLCipher-Lücke: Soll das vor M2 (Refactoring) angegangen
werden, oder reicht die App-Sandbox als Schutz erstmal aus (Angreifer bräuchte
Root oder physischen Zugriff + ADB, um die Datei überhaupt zu lesen)? Die
Migration bestehender Installationen (Uta!) ist der aufwändige/riskante Teil,
nicht die Verschlüsselung selbst.
**Stolperfallen:**
- Eine DB-Verschlüsselungs-Migration ist genau die Art Änderung, die laut
CLAUDE.md nicht "mal eben" auf dem Pi ohne Testgerät gemacht werden darf:
scheitert die Migration (falscher Schlüssel, falsche PRAGMA-Reihenfolge,
Timing zwischen Haupt-App und Push-Hintergrund-Client), sind Utas
Nachrichten und Krypto-Schlüssel unwiederbringlich weg. Deshalb hier nur
dokumentiert, nicht implementiert.
---
## 2026-07-03 M1: Push-Regression vermutlich dieselbe Ursache wie Random-Logout
**Erledigt:** Vor dem Blick in den Push-Code die Vermutung geprüft, ob die
Push-Entschlüsselungs-Regression und der Uta-Random-Logout-Bug (siehe Eintrag
weiter unten) zusammenhängen Ergebnis: **sehr wahrscheinlich ja, exakt
dieselbe Ursache.**
- `client.getEventByPushNotification(...)` (aufgerufen in `_bgDecryptAndShow`,
`background_push.dart:84`) ruft als ALLERERSTES `ensureNotSoftLoggedOut()`
auf (matrix-Paket `client.dart:1878`). Diese Funktion prüft, ob der
Access-Token in < 1 Minute abläuft, und löst dann `_handleSoftLogout()` aus
**dieselbe Funktion**, die ohne Refresh-Token (unser Zustand vor Commit
9dc83f7) sofort einen echten `client.logout()` auslöst (siehe Eintrag unten).
- Der Hintergrund-Client aus `_buildClient()` liest **dieselbe** `pyramid.sqlite`
wie der Haupt-Client. Ein `logout()`/`clear()` aus dem Hintergrund-Isolat
würde also die komplette Session (inkl. Krypto-Schlüssel) für die GESAMTE
App zerstören nicht nur für den einen Push. Das erklärt, warum das Problem
als dauerhafte Regression erscheint ("seit dem letzten Update nie wieder"):
Nach dem ersten Auto-Logout-Ereignis (ausgelöst beim Sync ODER beim
Entschlüsseln eines Pushs) sind die Schlüssel weg, jede folgende
Entschlüsselung schlägt fehl → `BgEngine` zeigt nur noch den
„Neue Nachricht"-Platzhalter (Fallback nach 6 s), nie mehr den echten Text.
- Der bereits committete Fix (`refreshToken: true` beim Login, 9dc83f7) behebt
damit vermutlich BEIDE Symptome gleichzeitig: der Refresh-Token liegt in der
gemeinsamen DB, also kann auch der Hintergrund-Client ihn lesen und einen
drohenden Soft-Logout durch stillen Refresh abwenden, statt die Session zu
zerstören.
**Offen/Nächster Schritt:** Gehört zum selben Praxistest wie der
Random-Logout-Punkt oben zusätzlich beobachten, ob nach einer Weile
(mehrere Stunden/Tage) echte Push-Inhalte statt Platzhalter ankommen. Schlägt
das fehl, liegt eine zweite, unabhängige Ursache vor (dann `docs/NOTIFICATIONS.md`
+ Memory „Pyramid Push" erneut konsultieren, insbesondere die 3 dort gelisteten
Fallstricke der Hintergrund-Entschlüsselung).
**Stolperfallen:** Diese Verbindung wurde rein durch Lesen des matrix-SDK-
Quellcodes hergeleitet (`~/.pub-cache/hosted/pub.dev/matrix-6.2.0/lib/src/client.dart`),
nicht durch Beobachtung auf einem echten Gerät bewusst als Vermutung
markiert, bis der PC-Praxistest sie bestätigt.
---
## 2026-07-03 M1: Uta-Random-Logout Ursache gefunden + Fix (UNGETESTET/Pi)
**Erledigt:**
- Alle Logout-Auslöser im Code kartiert:
- 3 bewusste Nutzer-Logout-Buttons in `settings_modal.dart` (alle hinter
Bestätigungsdialog) kein Kandidat für "random"
- Der eigentliche Verdächtige: `matrix`-Paket (v6.2.0) `Client._innerSync()`
behandelt `M_UNKNOWN_TOKEN` vom `/sync`-Endpunkt so: bei `soft_logout: true`
`_handleSoftLogout()` → versucht `refreshAccessToken()` → **das SDK
hatte nie einen Refresh-Token angefordert** (`client.login(...)` in
`login_notifier.dart` rief ohne `refreshToken: true` auf) → `refreshAccessToken()`
wirft sofort "No refresh token available" → Catch-Block ruft `await logout()`
auf: ein **echter, destruktiver Logout**, ausgelöst rein durch einen normalen
Soft-Logout (z. B. abgelaufenes Access-Token), ganz ohne Nutzerinteraktion.
Quelle: `~/.pub-cache/hosted/pub.dev/matrix-6.2.0/lib/src/client.dart:2374-2384`
und `:2496-2509`.
- **Logging (Commit 63e4919):** neue `lib/core/auth_log.dart` schreibt jeden
Login-Status-Wechsel (`client.onLoginStateChanged`) sowie SDK-Warnungen/-Fehler
(`Logs().onLog`) in `auth_log.txt` im App-Support-Verzeichnis übersteht
App-Neustart. Zusätzlich loggen alle 3 Nutzer-Logout-Buttons sich selbst, damit
sich künftig unterscheiden lässt: Nutzer-Logout vs. SDK-Auto-Logout. Reine
Zusatzfunktion, keine Verhaltensänderung.
- **Fix (Commit 9dc83f7):** `client.login(...)` fordert jetzt `refreshToken: true`
an. Damit kann das SDK einen Soft-Logout künftig durch stillen Token-Refresh
überstehen, statt die Sitzung zu zerstören. Unterstützt der Server keine
Refresh-Tokens, bleibt das Verhalten exakt wie vorher (`response.refreshToken`
ist dann `null`) der Fix kann also nichts verschlimmern.
**Offen/Nächster Schritt:**
1. **Praxistest auf dem PC (zwingend vor Haken in ROADMAP!):** Login → Nachrichten
senden/lesen → Logout → erneuter Login → alte verschlüsselte Nachrichten noch
lesbar? Dabei prüfen, ob `auth_log.txt` (App-Support-Verzeichnis) die
Login-Status-Wechsel sauber mitschreibt.
2. Danach: den nächsten Punkt in M1 angehen (Push-Entschlüsselungs-Regression).
3. **Wichtig für Bernd/Uta:** Der Fix wirkt nur für NEUE Logins. Utas bereits
bestehende Sitzung bekommt erst nach einem einmaligen manuellen Neu-Login
einen Refresh-Token und ist bis dahin weiterhin anfällig. Sobald der PC-Test
grün ist, sollte Uta sich einmal aus- und wieder einloggen.
**Stolperfallen:**
- Konnte auf dem Pi nicht mit echtem Login/Logout getestet werden (kein GUI,
kein Matrix-Testaccount zur Hand) Fix beruht auf genauem Lesen des
matrix-SDK-Quellcodes, nicht auf Beobachtung im Betrieb. Deshalb zwingend
vor dem nächsten Haken in ROADMAP am PC nachprüfen.
- `chat_provider.dart:42` nutzt weiterhin das deprecated `Timeline.onUpdate`
(Re-Entschlüsseln nach Schlüssel-Empfang) hängt mit demselben Sync-Bereich
zusammen, aber bewusst nicht angefasst, um nicht zwei riskante Änderungen im
selben Bereich gleichzeitig ungetestet zu committen.
---
## 2026-07-03 M0 abgeschlossen: Ordnung, flutter analyze sauber, README
**Erledigt:**
- Liegen gebliebene Deprecation-Fixes aus einer abgebrochenen Session committet
(`withOpacity``withValues`, `activeColor``activeThumbColor`, `cacheExtent`
`ScrollCacheExtent`, `PublicRoomsChunk``PublishedRoomsChunk`) Commit 057ac1e
- Restliche `flutter analyze`-Hinweise behoben Commit 401bd6a:
- `document_viewer.dart`: `Matrix4.translate/scale``translateByDouble`/
`scaleByDouble` (PDF-Zoom, gleiches Verhalten)
- `rooms_panel.dart`: `onReorder``onReorderItem` (Index-Korrektur jetzt intern,
manuelles `newIdx--` entfernt); `implementation_imports`-Hinweis kommentiert
(SpaceChild wird vom `matrix`-Paket nicht öffentlich exportiert, kein Fix möglich)
- `voip_manager.dart`: `getSources()`-Override kommentiert (Pflicht-Override der
abstrakten `MediaDevices`-Klasse, die die Methode selbst deprecated hat)
- `settings_modal.dart`: zwei `BuildContext`-nach-`await`-Stellen mit
`context.mounted` abgesichert (Geräte umbenennen, Passwortabfrage bei Massenlogout)
- **Bewusst NICHT gefixt:** `chat_provider.dart:42` (`Timeline.onUpdate` deprecated,
Ersatz wäre `client.onSync` + Raum-Filter). Hängt am E2EE-Redecrypt-Pfad
(Re-Entschlüsseln nach Schlüssel-Empfang) laut CLAUDE.md heiliger Bereich,
Umbau nur mit dediziertem Login→Nachrichten→Logout→Login-Test, nicht nebenbei.
- `lib/features/calls/` (Leiche) geprüft: existiert nicht mehr, nur `lib/features/call/`
mit den echten Dateien Punkt war bereits erledigt, nur nicht abgehakt.
- README.md durch echte Projektbeschreibung ersetzt (Funktionsumfang, Build, Struktur) Commit 74d38fd
- ROADMAP.md: M0 komplett abgehakt.
**Offen/Nächster Schritt:** M1, erster Punkt Uta-Random-Logout-Bug untersuchen
(Logging an allen Logout-Pfaden einbauen, dann Ursache finden). Direkt danach die
Push-Entschlüsselungs-Regression (`docs/NOTIFICATIONS.md` + Memory „Pyramid Push"
vorher lesen).
**Stolperfallen:**
- Kein `test/`-Ordner vorhanden → `flutter test` läuft ins Leere, keine automatisierte
Absicherung. `flutter analyze` lief sauber (0 Fehler/Warnungen), aber die Lint-Fixes
in `document_viewer.dart` (PDF-Zoom) und `rooms_panel.dart` (Drag-Reorder der
Raumliste) sind **UNGETESTET** in echter UI nächster Windows-Lauf sollte kurz
PDF-Zoom und Raum-Umsortieren per Drag prüfen.
- Der `ignore:`-Kommentar für einen deprecated-Aufruf muss auf der Zeile *unmittelbar*
über der betroffenen Codezeile stehen, nicht nur "in der Nähe" sonst wirkt er nicht
(bei `voip_manager.dart` erst falsch über `@override` gesetzt, dann korrigiert).
---
## 2026-07-03 Arbeitsstruktur angelegt + Vollbackup (Setup, noch kein Code)
**Erledigt:**
+50 -11
View File
@@ -1,17 +1,56 @@
# 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
```
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/NOTIFICATIONS.md` Push-Architektur (native Anzeige vs. Hintergrund-Entschlüsselung)
- `docs/matrix-sdk/` Hinweise zum verwendeten Matrix-SDK
+92 -28
View File
@@ -8,29 +8,62 @@ 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.
- [ ] **REGRESSION: Push-Benachrichtigungen werden nicht mehr entschlüsselt** (seit dem
letzten Update; in der Version davor funktionierte es). Vorgehen: Diff der letzten
Release-Versionen auf Push-/E2EE-Code prüfen (`background_push.dart`,
`fcm_push_service.dart`, `notification_service.dart`, Kotlin `PushService.kt`/`BgEngine.kt`),
`docs/NOTIFICATIONS.md` + Memory „Pyramid Push" lesen (dort stehen 3 bekannte
Fallstricke der Hintergrund-Entschlüsselung!), dann Fix + Test auf echtem Gerät.
- [ ] 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.**
`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)
- [ ] 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)
## M2 Das große Refactoring (erst nach M0/M1!)
@@ -38,16 +71,47 @@ 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)
- [ ] Einheitliches State-Management (Riverpod konsequent, keine Misch-Patterns)
- [ ] Toten Code & Duplikate entfernen, Ordnerstruktur vereinheitlichen
- [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) bewusst NICHT mit angefasst
beide hängen eng mit der noch unentschiedenen Call-Fassade bzw. dem
Bootstrap/Login-Pfad zusammen, siehe „Call-Schicht entwirren" unten.
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
## M3 Calls: Logik, UI & Audio (Discord als Referenz)
+6 -4
View File
@@ -9,6 +9,10 @@
cd "$(dirname "$0")"
export PATH="$HOME/.local/bin:$HOME/flutter/bin:$PATH"
# Pyramid laeuft auf eigenem Claude-Konto (Profil ~/.claude-pyramid),
# das Standard-Profil ~/.claude bleibt fuer die Webseiten-Arbeit.
export CLAUDE_CONFIG_DIR="$HOME/.claude-pyramid"
# --- Matrix-Statusberichte (Raum "🔺 Pyramid Autopilot", Token vom Gatus-Bot) ---
MATRIX_URL="http://127.0.0.1:6167"
MATRIX_ROOM="!sYfOwD4Dw4jBDTlNmL:steggi-matrix.work"
@@ -24,9 +28,7 @@ notify_matrix() {
-d "$body" > /dev/null || true
}
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."
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
@@ -36,7 +38,7 @@ while true; do
fi
echo "===== $(date '+%F %T') Neue Claude-Session =====" | tee -a autopilot.log
BEFORE=$(git rev-parse HEAD 2>/dev/null)
claude -p "$PROMPT" --dangerously-skip-permissions 2>&1 | tee -a autopilot.log
claude -p "$PROMPT" --model claude-fable-5 --dangerously-skip-permissions 2>&1 | tee -a autopilot.log
AFTER=$(git rev-parse HEAD 2>/dev/null)
if [ -n "$BEFORE" ] && [ "$BEFORE" != "$AFTER" ]; then
COMMITS=$(git log --oneline --no-decorate "$BEFORE..$AFTER" | head -15)
+165
View File
@@ -0,0 +1,165 @@
# M2 Modul-Schnitt (Entwurf)
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 29214545 (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.
+103
View File
@@ -0,0 +1,103 @@
# PC-Testplan alle aufgelaufenen UNGETESTET(Pi)-Punkte
Stand: 2026-07-03. 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`.
## 1. Härtetest Login/Krypto (M1 PFLICHT, zuerst!)
Deckt die Fixes 63e4919, 9dc83f7, 891f348 ab (Uta-Random-Logout + Guard).
- [ ] Login mit Test-Account → Nachrichten in verschlüsseltem Raum senden/lesen
- [ ] `auth_log.txt` (App-Support-Verzeichnis) prüfen: LoginState-Wechsel
werden mitgeschrieben, keine unerwarteten Warnungen
- [ ] Logout (Warnung bei fehlendem Key-Backup erscheint? b5762ea)
- [ ] Erneuter Login → **alte verschlüsselte Nachrichten noch lesbar?**
- [ ] Mehrere Stunden laufen lassen: kein Random-Logout, kein
Login-Screen-Flackern (isLoggedInProvider-Änderung), `auth_log.txt`
zeigt ggf. „Soft-Logout durch Token-Refresh abgewendet"
- [ ] 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
+125
View File
@@ -0,0 +1,125 @@
# SQLCipher-Migration der `pyramid.sqlite` Umsetzungsplan (PC-Termin)
Stand: 2026-07-03, geschrieben auf dem Pi (Plan gegen den echten Code verifiziert,
Umsetzung braucht PC + Android-Testgerät). Gehört zum offenen M1-Punkt
„Sichere Speicherung von Access-Token & Krypto-DB".
## 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 14 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.
+51
View File
@@ -0,0 +1,51 @@
import 'dart:async';
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.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 getApplicationSupportDirectory();
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';
}
}
}
+7
View File
@@ -9,6 +9,7 @@ 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/soft_logout_guard.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:sqflite/sqflite.dart' as sqflite_native;
@@ -201,6 +202,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
+19
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
@@ -10,6 +11,8 @@ 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/auth_log.dart';
import 'package:pyramid/core/soft_logout_guard.dart';
import 'package:pyramid/core/voip_manager.dart';
final matrixClientProvider = FutureProvider<Client>((ref) async {
@@ -57,12 +60,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,
+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.',
);
}
+3
View File
@@ -528,7 +528,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);
+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;
}
});
+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,
),
],
),
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
+9 -9
View File
@@ -266,7 +266,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),
);
}
}
@@ -603,9 +603,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 +682,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 +722,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 +750,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 +761,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 +812,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';
@@ -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,454 @@
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) {
final pt = PyramidTheme.of(context);
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.
Widget content = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: rows,
);
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: RichText(text: 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: RichText(text: 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: 'Search',
onPressed: onOpenSearch,
),
PyrIconBtn(
size: 28,
icon: const Icon(Icons.group_rounded),
tooltip: 'Members',
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 PyramidVoipManager.instance.startCall(room.id, video: cam);
return;
}
// Use LiveKit for group voice rooms
ref.read(viewModeProvider.notifier).state = ViewMode.voice;
await ref.read(callStateProvider).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: 'Search',
onPressed: widget.onOpenSearch,
),
PyrIconBtn(
size: 28,
icon: const Icon(Icons.group_rounded),
tooltip: 'Members',
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 PyramidVoipManager.instance.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(
'Select a room to start chatting',
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,
);
}
}
+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' : 'Rooms');
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: 'Filter rooms',
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)),
),
),
]),
]),
);
}
}
+608
View File
@@ -0,0 +1,608 @@
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('No rooms', style: TextStyle(color: pt.fgDim, fontSize: 13)));
}
final sectionTitle = isDms ? 'Direct Messages' : 'Rooms';
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) => _RoomItem(
room: room,
isActive: activeRoomId == room.id,
pt: pt,
onTap: () {
ref.read(activeRoomIdProvider.notifier).state = room.id;
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
},
)),
],
);
}
}
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;
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,146 @@
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 = {};
@override
void dispose() {
_filterCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
final roomsAsync = ref.watch(roomListProvider);
final activeSpaceId = ref.watch(activeSpaceIdProvider);
final call = ref.watch(callStateProvider);
final voip = ref.watch(voipStateProvider);
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()],
);
}),
],
),
);
}
}
+279
View File
@@ -0,0 +1,279 @@
part of '../rooms_panel.dart';
class _DmAvatar extends ConsumerStatefulWidget {
final Room room;
final PyramidTheme pt;
const _DmAvatar({required this.room, required this.pt});
@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: 22,
borderRadius: BorderRadius.circular(11),
placeholder: (_) =>
_InitialCircle(initial: initial, color: color, size: 22),
);
} else {
avatar = _InitialCircle(initial: initial, color: color, size: 22);
}
final dot = _dotColor();
if (dot == null) return avatar;
return Stack(clipBehavior: Clip.none, children: [
avatar,
Positioned(
right: -2,
bottom: -2,
child: Container(
width: 9,
height: 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: const TextStyle(
color: Colors.white,
fontSize: 10,
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'),
),
),
],
),
],
),
),
);
}
}
@@ -0,0 +1,569 @@
part of '../rooms_panel.dart';
class _SpaceRoomsList extends ConsumerStatefulWidget {
final String spaceId;
final String filter;
final Map<String, bool> collapsed;
final PyramidTheme pt;
final ValueChanged<String> onToggleSection;
const _SpaceRoomsList({
required this.spaceId,
required this.filter,
required this.collapsed,
required this.pt,
required this.onToggleSection,
});
@override
ConsumerState<_SpaceRoomsList> createState() => _SpaceRoomsListState();
}
class _SpaceRoomsListState extends ConsumerState<_SpaceRoomsList> {
// Local order override after drag-and-drop (room IDs in order per category key)
final Map<String, List<String>> _localOrder = {};
// Joinable channels discovered via the space hierarchy (not yet a member).
List<_JoinableRoom> _joinable = [];
final Set<String> _joining = {};
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final client = ref.read(matrixClientProvider).valueOrNull;
if (client != null) _loadHierarchy(client);
});
}
@override
void didUpdateWidget(covariant _SpaceRoomsList old) {
super.didUpdateWidget(old);
if (old.spaceId != widget.spaceId) {
setState(() => _joinable = []);
final client = ref.read(matrixClientProvider).valueOrNull;
if (client != null) _loadHierarchy(client);
}
}
/// Queries the server-side space hierarchy and keeps only rooms the user is
/// not yet a member of these are the channels they can still join.
Future<void> _loadHierarchy(Client client) async {
try {
final resp = await client.getSpaceHierarchy(
widget.spaceId,
maxDepth: 3,
suggestedOnly: false,
);
final joinable = <_JoinableRoom>[];
for (final chunk in resp.rooms) {
if (chunk.roomId == widget.spaceId) continue;
if (chunk.roomType == 'm.space') continue; // skip sub-spaces
// Keep every channel the hierarchy reports. The build method filters out
// the ones already rendered in the joined sections relying on local
// membership here is unreliable because Continuwuity's sync lags behind.
joinable.add(_JoinableRoom(
roomId: chunk.roomId,
name: chunk.name ?? chunk.canonicalAlias ?? chunk.roomId,
isVoice: chunk.roomType == _kVoiceRoomType,
memberCount: chunk.numJoinedMembers,
));
}
if (mounted) setState(() => _joinable = joinable);
} catch (_) {
// Hierarchy endpoint unavailable / no permission silently ignore.
}
}
Future<void> _joinAndEnter(Client client, _JoinableRoom jr) async {
setState(() => _joining.add(jr.roomId));
try {
await client.joinRoomById(jr.roomId).timeout(const Duration(seconds: 20));
// Give the sync a brief chance to deliver the room object, but don't block
// on it the local membership may lag behind the server.
Room? room = client.getRoomById(jr.roomId);
if (room == null) {
try {
await client.onSync.stream
.firstWhere((_) => client.getRoomById(jr.roomId) != null)
.timeout(const Duration(seconds: 4));
} catch (_) {}
room = client.getRoomById(jr.roomId);
}
if (!mounted) return;
setState(() => _joining.remove(jr.roomId));
if (jr.isVoice) {
// LiveKit only needs the room id + a display name, so connect even if the
// matrix room object hasn't synced locally yet.
final call = ref.read(callStateProvider);
if (call.isActive) await call.hangUp();
ref.read(activeVoiceRoomIdProvider.notifier).state = jr.roomId;
await call.startCall(
roomName: jr.roomId,
roomDisplayName: room?.getLocalizedDisplayname() ?? jr.name,
identity: client.userID ?? '',
audioOnly: true,
voiceChannel: true,
matrixClient: client,
matrixRoomId: jr.roomId,
);
} else if (room != null) {
ref.read(activeRoomIdProvider.notifier).state = room.id;
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
}
} catch (e) {
if (mounted) {
setState(() => _joining.remove(jr.roomId));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content:
Text('Beitritt fehlgeschlagen: ${e.toString().split('\n').first}'),
backgroundColor: widget.pt.danger,
));
}
}
}
Future<void> _applyReorder(
Room space, String categoryKey, List<SpaceChild> newOrder) async {
try {
for (var i = 0; i < newOrder.length; i++) {
final child = newOrder[i];
if (child.roomId == null) continue;
final newOrd = (i + 1).toString().padLeft(4, '0');
if (child.order != newOrd) {
await space.setSpaceChild(child.roomId!,
via: child.via.isEmpty ? null : child.via, order: newOrd);
}
}
} catch (_) {
}
}
@override
Widget build(BuildContext context) {
// Rebuild when rooms list changes (sync)
ref.watch(roomListProvider);
final client = ref.watch(matrixClientProvider).valueOrNull;
final activeRoomId = ref.watch(activeRoomIdProvider);
if (client == null) {
return Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: widget.pt.accent)));
}
final space = client.getRoomById(widget.spaceId);
if (space == null) {
return Center(
child: Text('Space nicht gefunden',
style: TextStyle(color: widget.pt.fgDim, fontSize: 12)));
}
final isAdmin = space.ownPowerLevel >= 50;
final allChildren = space.spaceChildren;
// Separate sub-spaces (categories) from direct rooms
final subSpaceEntries = <SpaceChild>[];
final directEntries = <SpaceChild>[];
for (final c in allChildren) {
if (c.roomId == null) continue;
final room = client.getRoomById(c.roomId!);
if (room == null || room.membership == Membership.leave) continue;
if (room.isSpace) {
subSpaceEntries.add(c);
} else {
directEntries.add(c);
}
}
subSpaceEntries.sort(_compareSpaceChildren);
directEntries.sort(_compareSpaceChildren);
// Collect invited rooms
final invites = allChildren
.where((c) => c.roomId != null)
.map((c) => client.getRoomById(c.roomId!))
.whereType<Room>()
.where((r) => r.membership == Membership.invite)
.toList();
// IDs already rendered in the joined/invited sections used to avoid showing
// them again under "available channels" from the hierarchy.
final shownIds = <String>{...invites.map((r) => r.id)};
void selectRoom(Room room) {
ref.read(activeRoomIdProvider.notifier).state = room.id;
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
}
void joinVoiceChannel(Room room) async {
final call = ref.read(callStateProvider);
final activeVoice = ref.read(activeVoiceRoomIdProvider);
// Toggle: leave if already in this channel
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;
final userId = client.userID ?? '';
await call.startCall(
roomName: room.id,
roomDisplayName: room.getLocalizedDisplayname(),
identity: userId,
audioOnly: true,
voiceChannel: true,
matrixClient: client,
matrixRoomId: room.id,
);
}
// Build category sections for sub-spaces
List<Widget> categoryWidgets = [];
for (final catEntry in subSpaceEntries) {
final subSpace = client.getRoomById(catEntry.roomId!)!;
final catId = subSpace.id;
final catName = subSpace.getLocalizedDisplayname();
final isCollapsed = widget.collapsed[catId] ?? false;
final catChildren = subSpace.spaceChildren
.where((c) => c.roomId != null)
.map((c) => client.getRoomById(c.roomId!))
.whereType<Room>()
.where((r) =>
r.membership != Membership.leave && !r.isSpace)
.toList();
shownIds.addAll(catChildren.map((r) => r.id));
final filtered = catChildren
.where((r) => widget.filter.isEmpty ||
r.getLocalizedDisplayname()
.toLowerCase()
.contains(widget.filter.toLowerCase()))
.toList();
categoryWidgets.add(_CategoryHeader(
name: catName.toUpperCase(),
isCollapsed: isCollapsed,
pt: widget.pt,
onTap: () => widget.onToggleSection(catId),
));
if (!isCollapsed) {
for (final room in filtered) {
final isVoice = _isVoiceRoom(room);
categoryWidgets.add(_RoomItem(
key: ValueKey('cat_${room.id}'),
room: room,
isActive: isVoice ? false : activeRoomId == room.id,
isInVoice: isVoice && ref.read(activeVoiceRoomIdProvider) == room.id,
pt: widget.pt,
onTap: () => isVoice ? joinVoiceChannel(room) : selectRoom(room),
));
}
}
}
// Build direct rooms section (reorderable for admin)
final directRooms = directEntries
.map((c) => client.getRoomById(c.roomId!))
.whereType<Room>()
.toList();
shownIds.addAll(directRooms.map((r) => r.id));
// Apply local order override if present
final localOrd = _localOrder['__direct__'];
if (localOrd != null) {
directRooms.sort((a, b) {
final ai = localOrd.indexOf(a.id);
final bi = localOrd.indexOf(b.id);
if (ai == -1 && bi == -1) return 0;
if (ai == -1) return 1;
if (bi == -1) return -1;
return ai.compareTo(bi);
});
}
final filteredDirect = directRooms
.where((r) => widget.filter.isEmpty ||
r.getLocalizedDisplayname()
.toLowerCase()
.contains(widget.filter.toLowerCase()))
.toList();
// Joinable channels from the hierarchy that aren't already listed above.
final visibleJoinable = _joinable
.where((jr) =>
!shownIds.contains(jr.roomId) &&
(widget.filter.isEmpty ||
jr.name.toLowerCase().contains(widget.filter.toLowerCase())))
.toList();
// Build the complete list
return ListView(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 12),
children: [
// Invite cards
if (invites.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 8, 2),
child: Text('EINLADUNGEN',
style: TextStyle(
color: widget.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: widget.pt))),
const SizedBox(height: 4),
],
// Sub-space categories
...categoryWidgets,
// Direct rooms
if (filteredDirect.isNotEmpty) ...[
if (subSpaceEntries.isNotEmpty)
_CategoryHeader(
name: 'KANÄLE',
isCollapsed: widget.collapsed['__direct__'] ?? false,
pt: widget.pt,
onTap: () => widget.onToggleSection('__direct__'),
),
if (!(widget.collapsed['__direct__'] ?? false))
if (isAdmin)
// Reorderable list for admins
ReorderableListView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
onReorderItem: (oldIdx, newIdx) {
final ids = filteredDirect.map((r) => r.id).toList();
final moved = ids.removeAt(oldIdx);
ids.insert(newIdx, moved);
setState(() => _localOrder['__direct__'] = ids);
// Apply to Matrix
final newOrder = ids
.map((id) =>
directEntries.firstWhere((c) => c.roomId == id,
orElse: () => directEntries.first))
.toList();
_applyReorder(space, '__direct__', newOrder);
},
children: [
for (var i = 0; i < filteredDirect.length; i++)
_DraggableRoomItem(
key: ValueKey(filteredDirect[i].id),
room: filteredDirect[i],
index: i,
isActive: _isVoiceRoom(filteredDirect[i]) ? false : activeRoomId == filteredDirect[i].id,
isInVoice: _isVoiceRoom(filteredDirect[i]) && ref.read(activeVoiceRoomIdProvider) == filteredDirect[i].id,
pt: widget.pt,
onTap: () => _isVoiceRoom(filteredDirect[i]) ? joinVoiceChannel(filteredDirect[i]) : selectRoom(filteredDirect[i]),
),
],
)
else
...filteredDirect.map((r) {
final isVoice = _isVoiceRoom(r);
return _RoomItem(
key: ValueKey(r.id),
room: r,
isActive: isVoice ? false : activeRoomId == r.id,
isInVoice: isVoice && ref.read(activeVoiceRoomIdProvider) == r.id,
pt: widget.pt,
onTap: () => isVoice ? joinVoiceChannel(r) : selectRoom(r),
);
}),
],
// Joinable channels from the space hierarchy (not yet a member)
if (visibleJoinable.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(8, 12, 8, 2),
child: Text('VERFÜGBARE KANÄLE',
style: TextStyle(
color: widget.pt.fgDim,
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 0.6)),
),
...visibleJoinable.map((jr) => _JoinableRoomItem(
jr: jr,
joining: _joining.contains(jr.roomId),
pt: widget.pt,
onJoin: () => _joinAndEnter(client, jr),
)),
],
if (filteredDirect.isEmpty &&
categoryWidgets.isEmpty &&
invites.isEmpty &&
visibleJoinable.isEmpty)
Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: Text('Keine Kanäle',
style:
TextStyle(color: widget.pt.fgDim, fontSize: 13))),
),
],
);
}
}
class _JoinableRoom {
final String roomId;
final String name;
final bool isVoice;
final int memberCount;
const _JoinableRoom({
required this.roomId,
required this.name,
required this.isVoice,
required this.memberCount,
});
}
class _JoinableRoomItem extends StatefulWidget {
final _JoinableRoom jr;
final bool joining;
final PyramidTheme pt;
final VoidCallback onJoin;
const _JoinableRoomItem({
required this.jr,
required this.joining,
required this.pt,
required this.onJoin,
});
@override
State<_JoinableRoomItem> createState() => _JoinableRoomItemState();
}
class _JoinableRoomItemState extends State<_JoinableRoomItem> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final jr = widget.jr;
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.joining ? null : widget.onJoin,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.symmetric(vertical: 1),
padding: EdgeInsets.symmetric(
horizontal: pt.rowPadX, vertical: pt.rowPadY),
decoration: BoxDecoration(
color: _hovered ? pt.bgHover : Colors.transparent,
borderRadius: BorderRadius.circular(pt.rSm),
),
child: Row(
children: [
SizedBox(
width: 16,
height: 16,
child: Icon(
jr.isVoice ? Icons.mic_rounded : Icons.tag_rounded,
size: 14,
color: pt.fgDim,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
jr.name,
style: TextStyle(
color: pt.fgMuted,
fontSize: 14,
fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis,
),
),
if (widget.joining)
const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
)
else
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: _hovered ? pt.accent : pt.bg3,
borderRadius: BorderRadius.circular(6),
),
child: Text(
'Beitreten',
style: TextStyle(
color: _hovered ? pt.accentFg : pt.fgMuted,
fontSize: 11,
fontWeight: FontWeight.w600),
),
),
],
),
),
),
);
}
}
class _CategoryHeader extends StatefulWidget {
final String name;
final bool isCollapsed;
final PyramidTheme pt;
final VoidCallback onTap;
const _CategoryHeader(
{required this.name,
required this.isCollapsed,
required this.pt,
required this.onTap});
@override
State<_CategoryHeader> createState() => _CategoryHeaderState();
}
class _CategoryHeaderState extends State<_CategoryHeader> {
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: Padding(
padding: const EdgeInsets.fromLTRB(8, 10, 8, 2),
child: Row(children: [
AnimatedRotation(
turns: widget.isCollapsed ? -0.25 : 0,
duration: const Duration(milliseconds: 180),
child: Icon(Icons.keyboard_arrow_down_rounded,
size: 12,
color: _hovered ? pt.fgMuted : pt.fgDim),
),
const SizedBox(width: 4),
Text(
widget.name,
style: TextStyle(
color: _hovered ? pt.fgMuted : pt.fgDim,
fontSize: 11,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
),
]),
),
),
);
}
}
+132
View File
@@ -0,0 +1,132 @@
part of '../rooms_panel.dart';
class _VoiceParticipantList extends StatelessWidget {
final List<Map<String, dynamic>> participants;
final Client? client;
final PyramidTheme pt;
const _VoiceParticipantList(
{required this.participants, required this.client, required this.pt});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: participants.map((p) {
final avatarUrl = p['avatarUrl'] as String?;
final name = (p['displayName'] as String? ?? '?');
final letter = name.isNotEmpty ? name[0].toUpperCase() : '?';
return Padding(
padding: const EdgeInsets.only(top: 3),
child: Row(
children: [
SizedBox(
width: 16,
height: 16,
child: client != null && avatarUrl != null
? MxcAvatar(
mxcUri: Uri.tryParse(avatarUrl),
client: client!,
size: 16,
borderRadius: BorderRadius.circular(8),
placeholder: (_) => _Initials(letter: letter, pt: pt, size: 16),
)
: _Initials(letter: letter, pt: pt, size: 16),
),
const SizedBox(width: 6),
Expanded(
child: Text(
name,
style: TextStyle(
color: const Color(0xFF57F287).withAlpha(200),
fontSize: 12,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
Icon(Icons.mic_rounded,
size: 11,
color: const Color(0xFF57F287).withAlpha(140)),
],
),
);
}).toList(),
);
}
}
class _Initials extends StatelessWidget {
final String letter;
final PyramidTheme pt;
final double size;
const _Initials({required this.letter, required this.pt, this.size = 18});
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: pt.bg3,
borderRadius: BorderRadius.circular(size / 2),
),
child: Center(
child: Text(letter,
style: TextStyle(
color: pt.fg,
fontSize: size * 0.5,
fontWeight: FontWeight.w700)),
),
);
}
}
class _VoiceBarBtn extends StatefulWidget {
final IconData icon;
final bool active;
final Color activeColor;
final Color inactiveColor;
final PyramidTheme pt;
final VoidCallback onTap;
const _VoiceBarBtn({
required this.icon,
required this.active,
required this.activeColor,
required this.inactiveColor,
required this.pt,
required this.onTap,
});
@override
State<_VoiceBarBtn> createState() => _VoiceBarBtnState();
}
class _VoiceBarBtnState extends State<_VoiceBarBtn> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final color = widget.active ? widget.activeColor : widget.inactiveColor;
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),
width: 28,
height: 28,
decoration: BoxDecoration(
color: _hovered ? pt.bg3 : Colors.transparent,
borderRadius: BorderRadius.circular(6),
),
child: Center(child: Icon(widget.icon, size: 16, color: color)),
),
),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,587 @@
part of '../space_admin_dialog.dart';
const _kSpaceChild = 'm.space.child';
const _kSpaceParent = 'm.space.parent';
const _kVoiceChannelType = 'io.pyramid.voice_channel';
class _ChannelsTab extends ConsumerStatefulWidget {
final Room space;
const _ChannelsTab({required this.space});
@override
ConsumerState<_ChannelsTab> createState() => _ChannelsTabState();
}
class _ChannelsTabState extends ConsumerState<_ChannelsTab> {
bool _busy = false;
List<({String roomId, Room? room, bool isVoice})> _getChildren(Client client) {
final childStates = widget.space.states[_kSpaceChild] ?? {};
final result = <({String roomId, Room? room, bool isVoice})>[];
for (final entry in childStates.entries) {
final content = entry.value.content;
if (content.isEmpty) continue;
final childRoomId = entry.key;
final room = client.getRoomById(childRoomId);
final createContent = room?.getState('m.room.create')?.content;
final isVoice = createContent?['type'] == _kVoiceChannelType;
result.add((roomId: childRoomId, room: room, isVoice: isVoice));
}
result.sort((a, b) {
final nameA = a.room?.getLocalizedDisplayname() ?? a.roomId;
final nameB = b.room?.getLocalizedDisplayname() ?? b.roomId;
return nameA.compareTo(nameB);
});
return result;
}
Future<void> _removeChild(String roomId) async {
final client = ref.read(matrixClientProvider).valueOrNull;
if (client == null) return;
setState(() => _busy = true);
try {
await client.setRoomStateWithKey(widget.space.id, _kSpaceChild, roomId, {});
if (mounted) setState(() {});
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(e.toString())));
}
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _addChild(String roomId) async {
final client = ref.read(matrixClientProvider).valueOrNull;
if (client == null) return;
final serverName = client.userID?.split(':').last ?? '';
try {
await client.setRoomStateWithKey(
widget.space.id,
_kSpaceChild,
roomId,
{'via': [serverName], 'suggested': false},
);
// Best-effort: set parent in child room
try {
await client.setRoomStateWithKey(
roomId,
_kSpaceParent,
widget.space.id,
{'via': [serverName], 'canonical': true},
);
} catch (_) {}
if (mounted) setState(() {});
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(e.toString())));
}
}
}
Future<void> _createAndAdd({
required String name,
required bool isVoice,
required Client client,
}) async {
setState(() => _busy = true);
try {
final serverName = client.userID?.split(':').last ?? '';
final roomId = await client.createRoom(
name: name,
visibility: Visibility.private,
preset: CreateRoomPreset.privateChat,
creationContent: isVoice ? {'type': _kVoiceChannelType} : null,
initialState: [
StateEvent(
type: 'm.room.join_rules',
content: {'join_rule': 'restricted', 'allow': [
{'type': 'm.room_membership', 'room_id': widget.space.id}
]},
),
],
);
await client.setRoomStateWithKey(
widget.space.id,
_kSpaceChild,
roomId,
{'via': [serverName], 'suggested': false},
);
try {
await client.setRoomStateWithKey(
roomId,
_kSpaceParent,
widget.space.id,
{'via': [serverName], 'canonical': true},
);
} catch (_) {}
if (mounted) setState(() {});
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(e.toString())));
}
} finally {
if (mounted) setState(() => _busy = false);
}
}
void _showCreateDialog(BuildContext context, Client client) {
showDialog(
context: context,
builder: (_) => _CreateChannelDialog(
onConfirm: (name, isVoice) =>
_createAndAdd(name: name, isVoice: isVoice, client: client),
),
);
}
void _showAddExistingDialog(BuildContext context, Client client) {
final childStates = widget.space.states[_kSpaceChild] ?? {};
final existingIds = {
for (final e in childStates.entries)
if (e.value.content.isNotEmpty) e.key
};
final available = client.rooms
.where((r) => !r.isSpace && !existingIds.contains(r.id))
.toList()
..sort((a, b) => a.getLocalizedDisplayname()
.compareTo(b.getLocalizedDisplayname()));
showDialog(
context: context,
builder: (_) => _AddExistingDialog(
rooms: available,
client: client,
onConfirm: (roomId) => _addChild(roomId),
),
);
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
final client = ref.watch(matrixClientProvider).valueOrNull;
final ownPower = widget.space.ownPowerLevel;
final canManage = ownPower >= _kPowerMod;
if (client == null) {
return Center(
child: CircularProgressIndicator(color: pt.accent, strokeWidth: 2));
}
final children = _getChildren(client);
return Column(
children: [
if (canManage)
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Row(
children: [
Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.add, size: 16),
label: const Text('Kanal erstellen'),
style: OutlinedButton.styleFrom(
foregroundColor: pt.accent,
side: BorderSide(color: pt.accent),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rSm)),
),
onPressed: _busy
? null
: () => _showCreateDialog(context, client),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.link, size: 16),
label: const Text('Raum hinzufügen'),
style: OutlinedButton.styleFrom(
foregroundColor: pt.fgDim,
side: BorderSide(color: pt.border),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rSm)),
),
onPressed: _busy
? null
: () => _showAddExistingDialog(context, client),
),
),
],
),
),
if (children.isEmpty)
Expanded(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.forum_outlined, color: pt.fgDim, size: 40),
const SizedBox(height: 8),
Text('Noch keine Kanäle',
style: TextStyle(color: pt.fgDim, fontSize: 13)),
],
),
),
)
else
Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
itemCount: children.length,
itemBuilder: (context, i) {
final child = children[i];
final name = child.room?.getLocalizedDisplayname() ??
child.roomId;
return _ChannelItem(
name: name,
isVoice: child.isVoice,
roomId: child.roomId,
pt: pt,
canRemove: canManage,
onRemove: () => _removeChild(child.roomId),
);
},
),
),
],
);
}
}
class _ChannelItem extends StatefulWidget {
final String name;
final bool isVoice;
final String roomId;
final PyramidTheme pt;
final bool canRemove;
final VoidCallback onRemove;
const _ChannelItem({
required this.name,
required this.isVoice,
required this.roomId,
required this.pt,
required this.canRemove,
required this.onRemove,
});
@override
State<_ChannelItem> createState() => _ChannelItemState();
}
class _ChannelItemState extends State<_ChannelItem> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final pt = widget.pt;
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: Container(
margin: const EdgeInsets.symmetric(vertical: 1),
decoration: BoxDecoration(
color: _hovered ? pt.bg2 : Colors.transparent,
borderRadius: BorderRadius.circular(pt.rSm),
),
child: ListTile(
dense: true,
contentPadding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 0),
leading: Icon(
widget.isVoice
? Icons.volume_up_outlined
: Icons.tag,
color: pt.fgDim,
size: 18,
),
title: Text(
widget.name,
style: TextStyle(
color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
widget.isVoice ? 'Sprachkanal' : 'Textkanal',
style: TextStyle(color: pt.fgDim, fontSize: 11),
),
trailing: widget.canRemove && _hovered
? IconButton(
icon: Icon(Icons.link_off,
color: const Color(0xFFED4245), size: 18),
tooltip: 'Aus Space entfernen',
onPressed: widget.onRemove,
)
: null,
),
),
);
}
}
class _CreateChannelDialog extends StatefulWidget {
final void Function(String name, bool isVoice) onConfirm;
const _CreateChannelDialog({required this.onConfirm});
@override
State<_CreateChannelDialog> createState() => _CreateChannelDialogState();
}
class _CreateChannelDialogState extends State<_CreateChannelDialog> {
final _ctrl = TextEditingController();
bool _isVoice = false;
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
return AlertDialog(
backgroundColor: pt.bg1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rLg)),
title: Text('Neuen Kanal erstellen',
style: TextStyle(color: pt.fg, fontWeight: FontWeight.w700)),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SectionLabel('Name', pt),
const SizedBox(height: 6),
_Field(controller: _ctrl, hint: 'kanal-name', pt: pt),
const SizedBox(height: 16),
_SectionLabel('Typ', pt),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: GestureDetector(
onTap: () => setState(() => _isVoice = false),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: !_isVoice ? pt.accent.withAlpha(40) : pt.bg0,
border: Border.all(
color: !_isVoice ? pt.accent : pt.border),
borderRadius: BorderRadius.circular(pt.rSm),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.tag,
color: !_isVoice ? pt.accent : pt.fgDim,
size: 16),
const SizedBox(width: 6),
Text('Text',
style: TextStyle(
color: !_isVoice ? pt.accent : pt.fgDim,
fontSize: 13,
fontWeight: FontWeight.w600)),
],
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: GestureDetector(
onTap: () => setState(() => _isVoice = true),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: _isVoice ? pt.accent.withAlpha(40) : pt.bg0,
border: Border.all(
color: _isVoice ? pt.accent : pt.border),
borderRadius: BorderRadius.circular(pt.rSm),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.volume_up_outlined,
color: _isVoice ? pt.accent : pt.fgDim,
size: 16),
const SizedBox(width: 6),
Text('Sprache',
style: TextStyle(
color: _isVoice ? pt.accent : pt.fgDim,
fontSize: 13,
fontWeight: FontWeight.w600)),
],
),
),
),
),
],
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Abbrechen')),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: pt.accent),
onPressed: () {
final name = _ctrl.text.trim();
if (name.isEmpty) return;
Navigator.pop(context);
widget.onConfirm(name, _isVoice);
},
child: const Text('Erstellen',
style: TextStyle(color: Colors.white)),
),
],
);
}
}
class _AddExistingDialog extends StatefulWidget {
final List<Room> rooms;
final Client client;
final void Function(String roomId) onConfirm;
const _AddExistingDialog({
required this.rooms,
required this.client,
required this.onConfirm,
});
@override
State<_AddExistingDialog> createState() => _AddExistingDialogState();
}
class _AddExistingDialogState extends State<_AddExistingDialog> {
String _filter = '';
final _ctrl = TextEditingController();
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
final filtered = _filter.isEmpty
? widget.rooms
: widget.rooms
.where((r) => r
.getLocalizedDisplayname()
.toLowerCase()
.contains(_filter.toLowerCase()))
.toList();
return Dialog(
backgroundColor: pt.bg1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rLg)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400, maxHeight: 520),
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text('Raum hinzufügen',
style: TextStyle(
color: pt.fg,
fontSize: 17,
fontWeight: FontWeight.w700)),
),
IconButton(
icon: Icon(Icons.close, color: pt.fgDim, size: 20),
onPressed: () => Navigator.pop(context),
),
],
),
const SizedBox(height: 12),
_SearchField(
controller: _ctrl,
hint: 'Raum suchen…',
pt: pt,
onChanged: (v) => setState(() => _filter = v),
),
],
),
),
const Divider(height: 1),
if (filtered.isEmpty)
Expanded(
child: Center(
child: Text('Keine Räume gefunden',
style: TextStyle(color: pt.fgDim, fontSize: 13)),
),
)
else
Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
itemCount: filtered.length,
itemBuilder: (context, i) {
final room = filtered[i];
final name = room.getLocalizedDisplayname();
final letter =
name.isNotEmpty ? name[0].toUpperCase() : '?';
final avatarUri = room.avatar;
final createContent =
room.getState('m.room.create')?.content;
final isVoice =
createContent?['type'] == _kVoiceChannelType;
return ListTile(
dense: true,
leading: avatarUri != null
? MxcAvatar(
mxcUri: avatarUri,
client: widget.client,
size: 32,
borderRadius: BorderRadius.circular(pt.rSm),
placeholder: (_) => _InitialAvatar(
letter: letter, pt: pt, size: 32),
)
: _InitialAvatar(letter: letter, pt: pt, size: 32),
title: Text(name,
style: TextStyle(
color: pt.fg,
fontSize: 13,
fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis),
subtitle: Text(
isVoice ? 'Sprachkanal' : 'Textkanal',
style: TextStyle(color: pt.fgDim, fontSize: 11),
),
trailing:
Icon(isVoice ? Icons.volume_up_outlined : Icons.tag,
color: pt.fgDim, size: 16),
onTap: () {
Navigator.pop(context);
widget.onConfirm(room.id);
},
);
},
),
),
],
),
),
);
}
}
@@ -0,0 +1,205 @@
part of '../space_admin_dialog.dart';
const _kSpaceBannerEvent = 'io.pyramid.space.banner';
/// Ändert die Power-Levels auf Basis des aktuellen SERVER-Stands (nicht des
/// lokalen Caches!). Das SDK-`setPower` und ein lokal fehlendes
/// power_levels-Event haben sonst zur Folge, dass die users-Map des Servers
/// mit einem Minimal-Event überschrieben wird und Admins sich selbst
/// aussperren (genau das ist hier bereits passiert).
Future<void> updatePowerLevelsSafely(
Room room,
void Function(Map<String, dynamic> content) mutate,
) async {
Map<String, dynamic> content;
try {
content = Map<String, dynamic>.from(
await room.client
.getRoomStateWithKey(room.id, EventTypes.RoomPowerLevels, ''),
);
} on MatrixException catch (e) {
if (e.errcode == 'M_NOT_FOUND') {
content = <String, dynamic>{};
} else {
rethrow;
}
}
final users = content['users'];
content['users'] =
users is Map ? Map<String, dynamic>.from(users) : <String, dynamic>{};
mutate(content);
// Selbst-Aussperr-Schutz: Nach der Änderung muss der eigene Account immer
// noch hoch genug stehen, um Power-Levels erneut zu ändern sonst gäbe es
// kein Zurück mehr. Greift bei versehentlicher Selbst-Degradierung ODER
// wenn ein neu geschriebenes Event den eigenen Eintrag nicht enthält.
final myId = room.client.userID;
if (myId != null) {
int asInt(Object? v, int fallback) => v is int ? v : fallback;
final usersMap = content['users'] as Map<String, dynamic>;
final myLevel = asInt(usersMap[myId], asInt(content['users_default'], 0));
final events = content['events'];
final neededForPl = (events is Map && events['m.room.power_levels'] != null)
? asInt(events['m.room.power_levels'], 50)
: asInt(content['state_default'], 50);
if (myLevel < neededForPl) {
throw Exception(
'Abgebrochen, um eine Selbst-Aussperrung zu verhindern: '
'Diese Änderung würde dein Level auf $myLevel senken, '
'aber zum Verwalten der Berechtigungen sind $neededForPl nötig.',
);
}
}
await room.client
.setRoomStateWithKey(room.id, EventTypes.RoomPowerLevels, '', content);
}
// Power level labels
const _kPowerAdmin = 100;
const _kPowerMod = 50;
const _kPowerMember = 0;
String _powerLabel(int level) {
if (level >= _kPowerAdmin) return 'Admin';
if (level >= _kPowerMod) return 'Moderator';
return 'Mitglied';
}
Color _powerColor(int level, PyramidTheme pt) {
if (level >= _kPowerAdmin) return const Color(0xFFED4245);
if (level >= _kPowerMod) return const Color(0xFFF59E0B);
return pt.fgDim;
}
class SpaceAdminDialog extends ConsumerStatefulWidget {
final Room space;
const SpaceAdminDialog({super.key, required this.space});
@override
ConsumerState<SpaceAdminDialog> createState() => _SpaceAdminDialogState();
}
class _SpaceAdminDialogState extends ConsumerState<SpaceAdminDialog>
with SingleTickerProviderStateMixin {
late TabController _tabs;
@override
void initState() {
super.initState();
_tabs = TabController(length: 4, vsync: this);
}
@override
void dispose() {
_tabs.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
return Dialog(
backgroundColor: pt.bg1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(pt.rLg)),
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 40),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560, maxHeight: 680),
child: Column(
children: [
_DialogHeader(space: widget.space, pt: pt, tabs: _tabs),
Expanded(
child: TabBarView(
controller: _tabs,
children: [
_OverviewTab(space: widget.space),
_MembersTab(space: widget.space),
_PermissionsTab(space: widget.space),
_ChannelsTab(space: widget.space),
],
),
),
],
),
),
);
}
}
class _DialogHeader extends ConsumerWidget {
final Room space;
final PyramidTheme pt;
final TabController tabs;
const _DialogHeader({required this.space, required this.pt, required this.tabs});
@override
Widget build(BuildContext context, WidgetRef ref) {
final client = ref.watch(matrixClientProvider).valueOrNull;
final name = space.getLocalizedDisplayname();
final avatarUri = space.avatar;
final letter = name.isNotEmpty ? name[0].toUpperCase() : '?';
Widget avatar;
if (client != null && avatarUri != null) {
avatar = MxcAvatar(
mxcUri: avatarUri,
client: client,
size: 40,
borderRadius: BorderRadius.circular(pt.rBase),
placeholder: (_) => _InitialAvatar(letter: letter, pt: pt, size: 40),
);
} else {
avatar = _InitialAvatar(letter: letter, pt: pt, size: 40);
}
return Container(
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: pt.border)),
),
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 12, 12),
child: Row(
children: [
avatar,
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(name,
style: TextStyle(
color: pt.fg,
fontSize: 17,
fontWeight: FontWeight.w700)),
Text('Space-Einstellungen',
style: TextStyle(color: pt.fgDim, fontSize: 12)),
],
),
),
IconButton(
icon: Icon(Icons.close, color: pt.fgDim, size: 20),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
TabBar(
controller: tabs,
labelColor: pt.accent,
unselectedLabelColor: pt.fgDim,
indicatorColor: pt.accent,
indicatorSize: TabBarIndicatorSize.label,
tabs: const [
Tab(text: 'Übersicht'),
Tab(text: 'Mitglieder'),
Tab(text: 'Berechtigungen'),
Tab(text: 'Kanäle'),
],
),
],
),
);
}
}
@@ -0,0 +1,462 @@
part of '../space_admin_dialog.dart';
class _MembersTab extends ConsumerStatefulWidget {
final Room space;
const _MembersTab({required this.space});
@override
ConsumerState<_MembersTab> createState() => _MembersTabState();
}
class _MembersTabState extends ConsumerState<_MembersTab> {
List<User>? _members;
bool _loading = true;
String? _error;
String _filter = '';
final _filterCtrl = TextEditingController();
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_filterCtrl.dispose();
super.dispose();
}
Future<void> _load() async {
try {
final members =
await widget.space.requestParticipants([Membership.join, Membership.invite]);
if (mounted) setState(() { _members = members; _loading = false; });
} catch (e) {
if (mounted) setState(() { _error = e.toString(); _loading = false; });
}
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
if (_loading) {
return Center(
child: CircularProgressIndicator(color: pt.accent, strokeWidth: 2));
}
if (_error != null) {
return Center(
child: Text(_error!, style: TextStyle(color: pt.fgDim, fontSize: 13)));
}
final members = _members ?? [];
final filtered = _filter.isEmpty
? members
: members
.where((m) =>
(m.displayName ?? m.id)
.toLowerCase()
.contains(_filter.toLowerCase()) ||
m.id.toLowerCase().contains(_filter.toLowerCase()))
.toList()
..sort((a, b) => (b.powerLevel).compareTo(a.powerLevel));
final ownPower = widget.space.ownPowerLevel;
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Row(
children: [
Expanded(
child: _SearchField(
controller: _filterCtrl,
hint: 'Mitglieder suchen…',
pt: pt,
onChanged: (v) => setState(() => _filter = v),
),
),
if (ownPower >= _kPowerMod) ...[
const SizedBox(width: 8),
_InviteButton(space: widget.space),
],
],
),
),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 8),
itemCount: filtered.length,
itemBuilder: (context, i) {
final member = filtered[i];
return _MemberTile(
member: member,
space: widget.space,
ownPower: ownPower,
onChanged: _load,
);
},
),
),
],
);
}
}
class _InviteButton extends ConsumerStatefulWidget {
final Room space;
const _InviteButton({required this.space});
@override
ConsumerState<_InviteButton> createState() => _InviteButtonState();
}
class _InviteButtonState extends ConsumerState<_InviteButton> {
bool _inviting = false;
String? _inviteError;
void _showInvite() {
final pt = PyramidTheme.of(context);
final ctrl = TextEditingController();
showDialog(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setS) => AlertDialog(
backgroundColor: pt.bg1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rLg)),
title: Text('Zu Space einladen',
style: TextStyle(color: pt.fg, fontWeight: FontWeight.w700)),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Field(controller: ctrl, hint: '@user:server.org', pt: pt),
const SizedBox(height: 6),
Text('Lädt zu Space und allen Kanälen ein.',
style: TextStyle(color: pt.fgDim, fontSize: 11)),
if (_inviteError != null) ...[
const SizedBox(height: 6),
Text(_inviteError!,
style: TextStyle(color: pt.danger, fontSize: 12)),
],
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Abbrechen')),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: pt.accent),
onPressed: _inviting
? null
: () async {
final userId = ctrl.text.trim();
if (userId.isEmpty) return;
setS(() { _inviting = true; _inviteError = null; });
try {
// 1. Invite to space itself
await widget.space.invite(userId);
// 2. Invite to every joined child room
final client = widget.space.client;
for (final child in widget.space.spaceChildren) {
final roomId = child.roomId;
if (roomId == null) continue;
final room = client.getRoomById(roomId);
if (room == null || room.membership == Membership.leave) continue;
try {
await room.invite(userId);
} catch (_) {}
}
if (ctx.mounted) Navigator.pop(ctx);
} catch (e) {
setS(() { _inviteError = e.toString().split('\n').first; });
} finally {
setS(() => _inviting = false);
}
},
child: _inviting
? const SizedBox(
width: 16, height: 16,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Text('Einladen',
style: TextStyle(color: Colors.white)),
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
return IconButton(
icon: Icon(Icons.person_add_outlined, color: pt.accent, size: 20),
tooltip: 'Einladen',
onPressed: _showInvite,
);
}
}
class _MemberTile extends ConsumerStatefulWidget {
final User member;
final Room space;
final int ownPower;
final VoidCallback onChanged;
const _MemberTile({
required this.member,
required this.space,
required this.ownPower,
required this.onChanged,
});
@override
ConsumerState<_MemberTile> createState() => _MemberTileState();
}
class _MemberTileState extends ConsumerState<_MemberTile> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
final member = widget.member;
final power = member.powerLevel;
final name = member.displayName ?? member.id;
final client = ref.watch(matrixClientProvider).valueOrNull;
final avatarUri = member.avatarUrl;
final isMe = member.id == widget.space.client.userID;
final canManage = widget.ownPower > power && !isMe;
Widget avatar;
final letter = name.isNotEmpty ? name[0].toUpperCase() : '?';
if (client != null && avatarUri != null) {
avatar = MxcAvatar(
mxcUri: avatarUri,
client: client,
size: 32,
borderRadius: BorderRadius.circular(16),
placeholder: (_) => _InitialAvatar(letter: letter, pt: pt, size: 32),
);
} else {
avatar = _InitialAvatar(letter: letter, pt: pt, size: 32);
}
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: Container(
margin: const EdgeInsets.symmetric(vertical: 1),
decoration: BoxDecoration(
color: _hovered ? pt.bg2 : Colors.transparent,
borderRadius: BorderRadius.circular(pt.rSm),
),
child: ListTile(
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
leading: avatar,
title: Text(name,
style: TextStyle(
color: pt.fg,
fontSize: 13,
fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis),
subtitle: Text(member.id,
style: TextStyle(color: pt.fgDim, fontSize: 11),
overflow: TextOverflow.ellipsis),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
decoration: BoxDecoration(
color: _powerColor(power, pt).withAlpha(30),
borderRadius: BorderRadius.circular(6),
),
child: Text(_powerLabel(power),
style: TextStyle(
color: _powerColor(power, pt),
fontSize: 11,
fontWeight: FontWeight.w600)),
),
if (canManage) ...[
const SizedBox(width: 4),
_MemberActions(
member: member,
space: widget.space,
ownPower: widget.ownPower,
onChanged: widget.onChanged),
],
],
),
),
),
);
}
}
class _MemberActions extends ConsumerWidget {
final User member;
final Room space;
final int ownPower;
final VoidCallback onChanged;
const _MemberActions({
required this.member,
required this.space,
required this.ownPower,
required this.onChanged,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pt = PyramidTheme.of(context);
return PopupMenuButton<_MemberAction>(
icon: Icon(Icons.more_vert, color: pt.fgDim, size: 18),
color: pt.bg2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rBase)),
itemBuilder: (_) => [
if (ownPower >= _kPowerAdmin && member.powerLevel < _kPowerMod)
const PopupMenuItem(
value: _MemberAction.makeMod,
child: Text('Zum Moderator machen'),
),
if (ownPower >= _kPowerAdmin && member.powerLevel == _kPowerMod)
const PopupMenuItem(
value: _MemberAction.removeMod,
child: Text('Moderator-Rechte entziehen'),
),
if (ownPower >= _kPowerAdmin && member.powerLevel < _kPowerAdmin)
const PopupMenuItem(
value: _MemberAction.makeAdmin,
child: Text('Zum Admin machen'),
),
const PopupMenuItem(
value: _MemberAction.kick,
child: Text('Kicken', style: TextStyle(color: Color(0xFFF59E0B))),
),
const PopupMenuItem(
value: _MemberAction.ban,
child: Text('Bannen', style: TextStyle(color: Color(0xFFED4245))),
),
],
onSelected: (action) => _handleAction(context, action),
);
}
Future<void> _handleAction(BuildContext context, _MemberAction action) async {
switch (action) {
case _MemberAction.makeMod:
await _setPower(context, _kPowerMod);
case _MemberAction.removeMod:
await _setPower(context, _kPowerMember);
case _MemberAction.makeAdmin:
_confirmAdmin(context);
case _MemberAction.kick:
_confirmKick(context);
case _MemberAction.ban:
_confirmBan(context);
}
}
Future<void> _setPower(BuildContext context, int level) async {
try {
// NICHT space.setPower() das merged nur mit dem lokalen Cache und
// kann die users-Map des Servers zerstören (Admin-Aussperrung).
await updatePowerLevelsSafely(space, (content) {
(content['users'] as Map<String, dynamic>)[member.id] = level;
});
onChanged();
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())));
}
}
}
void _confirmAdmin(BuildContext context) {
showDialog(
context: context,
builder: (_) => AlertDialog(
title: const Text('Admin machen?'),
content: Text(
'${member.displayName ?? member.id} zum Admin befördern? '
'Admin-Rechte können nicht einfach entzogen werden.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Abbrechen')),
TextButton(
onPressed: () async {
Navigator.pop(context);
await _setPower(context, _kPowerAdmin);
},
child: const Text('Admin machen',
style: TextStyle(color: Color(0xFFED4245))),
),
],
),
);
}
void _confirmKick(BuildContext context) {
showDialog(
context: context,
builder: (_) => AlertDialog(
title: const Text('Mitglied kicken?'),
content: Text('${member.displayName ?? member.id} aus dem Space entfernen?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Abbrechen')),
TextButton(
onPressed: () async {
Navigator.pop(context);
try {
await space.kick(member.id);
onChanged();
} catch (_) {}
},
child: const Text('Kicken',
style: TextStyle(color: Color(0xFFF59E0B))),
),
],
),
);
}
void _confirmBan(BuildContext context) {
showDialog(
context: context,
builder: (_) => AlertDialog(
title: const Text('Mitglied bannen?'),
content: Text(
'${member.displayName ?? member.id} dauerhaft aus dem Space ausschließen?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Abbrechen')),
TextButton(
onPressed: () async {
Navigator.pop(context);
try {
await space.ban(member.id);
onChanged();
} catch (_) {}
},
child: const Text('Bannen',
style: TextStyle(color: Color(0xFFED4245))),
),
],
),
);
}
}
enum _MemberAction { makeMod, removeMod, makeAdmin, kick, ban }
@@ -0,0 +1,572 @@
part of '../space_admin_dialog.dart';
class _OverviewTab extends ConsumerStatefulWidget {
final Room space;
const _OverviewTab({required this.space});
@override
ConsumerState<_OverviewTab> createState() => _OverviewTabState();
}
class _OverviewTabState extends ConsumerState<_OverviewTab> {
late TextEditingController _nameCtrl;
late TextEditingController _topicCtrl;
bool _saving = false;
String? _error;
String? _success;
Uint8List? _pendingBanner;
String? _bannerMxcUri;
Uint8List? _pendingAvatar;
@override
void initState() {
super.initState();
_nameCtrl = TextEditingController(
text: widget.space.getLocalizedDisplayname());
_topicCtrl = TextEditingController(text: widget.space.topic);
// Prefer local override (set on last save) over room state event
final overrides = ref.read(spaceBannerOverrideProvider);
_bannerMxcUri = overrides.containsKey(widget.space.id)
? overrides[widget.space.id]
: widget.space.getState(_kSpaceBannerEvent)?.content['url'] as String?;
}
@override
void dispose() {
_nameCtrl.dispose();
_topicCtrl.dispose();
super.dispose();
}
Future<void> _pickBanner() async {
final result = await FilePicker.platform
.pickFiles(type: FileType.image, withData: true);
final file = result?.files.first;
if (file == null) return;
final raw = file.bytes ?? await file.xFile.readAsBytes();
if (!mounted) return;
final cropped = await showDialog<Uint8List>(
context: context,
builder: (_) => BannerCropDialog(imageBytes: raw, aspectRatio: 2.4),
);
if (cropped == null || !mounted) return;
setState(() => _pendingBanner = cropped);
}
Future<void> _pickAvatar() async {
final result = await FilePicker.platform
.pickFiles(type: FileType.image, withData: true);
final file = result?.files.first;
if (file == null) return;
final raw = file.bytes ?? await file.xFile.readAsBytes();
if (!mounted) return;
setState(() => _pendingAvatar = raw);
}
Future<void> _removeAvatar() async {
setState(() => _pendingAvatar = null);
try {
await widget.space.client.setRoomStateWithKey(
widget.space.id, EventTypes.RoomAvatar, '', {});
} catch (_) {}
}
Future<void> _removeBanner() async {
setState(() { _pendingBanner = null; _bannerMxcUri = null; });
try {
await widget.space.client.setRoomStateWithKey(
widget.space.id, _kSpaceBannerEvent, '', {});
} catch (_) {}
}
Future<void> _save() async {
setState(() { _saving = true; _error = null; _success = null; });
// Welcher Schritt gerade läuft für präzise Fehlermeldungen statt eines
// pauschalen "keine Berechtigung" (das auch von Upload-Fehlern kam).
var step = 'Speichern';
try {
final client = widget.space.client;
final newName = _nameCtrl.text.trim();
final newTopic = _topicCtrl.text.trim();
if (newName.isNotEmpty &&
newName != widget.space.getLocalizedDisplayname()) {
step = 'Name ändern';
await widget.space.setName(newName);
}
if (newTopic != widget.space.topic) {
step = 'Beschreibung ändern';
await widget.space.setDescription(newTopic);
}
if (_pendingAvatar != null) {
step = 'Avatar hochladen';
final uri = await client.uploadContent(
_pendingAvatar!,
filename: 'space_avatar.jpg',
contentType: 'image/jpeg',
);
step = 'Avatar setzen';
await _setStateWithRepair(
client,
EventTypes.RoomAvatar,
{'url': uri.toString()},
);
setState(() => _pendingAvatar = null);
}
if (_pendingBanner != null) {
step = 'Banner hochladen';
final uri = await client.uploadContent(
_pendingBanner!,
filename: 'space_banner.jpg',
contentType: 'image/jpeg',
);
step = 'Banner setzen';
await _setStateWithRepair(
client,
_kSpaceBannerEvent,
{'url': uri.toString()},
);
final uriStr = uri.toString();
// Update local override immediately so header updates before next sync
ref.read(spaceBannerOverrideProvider.notifier).update(
(m) => {...m, widget.space.id: uriStr},
);
setState(() {
_bannerMxcUri = uriStr;
_pendingBanner = null;
});
}
setState(() { _success = 'Gespeichert'; });
} catch (e) {
// Server-Fehlertext anzeigen (MatrixException liefert lesbare Meldung)
// nicht pauschal auf "Admin-Level erforderlich" mappen.
final raw = e is MatrixException
? '${e.errcode}: ${e.errorMessage}'
: e.toString().split('\n').first;
var detail = '';
if (e is MatrixException && e.errcode == 'M_FORBIDDEN') {
detail = '\n${_permDiagnostics()}';
// Zusätzlich die SERVER-Sicht der Power-Levels holen die lokale
// Kopie kann von der des Servers abweichen.
try {
final pl = await widget.space.client.getRoomStateWithKey(
widget.space.id, EventTypes.RoomPowerLevels, '');
detail +=
'\nServer-PL: users=${pl['users']}, state_default=${pl['state_default']}, events=${pl['events']}';
} on MatrixException catch (se) {
detail += '\nServer-PL: ${se.errcode} (Event fehlt auch serverseitig)';
} catch (_) {}
}
setState(() {
_error = '$step fehlgeschlagen $raw$detail';
});
} finally {
setState(() { _saving = false; });
}
}
/// Sendet ein State-Event; bei M_FORBIDDEN in einem Raum OHNE
/// power_levels-Event (Continuwuity-Sonderfall: Server verweigert dann
/// Custom-State trotz Creator-Rechten) werden einmalig Standard-Power-Levels
/// mit uns als Admin angelegt und der Vorgang wiederholt.
Future<void> _setStateWithRepair(
Client client,
String type,
Map<String, Object?> content,
) async {
try {
await client.setRoomStateWithKey(widget.space.id, type, '', content);
} on MatrixException catch (e) {
if (e.errcode != 'M_FORBIDDEN') rethrow;
final repaired = await _ensurePowerLevels(client);
if (!repaired) rethrow;
await client.setRoomStateWithKey(widget.space.id, type, '', content);
}
}
/// Legt Standard-Power-Levels an, falls der Raum keine hat.
/// Gibt true zurück, wenn das Event neu angelegt wurde.
Future<bool> _ensurePowerLevels(Client client) async {
try {
await client.getRoomStateWithKey(
widget.space.id, EventTypes.RoomPowerLevels, '');
return false; // existiert bereits Ablehnung hat andere Ursache
} catch (_) {
// fehlt (M_NOT_FOUND) anlegen
}
try {
await client.setRoomStateWithKey(
widget.space.id,
EventTypes.RoomPowerLevels,
'',
{
'users': {client.userID!: 100},
'users_default': 0,
'events': const <String, Object?>{},
'events_default': 0,
'state_default': 50,
'ban': 50,
'kick': 50,
'redact': 50,
'invite': 0,
'notifications': {'room': 50},
},
);
return true;
} catch (_) {
return false;
}
}
/// Kompakte Berechtigungs-Diagnose für M_FORBIDDEN-Fehler: zeigt, wie der
/// Server die Lage vermutlich sieht (Raumversion, Creator, Power-Level).
String _permDiagnostics() {
try {
final space = widget.space;
final create = space.getState(EventTypes.RoomCreate);
final version = create?.content['room_version'] ?? '1';
final creator = create?.senderId ?? '?';
final own = space.ownPowerLevel;
final needed = space.powerForChangingStateEvent(_kSpaceBannerEvent);
final hasPl = space.getState(EventTypes.RoomPowerLevels) != null;
return 'Diagnose: dein Level $own, benötigt $needed, '
'Raumversion $version, Creator $creator'
'${hasPl ? '' : ', kein power_levels-Event vorhanden!'}';
} catch (_) {
return '';
}
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
final client = ref.watch(matrixClientProvider).valueOrNull;
// SDK-Berechtigungsprüfung (berücksichtigt events-Overrides und
// state_default) statt hartcodiertem Admin-Level 100.
final canEdit = widget.space.canChangeStateEvent(EventTypes.RoomName) ||
widget.space.ownPowerLevel >= _kPowerAdmin;
return SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Banner
_SectionLabel('Banner', pt),
const SizedBox(height: 6),
GestureDetector(
onTap: canEdit ? _pickBanner : null,
child: Stack(
children: [
AspectRatio(
aspectRatio: 2.4,
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: pt.bg0,
borderRadius: BorderRadius.circular(pt.rBase),
border: Border.all(color: pt.border),
),
clipBehavior: Clip.hardEdge,
child: _pendingBanner != null
? Image.memory(_pendingBanner!,
fit: BoxFit.cover, width: double.infinity)
: (_bannerMxcUri != null && client != null
? MxcImage(
mxcUri: _bannerMxcUri!,
client: client,
fit: BoxFit.cover,
width: double.infinity,
placeholder: Container(color: pt.bg0),
)
: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.add_photo_alternate_outlined,
size: 24, color: pt.fgDim),
const SizedBox(height: 4),
Text('Banner hinzufügen',
style: TextStyle(
color: pt.fgDim, fontSize: 12)),
],
),
)),
), // Container
), // AspectRatio
if (canEdit)
Positioned(
right: 6,
bottom: 6,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_bannerMxcUri != null || _pendingBanner != null)
GestureDetector(
onTap: _removeBanner,
child: Container(
margin: const EdgeInsets.only(right: 4),
padding: const EdgeInsets.symmetric(
horizontal: 7, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFFED4245).withAlpha(200),
borderRadius: BorderRadius.circular(5),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.delete_outline,
size: 11, color: Colors.white),
SizedBox(width: 3),
Text('Entfernen',
style: TextStyle(
color: Colors.white, fontSize: 11)),
],
),
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 7, vertical: 3),
decoration: BoxDecoration(
color: pt.bg0.withAlpha(200),
borderRadius: BorderRadius.circular(5),
border: Border.all(color: pt.border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.edit_rounded,
size: 11, color: pt.fgMuted),
const SizedBox(width: 3),
Text('Banner ändern',
style: TextStyle(
color: pt.fgMuted, fontSize: 11)),
],
),
),
],
),
),
],
),
),
const SizedBox(height: 16),
// Avatar
_SectionLabel('Serverbild', pt),
const SizedBox(height: 8),
_AvatarEditor(
space: widget.space,
pendingAvatar: _pendingAvatar,
canEdit: canEdit,
onPick: _pickAvatar,
onRemove: _removeAvatar,
pt: pt,
client: client,
),
const SizedBox(height: 16),
// Name / Topic
_SectionLabel('Name', pt),
const SizedBox(height: 6),
_Field(controller: _nameCtrl, hint: 'Space-Name', pt: pt),
const SizedBox(height: 16),
_SectionLabel('Beschreibung', pt),
const SizedBox(height: 6),
_Field(
controller: _topicCtrl,
hint: 'Worum geht es in diesem Space?',
maxLines: 4,
pt: pt),
const SizedBox(height: 8),
_SpaceVisibilityToggle(space: widget.space),
const SizedBox(height: 20),
if (_error != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(_error!,
style: const TextStyle(color: Color(0xFFED4245), fontSize: 12)),
),
if (_success != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(_success!,
style: const TextStyle(color: Color(0xFF57F287), fontSize: 12)),
),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _saving ? null : _save,
style: FilledButton.styleFrom(
backgroundColor: pt.accent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rBase)),
),
child: _saving
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2, color: pt.bg0))
: const Text('Speichern',
style: TextStyle(
color: Colors.white, fontWeight: FontWeight.w600)),
),
),
const SizedBox(height: 24),
_DangerZone(space: widget.space),
],
),
);
}
}
class _SpaceVisibilityToggle extends ConsumerStatefulWidget {
final Room space;
const _SpaceVisibilityToggle({required this.space});
@override
ConsumerState<_SpaceVisibilityToggle> createState() =>
_SpaceVisibilityToggleState();
}
class _SpaceVisibilityToggleState
extends ConsumerState<_SpaceVisibilityToggle> {
bool? _isPublic;
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final joinRules = widget.space.joinRules;
setState(() {
_isPublic = joinRules == JoinRules.public;
_loading = false;
});
} catch (_) {
setState(() => _loading = false);
}
}
Future<void> _toggle(bool value) async {
setState(() => _isPublic = value);
try {
await widget.space.setJoinRules(
value ? JoinRules.public : JoinRules.invite);
} catch (_) {
setState(() => _isPublic = !value);
}
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
if (_loading) return const SizedBox.shrink();
return Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Öffentlicher Space',
style: TextStyle(
color: pt.fg,
fontSize: 13,
fontWeight: FontWeight.w600)),
Text('Jeder kann diesem Space beitreten',
style: TextStyle(color: pt.fgDim, fontSize: 12)),
],
),
),
Switch(
value: _isPublic ?? false,
onChanged: _toggle,
activeThumbColor: pt.accent,
),
],
);
}
}
class _DangerZone extends ConsumerWidget {
final Room space;
const _DangerZone({required this.space});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pt = PyramidTheme.of(context);
return Container(
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFED4245).withAlpha(80)),
borderRadius: BorderRadius.circular(pt.rBase),
),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Gefahrenzone',
style: TextStyle(
color: const Color(0xFFED4245),
fontSize: 13,
fontWeight: FontWeight.w700)),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Space verlassen',
style: TextStyle(
color: pt.fg,
fontSize: 13,
fontWeight: FontWeight.w600)),
Text('Du verlässt diesen Space',
style: TextStyle(color: pt.fgDim, fontSize: 12)),
],
),
),
OutlinedButton(
onPressed: () => _confirmLeave(context, ref),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFFED4245),
side: const BorderSide(color: Color(0xFFED4245)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
),
child: const Text('Verlassen'),
),
],
),
],
),
);
}
void _confirmLeave(BuildContext context, WidgetRef ref) {
showDialog(
context: context,
builder: (_) => AlertDialog(
title: const Text('Space verlassen?'),
content: Text(
'Möchtest du "${space.getLocalizedDisplayname()}" wirklich verlassen?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Abbrechen')),
TextButton(
onPressed: () async {
Navigator.pop(context);
Navigator.pop(context);
await space.leave();
},
child: const Text('Verlassen',
style: TextStyle(color: Color(0xFFED4245))),
),
],
),
);
}
}
@@ -0,0 +1,201 @@
part of '../space_admin_dialog.dart';
class _PermissionsTab extends ConsumerStatefulWidget {
final Room space;
const _PermissionsTab({required this.space});
@override
ConsumerState<_PermissionsTab> createState() => _PermissionsTabState();
}
class _PermissionsTabState extends ConsumerState<_PermissionsTab> {
bool _saving = false;
String? _status;
// Current power level values (loaded from room state)
late Map<String, int> _levels;
static const _defaults = {
'events_default': 0,
'state_default': 50,
'invite': 0,
'kick': 50,
'ban': 50,
'redact': 50,
};
static const _labels = {
'events_default': 'Nachrichten senden',
'state_default': 'Raum-Einstellungen ändern',
'invite': 'Mitglieder einladen',
'kick': 'Mitglieder kicken',
'ban': 'Mitglieder bannen',
'redact': 'Nachrichten löschen',
};
@override
void initState() {
super.initState();
_load();
}
void _load() {
final content = widget.space
.getState(EventTypes.RoomPowerLevels)
?.content ??
{};
_levels = {
for (final key in _defaults.keys)
key: (content[key] as int?) ?? _defaults[key]!,
};
}
Future<void> _save() async {
setState(() { _saving = true; _status = null; });
try {
// Server-Stand holen und nur die geänderten Schlüssel mergen der
// lokale Cache kann unvollständig sein und würde users/events wipen.
await updatePowerLevelsSafely(widget.space, (content) {
for (final entry in _levels.entries) {
content[entry.key] = entry.value;
}
});
setState(() => _status = 'Gespeichert');
} catch (e) {
final msg = e is MatrixException
? '${e.errcode}: ${e.errorMessage}'
: e.toString().split('\n').first;
setState(() => _status = 'Fehler: $msg');
} finally {
setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
final ownPower = widget.space.ownPowerLevel;
final canEdit = ownPower >= _kPowerAdmin;
return SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Mindestberechtigungen',
style: TextStyle(
color: pt.fgDim,
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 0.8)),
const SizedBox(height: 4),
Text(
'Lege fest, welche Stufe ein Mitglied mindestens haben muss, um diese Aktion auszuführen.',
style: TextStyle(color: pt.fgDim, fontSize: 12)),
const SizedBox(height: 16),
...(_labels.entries.map((e) => _PermissionRow(
label: e.value,
value: _levels[e.key]!,
enabled: canEdit,
pt: pt,
onChanged: (v) => setState(() => _levels[e.key] = v),
))),
if (canEdit) ...[
const SizedBox(height: 20),
if (_status != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(_status!,
style: TextStyle(
color: _status!.startsWith('F')
? const Color(0xFFED4245)
: const Color(0xFF57F287),
fontSize: 12)),
),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _saving ? null : _save,
style: FilledButton.styleFrom(
backgroundColor: pt.accent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rBase)),
),
child: _saving
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2, color: pt.bg0))
: const Text('Speichern',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600)),
),
),
],
],
),
);
}
}
class _PermissionRow extends StatelessWidget {
final String label;
final int value;
final bool enabled;
final PyramidTheme pt;
final ValueChanged<int> onChanged;
const _PermissionRow({
required this.label,
required this.value,
required this.enabled,
required this.pt,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
children: [
Expanded(
child: Text(label,
style: TextStyle(
color: pt.fg,
fontSize: 13,
fontWeight: FontWeight.w500)),
),
if (enabled)
DropdownButton<int>(
value: value,
dropdownColor: pt.bg2,
underline: const SizedBox.shrink(),
style: TextStyle(color: pt.fg, fontSize: 13),
items: const [
DropdownMenuItem(value: 0, child: Text('Mitglied (0)')),
DropdownMenuItem(value: 50, child: Text('Moderator (50)')),
DropdownMenuItem(value: 100, child: Text('Admin (100)')),
],
onChanged: (v) => v != null ? onChanged(v) : null,
)
else
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: _powerColor(value, pt).withAlpha(30),
borderRadius: BorderRadius.circular(6),
),
child: Text(_powerLabel(value),
style: TextStyle(
color: _powerColor(value, pt),
fontSize: 12,
fontWeight: FontWeight.w600)),
),
],
),
);
}
}
@@ -0,0 +1,232 @@
part of '../space_admin_dialog.dart';
class _SectionLabel extends StatelessWidget {
final String text;
final PyramidTheme pt;
const _SectionLabel(this.text, this.pt);
@override
Widget build(BuildContext context) {
return Text(text,
style: TextStyle(
color: pt.fgDim,
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 0.8));
}
}
class _Field extends StatelessWidget {
final TextEditingController controller;
final String hint;
final int maxLines;
final PyramidTheme pt;
const _Field({
required this.controller,
required this.hint,
this.maxLines = 1,
required this.pt,
});
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
maxLines: maxLines,
style: TextStyle(color: pt.fg, fontSize: 13),
decoration: InputDecoration(
hintText: hint,
hintStyle: TextStyle(color: pt.fgDim),
filled: true,
fillColor: pt.bg0,
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(pt.rSm),
borderSide: BorderSide(color: pt.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(pt.rSm),
borderSide: BorderSide(color: pt.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(pt.rSm),
borderSide: BorderSide(color: pt.accent),
),
),
);
}
}
class _SearchField extends StatelessWidget {
final TextEditingController controller;
final String hint;
final PyramidTheme pt;
final ValueChanged<String> onChanged;
const _SearchField({
required this.controller,
required this.hint,
required this.pt,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
onChanged: onChanged,
style: TextStyle(color: pt.fg, fontSize: 13),
decoration: InputDecoration(
hintText: hint,
hintStyle: TextStyle(color: pt.fgDim),
prefixIcon: Icon(Icons.search, color: pt.fgDim, size: 18),
filled: true,
fillColor: pt.bg0,
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
isDense: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(pt.rSm),
borderSide: BorderSide(color: pt.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(pt.rSm),
borderSide: BorderSide(color: pt.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(pt.rSm),
borderSide: BorderSide(color: pt.accent),
),
),
);
}
}
class _AvatarEditor extends StatelessWidget {
final Room space;
final Uint8List? pendingAvatar;
final bool canEdit;
final VoidCallback onPick;
final VoidCallback onRemove;
final PyramidTheme pt;
final dynamic client;
const _AvatarEditor({
required this.space,
required this.pendingAvatar,
required this.canEdit,
required this.onPick,
required this.onRemove,
required this.pt,
required this.client,
});
@override
Widget build(BuildContext context) {
final letter = space.getLocalizedDisplayname().isNotEmpty
? space.getLocalizedDisplayname()[0].toUpperCase()
: '?';
final avatarUri = space.avatar;
final hasAvatar = pendingAvatar != null || avatarUri != null;
Widget avatarWidget;
if (pendingAvatar != null) {
avatarWidget = Image.memory(pendingAvatar!, fit: BoxFit.cover,
width: 64, height: 64);
} else if (avatarUri != null && client != null) {
avatarWidget = MxcAvatar(
mxcUri: avatarUri,
client: client,
size: 64,
borderRadius: BorderRadius.circular(32),
placeholder: (_) => _InitialAvatar(letter: letter, pt: pt, size: 64),
);
} else {
avatarWidget = _InitialAvatar(letter: letter, pt: pt, size: 64);
}
return Row(
children: [
GestureDetector(
onTap: canEdit ? onPick : null,
child: Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: pt.border),
),
clipBehavior: Clip.hardEdge,
child: avatarWidget,
),
if (canEdit)
Positioned(
right: -2,
bottom: -2,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: pt.accent,
shape: BoxShape.circle,
border: Border.all(color: pt.bg1, width: 2),
),
child: const Icon(Icons.edit, size: 10, color: Colors.white),
),
),
],
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
canEdit ? 'Klicken zum Ändern' : 'Serverbild',
style: TextStyle(color: pt.fg, fontSize: 13,
fontWeight: FontWeight.w500),
),
if (canEdit && hasAvatar)
GestureDetector(
onTap: onRemove,
child: Text('Entfernen',
style: TextStyle(
color: const Color(0xFFED4245), fontSize: 12)),
),
],
),
],
);
}
}
class _InitialAvatar extends StatelessWidget {
final String letter;
final PyramidTheme pt;
final double size;
const _InitialAvatar(
{required this.letter, required this.pt, required this.size});
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: pt.bg3,
borderRadius: BorderRadius.circular(size / 2),
),
child: Center(
child: Text(letter,
style: TextStyle(
color: pt.fg,
fontSize: size * 0.4,
fontWeight: FontWeight.w700)),
),
);
}
}
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -40,7 +40,7 @@ class _CreateJoinDialogState extends ConsumerState<CreateJoinDialog> {
// Discover
final _discoverServerCtrl = TextEditingController();
final _discoverSearchCtrl = TextEditingController();
List<PublicRoomsChunk> _publicRooms = [];
List<PublishedRoomsChunk> _publicRooms = [];
String? _nextBatch;
bool _discoverLoading = false;
bool _discoverLoadingMore = false;
@@ -911,7 +911,7 @@ class _OptionRow extends StatelessWidget {
Text(label, style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500)),
Text(desc, style: TextStyle(color: pt.fgDim, fontSize: 11)),
])),
Switch(value: value, onChanged: onChanged, activeColor: pt.accent),
Switch(value: value, onChanged: onChanged, activeThumbColor: pt.accent),
]),
);
}
@@ -981,7 +981,7 @@ class _ErrorCard extends StatelessWidget {
}
class _PublicRoomItem extends ConsumerWidget {
final PublicRoomsChunk room;
final PublishedRoomsChunk room;
final PyramidTheme pt;
final bool isJoining;
final VoidCallback onJoin;
+4 -5
View File
@@ -127,11 +127,10 @@ class _PyramidPainter extends CustomPainter {
Offset _proj(List<double> v, double sc, double cx, double cy, double tr) =>
Offset(cx + v[0] * sc, cy + (v[1] * cos(tr) + v[2] * sin(tr)) * sc);
Color _darken(Color c, double amount) => Color.fromARGB(
c.alpha,
max(0, (c.red * (1 - amount)).round()),
max(0, (c.green * (1 - amount)).round()),
max(0, (c.blue * (1 - amount)).round()),
Color _darken(Color c, double amount) => c.withValues(
red: max(0.0, c.r * (1 - amount)),
green: max(0.0, c.g * (1 - amount)),
blue: max(0.0, c.b * (1 - amount)),
);
@override
+265
View File
@@ -0,0 +1,265 @@
part of '../settings_modal.dart';
class _AboutSection extends ConsumerWidget {
final PyramidTheme pt;
const _AboutSection({required this.pt});
@override
Widget build(BuildContext context, WidgetRef ref) {
final clientAsync = ref.watch(matrixClientProvider);
final client = clientAsync.valueOrNull;
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SectionTitle(title: 'Über Pyramid', subtitle: 'Version & Konto-Infos.', pt: pt),
_SettingsGroup(title: 'Version', pt: pt, children: [
_SettingsRow(
title: 'Pyramid',
desc: '0.1.0 · Flutter · Matrix SDK',
pt: pt,
control: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: pt.accentSoft,
borderRadius: BorderRadius.circular(6)),
child: Text('Beta',
style: TextStyle(
color: pt.accent, fontSize: 10, fontWeight: FontWeight.w700)),
)),
_Divider(pt: pt),
_SettingsRow(
title: 'Matrix SDK',
desc: 'matrix-dart-sdk ^6.2.0',
pt: pt),
]),
const SizedBox(height: 20),
_UpdatesSettingsGroup(pt: pt),
const SizedBox(height: 20),
_NotificationsSettingsGroup(pt: pt),
const SizedBox(height: 20),
if (client != null)
_SettingsGroup(title: 'Konto', pt: pt, children: [
_SettingsRow(
title: 'Matrix-ID',
desc: client.userID ?? '',
pt: pt,
control: GestureDetector(
onTap: () => Clipboard.setData(ClipboardData(text: client.userID ?? '')),
child: Tooltip(
message: 'Kopieren',
child: Icon(Icons.copy_rounded, size: 14, color: pt.fgDim),
),
),
),
_Divider(pt: pt),
_SettingsRow(
title: 'Homeserver',
desc: client.homeserver?.toString() ?? '',
pt: pt,
),
_Divider(pt: pt),
_SettingsRow(
title: 'Gerät',
desc: client.deviceID ?? '',
pt: pt,
control: GestureDetector(
onTap: () =>
Clipboard.setData(ClipboardData(text: client.deviceID ?? '')),
child: Tooltip(
message: 'Kopieren',
child: Icon(Icons.copy_rounded, size: 14, color: pt.fgDim),
),
),
),
]),
const SizedBox(height: 20),
_SettingsGroup(title: 'Konto-Aktionen', pt: pt, children: [
_SettingsRow(
title: 'Abmelden',
desc: 'Auf diesem Gerät ausloggen',
pt: pt,
destructive: true,
showArrow: true,
onTap: () => _confirmLogout(context, ref),
),
]),
],
),
);
}
Future<void> _confirmLogout(BuildContext context, WidgetRef ref) async {
final client = await ref.read(matrixClientProvider.future);
final backupMissing = await isKeyBackupMissing(client);
if (!context.mounted) return;
final confirm = 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('Abmelden?',
style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)),
content: Text(
backupMissing
? 'Du wirst auf diesem Gerät abgemeldet. Es ist kein Schlüssel-Backup eingerichtet — '
'ältere verschlüsselte Nachrichten sind danach auf keinem Gerät mehr lesbar.'
: 'Du wirst auf diesem Gerät abgemeldet. Deine Nachrichten bleiben erhalten.',
style: TextStyle(
color: backupMissing ? pt.danger : pt.fgMuted, fontSize: 13, height: 1.4),
),
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('Abmelden'),
),
],
),
);
if (confirm == true) {
unawaited(AuthLog.write('User-initiated logout (Settings)'));
await client.logout().catchError((_) {});
}
}
}
class _UpdatesSettingsGroup extends ConsumerStatefulWidget {
final PyramidTheme pt;
const _UpdatesSettingsGroup({required this.pt});
@override
ConsumerState<_UpdatesSettingsGroup> createState() =>
_UpdatesSettingsGroupState();
}
class _UpdatesSettingsGroupState extends ConsumerState<_UpdatesSettingsGroup> {
bool _checking = false;
Future<void> _checkNow() async {
setState(() => _checking = true);
await resetUpdateCheckTimer();
ref.invalidate(updateInfoProvider);
// Give the async provider a moment to resolve before resetting the spinner.
await Future<void>.delayed(const Duration(seconds: 2));
if (mounted) setState(() => _checking = false);
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final updateAsync = ref.watch(updateInfoProvider);
return _SettingsGroup(title: 'Updates', pt: pt, children: [
updateAsync.when(
loading: () => _SettingsRow(
title: 'Nach Updates suchen…',
desc: 'Gitea Releases wird geprüft',
pt: pt,
control: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: pt.accent,
),
),
),
error: (e, _) => _SettingsRow(
title: 'Update-Prüfung fehlgeschlagen',
desc: 'Keine Verbindung oder Gitea nicht erreichbar (nur im Heimnetz).',
pt: pt,
control: _checking
? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2, color: pt.accent),
)
: GestureDetector(
onTap: _checkNow,
child: Tooltip(
message: 'Erneut prüfen',
child: Icon(Icons.refresh_rounded,
size: 16, color: pt.fgDim),
),
),
),
data: (info) {
if (info == null) {
return _SettingsRow(
title: 'Pyramid ist aktuell',
desc: 'Du verwendest die neueste Version.',
pt: pt,
onTap: _checking ? null : _checkNow,
control: _checking
? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2, color: pt.accent),
)
: Icon(Icons.refresh_rounded, size: 16, color: pt.fgDim),
);
}
// New version available
return Column(
children: [
_SettingsRow(
title: 'Update verfügbar: ${info.tagName}',
desc: info.releaseName,
pt: pt,
showArrow: true,
onTap: () async {
if (info.canDownload) {
await showUpdateDownloadDialog(context, info);
} else {
final uri = Uri.parse(info.htmlUrl);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
},
control: Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: pt.accent,
borderRadius: BorderRadius.circular(6)),
child: Text(
'Neu',
style: TextStyle(
color: pt.accentFg,
fontSize: 10,
fontWeight: FontWeight.w700),
),
),
),
_Divider(pt: pt),
_SettingsRow(
title: 'Diese Version überspringen',
desc: '${info.tagName} nicht mehr anzeigen',
pt: pt,
onTap: () async {
await skipUpdateVersion(info.tagName);
ref.invalidate(updateInfoProvider);
},
),
],
);
},
),
]);
}
}
+364
View File
@@ -0,0 +1,364 @@
part of '../settings_modal.dart';
class _ChangePasswordDialog extends ConsumerStatefulWidget {
final PyramidTheme pt;
const _ChangePasswordDialog({required this.pt});
@override
ConsumerState<_ChangePasswordDialog> createState() => _ChangePasswordDialogState();
}
class _ChangePasswordDialogState extends ConsumerState<_ChangePasswordDialog> {
final _oldCtrl = TextEditingController();
final _newCtrl = TextEditingController();
final _confirmCtrl = TextEditingController();
bool _oldVisible = false;
bool _newVisible = false;
bool _loading = false;
String? _error;
bool _done = false;
@override
void dispose() {
_oldCtrl.dispose();
_newCtrl.dispose();
_confirmCtrl.dispose();
super.dispose();
}
Future<void> _submit() async {
final oldPw = _oldCtrl.text;
final newPw = _newCtrl.text;
final confirm = _confirmCtrl.text;
if (oldPw.isEmpty || newPw.isEmpty) {
setState(() => _error = 'Bitte alle Felder ausfüllen.');
return;
}
if (newPw != confirm) {
setState(() => _error = 'Die neuen Passwörter stimmen nicht überein.');
return;
}
if (newPw.length < 8) {
setState(() => _error = 'Das neue Passwort muss mindestens 8 Zeichen lang sein.');
return;
}
setState(() { _loading = true; _error = null; });
try {
final client = await ref.read(matrixClientProvider.future);
await client.changePassword(newPw, oldPassword: oldPw);
if (mounted) setState(() { _loading = false; _done = true; });
} on MatrixException catch (e) {
if (mounted) setState(() { _loading = false; _error = e.errorMessage; });
} catch (e) {
if (mounted) setState(() { _loading = false; _error = e.toString().split('\n').first; });
}
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
return Dialog(
backgroundColor: pt.bg2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420, minWidth: 300),
child: Padding(
padding: const EdgeInsets.all(24),
child: _done ? _buildDone(pt) : _buildForm(pt),
),
),
);
}
Widget _buildDone(PyramidTheme pt) => Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle_outline_rounded, color: PyramidColors.success, size: 52),
const SizedBox(height: 12),
Text('Passwort geändert', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)),
const SizedBox(height: 6),
Text('Dein Passwort wurde erfolgreich aktualisiert.',
textAlign: TextAlign.center, style: TextStyle(color: pt.fgMuted, fontSize: 13)),
const SizedBox(height: 20),
SizedBox(width: double.infinity, 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: () => Navigator.of(context).pop(),
child: const Text('Schließen'),
)),
],
);
Widget _buildForm(PyramidTheme pt) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Icon(Icons.lock_outline_rounded, size: 18, color: pt.accent),
const SizedBox(width: 8),
Text('Passwort ändern', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)),
const Spacer(),
IconButton(
icon: Icon(Icons.close_rounded, color: pt.fgDim, size: 16),
onPressed: () => Navigator.of(context).pop(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28),
),
]),
const SizedBox(height: 20),
_PwField(ctrl: _oldCtrl, label: 'Aktuelles Passwort', pt: pt,
visible: _oldVisible, onToggle: () => setState(() => _oldVisible = !_oldVisible)),
const SizedBox(height: 12),
_PwField(ctrl: _newCtrl, label: 'Neues Passwort', pt: pt,
visible: _newVisible, onToggle: () => setState(() => _newVisible = !_newVisible)),
const SizedBox(height: 12),
_PwField(ctrl: _confirmCtrl, label: 'Neues Passwort bestätigen', pt: pt,
visible: _newVisible, onToggle: null,
onSubmitted: (_) => _submit()),
if (_error != null) ...[
const SizedBox(height: 10),
_StatusCard(pt: pt, color: pt.danger, icon: Icons.error_outline_rounded, text: _error!),
],
const SizedBox(height: 20),
SizedBox(width: double.infinity, 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: _loading ? null : _submit,
child: _loading
? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator.adaptive(strokeWidth: 2))
: const Text('Passwort ändern'),
)),
],
);
}
class _DeleteAccountDialog extends ConsumerStatefulWidget {
final PyramidTheme pt;
const _DeleteAccountDialog({required this.pt});
@override
ConsumerState<_DeleteAccountDialog> createState() => _DeleteAccountDialogState();
}
enum _DeleteStep { confirm, auth, deleting, error }
class _DeleteAccountDialogState extends ConsumerState<_DeleteAccountDialog> {
_DeleteStep _step = _DeleteStep.confirm;
final _pwCtrl = TextEditingController();
bool _pwVisible = false;
bool _eraseData = true;
String? _error;
@override
void dispose() { _pwCtrl.dispose(); super.dispose(); }
Future<void> _proceed() async {
setState(() { _step = _DeleteStep.deleting; _error = null; });
final client = await ref.read(matrixClientProvider.future);
try {
await client.deactivateAccount(erase: _eraseData);
} on MatrixException catch (e) {
if (e.requireAdditionalAuthentication && mounted) {
// UIA required try with password
try {
await client.deactivateAccount(
erase: _eraseData,
auth: AuthenticationData(
type: AuthenticationTypes.password,
session: e.session,
additionalFields: {
'identifier': {'type': 'm.id.user', 'user': client.userID},
'password': _pwCtrl.text,
},
),
);
} on MatrixException catch (e2) {
if (mounted) setState(() { _step = _DeleteStep.error; _error = e2.errorMessage; });
return;
} catch (e2) {
if (mounted) setState(() { _step = _DeleteStep.error; _error = e2.toString().split('\n').first; });
return;
}
} else {
if (mounted) setState(() { _step = _DeleteStep.error; _error = e.errorMessage; });
return;
}
} catch (e) {
if (mounted) setState(() { _step = _DeleteStep.error; _error = e.toString().split('\n').first; });
return;
}
// Konto wurde gelöscht ausloggen
unawaited(AuthLog.write('User-initiated logout (Konto gelöscht)'));
try { await client.logout(); } catch (_) {}
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
return Dialog(
backgroundColor: pt.bg2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440, minWidth: 300),
child: Padding(
padding: const EdgeInsets.all(24),
child: switch (_step) {
_DeleteStep.confirm => _buildConfirm(pt),
_DeleteStep.auth => _buildAuth(pt),
_DeleteStep.deleting => const Center(
child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator.adaptive()),
),
_DeleteStep.error => _buildError(pt),
},
),
),
);
}
Widget _buildConfirm(PyramidTheme pt) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Icon(Icons.warning_rounded, size: 20, color: pt.danger),
const SizedBox(width: 8),
Text('Konto löschen', style: TextStyle(color: pt.danger, fontSize: 15, fontWeight: FontWeight.w700)),
const Spacer(),
IconButton(
icon: Icon(Icons.close_rounded, color: pt.fgDim, size: 16),
onPressed: () => Navigator.of(context).pop(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28),
),
]),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: pt.danger.withAlpha(20),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: pt.danger.withAlpha(60)),
),
child: Text(
'Diese Aktion ist unwiderruflich. Dein Konto, alle Sitzungen und '
'deine Mitgliedschaft in Räumen werden dauerhaft gelöscht.',
style: TextStyle(color: pt.fg, fontSize: 13, height: 1.5),
),
),
const SizedBox(height: 16),
GestureDetector(
onTap: () => setState(() => _eraseData = !_eraseData),
child: Row(children: [
SizedBox(
width: 18, height: 18,
child: Checkbox(
value: _eraseData,
onChanged: (v) => setState(() => _eraseData = v ?? true),
activeColor: pt.accent,
side: BorderSide(color: pt.border),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
const SizedBox(width: 8),
Expanded(child: Text('Nachrichten und Daten vom Server löschen',
style: TextStyle(color: pt.fg, fontSize: 13))),
]),
),
const SizedBox(height: 20),
Row(children: [
Expanded(child: OutlinedButton(
style: OutlinedButton.styleFrom(foregroundColor: pt.fg, side: BorderSide(color: pt.border),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
onPressed: () => Navigator.of(context).pop(),
child: const Text('Abbrechen'),
)),
const SizedBox(width: 12),
Expanded(child: ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: pt.danger, foregroundColor: Colors.white,
elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
onPressed: () => setState(() => _step = _DeleteStep.auth),
child: const Text('Weiter'),
)),
]),
],
);
Widget _buildAuth(PyramidTheme pt) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Icon(Icons.lock_outline_rounded, size: 18, color: pt.danger),
const SizedBox(width: 8),
Text('Konto löschen bestätigen', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)),
]),
const SizedBox(height: 16),
Text('Gib dein Passwort ein, um die Löschung zu bestätigen.',
style: TextStyle(color: pt.fgMuted, fontSize: 13)),
const SizedBox(height: 16),
TextField(
controller: _pwCtrl,
autofocus: true,
obscureText: !_pwVisible,
style: TextStyle(color: pt.fg, fontSize: 13),
decoration: InputDecoration(
labelText: 'Passwort',
labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13),
filled: true, fillColor: pt.bg3,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.danger)),
suffixIcon: IconButton(
icon: Icon(_pwVisible ? Icons.visibility_off_rounded : Icons.visibility_rounded, size: 16, color: pt.fgDim),
onPressed: () => setState(() => _pwVisible = !_pwVisible),
),
),
cursorColor: pt.danger,
onSubmitted: (_) => _proceed(),
),
const SizedBox(height: 20),
Row(children: [
Expanded(child: OutlinedButton(
style: OutlinedButton.styleFrom(foregroundColor: pt.fg, side: BorderSide(color: pt.border),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
onPressed: () => setState(() => _step = _DeleteStep.confirm),
child: const Text('Zurück'),
)),
const SizedBox(width: 12),
Expanded(child: ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: pt.danger, foregroundColor: Colors.white,
elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
onPressed: _proceed,
child: const Text('Konto endgültig löschen'),
)),
]),
],
);
Widget _buildError(PyramidTheme pt) => Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline_rounded, color: pt.danger, size: 48),
const SizedBox(height: 12),
Text(_error ?? 'Ein Fehler ist aufgetreten.',
textAlign: TextAlign.center, style: TextStyle(color: pt.fgMuted, fontSize: 13)),
const SizedBox(height: 20),
SizedBox(width: double.infinity, child: OutlinedButton(
style: OutlinedButton.styleFrom(foregroundColor: pt.fg, side: BorderSide(color: pt.border),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
onPressed: () => Navigator.of(context).pop(),
child: const Text('Schließen'),
)),
],
);
}
@@ -0,0 +1,187 @@
part of '../settings_modal.dart';
class _AppearanceSection extends StatelessWidget {
final PyramidTheme pt;
final bool isDark;
final int accentIdx;
final double radius;
final double density;
final double motion;
final WidgetRef ref;
const _AppearanceSection({
required this.pt,
required this.isDark,
required this.accentIdx,
required this.radius,
required this.density,
required this.motion,
required this.ref,
});
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SectionTitle(
title: 'Erscheinungsbild',
subtitle: 'Gestalte Pyramid nach deinem Geschmack.',
pt: pt),
_SettingsGroup(title: 'Theme', pt: pt, children: [
_SettingsRow(
title: 'Farbmodus',
desc: 'Hell oder dunkel',
pt: pt,
control: _SegControl(
options: const ['Dunkel', 'Hell'],
selected: isDark ? 0 : 1,
pt: pt,
onSelect: (i) {
final dark = i == 0;
ref.read(themeModeProvider.notifier).state = dark;
saveThemePrefs(isDark: dark);
},
),
),
_Divider(pt: pt),
_SettingsRow(
title: 'Akzentfarbe',
desc: 'Farbe für Hervorhebungen und Buttons',
pt: pt,
control: Row(
children: accentPresets.asMap().entries.map((e) {
final i = e.key;
final preset = e.value;
final isActive = accentIdx == i;
return GestureDetector(
onTap: () {
ref.read(accentProvider.notifier).state = i;
saveThemePrefs(accentIdx: i);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: 22,
height: 22,
margin: const EdgeInsets.only(right: 6),
decoration: BoxDecoration(
color: preset.color,
shape: BoxShape.circle,
border: isActive ? Border.all(color: Colors.white, width: 2) : null,
boxShadow: isActive
? [BoxShadow(color: preset.color.withAlpha(100), blurRadius: 8)]
: null,
),
),
);
}).toList(),
),
),
]),
const SizedBox(height: 20),
_SettingsGroup(title: 'Form & Bewegung', pt: pt, children: [
_SettingsRow(
title: 'Eckenrundung',
desc: '${radius.round()} px',
pt: pt,
control: SizedBox(
width: 150,
child: SliderTheme(
data: SliderThemeData(
activeTrackColor: pt.accent,
thumbColor: pt.accent,
inactiveTrackColor: pt.border,
trackHeight: 3,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
),
child: Slider(
value: radius,
min: 4,
max: 20,
onChanged: (v) {
ref.read(radiusProvider.notifier).state = v;
saveThemePrefs(radius: v);
},
),
),
),
),
_Divider(pt: pt),
_SettingsRow(
title: 'Animationen',
desc: '${(motion * 100).round()}%',
pt: pt,
control: SizedBox(
width: 150,
child: SliderTheme(
data: SliderThemeData(
activeTrackColor: pt.accent,
thumbColor: pt.accent,
inactiveTrackColor: pt.border,
trackHeight: 3,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
),
child: Slider(
value: motion,
min: 0,
max: 1,
onChanged: (v) {
ref.read(motionProvider.notifier).state = v;
saveThemePrefs(motion: v);
},
),
),
),
),
]),
const SizedBox(height: 20),
_SettingsGroup(title: 'Vorschau', pt: pt, children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: Column(
children: [
Container(
height: 100,
decoration: BoxDecoration(
color: pt.bg3,
borderRadius: BorderRadius.circular(pt.rBase),
border: Border.all(color: pt.border),
),
child: const Center(child: PyramidLoader(size: 70, useLottie: true)),
),
const SizedBox(height: 6),
Text('Lottie', style: TextStyle(color: pt.fgMuted, fontSize: 11)),
],
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
children: [
Container(
height: 100,
decoration: BoxDecoration(
color: pt.bg3,
borderRadius: BorderRadius.circular(pt.rBase),
border: Border.all(color: pt.border),
),
child: const Center(child: PyramidLoader(size: 70, useLottie: false)),
),
const SizedBox(height: 6),
Text('SVG', style: TextStyle(color: pt.fgMuted, fontSize: 11)),
],
),
),
],
),
),
]),
],
),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,67 @@
part of '../settings_modal.dart';
class _KeysSection extends StatelessWidget {
final PyramidTheme pt;
const _KeysSection({required this.pt});
@override
Widget build(BuildContext context) {
final keys = [
('Schnellsuche', 'Strg+K'),
('Raum als gelesen markieren', 'Shift+Esc'),
('Zu ungelesen springen', 'Strg+J'),
('Linke Leiste ein-/ausblenden', 'Strg+B'),
('Mitglieder ein-/ausblenden', 'Strg+Shift+U'),
('Nachrichten durchsuchen', 'Strg+F'),
('Antworten', 'R'),
('Auswahl aufheben', 'Esc'),
];
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SectionTitle(
title: 'Tastaturkürzel',
subtitle: 'Schnelle Aktionen per Tastatur.',
pt: pt),
_SettingsGroup(
title: 'Navigieren',
pt: pt,
children: keys.asMap().entries.map((entry) {
final (action, binding) = entry.value;
return Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Row(
children: [
Expanded(
child: Text(action,
style: TextStyle(color: pt.fg, fontSize: 13))),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: pt.bg3,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: pt.border),
),
child: Text(binding,
style: TextStyle(
color: pt.fgMuted,
fontSize: 12,
fontFamily: 'monospace')),
),
],
),
),
if (entry.key < keys.length - 1) _Divider(pt: pt),
],
);
}).toList(),
),
],
),
);
}
}
@@ -0,0 +1,168 @@
part of '../settings_modal.dart';
class _NotificationsSection extends ConsumerWidget {
final PyramidTheme pt;
const _NotificationsSection({required this.pt});
@override
Widget build(BuildContext context, WidgetRef ref) {
final desktop = ref.watch(notifDesktopProvider);
final sound = ref.watch(notifSoundProvider);
final preview = ref.watch(notifPreviewProvider);
final dmMentionOnly = ref.watch(notifMentionOnlyDmProvider);
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SectionTitle(
title: 'Benachrichtigungen',
subtitle: 'Bestimme, wann und wie du benachrichtigt wirst.',
pt: pt),
_SettingsGroup(title: 'Desktop', pt: pt, children: [
_SettingsRow(
title: 'Desktop-Benachrichtigungen',
desc: 'Zeige Systembenachrichtigungen bei neuen Nachrichten',
pt: pt,
control: _Toggle(
value: desktop,
pt: pt,
onChanged: (v) => ref.read(notifDesktopProvider.notifier).set(v),
),
),
_Divider(pt: pt),
_SettingsRow(
title: 'Benachrichtigungston',
desc: 'Sound abspielen wenn eine Nachricht eingeht',
pt: pt,
control: _Toggle(
value: sound,
pt: pt,
onChanged: (v) => ref.read(notifSoundProvider.notifier).set(v),
),
),
_Divider(pt: pt),
_SettingsRow(
title: 'Nachrichtenvorschau',
desc: 'Nachrichteninhalt in Benachrichtigung anzeigen',
pt: pt,
control: _Toggle(
value: preview,
pt: pt,
onChanged: (v) => ref.read(notifPreviewProvider.notifier).set(v),
),
),
]),
const SizedBox(height: 20),
_SettingsGroup(title: 'Filter', pt: pt, children: [
_SettingsRow(
title: 'Nur Erwähnungen (DMs)',
desc: 'Benachrichtigung nur bei direkten @Erwähnungen',
pt: pt,
control: _Toggle(
value: dmMentionOnly,
pt: pt,
onChanged: (v) => ref.read(notifMentionOnlyDmProvider.notifier).set(v),
),
),
]),
const SizedBox(height: 20),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: pt.bg2,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: pt.border),
),
child: Row(
children: [
Icon(Icons.info_outline_rounded, size: 16, color: pt.fgDim),
const SizedBox(width: 10),
Expanded(
child: Text(
'Push-Benachrichtigungen für Android/iOS sind in Vorbereitung.',
style: TextStyle(color: pt.fgMuted, fontSize: 12),
),
),
],
),
),
],
),
);
}
}
class _NotificationsSettingsGroup extends ConsumerStatefulWidget {
final PyramidTheme pt;
const _NotificationsSettingsGroup({required this.pt});
@override
ConsumerState<_NotificationsSettingsGroup> createState() =>
_NotificationsSettingsGroupState();
}
class _NotificationsSettingsGroupState
extends ConsumerState<_NotificationsSettingsGroup> {
bool _running = false;
String? _result;
bool _retryRunning = false;
String? _retryResult;
Future<void> _runDiag() async {
setState(() { _running = true; _result = null; });
final client = await ref.read(matrixClientProvider.future);
final diag = await fcmDiagnostics(client);
if (mounted) setState(() { _running = false; _result = diag; });
}
Future<void> _runRetry() async {
setState(() { _retryRunning = true; _retryResult = null; });
final client = await ref.read(matrixClientProvider.future);
final result = await retryPusherRegistration(client);
if (mounted) setState(() { _retryRunning = false; _retryResult = result; });
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
if (!Platform.isAndroid) return const SizedBox.shrink();
return _SettingsGroup(title: 'Push-Benachrichtigungen', pt: pt, children: [
_SettingsRow(
title: 'Akku-Optimierung deaktivieren',
desc: 'Samsung / andere OEMs unterdrücken sonst Benachrichtigungen bei geschlossener App.',
pt: pt,
showArrow: true,
onTap: openBatteryOptimizationSettings,
control: Icon(Icons.battery_saver_outlined, size: 16, color: pt.fgDim),
),
_Divider(pt: pt),
_SettingsRow(
title: 'FCM Diagnose',
desc: _result ?? 'Prüft ob FCM-Token & Pusher korrekt registriert sind.',
pt: pt,
onTap: _running ? null : _runDiag,
control: _running
? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent),
)
: Icon(Icons.bug_report_outlined, size: 16, color: pt.fgDim),
),
_Divider(pt: pt),
_SettingsRow(
title: 'Pusher neu registrieren',
desc: _retryResult ?? 'Pusher beim Homeserver erneut anmelden.',
pt: pt,
onTap: _retryRunning ? null : _runRetry,
control: _retryRunning
? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent),
)
: Icon(Icons.sync_outlined, size: 16, color: pt.fgDim),
),
]);
}
}
+162
View File
@@ -0,0 +1,162 @@
part of '../settings_modal.dart';
class _PrivacySection extends ConsumerStatefulWidget {
final PyramidTheme pt;
const _PrivacySection({required this.pt});
@override
ConsumerState<_PrivacySection> createState() => _PrivacySectionState();
}
class _PrivacySectionState extends ConsumerState<_PrivacySection> {
List<String> _ignoredUsers = [];
final Set<String> _unblocking = {};
@override
void initState() {
super.initState();
_loadIgnoredUsers();
}
Future<void> _loadIgnoredUsers() async {
final client = await ref.read(matrixClientProvider.future);
if (mounted) setState(() => _ignoredUsers = List.from(client.ignoredUsers));
}
Future<void> _unblockUser(String userId) async {
setState(() => _unblocking.add(userId));
try {
final client = await ref.read(matrixClientProvider.future);
await client.unignoreUser(userId);
await _loadIgnoredUsers();
} catch (e) {
// ignore list will be stale but not crash
} finally {
if (mounted) setState(() => _unblocking.remove(userId));
}
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final presence = ref.watch(privacyPresenceProvider);
final readReceipts = ref.watch(privacyReadReceiptsProvider);
final typing = ref.watch(privacyTypingProvider);
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SectionTitle(
title: 'Datenschutz',
subtitle: 'Steuere, was andere über dich erfahren.',
pt: pt),
_SettingsGroup(title: 'Sichtbarkeit', pt: pt, children: [
_SettingsRow(
title: 'Online-Status anzeigen',
desc: 'Anderen anzeigen, wenn du aktiv bist',
pt: pt,
control: _Toggle(
value: presence,
pt: pt,
onChanged: (v) async {
ref.read(privacyPresenceProvider.notifier).set(v);
final client = await ref.read(matrixClientProvider.future);
await client.setPresence(
client.userID!,
v ? PresenceType.online : PresenceType.offline,
).catchError((_) {});
},
),
),
_Divider(pt: pt),
_SettingsRow(
title: 'Lesebestätigungen senden',
desc: 'Anderen zeigen, dass du ihre Nachrichten gelesen hast',
pt: pt,
control: _Toggle(
value: readReceipts,
pt: pt,
onChanged: (v) => ref.read(privacyReadReceiptsProvider.notifier).set(v),
),
),
_Divider(pt: pt),
_SettingsRow(
title: 'Tipp-Indikator senden',
desc: '„schreibt…" Anzeige aktivieren',
pt: pt,
control: _Toggle(
value: typing,
pt: pt,
onChanged: (v) => ref.read(privacyTypingProvider.notifier).set(v),
),
),
]),
const SizedBox(height: 20),
// Blocked users
_SettingsGroup(title: 'Blockierte Nutzer (${_ignoredUsers.length})', pt: pt, children: [
if (_ignoredUsers.isEmpty)
Padding(
padding: const EdgeInsets.all(14),
child: Text('Keine blockierten Nutzer.',
style: TextStyle(color: pt.fgMuted, fontSize: 13)),
)
else
..._ignoredUsers.asMap().entries.map((entry) {
final i = entry.key;
final userId = entry.value;
final isUnblocking = _unblocking.contains(userId);
return Column(children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Row(children: [
Expanded(
child: Text(userId,
style: TextStyle(color: pt.fg, fontSize: 13),
overflow: TextOverflow.ellipsis),
),
if (isUnblocking)
const SizedBox(width: 16, height: 16, child: CircularProgressIndicator.adaptive(strokeWidth: 2))
else
TextButton(
style: TextButton.styleFrom(
foregroundColor: pt.accent,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: () => _unblockUser(userId),
child: const Text('Entblocken', style: TextStyle(fontSize: 12)),
),
]),
),
if (i < _ignoredUsers.length - 1) _Divider(pt: pt),
]);
}),
]),
const SizedBox(height: 20),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: pt.accentSoft,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: pt.accent.withAlpha(60)),
),
child: Row(
children: [
Icon(Icons.shield_outlined, size: 16, color: pt.accent),
const SizedBox(width: 10),
Expanded(
child: Text(
'Alle Nachrichten sind Ende-zu-Ende-verschlüsselt.',
style: TextStyle(color: pt.fg, fontSize: 12),
),
),
],
),
),
],
),
);
}
}
+513
View File
@@ -0,0 +1,513 @@
part of '../settings_modal.dart';
class _ProfileSection extends ConsumerStatefulWidget {
final PyramidTheme pt;
const _ProfileSection({required this.pt});
@override
ConsumerState<_ProfileSection> createState() => _ProfileSectionState();
}
class _ProfileSectionState extends ConsumerState<_ProfileSection> {
late TextEditingController _nameCtrl;
late TextEditingController _statusCtrl;
bool _saving = false;
bool _saved = false;
String? _error;
Uint8List? _pendingAvatar;
Uri? _avatarUri;
Uint8List? _pendingBanner;
String? _bannerMxcUri;
Uint8List? _pendingServerBanner;
bool _serverBannerSaving = false;
@override
void initState() {
super.initState();
_nameCtrl = TextEditingController();
_statusCtrl = TextEditingController();
_loadProfile();
}
@override
void dispose() {
_nameCtrl.dispose();
_statusCtrl.dispose();
super.dispose();
}
Future<void> _loadProfile() async {
final client = await ref.read(matrixClientProvider.future);
if (!mounted) return;
// 1) SOFORT aus dem lokalen Cache füllen kein Warten auf den Server.
_nameCtrl.text = client.userID ?? '';
for (final room in client.rooms) {
final u = room.unsafeGetUserFromMemoryOrFallback(client.userID!);
if (u.displayName != null || u.avatarUrl != null) {
if (u.displayName != null) _nameCtrl.text = u.displayName!;
_avatarUri = u.avatarUrl;
break;
}
}
try {
final bannerData = client.accountData['com.pyramid.profile.banner'];
_bannerMxcUri = bannerData?.content['url'] as String?;
} catch (_) {}
setState(() {});
// 2) Server-Daten PARALLEL nachladen, UI aktualisiert sich pro Antwort.
client
.getProfileFromUserId(client.userID!)
.then((profile) {
if (!mounted) return;
setState(() {
if (profile.displayName != null) _nameCtrl.text = profile.displayName!;
_avatarUri = profile.avatarUrl ?? _avatarUri;
});
}).catchError((_) {});
() async {
try {
final apiUri = client.homeserver!.replace(
path:
'/_matrix/client/v3/profile/${Uri.encodeComponent(client.userID!)}',
);
final resp = await client.httpClient.get(
apiUri,
headers: {'Authorization': 'Bearer ${client.accessToken}'},
);
if (resp.statusCode == 200 && mounted) {
final data = jsonDecode(resp.body) as Map<String, dynamic>;
final url = data['io.pyramid.profile.banner_url'] as String?;
if (url != null) setState(() => _bannerMxcUri = url);
}
} catch (_) {}
}();
}
Future<void> _pickAvatar() async {
final result = await FilePicker.platform.pickFiles(type: FileType.image, withData: true);
final file = result?.files.first;
if (file == null) return;
final bytes = file.bytes ?? await file.xFile.readAsBytes();
setState(() => _pendingAvatar = bytes);
}
Future<void> _pickBanner() async {
final result = await FilePicker.platform.pickFiles(type: FileType.image, withData: true);
final file = result?.files.first;
if (file == null) return;
final raw = file.bytes ?? await file.xFile.readAsBytes();
if (!mounted) return;
final cropped = await showDialog<Uint8List>(
context: context,
builder: (_) => BannerCropDialog(imageBytes: raw, aspectRatio: 3.0),
);
if (cropped == null || !mounted) return;
setState(() => _pendingBanner = cropped);
}
Future<void> _pickServerBanner() async {
final result = await FilePicker.platform.pickFiles(type: FileType.image, withData: true);
final file = result?.files.first;
if (file == null) return;
final raw = file.bytes ?? await file.xFile.readAsBytes();
if (!mounted) return;
final cropped = await showDialog<Uint8List>(
context: context,
builder: (_) => BannerCropDialog(imageBytes: raw, aspectRatio: 2.4),
);
if (cropped == null || !mounted) return;
setState(() => _serverBannerSaving = true);
try {
final client = await ref.read(matrixClientProvider.future);
final uri = await client.uploadContent(cropped, filename: 'server_banner.png', contentType: 'image/png');
await ref.read(serverBannerProvider.notifier).set(client.homeserver.toString(), uri.toString());
if (mounted) setState(() { _pendingServerBanner = cropped; _serverBannerSaving = false; });
} catch (_) {
if (mounted) setState(() => _serverBannerSaving = false);
}
}
Future<void> _removeServerBanner() async {
final client = ref.read(matrixClientProvider).valueOrNull;
if (client == null) return;
await ref.read(serverBannerProvider.notifier).set(client.homeserver.toString(), null);
if (mounted) setState(() => _pendingServerBanner = null);
}
Future<void> _save() async {
setState(() { _saving = true; _error = null; _saved = false; });
try {
final client = await ref.read(matrixClientProvider.future);
final name = _nameCtrl.text.trim();
if (name.isNotEmpty) {
await client.setProfileField(
client.userID!,
'displayname',
{'displayname': name},
);
}
if (_pendingAvatar != null) {
await client.setAvatar(MatrixFile(
bytes: _pendingAvatar!,
name: 'avatar.jpg',
mimeType: 'image/jpeg',
));
setState(() => _pendingAvatar = null);
}
if (_pendingBanner != null) {
final uri = await client.uploadContent(
_pendingBanner!, filename: 'banner.png', contentType: 'image/png');
final uriStr = uri.toString();
// Store as a profile custom field so other users can see it
await client.setProfileField(
client.userID!,
'io.pyramid.profile.banner_url',
{'io.pyramid.profile.banner_url': uriStr},
);
// Also keep in account data as legacy fallback
await client.setAccountData(client.userID!, 'com.pyramid.profile.banner',
{'url': uriStr});
setState(() { _bannerMxcUri = uriStr; _pendingBanner = null; });
}
final status = _statusCtrl.text.trim();
await client.setPresence(
client.userID!,
PresenceType.online,
statusMsg: status.isEmpty ? null : status,
);
if (mounted) setState(() { _saving = false; _saved = true; });
await Future.delayed(const Duration(seconds: 2));
if (mounted) setState(() => _saved = false);
} catch (e) {
if (mounted) setState(() { _saving = false; _error = e.toString().split('\n').first; });
}
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final clientAsync = ref.watch(matrixClientProvider);
final client = clientAsync.valueOrNull;
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SectionTitle(
title: 'Mein Profil',
subtitle: 'Wie du in Pyramid erscheinst.',
pt: pt),
// Banner
GestureDetector(
onTap: _pickBanner,
child: Stack(
children: [
Container(
height: 100,
decoration: BoxDecoration(
color: pt.bg3,
borderRadius: BorderRadius.circular(pt.rBase),
border: Border.all(color: pt.border),
),
clipBehavior: Clip.hardEdge,
child: _pendingBanner != null
? Image.memory(_pendingBanner!, fit: BoxFit.cover, width: double.infinity)
: (client != null && _bannerMxcUri != null
? MxcImage(
mxcUri: _bannerMxcUri!,
client: client,
fit: BoxFit.cover,
width: double.infinity,
)
: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.add_photo_alternate_outlined, size: 28, color: pt.fgDim),
const SizedBox(height: 4),
Text('Profilbanner auswählen', style: TextStyle(color: pt.fgDim, fontSize: 12)),
],
),
)),
),
Positioned(
right: 8,
bottom: 8,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: pt.bg0.withAlpha(200),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: pt.border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.edit_rounded, size: 11, color: pt.fgMuted),
const SizedBox(width: 4),
Text('Banner ändern', style: TextStyle(color: pt.fgMuted, fontSize: 11)),
],
),
),
),
],
),
),
const SizedBox(height: 16),
// Avatar row
Row(
children: [
GestureDetector(
onTap: _pickAvatar,
child: Stack(
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: pt.border, width: 2),
),
child: ClipOval(
child: _pendingAvatar != null
? Image.memory(_pendingAvatar!, fit: BoxFit.cover)
: (client != null && _avatarUri != null
? MxcAvatar(
mxcUri: _avatarUri,
client: client,
size: 72,
placeholder: (_) => _AvatarFallback(
name: _nameCtrl.text, size: 72, pt: pt),
)
: _AvatarFallback(
name: _nameCtrl.text, size: 72, pt: pt)),
),
),
Positioned(
right: 0,
bottom: 0,
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: pt.accent,
shape: BoxShape.circle,
border: Border.all(color: pt.bg1, width: 2),
),
child: Icon(Icons.edit_rounded, size: 12, color: pt.accentFg),
),
),
],
),
),
const SizedBox(width: 20),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_nameCtrl.text.isNotEmpty ? _nameCtrl.text : (client?.userID ?? ''),
style: TextStyle(
color: pt.fg, fontSize: 16, fontWeight: FontWeight.w600),
),
const SizedBox(height: 2),
SelectableText(
client?.userID ?? '',
style: TextStyle(color: pt.fgDim, fontSize: 12),
),
const SizedBox(height: 6),
if (_pendingAvatar != null)
Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: pt.accent.withAlpha(40),
borderRadius: BorderRadius.circular(6),
),
child: Text('Neues Bild ausgewählt',
style: TextStyle(color: pt.accent, fontSize: 11)),
),
],
),
),
],
),
const SizedBox(height: 24),
_SettingsGroup(title: 'Name & Status', pt: pt, children: [
Padding(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 4),
child: _PrefTextField(
controller: _nameCtrl,
label: 'Anzeigename',
pt: pt,
),
),
const SizedBox(height: 2),
Padding(
padding: const EdgeInsets.fromLTRB(14, 4, 14, 14),
child: _PrefTextField(
controller: _statusCtrl,
label: 'Statusnachricht (optional)',
pt: pt,
maxLength: 120,
),
),
]),
const SizedBox(height: 20),
if (_error != null)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _StatusCard(
pt: pt, color: pt.danger, icon: Icons.error_outline_rounded, text: _error!),
),
if (_saved)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _StatusCard(
pt: pt,
color: PyramidColors.success,
icon: Icons.check_circle_outline_rounded,
text: 'Profil gespeichert.'),
),
_PrimaryBtn(
label: _saving ? 'Speichern…' : 'Speichern',
icon: Icons.save_outlined,
pt: pt,
loading: _saving,
onPressed: _save,
),
const SizedBox(height: 24),
// Server-Banner
_SectionTitle(
title: 'Server-Darstellung',
subtitle: 'Banner für ${client?.homeserver?.host ?? 'diesen Server'}',
pt: pt,
),
Consumer(builder: (context, ref, _) {
final serverBanners = ref.watch(serverBannerProvider).valueOrNull ?? {};
final homeserver = client?.homeserver.toString();
final existingMxc = homeserver != null ? serverBanners[homeserver] : null;
final hasBanner = _pendingServerBanner != null || existingMxc != null;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: _serverBannerSaving ? null : _pickServerBanner,
child: Container(
height: 90,
decoration: BoxDecoration(
color: pt.bg3,
borderRadius: BorderRadius.circular(pt.rBase),
border: Border.all(color: pt.border),
),
clipBehavior: Clip.hardEdge,
child: Stack(
fit: StackFit.expand,
children: [
if (_pendingServerBanner != null)
Image.memory(_pendingServerBanner!, fit: BoxFit.cover,
errorBuilder: (_, _, _) => const SizedBox.shrink())
else if (existingMxc != null && client != null)
MxcImage(mxcUri: existingMxc, client: client, fit: BoxFit.cover)
else
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.add_photo_alternate_outlined, size: 24, color: pt.fgDim),
const SizedBox(height: 4),
Text('Server-Banner auswählen', style: TextStyle(color: pt.fgDim, fontSize: 12)),
],
),
),
if (_serverBannerSaving)
Container(
color: Colors.black38,
child: const Center(child: CircularProgressIndicator(strokeWidth: 2)),
),
if (hasBanner)
Positioned(
right: 8,
bottom: 8,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: pt.bg0.withAlpha(200),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: pt.border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.edit_rounded, size: 11, color: pt.fgMuted),
const SizedBox(width: 4),
Text('Banner ändern', style: TextStyle(color: pt.fgMuted, fontSize: 11)),
],
),
),
),
],
),
),
),
if (hasBanner) ...[
const SizedBox(height: 8),
GestureDetector(
onTap: _removeServerBanner,
child: Text(
'Banner entfernen',
style: TextStyle(color: pt.danger, fontSize: 12),
),
),
],
],
);
}),
const SizedBox(height: 24),
_SettingsGroup(title: 'Konto', pt: pt, children: [
_SettingsRow(
title: 'Passwort ändern',
desc: 'Aktuelles Passwort zum Ändern erforderlich',
pt: pt,
showArrow: true,
onTap: _changePassword,
),
]),
const SizedBox(height: 12),
_SettingsGroup(title: 'Gefahrenbereich', pt: pt, children: [
_SettingsRow(
title: 'Konto löschen',
desc: 'Konto dauerhaft deaktivieren nicht rückgängig zu machen',
pt: pt,
showArrow: true,
destructive: true,
onTap: _deleteAccount,
),
]),
],
),
);
}
Future<void> _changePassword() async {
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) => _ChangePasswordDialog(pt: widget.pt),
);
}
Future<void> _deleteAccount() async {
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) => _DeleteAccountDialog(pt: widget.pt),
);
}
}
+468
View File
@@ -0,0 +1,468 @@
part of '../settings_modal.dart';
class _SessionsSection extends ConsumerStatefulWidget {
final PyramidTheme pt;
const _SessionsSection({required this.pt});
@override
ConsumerState<_SessionsSection> createState() => _SessionsSectionState();
}
class _SessionsSectionState extends ConsumerState<_SessionsSection> {
List<DeviceKeys> _devices = [];
bool _loading = true;
String? _currentDeviceId;
final Set<String> _revoking = {};
bool _logoutAllLoading = false;
String? _error;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() { _loading = true; _error = null; });
try {
final client = await ref.read(matrixClientProvider.future);
_currentDeviceId = client.deviceID;
await client.updateUserDeviceKeys(additionalUsers: {client.userID!});
final keys = client.userDeviceKeys[client.userID];
if (mounted) {
setState(() {
_devices = keys?.deviceKeys.values.toList() ?? [];
_loading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_loading = false;
_error = e.toString().split('\n').first;
});
}
}
}
Future<void> _revokeDevice(String deviceId) async {
setState(() => _revoking.add(deviceId));
try {
final client = await ref.read(matrixClientProvider.future);
await client.deleteDevice(deviceId);
await _load();
} catch (e) {
if (mounted) {
setState(() {
_revoking.remove(deviceId);
_error = 'Abmelden fehlgeschlagen: ${e.toString().split('\n').first}';
});
}
}
}
Future<void> _renameCurrentDevice(BuildContext context) async {
final client = await ref.read(matrixClientProvider.future);
if (!context.mounted) return;
final currentName = _devices
.where((d) => d.deviceId == _currentDeviceId)
.map((d) => d.unsigned?['device_display_name'] as String? ?? d.deviceId ?? '')
.firstOrNull ?? '';
final ctrl = TextEditingController(text: currentName);
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('Gerät umbenennen',
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(
labelText: 'Gerätename',
labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13),
filled: true, fillColor: pt.bg3,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
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,
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: pt.accent, foregroundColor: pt.accentFg, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Umbenennen'),
),
],
),
);
ctrl.dispose();
if (confirmed != true || _currentDeviceId == null) return;
try {
await client.updateDevice(_currentDeviceId!, displayName: ctrl.text.trim());
await _load();
} catch (e) {
if (mounted) setState(() => _error = 'Umbenennen fehlgeschlagen: ${e.toString().split('\n').first}');
}
}
Future<void> _logoutAllOtherDevices(BuildContext context) async {
final pt = widget.pt;
final otherIds = _devices
.where((d) => d.deviceId != _currentDeviceId && d.deviceId != null)
.map((d) => d.deviceId!)
.toList();
if (otherIds.isEmpty) return;
final confirm = 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('Alle anderen Sitzungen abmelden?',
style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)),
content: Text(
'${otherIds.length} Gerät${otherIds.length == 1 ? '' : 'e'} werden abgemeldet. Nicht gespeicherte Nachrichten auf diesen Geräten können verloren gehen.',
style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5),
),
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('Alle abmelden'),
),
],
),
);
if (confirm != true || !mounted) return;
setState(() { _logoutAllLoading = true; _error = null; });
final client = await ref.read(matrixClientProvider.future);
try {
await client.deleteDevices(otherIds);
} on MatrixException catch (e) {
if (e.requireAdditionalAuthentication && context.mounted) {
final password = await _askPassword(context, pt);
if (password == null || !mounted) { setState(() => _logoutAllLoading = false); return; }
try {
await client.deleteDevices(otherIds, auth: AuthenticationData(
type: AuthenticationTypes.password,
session: e.session,
additionalFields: {
'identifier': {'type': 'm.id.user', 'user': client.userID},
'password': password,
},
));
} catch (e2) {
if (mounted) setState(() { _logoutAllLoading = false; _error = 'Abmelden fehlgeschlagen: ${e2.toString().split('\n').first}'; });
return;
}
} else {
if (mounted) setState(() { _logoutAllLoading = false; _error = 'Abmelden fehlgeschlagen: ${e.toString().split('\n').first}'; });
return;
}
} catch (e) {
if (mounted) setState(() { _logoutAllLoading = false; _error = 'Abmelden fehlgeschlagen: ${e.toString().split('\n').first}'; });
return;
}
await _load();
if (mounted) setState(() => _logoutAllLoading = false);
}
Future<String?> _askPassword(BuildContext context, PyramidTheme pt) {
final ctrl = TextEditingController();
return showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: pt.bg2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)),
title: Text('Passwort bestätigen', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w700)),
content: TextField(
controller: ctrl,
autofocus: true,
obscureText: true,
style: TextStyle(color: pt.fg, fontSize: 13),
decoration: InputDecoration(
labelText: 'Passwort',
labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13),
filled: true, fillColor: pt.bg3,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
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: (_) => Navigator.pop(ctx, ctrl.text),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, null), child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: pt.accent, foregroundColor: pt.accentFg, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
onPressed: () => Navigator.pop(ctx, ctrl.text),
child: const Text('Bestätigen'),
),
],
),
).then((v) { ctrl.dispose(); return v; });
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SectionTitle(
title: 'Sitzungen',
subtitle: 'Geräte, auf denen du angemeldet bist.',
pt: pt),
if (_loading)
const Center(
child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator.adaptive()))
else if (_error != null)
_StatusCard(
pt: pt, color: pt.danger, icon: Icons.error_outline_rounded, text: _error!)
else ...[
_SettingsGroup(
title: 'Aktive Sitzungen (${_devices.length})',
pt: pt,
children: _devices.isEmpty
? [
Padding(
padding: const EdgeInsets.all(16),
child: Text('Keine Sitzungen gefunden.',
style: TextStyle(color: pt.fgMuted, fontSize: 13)),
)
]
: _devices.asMap().entries.map((entry) {
final i = entry.key;
final device = entry.value;
final isCurrent = device.deviceId == _currentDeviceId;
final isRevoking = _revoking.contains(device.deviceId);
final isVerified = device.verified;
final displayName = device.unsigned?['device_display_name'] as String? ??
device.deviceId ??
'Unbekanntes Gerät';
return Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 12),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: isCurrent
? pt.accentSoft
: pt.bg3,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.devices_rounded,
size: 18,
color: isCurrent ? pt.accent : pt.fgMuted,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
displayName,
style: TextStyle(
color: pt.fg,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
if (isCurrent) ...[
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: pt.accentSoft,
borderRadius: BorderRadius.circular(4),
),
child: Text('Dieses Gerät',
style: TextStyle(
color: pt.accent,
fontSize: 10,
fontWeight: FontWeight.w600)),
),
const SizedBox(width: 4),
GestureDetector(
onTap: () => _renameCurrentDevice(context),
child: Tooltip(
message: 'Gerät umbenennen',
child: Icon(Icons.edit_rounded, size: 13, color: pt.fgDim),
),
),
],
if (isVerified == true && !isCurrent)
Icon(Icons.verified_rounded,
size: 14, color: PyramidColors.success),
if (isVerified == false && !isCurrent)
Icon(Icons.warning_amber_rounded,
size: 14, color: pt.away),
],
),
const SizedBox(height: 2),
SelectableText(
device.deviceId ?? '',
style: TextStyle(
color: pt.fgDim,
fontSize: 11,
fontFamily: 'monospace'),
),
],
),
),
if (!isCurrent) ...[
const SizedBox(width: 8),
if (isVerified == false)
GestureDetector(
onTap: () => _verifyDevice(context, device, displayName),
child: Tooltip(
message: 'Verifizieren',
child: Icon(Icons.verified_user_outlined,
size: 16, color: pt.accent),
),
),
const SizedBox(width: 6),
if (isRevoking)
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2, color: pt.danger),
)
else
GestureDetector(
onTap: () => _confirmRevoke(context, device, displayName),
child: Tooltip(
message: 'Abmelden',
child: Icon(Icons.logout_rounded,
size: 16, color: pt.fgDim),
),
),
],
],
),
),
if (i < _devices.length - 1) _Divider(pt: pt),
],
);
}).toList(),
),
const SizedBox(height: 12),
Row(
children: [
TextButton.icon(
style: TextButton.styleFrom(
foregroundColor: pt.fgMuted,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
onPressed: _load,
icon: const Icon(Icons.refresh_rounded, size: 14),
label: const Text('Aktualisieren', style: TextStyle(fontSize: 12)),
),
const Spacer(),
if (_logoutAllLoading)
const SizedBox(width: 16, height: 16, child: CircularProgressIndicator.adaptive(strokeWidth: 2))
else if (_devices.where((d) => d.deviceId != _currentDeviceId).isNotEmpty)
TextButton.icon(
style: TextButton.styleFrom(
foregroundColor: pt.danger,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
onPressed: () => _logoutAllOtherDevices(context),
icon: const Icon(Icons.logout_rounded, size: 14),
label: const Text('Alle anderen abmelden', style: TextStyle(fontSize: 12)),
),
],
),
],
],
),
);
}
Future<void> _verifyDevice(
BuildContext context, DeviceKeys device, String displayName) async {
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) => _SasVerificationDialog(
pt: widget.pt,
deviceName: displayName,
device: device,
),
);
await _load();
}
Future<void> _confirmRevoke(
BuildContext context, DeviceKeys device, String displayName) async {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) {
final pt = widget.pt;
return AlertDialog(
backgroundColor: pt.bg2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rXl),
side: BorderSide(color: pt.border)),
title: Text('Gerät abmelden?',
style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)),
content: Text(
'"$displayName" wird abgemeldet und alle lokalen Schlüssel werden gelöscht.',
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('Abmelden'),
),
],
);
},
);
if (confirm == true && device.deviceId != null) {
await _revokeDevice(device.deviceId!);
}
}
}
+639
View File
@@ -0,0 +1,639 @@
part of '../settings_modal.dart';
class _MobileHeader extends StatelessWidget {
final String title;
final PyramidTheme pt;
final VoidCallback? onBack;
final VoidCallback onClose;
const _MobileHeader({required this.title, required this.pt, this.onBack, required this.onClose});
@override
Widget build(BuildContext context) {
return Container(
height: 52,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: pt.bg2,
border: Border(bottom: BorderSide(color: pt.border)),
),
child: Row(
children: [
if (onBack != null)
IconButton(
onPressed: onBack,
icon: Icon(Icons.arrow_back_rounded, size: 20, color: pt.fg),
tooltip: 'Zurück',
)
else
const SizedBox(width: 8),
Expanded(
child: Text(
title,
style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w600),
overflow: TextOverflow.ellipsis,
),
),
IconButton(
onPressed: onClose,
icon: Icon(Icons.close_rounded, size: 20, color: pt.fgMuted),
tooltip: 'Schließen',
),
],
),
);
}
}
class _MobileCategoryTile extends StatelessWidget {
final String label;
final IconData icon;
final PyramidTheme pt;
final VoidCallback onTap;
const _MobileCategoryTile({required this.label, required this.icon, required this.pt, required this.onTap});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15),
child: Row(
children: [
Icon(icon, size: 21, color: pt.fgMuted),
const SizedBox(width: 16),
Expanded(
child: Text(label, style: TextStyle(color: pt.fg, fontSize: 15)),
),
Icon(Icons.chevron_right_rounded, size: 20, color: pt.fgDim),
],
),
),
);
}
}
class _NavSection extends StatelessWidget {
final String title;
final PyramidTheme pt;
const _NavSection(this.title, this.pt);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 4),
child: Text(
title.toUpperCase(),
style: TextStyle(
color: pt.fgDim,
fontSize: 11,
fontWeight: FontWeight.w600,
letterSpacing: 0.04 * 11,
),
),
);
}
}
class _NavItem extends StatefulWidget {
final String id;
final String label;
final IconData icon;
final String active;
final PyramidTheme pt;
final VoidCallback onTap;
const _NavItem(this.id, this.label, this.icon, this.active, this.pt, this.onTap);
@override
State<_NavItem> createState() => _NavItemState();
}
class _NavItemState extends State<_NavItem> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final isActive = widget.id == widget.active;
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: 150),
margin: const EdgeInsets.fromLTRB(8, 1, 8, 1),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
decoration: BoxDecoration(
color: isActive
? pt.accentSoft
: _hovered
? pt.bgHover
: Colors.transparent,
borderRadius: BorderRadius.circular(pt.rSm),
),
child: Row(
mainAxisAlignment: widget.label.isEmpty
? MainAxisAlignment.center
: MainAxisAlignment.start,
children: [
Icon(
widget.icon,
size: 14,
color: isActive ? pt.accent : _hovered ? pt.fg : pt.fgMuted,
),
if (widget.label.isNotEmpty) ...[
const SizedBox(width: 8),
Text(
widget.label,
style: TextStyle(
color: isActive ? pt.fg : _hovered ? pt.fg : pt.fgMuted,
fontSize: 13,
fontWeight: isActive ? FontWeight.w600 : FontWeight.w400,
),
),
],
],
),
),
),
);
}
}
class _LogoutNavButton extends ConsumerStatefulWidget {
final PyramidTheme pt;
final bool isMobile;
final VoidCallback onClose;
const _LogoutNavButton({required this.pt, required this.isMobile, required this.onClose});
@override
ConsumerState<_LogoutNavButton> createState() => _LogoutNavButtonState();
}
class _LogoutNavButtonState extends ConsumerState<_LogoutNavButton> {
bool _hovered = false;
bool _loading = false;
Future<void> _logout() async {
final pt = widget.pt;
final client = await ref.read(matrixClientProvider.future);
final backupMissing = await isKeyBackupMissing(client);
if (!mounted) return;
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('Abmelden?',
style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)),
content: Text(
backupMissing
? 'Du wirst von diesem Gerät abgemeldet. Es ist kein Schlüssel-Backup eingerichtet — '
'ältere verschlüsselte Nachrichten sind danach auf keinem Gerät mehr lesbar.'
: 'Du wirst von diesem Gerät abgemeldet.',
style: TextStyle(
color: backupMissing ? pt.danger : pt.fgMuted, fontSize: 13, height: 1.4),
),
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('Abmelden'),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _loading = true);
try {
widget.onClose();
unawaited(AuthLog.write('User-initiated logout (Settings-Sidebar)'));
await client.logout();
} catch (_) {}
}
@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: _loading ? null : _logout,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.fromLTRB(8, 1, 8, 1),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
decoration: BoxDecoration(
color: _hovered ? pt.danger.withAlpha(30) : Colors.transparent,
borderRadius: BorderRadius.circular(pt.rSm),
),
child: Row(
mainAxisAlignment: widget.isMobile ? MainAxisAlignment.center : MainAxisAlignment.start,
children: [
_loading
? SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: pt.danger))
: Icon(Icons.logout_rounded, size: 14, color: pt.danger),
if (!widget.isMobile) ...[
const SizedBox(width: 8),
Text('Abmelden', style: TextStyle(color: pt.danger, fontSize: 13)),
],
],
),
),
),
);
}
}
class _SectionTitle extends StatelessWidget {
final String title;
final String subtitle;
final PyramidTheme pt;
const _SectionTitle({required this.title, required this.subtitle, required this.pt});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: TextStyle(color: pt.fg, fontSize: 20, fontWeight: FontWeight.w700)),
const SizedBox(height: 4),
Text(subtitle, style: TextStyle(color: pt.fgMuted, fontSize: 13)),
const SizedBox(height: 24),
],
);
}
}
class _SettingsGroup extends StatelessWidget {
final String title;
final PyramidTheme pt;
final List<Widget> children;
const _SettingsGroup({required this.title, required this.pt, required this.children});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title.toUpperCase(),
style: TextStyle(
color: pt.fgDim,
fontSize: 11,
fontWeight: FontWeight.w600,
letterSpacing: 0.04 * 11),
),
const SizedBox(height: 10),
Container(
decoration: BoxDecoration(
color: pt.bg2,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: pt.border),
),
child: Column(children: children),
),
],
);
}
}
class _SettingsRow extends StatelessWidget {
final String title;
final String? desc;
final PyramidTheme pt;
final Widget? control;
final VoidCallback? onTap;
final bool destructive;
final bool showArrow;
const _SettingsRow({
required this.title,
required this.pt,
this.desc,
this.control,
this.onTap,
this.destructive = false,
this.showArrow = false,
});
@override
Widget build(BuildContext context) {
Widget content = Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
color: destructive ? pt.danger : pt.fg,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
if (desc != null) ...[
const SizedBox(height: 2),
Text(desc!, style: TextStyle(color: pt.fgDim, fontSize: 12)),
],
],
),
),
?control,
if (showArrow)
Icon(Icons.chevron_right_rounded, size: 16, color: pt.fgDim),
],
),
);
if (onTap != null) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: content,
),
);
}
return content;
}
}
class _Divider extends StatelessWidget {
final PyramidTheme pt;
const _Divider({required this.pt});
@override
Widget build(BuildContext context) {
return Container(height: 1, color: pt.border, margin: const EdgeInsets.only(left: 14));
}
}
class _Toggle extends StatelessWidget {
final bool value;
final ValueChanged<bool> onChanged;
final PyramidTheme pt;
const _Toggle({required this.value, required this.onChanged, required this.pt});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => onChanged(!value),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 42,
height: 24,
decoration: BoxDecoration(
color: value ? pt.accent : pt.bg3,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: value ? pt.accent : pt.borderStrong),
),
child: Padding(
padding: const EdgeInsets.all(2),
child: AnimatedAlign(
duration: const Duration(milliseconds: 200),
alignment: value ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
width: 18,
height: 18,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: [BoxShadow(color: Colors.black.withAlpha(60), blurRadius: 4)],
),
),
),
),
),
);
}
}
class _SegControl extends StatelessWidget {
final List<String> options;
final int selected;
final PyramidTheme pt;
final ValueChanged<int> onSelect;
const _SegControl(
{required this.options, required this.selected, required this.pt, required this.onSelect});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: pt.bg3,
borderRadius: BorderRadius.circular(pt.rSm),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: options.asMap().entries.map((e) {
final isActive = e.key == selected;
return GestureDetector(
onTap: () => onSelect(e.key),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
decoration: BoxDecoration(
color: isActive ? pt.accent : Colors.transparent,
borderRadius: BorderRadius.circular(pt.rSm - 2),
),
child: Text(
e.value,
style: TextStyle(
color: isActive ? pt.accentFg : pt.fgMuted,
fontSize: 13,
fontWeight: isActive ? FontWeight.w600 : FontWeight.w400,
),
),
),
);
}).toList(),
),
);
}
}
class _PrimaryBtn extends StatelessWidget {
final String label;
final IconData? icon;
final VoidCallback? onPressed;
final PyramidTheme pt;
final bool loading;
const _PrimaryBtn({
required this.label,
required this.pt,
this.icon,
this.onPressed,
this.loading = false,
});
@override
Widget build(BuildContext context) {
final bg = pt.accent;
final fg = pt.accentFg;
return ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: bg,
foregroundColor: fg,
disabledBackgroundColor: bg.withAlpha(100),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
elevation: 0,
),
onPressed: loading ? null : onPressed,
icon: loading
? SizedBox(
width: 14, height: 14, child: CircularProgressIndicator(color: fg, strokeWidth: 2))
: (icon != null ? Icon(icon, size: 16) : const SizedBox.shrink()),
label: Text(label),
);
}
}
class _AvatarFallback extends StatelessWidget {
final String name;
final double size;
final PyramidTheme pt;
const _AvatarFallback({required this.name, required this.size, required this.pt});
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
color: pt.accent,
child: Center(
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
style:
TextStyle(color: pt.accentFg, fontSize: size * 0.4, fontWeight: FontWeight.w700),
),
),
);
}
}
class _PrefTextField extends StatelessWidget {
final TextEditingController controller;
final String label;
final PyramidTheme pt;
final int? maxLength;
const _PrefTextField({
required this.controller,
required this.label,
required this.pt,
this.maxLength,
});
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
maxLength: maxLength,
style: TextStyle(color: pt.fg, fontSize: 13),
decoration: InputDecoration(
labelText: label,
labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13),
counterText: '',
filled: true,
fillColor: pt.bg3,
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
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,
);
}
}
class _PwField extends StatelessWidget {
final TextEditingController ctrl;
final String label;
final PyramidTheme pt;
final bool visible;
final VoidCallback? onToggle;
final ValueChanged<String>? onSubmitted;
const _PwField({
required this.ctrl, required this.label, required this.pt,
required this.visible, required this.onToggle, this.onSubmitted,
});
@override
Widget build(BuildContext context) => TextField(
controller: ctrl,
obscureText: !visible,
onSubmitted: onSubmitted,
style: TextStyle(color: pt.fg, fontSize: 13),
decoration: InputDecoration(
labelText: label,
labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13),
filled: true, fillColor: pt.bg3,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent)),
suffixIcon: onToggle != null
? IconButton(
icon: Icon(visible ? Icons.visibility_off_rounded : Icons.visibility_rounded, size: 16, color: pt.fgDim),
onPressed: onToggle,
)
: null,
),
cursorColor: pt.accent,
);
}
class _StatusCard extends StatelessWidget {
final PyramidTheme pt;
final IconData icon;
final Color color;
final String text;
const _StatusCard(
{required this.pt, required this.icon, required this.color, required this.text});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: pt.bg2,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: pt.border),
),
child: Row(
children: [
Icon(icon, color: color, size: 18),
const SizedBox(width: 10),
Expanded(child: Text(text, style: TextStyle(color: pt.fgMuted, fontSize: 13))),
],
),
);
}
}
@@ -0,0 +1,482 @@
part of '../settings_modal.dart';
class _SasVerificationDialog extends ConsumerStatefulWidget {
final PyramidTheme pt;
final String deviceName;
final DeviceKeys device;
const _SasVerificationDialog({
required this.pt,
required this.deviceName,
required this.device,
});
@override
ConsumerState<_SasVerificationDialog> createState() => _SasVerificationDialogState();
}
class _SasVerificationDialogState extends ConsumerState<_SasVerificationDialog> {
KeyVerification? _verification;
KeyVerificationState _state = KeyVerificationState.waitingAccept;
bool _starting = true;
String? _error;
@override
void initState() {
super.initState();
_startVerification();
}
@override
void dispose() {
final v = _verification;
if (v != null && !v.isDone) {
v.cancel('m.user').catchError((_) {});
}
super.dispose();
}
Future<void> _startVerification() async {
try {
final client = await ref.read(matrixClientProvider.future);
final deviceId = widget.device.deviceId;
if (deviceId == null) throw Exception('Keine Geräte-ID');
final deviceKeys = client.userDeviceKeys[client.userID]?.deviceKeys[deviceId];
if (deviceKeys == null) throw Exception('Gerät nicht gefunden');
final verification = await deviceKeys.startVerification();
_verification = verification;
verification.onUpdate = () {
if (!mounted) return;
setState(() => _state = verification.state);
};
if (mounted) {
setState(() {
_state = verification.state;
_starting = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_error = e.toString().split('\n').first;
_starting = false;
});
}
}
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
return AlertDialog(
backgroundColor: pt.bg2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rXl),
side: BorderSide(color: pt.border)),
contentPadding: const EdgeInsets.fromLTRB(24, 20, 24, 0),
title: Row(
children: [
Icon(Icons.verified_user_outlined, size: 20, color: pt.accent),
const SizedBox(width: 10),
Expanded(
child: Text(
'Gerät verifizieren',
style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700),
),
),
],
),
content: SizedBox(
width: 380,
child: _buildContent(pt),
),
actions: _buildActions(pt),
);
}
Widget _buildContent(PyramidTheme pt) {
if (_error != null) {
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: _StatusCard(
pt: pt,
icon: Icons.error_outline_rounded,
color: pt.danger,
text: _error!),
);
}
if (_starting) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(child: CircularProgressIndicator.adaptive()),
);
}
final v = _verification;
return switch (_state) {
KeyVerificationState.waitingAccept || KeyVerificationState.askAccept => Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator.adaptive(),
const SizedBox(height: 16),
Text(
'Warte auf Bestätigung von „${widget.deviceName}"…',
textAlign: TextAlign.center,
style: TextStyle(color: pt.fgMuted, fontSize: 13),
),
const SizedBox(height: 8),
Text(
'Öffne Pyramid auf dem anderen Gerät und bestätige die Verifikationsanfrage.',
textAlign: TextAlign.center,
style: TextStyle(color: pt.fgDim, fontSize: 12),
),
],
),
),
KeyVerificationState.askChoice => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Das andere Gerät hat die Anfrage akzeptiert.\nWähle eine Verifikationsmethode:',
textAlign: TextAlign.center,
style: TextStyle(color: pt.fgMuted, fontSize: 13),
),
const SizedBox(height: 16),
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: pt.accent,
foregroundColor: pt.accentFg,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(vertical: 12),
),
onPressed: () => v?.continueVerification('m.sas.v1').catchError((_) {}),
icon: const Icon(Icons.tag_faces_rounded, size: 18),
label: const Text('Emoji-Verifikation'),
),
const SizedBox(height: 8),
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: pt.bg3,
foregroundColor: pt.fg,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(vertical: 12),
),
onPressed: () => v?.continueVerification('m.qr_code.show.v1').catchError((_) {}),
icon: const Icon(Icons.qr_code_rounded, size: 18),
label: const Text('QR-Code anzeigen'),
),
const SizedBox(height: 8),
],
),
),
KeyVerificationState.confirmQRScan => _OutgoingQrCodeDisplay(
pt: pt,
request: v,
),
KeyVerificationState.showQRSuccess => Padding(
padding: const EdgeInsets.only(bottom: 16),
child: _StatusCard(
pt: pt,
icon: Icons.verified_rounded,
color: PyramidColors.success,
text: 'QR-Code erfolgreich gescannt. Verifikation abgeschlossen.'),
),
KeyVerificationState.askSas => _SasEmojiContent(
pt: pt,
verification: v!,
onConfirm: () => v.acceptSas().catchError((_) {}),
onReject: () {
v.rejectSas().catchError((_) {});
Navigator.of(context).pop();
},
),
KeyVerificationState.waitingSas => Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator.adaptive(),
const SizedBox(height: 16),
Text(
'Warte auf Bestätigung des anderen Geräts…',
textAlign: TextAlign.center,
style: TextStyle(color: pt.fgMuted, fontSize: 13),
),
],
),
),
KeyVerificationState.done => Padding(
padding: const EdgeInsets.only(bottom: 16),
child: _StatusCard(
pt: pt,
icon: Icons.verified_rounded,
color: PyramidColors.success,
text: '${widget.deviceName}" wurde erfolgreich verifiziert.'),
),
_ => Padding(
padding: const EdgeInsets.only(bottom: 16),
child: _StatusCard(
pt: pt,
icon: Icons.error_outline_rounded,
color: pt.danger,
text: 'Verifizierung fehlgeschlagen oder abgebrochen.'),
),
};
}
List<Widget> _buildActions(PyramidTheme pt) {
if (_state == KeyVerificationState.done ||
_state == KeyVerificationState.showQRSuccess ||
_error != null ||
_state == KeyVerificationState.error) {
return [
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: pt.accent,
foregroundColor: pt.accentFg,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
onPressed: () => Navigator.pop(context),
child: const Text('Schließen'),
),
];
}
if (_state == KeyVerificationState.confirmQRScan) {
return [
TextButton(
onPressed: () {
_verification?.cancel('m.user').catchError((_) {});
Navigator.pop(context);
},
child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF22C55E),
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
onPressed: () => _verification?.acceptQRScanConfirmation().catchError((_) {}),
child: const Text('Gescannt bestätigen'),
),
];
}
if (_state == KeyVerificationState.askSas || _state == KeyVerificationState.askChoice) {
return [];
}
return [
TextButton(
onPressed: () {
_verification?.cancel('m.user').catchError((_) {});
Navigator.pop(context);
},
child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)),
),
];
}
}
class _OutgoingQrCodeDisplay extends StatelessWidget {
final PyramidTheme pt;
final KeyVerification? request;
const _OutgoingQrCodeDisplay({required this.pt, required this.request});
@override
Widget build(BuildContext context) {
final qrCode = request?.qrCode;
if (qrCode == null) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator.adaptive(),
const SizedBox(height: 12),
Text('QR-Code wird generiert…', style: TextStyle(color: pt.fgMuted, fontSize: 13)),
],
),
);
}
try {
final rawBytes = Uint8List.fromList(qrCode.qrDataRawBytes.toList());
final qr = QrCode.fromUint8List(data: rawBytes, errorCorrectLevel: QrErrorCorrectLevel.L);
final qrImage = QrImage(qr);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Scanne diesen QR-Code mit dem anderen Gerät',
textAlign: TextAlign.center,
style: TextStyle(color: pt.fgMuted, fontSize: 13),
),
const SizedBox(height: 16),
Center(
child: Container(
width: 220,
height: 220,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: PrettyQrView(qrImage: qrImage),
),
),
const SizedBox(height: 12),
Text(
'Sobald das andere Gerät den Code gescannt hat, tippe auf „Gescannt bestätigen".',
textAlign: TextAlign.center,
style: TextStyle(color: pt.fgDim, fontSize: 12),
),
const SizedBox(height: 8),
],
),
);
} catch (_) {
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: _StatusCard(
pt: pt,
icon: Icons.error_outline_rounded,
color: pt.danger,
text: 'QR-Code konnte nicht generiert werden.',
),
);
}
}
}
class _SasEmojiContent extends StatelessWidget {
final PyramidTheme pt;
final KeyVerification verification; // ignore: library_private_types_in_public_api
final VoidCallback onConfirm;
final VoidCallback onReject;
const _SasEmojiContent({
required this.pt,
required this.verification,
required this.onConfirm,
required this.onReject,
});
@override
Widget build(BuildContext context) {
final emojis = verification.sasEmojis;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Vergleiche die Emojis auf beiden Geräten',
textAlign: TextAlign.center,
style: TextStyle(color: pt.fgMuted, fontSize: 13),
),
const SizedBox(height: 20),
if (emojis.isNotEmpty)
Wrap(
spacing: 12,
runSpacing: 12,
alignment: WrapAlignment.center,
children: emojis.map((e) => _SasEmojiChip(emoji: e, pt: pt)).toList(),
)
else
Text('Keine Emojis verfügbar.', style: TextStyle(color: pt.fgDim, fontSize: 13)),
const SizedBox(height: 24),
Text(
'Stimmen alle Emojis überein?',
style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
style: OutlinedButton.styleFrom(
foregroundColor: pt.danger,
side: BorderSide(color: pt.danger.withAlpha(120)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(vertical: 12),
),
onPressed: onReject,
icon: const Icon(Icons.close_rounded, size: 16),
label: const Text('Stimmt nicht überein'),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: PyramidColors.success,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(vertical: 12),
),
onPressed: onConfirm,
icon: const Icon(Icons.check_rounded, size: 16),
label: const Text('Stimmt überein'),
),
),
],
),
const SizedBox(height: 8),
],
),
);
}
}
class _SasEmojiChip extends StatelessWidget {
final KeyVerificationEmoji emoji;
final PyramidTheme pt;
const _SasEmojiChip({required this.emoji, required this.pt});
@override
Widget build(BuildContext context) {
return Container(
width: 68,
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
decoration: BoxDecoration(
color: pt.bg3,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: pt.border),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(emoji.emoji, style: const TextStyle(fontSize: 26)),
const SizedBox(height: 4),
Text(
emoji.name,
style: TextStyle(color: pt.fgMuted, fontSize: 10),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
}
+437
View File
@@ -0,0 +1,437 @@
part of '../settings_modal.dart';
class _VoiceSection extends StatefulWidget {
final PyramidTheme pt;
const _VoiceSection({required this.pt});
@override
State<_VoiceSection> createState() => _VoiceSectionState();
}
class _VoiceSectionState extends State<_VoiceSection> {
List<MediaDeviceInfo> _audioInputs = [];
List<MediaDeviceInfo> _audioOutputs = [];
List<MediaDeviceInfo> _videoInputs = [];
String? _selectedMic;
String? _selectedSpeaker;
String? _selectedCamera;
double _outputVolume = 1.0;
bool _loading = true;
String? _loadError;
static const _kMic = 'voice_mic_device';
static const _kSpeaker = 'voice_speaker_device';
static const _kCamera = 'voice_camera_device';
static const _kOutputVolume = 'voice_output_volume';
@override
void initState() {
super.initState();
_init();
}
Future<void> _init() async {
final prefs = await SharedPreferences.getInstance();
List<MediaDeviceInfo> devices = [];
String? loadError;
try {
devices = await navigator.mediaDevices.enumerateDevices();
} catch (_) {
devices = [];
}
// Windows (und teils Android) liefert eine LEERE Liste ohne Fehler,
// solange in diesem Prozess noch nie ein Media-Stream geöffnet wurde.
// Deshalb nicht nur bei Exception, sondern auch bei leerer Liste einmal
// kurz getUserMedia anfordern und erneut enumerieren und zwar SOLANGE
// der Stream noch offen ist (nach stop() melden manche Systeme wieder 0).
final hasAudioInput =
devices.any((d) => d.kind?.toLowerCase() == 'audioinput');
if (devices.isEmpty || !hasAudioInput) {
try {
final stream = await navigator.mediaDevices
.getUserMedia({'audio': true, 'video': false});
try {
devices = await navigator.mediaDevices.enumerateDevices();
} finally {
for (final t in stream.getTracks()) {
t.stop();
}
await stream.dispose();
}
if (devices.isEmpty) {
// Bekanntes libwebrtc-Problem auf manchen Windows-Systemen: das
// Audio-Device-Module meldet 0 Geräte, obwohl Aufnahme/Wiedergabe
// über das Standardgerät einwandfrei funktionieren.
loadError =
'Die Audiotreiber melden keine Geräteliste an WebRTC '
'(bekanntes libwebrtc-Problem auf manchen Windows-Systemen). '
'Anrufe funktionieren trotzdem — es wird das Windows-Standardgerät verwendet.';
}
} catch (e2) {
loadError = 'getUserMedia: ${e2.toString().split('\n').first}';
}
}
if (!mounted) return;
setState(() {
_audioInputs = devices.where((d) => d.kind?.toLowerCase() == 'audioinput').toList();
_audioOutputs = devices.where((d) => d.kind?.toLowerCase() == 'audiooutput').toList();
_videoInputs = devices.where((d) => d.kind?.toLowerCase() == 'videoinput').toList();
_selectedMic = prefs.getString(_kMic);
_selectedSpeaker = prefs.getString(_kSpeaker);
_selectedCamera = prefs.getString(_kCamera);
_outputVolume = prefs.getDouble(_kOutputVolume) ?? 1.0;
_loadError = loadError;
_loading = false;
});
}
Future<void> _setOutputVolume(double v) async {
setState(() => _outputVolume = v);
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble(_kOutputVolume, v);
// Live auf laufende Calls anwenden.
LiveKitCallManager.instance.setOutputVolume(v).catchError((_) {});
try {
PyramidVoipManager.instance.applyOutputVolume(v).catchError((_) {});
} catch (_) {} // Instanz existiert evtl. noch nicht
}
Future<void> _save(String key, String? value) async {
final prefs = await SharedPreferences.getInstance();
if (value == null || value.isEmpty) {
await prefs.remove(key);
} else {
await prefs.setString(key, value);
}
}
Widget _deviceDropdown({
required List<MediaDeviceInfo> devices,
required String? selected,
required String placeholder,
required IconData icon,
required ValueChanged<String?> onChanged,
}) {
final pt = widget.pt;
if (devices.isEmpty) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: pt.bg3,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: pt.border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 13, color: pt.fgDim),
const SizedBox(width: 6),
Text('Kein Gerät', style: TextStyle(color: pt.fgDim, fontSize: 12)),
],
),
);
}
final validSelected = devices.any((d) => d.deviceId == selected) ? selected : null;
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 220),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
color: pt.bg3,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: pt.border),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String?>(
value: validSelected,
hint: Row(children: [
Icon(icon, size: 13, color: pt.fgDim),
const SizedBox(width: 6),
Text(placeholder, style: TextStyle(color: pt.fgDim, fontSize: 12)),
]),
dropdownColor: pt.bg2,
iconEnabledColor: pt.fgDim,
isDense: true,
style: TextStyle(color: pt.fg, fontSize: 12),
onChanged: onChanged,
items: devices.map((d) {
final label = d.label.isNotEmpty ? d.label : 'Gerät ${d.deviceId.substring(0, 6)}';
return DropdownMenuItem<String?>(
value: d.deviceId,
child: Text(label, overflow: TextOverflow.ellipsis),
);
}).toList(),
),
),
),
);
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SectionTitle(
title: 'Sprache & Video',
subtitle: 'Mikrofon, Kamera und Anrufeinstellungen.',
pt: pt),
if (_loading)
const Center(child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator.adaptive()))
else ...[
if (_loadError != null)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: pt.bg2,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: pt.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Icon(Icons.warning_amber_rounded, size: 16, color: pt.away),
const SizedBox(width: 8),
Text('Geräteliste nicht verfügbar',
style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500)),
]),
const SizedBox(height: 6),
Text(_loadError!, style: TextStyle(color: pt.fgDim, fontSize: 11)),
const SizedBox(height: 6),
Text(
'Mikrofon und Lautsprecher legst du in diesem Fall über die '
'Windows-Soundeinstellungen (Standardgerät) fest. '
'Falls dort alles stimmt: Windows-Einstellungen → Datenschutz → Mikrofon prüfen.',
style: TextStyle(color: pt.fgMuted, fontSize: 11),
),
],
),
),
),
_SettingsGroup(title: 'Geräte', pt: pt, children: [
_SettingsRow(
title: 'Mikrofon',
desc: _audioInputs.isEmpty ? 'Kein Gerät gefunden' : '${_audioInputs.length} Gerät(e) gefunden',
pt: pt,
control: _deviceDropdown(
devices: _audioInputs,
selected: _selectedMic,
placeholder: 'Standard',
icon: Icons.mic_rounded,
onChanged: (v) {
setState(() => _selectedMic = v);
_save(_kMic, v);
},
),
),
_Divider(pt: pt),
_SettingsRow(
title: 'Lautsprecher',
desc: _audioOutputs.isEmpty ? 'Kein Gerät gefunden' : '${_audioOutputs.length} Gerät(e) gefunden',
pt: pt,
control: _deviceDropdown(
devices: _audioOutputs,
selected: _selectedSpeaker,
placeholder: 'Standard',
icon: Icons.volume_up_rounded,
onChanged: (v) {
setState(() => _selectedSpeaker = v);
_save(_kSpeaker, v);
},
),
),
_Divider(pt: pt),
_SettingsRow(
title: 'Kamera',
desc: _videoInputs.isEmpty ? 'Kein Gerät gefunden' : '${_videoInputs.length} Gerät(e) gefunden',
pt: pt,
control: _deviceDropdown(
devices: _videoInputs,
selected: _selectedCamera,
placeholder: 'Standard',
icon: Icons.videocam_rounded,
onChanged: (v) {
setState(() => _selectedCamera = v);
_save(_kCamera, v);
},
),
),
]),
const SizedBox(height: 16),
_SettingsGroup(title: 'Pegel', pt: pt, children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.volume_up_rounded, size: 15, color: pt.fgMuted),
const SizedBox(width: 8),
Text('Ausgabelautstärke',
style: TextStyle(
color: pt.fg,
fontSize: 13,
fontWeight: FontWeight.w500)),
const Spacer(),
Text('${(_outputVolume * 100).round()} %',
style: TextStyle(
color: pt.fgMuted,
fontSize: 12,
fontFamily: 'monospace')),
],
),
SliderTheme(
data: SliderThemeData(
activeTrackColor: pt.accent,
inactiveTrackColor: pt.bg3,
thumbColor: pt.accent,
overlayColor: pt.accent.withAlpha(30),
trackHeight: 3,
thumbShape: const RoundSliderThumbShape(
enabledThumbRadius: 7),
),
child: Slider(
value: _outputVolume.clamp(0.0, 1.5),
min: 0.0,
max: 1.5,
divisions: 30,
onChanged: _setOutputVolume,
),
),
Text(
'Lautstärke der anderen Teilnehmer in Calls. '
'Wirkt sofort auf laufende Anrufe.',
style: TextStyle(color: pt.fgDim, fontSize: 11),
),
],
),
),
]),
const SizedBox(height: 16),
_TurnUsageCard(pt: pt),
const SizedBox(height: 12),
TextButton.icon(
style: TextButton.styleFrom(
foregroundColor: pt.fgMuted,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
onPressed: () { setState(() { _loading = true; _loadError = null; }); _init(); },
icon: const Icon(Icons.refresh_rounded, size: 14),
label: const Text('Geräte neu laden', style: TextStyle(fontSize: 12)),
),
],
],
),
);
}
}
/// Zeigt den TURN-Relay-Verbrauch des laufenden Monats an. Die Daten kommen
/// aus der turn-status.json, die der Guard-Cron auf dem Pi stündlich schreibt.
/// Solange der Endpoint nicht existiert (Analytics-Token fehlt), bleibt die
/// Karte unsichtbar.
class _TurnUsageCard extends StatefulWidget {
final PyramidTheme pt;
const _TurnUsageCard({required this.pt});
@override
State<_TurnUsageCard> createState() => _TurnUsageCardState();
}
class _TurnUsageCardState extends State<_TurnUsageCard> {
Map<String, dynamic>? _status;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final resp = await http
.get(Uri.parse(
'https://steggi-matrix.work/.well-known/pyramid/turn-status.json'))
.timeout(const Duration(seconds: 5));
if (resp.statusCode == 200 && mounted) {
setState(
() => _status = jsonDecode(resp.body) as Map<String, dynamic>);
}
} catch (_) {
// Endpoint (noch) nicht vorhanden Karte einfach nicht anzeigen.
}
}
@override
Widget build(BuildContext context) {
final s = _status;
if (s == null) return const SizedBox.shrink();
final pt = widget.pt;
final used = (s['egress_gb'] as num?)?.toDouble() ?? 0;
final limit = (s['limit_gb'] as num?)?.toDouble() ?? 990;
final pct = limit > 0 ? (used / limit).clamp(0.0, 1.0) : 0.0;
final active = s['turn_active'] != false;
final barColor = !active
? pt.danger
: pct > 0.8
? pt.away
: pt.accent;
return _SettingsGroup(title: 'Call-Relay (TURN)', pt: pt, children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Icon(active ? Icons.cloud_done_rounded : Icons.cloud_off_rounded,
size: 15, color: active ? pt.online : pt.danger),
const SizedBox(width: 8),
Text(
active
? 'Relay aktiv'
: 'Relay deaktiviert (Limit erreicht)',
style: TextStyle(
color: pt.fg,
fontSize: 13,
fontWeight: FontWeight.w500)),
const Spacer(),
Text(
'${used.toStringAsFixed(1)} / ${limit.toStringAsFixed(0)} GB',
style: TextStyle(
color: pt.fgMuted,
fontSize: 12,
fontFamily: 'monospace')),
]),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(99),
child: LinearProgressIndicator(
value: pct,
minHeight: 5,
backgroundColor: pt.bg3,
color: barColor,
),
),
const SizedBox(height: 6),
Text(
active
? 'Cloudflare-TURN-Traffic diesen Monat (Free Tier: ${limit.toStringAsFixed(0)} GB). '
'Bei Erreichen des Limits laufen Calls bis Monatsende ohne Relay weiter.'
: 'Monatslimit erreicht — Calls nutzen bis Monatsende nur direkte Verbindungen (STUN).',
style: TextStyle(color: pt.fgDim, fontSize: 11),
),
],
),
),
]);
}
}
File diff suppressed because it is too large Load Diff