Compare commits
36 Commits
336c82fb76
..
beta
| Author | SHA1 | Date | |
|---|---|---|---|
| 87ff15d760 | |||
| 16561bbdeb | |||
| 87b29b5912 | |||
| 73420fe245 | |||
| 891f3489bd | |||
| f9979e4f32 | |||
| d254a36de7 | |||
| daf4cf3dad | |||
| a963262de2 | |||
| 31e4001fb0 | |||
| 0c7d359076 | |||
| a863fa3b9a | |||
| 5bf45a9366 | |||
| 5d772389d6 | |||
| 6400fb639c | |||
| f5b9ce9cd7 | |||
| f3fd451034 | |||
| db653ea7a3 | |||
| 254be1b8da | |||
| b5762ea08d | |||
| e8ab626c92 | |||
| fb7669a65b | |||
| 9dc83f7bcb | |||
| 63e491952d | |||
| 13512064a3 | |||
| 74d38fd267 | |||
| 401bd6a7f8 | |||
| 057ac1e4ea | |||
| 90593d2f81 | |||
| d2fcc59f7a | |||
| 2395db50be | |||
| 99f10c5210 | |||
| 133a8acbc8 | |||
| 9dc11757e2 | |||
| 594e6718be | |||
| f1fde9b45a |
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"model": "claude-fable-5",
|
||||||
"hooks": {
|
"hooks": {
|
||||||
"PostToolUse": [
|
"PostToolUse": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -45,3 +45,4 @@ app.*.map.json
|
|||||||
/android/app/release
|
/android/app/release
|
||||||
autopilot.log
|
autopilot.log
|
||||||
.claude/worktrees/
|
.claude/worktrees/
|
||||||
|
*.bak
|
||||||
|
|||||||
+121
@@ -1078,3 +1078,124 @@
|
|||||||
2026-06-12 00:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart
|
2026-06-12 00:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart
|
||||||
2026-06-12 00:35 [Edit] C:\Users\nordm\.claude\projects\C--Users-nordm-pyramid---Kopie\memory\call_system_status.md
|
2026-06-12 00:35 [Edit] C:\Users\nordm\.claude\projects\C--Users-nordm-pyramid---Kopie\memory\call_system_status.md
|
||||||
2026-06-12 00:36 [Edit] C:\Users\nordm\.claude\projects\C--Users-nordm-pyramid---Kopie\memory\infrastructure.md
|
2026-06-12 00:36 [Edit] C:\Users\nordm\.claude\projects\C--Users-nordm-pyramid---Kopie\memory\infrastructure.md
|
||||||
|
2026-07-03 06:20 [Edit] /home/steggi/pyramid/lib/features/call/mini_call_widget.dart
|
||||||
|
2026-07-03 06:20 [Edit] /home/steggi/pyramid/lib/features/call/mini_call_widget.dart
|
||||||
|
2026-07-03 06:20 [Edit] /home/steggi/pyramid/lib/features/rooms/user_panel.dart
|
||||||
|
2026-07-03 06:20 [Edit] /home/steggi/pyramid/lib/widgets/pyramid_loader.dart
|
||||||
|
2026-07-03 06:21 [Edit] /home/steggi/pyramid/lib/widgets/screen_share_picker.dart
|
||||||
|
2026-07-03 06:21 [Edit] /home/steggi/pyramid/lib/widgets/settings_modal.dart
|
||||||
|
2026-07-03 06:22 [Edit] /home/steggi/pyramid/lib/widgets/settings_modal.dart
|
||||||
|
2026-07-03 06:22 [Edit] /home/steggi/pyramid/lib/widgets/create_join_dialog.dart
|
||||||
|
2026-07-03 06:22 [Edit] /home/steggi/pyramid/lib/widgets/create_join_dialog.dart
|
||||||
|
2026-07-03 06:22 [Edit] /home/steggi/pyramid/lib/widgets/create_join_dialog.dart
|
||||||
|
2026-07-03 06:23 [Edit] /home/steggi/pyramid/lib/core/background_push.dart
|
||||||
|
2026-07-03 06:24 [Edit] /home/steggi/pyramid/lib/core/background_push.dart
|
||||||
|
2026-07-03 06:24 [Edit] /home/steggi/pyramid/lib/core/background_push.dart
|
||||||
|
2026-07-03 06:24 [Edit] /home/steggi/pyramid/lib/core/background_push.dart
|
||||||
|
2026-07-03 06:24 [Edit] /home/steggi/pyramid/lib/core/livekit_call_manager.dart
|
||||||
|
2026-07-03 06:26 [Write] /home/steggi/.claude/projects/-home-steggi-pyramid/memory/pyramid_push.md
|
||||||
|
2026-07-03 06:27 [Edit] /home/steggi/.claude/projects/-home-steggi-pyramid/memory/pyramid_push.md
|
||||||
|
2026-07-03 06:27 [Write] /home/steggi/.claude/projects/-home-steggi-pyramid/memory/MEMORY.md
|
||||||
|
2026-07-03 06:28 [Edit] /home/steggi/pyramid/lib/core/livekit_call_manager.dart
|
||||||
|
2026-07-03 06:28 [Edit] /home/steggi/pyramid/lib/core/notification_service.dart
|
||||||
|
2026-07-03 06:29 [Edit] /home/steggi/pyramid/lib/core/background_push.dart
|
||||||
|
2026-07-03 06:29 [Edit] /home/steggi/pyramid/lib/core/background_push.dart
|
||||||
|
2026-07-03 06:29 [Edit] /home/steggi/pyramid/lib/core/voip_manager.dart
|
||||||
|
2026-07-03 06:31 [Edit] /home/steggi/pyramid/lib/features/chat/chat_composer.dart
|
||||||
|
2026-07-03 06:31 [Edit] /home/steggi/pyramid/lib/widgets/settings_modal.dart
|
||||||
|
2026-07-03 06:31 [Edit] /home/steggi/pyramid/lib/widgets/create_join_dialog.dart
|
||||||
|
2026-07-03 06:31 [Edit] /home/steggi/pyramid/lib/features/chat/attachment_dialog.dart
|
||||||
|
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
@@ -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)
|
## 2026-07-03 – Arbeitsstruktur angelegt + Vollbackup (Setup, noch kein Code)
|
||||||
|
|
||||||
**Erledigt:**
|
**Erledigt:**
|
||||||
|
|||||||
@@ -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)
|
Voraussetzung: Flutter (stable channel, siehe `environment.sdk` in `pubspec.yaml`
|
||||||
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
für die unterstützte Dart-Version).
|
||||||
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
|
|
||||||
|
|
||||||
For help getting started with Flutter development, view the
|
```
|
||||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
flutter pub get
|
||||||
samples, guidance on mobile development, and a full API reference.
|
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
-22
@@ -8,23 +8,62 @@ Punkte abhaken (`[x]`), wenn erledigt UND in `PROGRESS.md` protokolliert.
|
|||||||
## M0 – Backup & Ordnung
|
## M0 – Backup & Ordnung
|
||||||
|
|
||||||
- [x] Vollbackup vor dem Refactoring (2026-07-03 → `MatrixPi\backups\pyramid-kopie_backup_2026-07-03.tar.gz`)
|
- [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
|
- [x] Die ~140 uncommitteten Dateien sichten, in thematische Commits aufteilen, zu Gitea pushen
|
||||||
- [ ] `flutter analyze` komplett sauber bekommen und sauber halten
|
(Sicherungs-Commit 25ed765 + Folge-Commits bis 74d38fd; Arbeitsbaum ist jetzt sauber)
|
||||||
- [ ] `lib/features/call/` vs. `lib/features/calls/` (leer?) klären – Leiche entfernen
|
- [x] `flutter analyze` komplett sauber bekommen und sauber halten (nur noch 1 bewusst
|
||||||
- [ ] README.md durch echte Projektbeschreibung ersetzen (was ist Pyramid, wie baut man es, wo liegt was)
|
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
|
- [x] **Uta-Random-Logout untersuchen:** UNGETESTET (Pi) – Ursache im matrix-SDK
|
||||||
|
gefunden + Fix committet (63e4919, 9dc83f7), siehe PROGRESS.md 2026-07-03.
|
||||||
- [ ] **Uta-Random-Logout untersuchen:** Ursache finden (Soft-Logout vom Server? Token
|
Nächster PC-Lauf MUSS den vollen Kreislauf praktisch prüfen (Login →
|
||||||
ungültig? Fehlerpfad im Sync, der zum Logout führt?). Erst Logging an allen
|
Nachrichten → Logout → erneuter Login → alte Nachrichten lesbar?), erst
|
||||||
Logout-Pfaden einbauen, dann Ursache fixen. Ein unerwarteter Logout darf NIE
|
dann gilt der Punkt als wirklich erledigt.
|
||||||
Krypto-Schlüssel wegwerfen.
|
- [x] **REGRESSION: Push-Benachrichtigungen werden nicht mehr entschlüsselt** –
|
||||||
- [ ] Sichere Speicherung von Access-Token & Krypto-DB prüfen (secure storage / sqlcipher korrekt?)
|
UNGETESTET (Pi), sehr wahrscheinlich DIESELBE Ursache wie der
|
||||||
- [ ] Key-Backup einrichten-Flow: Recovery-Key erzeugen, verständlich erklären, zum Sichern zwingen
|
Uta-Random-Logout-Bug (bereits mit 9dc83f7 mitgefixt), siehe PROGRESS.md
|
||||||
- [ ] Key-Backup wiederherstellen-Flow: nach Login auf neuem Gerät alte Nachrichten entschlüsseln
|
2026-07-03. Auf echtem Gerät prüfen: nach Neu-Login (für den Refresh-Token)
|
||||||
- [ ] Logout-Schutz: Warnung mit Klartext-Folgen, wenn Key-Backup fehlt
|
eine Weile laufen lassen und beobachten, ob Push-Nachrichten wieder mit
|
||||||
- [ ] Geräte-/Sessionverwaltung in den Einstellungen (Liste, Verifizieren, Abmelden)
|
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
|
- [ ] 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!)
|
## M2 – Das große Refactoring (erst nach M0/M1!)
|
||||||
|
|
||||||
@@ -32,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
|
Leitprinzip (Bernd): austauschbare Bausteine – jedes Modul muss sich komplett neu schreiben
|
||||||
lassen, ohne dass andere Module angefasst werden müssen.
|
lassen, ohne dass andere Module angefasst werden müssen.
|
||||||
|
|
||||||
- [ ] Ist-Analyse schreiben: Architektur-Schwächen auflisten (Call-Logik verteilt auf
|
- [x] Ist-Analyse geschrieben (PROGRESS.md 2026-07-03): Gott-Datei `settings_modal.dart`
|
||||||
`livekit_call_manager.dart`, `voip_manager.dart`, `voice_channel.dart`?, State-Management
|
(5541 Zeilen/50+ Klassen), Call-Schicht ohne gemeinsame Fassade (4 Module
|
||||||
uneinheitlich?, Gott-Dateien?, tote Widgets?) → als Refactoring-Plan in PROGRESS.md
|
importieren `voice_channel.dart` direkt), `app_state.dart` als Kopplungspunkt
|
||||||
- [ ] Modul-Schnitt definieren: Module (auth, chat, rooms, calls, streaming, push, storage, …)
|
aller Features, keine gemeinsame Storage-Schnittstelle (12 direkte
|
||||||
mit je einer schmalen öffentlichen Schnittstelle; Abhängigkeiten nur über Interfaces,
|
`SharedPreferences`-Zugriffe), toter Code (`message_bubble.dart`) + Duplikate
|
||||||
keine Quer-Importe zwischen Feature-Modulen
|
(`_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 /
|
- [ ] Call-Schicht entwirren: EINE klare Zuständigkeit pro Klasse (Signalisierung /
|
||||||
LiveKit-Verbindung / UI-State sauber getrennt)
|
LiveKit-Verbindung / UI-State sauber getrennt)
|
||||||
- [ ] Einheitliches State-Management (Riverpod konsequent, keine Misch-Patterns)
|
- [ ] 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
|
- [ ] Nach JEDEM Schritt: App läuft, Kernflows getestet, Commit
|
||||||
|
|
||||||
## M3 – Calls: Logik, UI & Audio (Discord als Referenz)
|
## M3 – Calls: Logik, UI & Audio (Discord als Referenz)
|
||||||
|
|||||||
Regular → Executable
+43
-7
@@ -1,19 +1,55 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Pyramid-Autopilot (Pi/Linux): startet Claude-Sessions in Endlosschleife.
|
# Pyramid-Autopilot (Pi/Linux): startet Claude-Sessions in Endlosschleife.
|
||||||
# Läuft eine Session ins 5h-Limit, wird alle 20 Minuten neu versucht.
|
# Läuft eine Session ins 5h-Limit, wird alle 20 Minuten neu versucht.
|
||||||
# Start (am besten in tmux): tmux new -s pyramid './autopilot.sh'
|
# Start (am besten in tmux): tmux new -d -s pyramid ./autopilot.sh
|
||||||
# Wieder reinschauen: tmux attach -t pyramid
|
# Wieder reinschauen: tmux attach -t pyramid (verlassen: Strg+B, dann D)
|
||||||
# Stoppen: Strg+C in der tmux-Session
|
# PAUSIEREN: touch ~/pyramid/PAUSE (greift vor der nächsten Session)
|
||||||
|
# FORTSETZEN: rm ~/pyramid/PAUSE
|
||||||
|
# GANZ STOPPEN: tmux kill-session -t pyramid
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
export PATH="$HOME/.local/bin:$HOME/flutter/bin:$PATH"
|
export PATH="$HOME/.local/bin:$HOME/flutter/bin:$PATH"
|
||||||
|
|
||||||
PROMPT="Lies CLAUDE.md und arbeite ROADMAP.md Punkt für Punkt weiter ab. \
|
# Pyramid laeuft auf eigenem Claude-Konto (Profil ~/.claude-pyramid),
|
||||||
Halte dich strikt an den Arbeitszyklus (PROGRESS.md pflegen, kleine Commits + Push). \
|
# das Standard-Profil ~/.claude bleibt fuer die Webseiten-Arbeit.
|
||||||
Arbeite so viele Punkte ab wie möglich."
|
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"
|
||||||
|
MATRIX_TOKEN="$(grep -s '^MATRIX_TOKEN=' "$HOME/gatus/.env" | cut -d= -f2-)"
|
||||||
|
|
||||||
|
notify_matrix() {
|
||||||
|
[ -z "$MATRIX_TOKEN" ] && return 0
|
||||||
|
local body
|
||||||
|
body=$(python3 -c 'import json,sys; print(json.dumps({"msgtype":"m.text","body":sys.stdin.read().strip()}))' <<< "$1")
|
||||||
|
curl -s -m 10 -X PUT \
|
||||||
|
"$MATRIX_URL/_matrix/client/v3/rooms/$MATRIX_ROOM/send/m.room.message/autopilot$(date +%s%N)" \
|
||||||
|
-H "Authorization: Bearer $MATRIX_TOKEN" -H "Content-Type: application/json" \
|
||||||
|
-d "$body" > /dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
PROMPT="Lies CLAUDE.md. Arbeite in dieser Reihenfolge: (1) Review-Pass: Gehe die in ROADMAP.md/PROGRESS.md als erledigt markierten Punkte durch, prüfe den zugehörigen Code kritisch und überarbeite/verbessere ihn, wo nötig. Führe in PROGRESS.md einen Abschnitt 'Fable-5-Review' und hake dort jeden geprüften Punkt ab, damit bereits reviewte Punkte nicht erneut geprüft werden. (2) Wenn alle erledigten Punkte reviewt sind, arbeite ROADMAP.md Punkt für Punkt weiter ab. Halte dich strikt an den Arbeitszyklus (PROGRESS.md pflegen, kleine Commits + Push). Arbeite so viel ab wie möglich."
|
||||||
|
|
||||||
while true; do
|
while true; do
|
||||||
|
if [ -f PAUSE ]; then
|
||||||
|
echo "===== $(date '+%F %T') PAUSE aktiv (rm PAUSE zum Fortsetzen) - pruefe wieder in 10 Minuten ====="
|
||||||
|
sleep 600
|
||||||
|
continue
|
||||||
|
fi
|
||||||
echo "===== $(date '+%F %T') Neue Claude-Session =====" | tee -a autopilot.log
|
echo "===== $(date '+%F %T') Neue Claude-Session =====" | tee -a autopilot.log
|
||||||
claude -p "$PROMPT" --dangerously-skip-permissions 2>&1 | tee -a autopilot.log
|
BEFORE=$(git rev-parse HEAD 2>/dev/null)
|
||||||
|
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)
|
||||||
|
NEXT=$(grep -m1 -A3 "Offen/Nächster Schritt" PROGRESS.md)
|
||||||
|
notify_matrix "🔺 Pyramid-Autopilot $(date '+%H:%M') – Session fertig.
|
||||||
|
|
||||||
|
Neue Commits:
|
||||||
|
$COMMITS
|
||||||
|
|
||||||
|
$NEXT"
|
||||||
|
fi
|
||||||
echo "===== $(date '+%F %T') Session beendet - Neustart in 20 Minuten (Strg+C zum Stoppen) =====" | tee -a autopilot.log
|
echo "===== $(date '+%F %T') Session beendet - Neustart in 20 Minuten (Strg+C zum Stoppen) =====" | tee -a autopilot.log
|
||||||
sleep 1200
|
sleep 1200
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -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 2921–4545 (SAS-Dialog,
|
||||||
|
QR-Anzeige, Emoji-Vergleich, Recovery-Key-Flow).
|
||||||
|
```dart
|
||||||
|
abstract class VerificationService {
|
||||||
|
Future<void> startSasVerification(DeviceKeys device);
|
||||||
|
Future<void> confirmSas();
|
||||||
|
Future<void> declineSas();
|
||||||
|
Stream<VerificationStep> get stepStream;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Eigenständig, weil SAS/QR-Verifikation künftig auch außerhalb der Settings
|
||||||
|
gebraucht werden könnte (z. B. beim Login auf einem neuen Gerät –
|
||||||
|
`bootstrap_dialog.dart` macht heute schon etwas Ähnliches separat).
|
||||||
|
|
||||||
|
### 6. `storage` (Fassade, kein Rewrite)
|
||||||
|
**Ausbau von:** `core/settings_prefs.dart`
|
||||||
|
```dart
|
||||||
|
abstract class Prefs {
|
||||||
|
Future<bool> getBool(String key, {bool fallback});
|
||||||
|
Future<void> setBool(String key, bool value);
|
||||||
|
// … String, double, StringSet analog zu BoolPref/StringSetPref
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Ziel ist NICHT, `SharedPreferences` zu ersetzen, sondern die bereits
|
||||||
|
vorhandene `BoolPref`/`StringSetPref`-Abstraktion zur Pflicht zu machen.
|
||||||
|
Die 12 identifizierten Direktzugriffsstellen (u. a. `voip_manager.dart:260`,
|
||||||
|
`livekit_call_manager.dart:246`, `settings_modal.dart:1804/1860/1870`) werden
|
||||||
|
schrittweise umgestellt – **nicht** in diesem Schritt, sondern sobald das
|
||||||
|
jeweilige Modul (z. B. `call_signaling`) ohnehin angefasst wird.
|
||||||
|
Krypto-/Access-Token-Speicherung (`flutter_secure_storage`,
|
||||||
|
`matrix_client.dart`) bleibt bewusst außen vor – hängt an der noch offenen
|
||||||
|
SQLCipher-Frage (M1) und wird dort separat behandelt, nicht hier.
|
||||||
|
|
||||||
|
### 7. `auth`
|
||||||
|
**Dateien:** `features/auth/login_notifier.dart`, `login_page.dart`,
|
||||||
|
`server_page.dart`, `bootstrap_dialog.dart` (Bootstrap-Teil, Verifikations-Teil
|
||||||
|
wandert ggf. zu `verification`, s. o.). Schnittstelle bleibt der bereits
|
||||||
|
vorhandene `matrixClientProvider`/`login_notifier`-Zustandsautomat – heute
|
||||||
|
schon relativ sauber gekapselt, kein akuter Handlungsbedarf.
|
||||||
|
|
||||||
|
### 8. `push`
|
||||||
|
**Dateien:** `core/background_push.dart`, `core/fcm_push_service.dart`,
|
||||||
|
`core/notification_service.dart`. Bereits über eigene Dateien getrennt
|
||||||
|
(nativ vs. Flutter-Engine, siehe Memory „Pyramid Push"). Einzige Lücke:
|
||||||
|
eigener `sqflite`-Zugriff in `background_push.dart` statt gemeinsamer
|
||||||
|
DB-Schicht – bewusst so gewollt (Background-Isolate-Zwang), daher **kein**
|
||||||
|
Refactoring-Kandidat, nur dokumentieren.
|
||||||
|
|
||||||
|
### 9. `chat_timeline` / `rooms`
|
||||||
|
Niedrigste Priorität für den Pilot-Umbau, da laut Ist-Analyse strukturell
|
||||||
|
am wenigsten verwoben (Hauptproblem dort ist Dateigröße + Duplikate, nicht
|
||||||
|
Modul-Kopplung). Wird nach dem Call-Pilot angegangen.
|
||||||
|
|
||||||
|
## Reihenfolge-Vorschlag für die Umsetzung
|
||||||
|
|
||||||
|
1. **Pilot: `call_signaling` + `voice_channel` + `call_ui`** (Punkt „Call-Schicht
|
||||||
|
entwirren" in ROADMAP.md) – kleinster klar abgegrenzter Schnitt, deckt
|
||||||
|
gleich zwei ROADMAP-Punkte ab (Modul-Schnitt beweisen + Call-Entwirrung).
|
||||||
|
2. `settings_modal.dart` aufsplitten (reine Datei-Teilung, geringes Risiko,
|
||||||
|
keine Verhaltensänderung).
|
||||||
|
3. `verification` aus Settings herauslösen.
|
||||||
|
4. `storage`-Fassade schrittweise erzwingen, immer im Zuge anderer Umbauten.
|
||||||
|
5. `chat_timeline`/`rooms` zuletzt.
|
||||||
|
|
||||||
|
**Bernd-Entscheidung nötig:** siehe Frage in PROGRESS.md – ob Punkt 1
|
||||||
|
(Call-Pilot) so wie hier vorgeschlagen als Nächstes umgesetzt werden soll.
|
||||||
@@ -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
@@ -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
|
## Erledigt
|
||||||
|
|
||||||
|
|||||||
@@ -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 1–4 grün: Release, Uta informieren (ihr Gerät migriert beim
|
||||||
|
ersten Start automatisch; vorher ihr Key-Backup verifizieren!).
|
||||||
|
|
||||||
|
## Bewusst NICHT im Scope
|
||||||
|
|
||||||
|
- `auth_log.txt`, `SharedPreferences`, Media-Cache bleiben Klartext (keine
|
||||||
|
Geheimnisse bzw. nur Cache; einzeln bewerten, falls Bernd es wünscht).
|
||||||
|
- Passphrase-basierter Schlüssel (Nutzer-Eingabe): bewusst nein – Schlüssel
|
||||||
|
liegt im Keystore, gleiche Vertrauensstufe wie der Rest der App-Daten,
|
||||||
|
aber Datei-Exfiltration allein reicht dann nicht mehr.
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
-115
@@ -1,6 +1,4 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:isolate';
|
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
@@ -11,11 +9,10 @@ import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod;
|
|||||||
import 'package:matrix/matrix.dart';
|
import 'package:matrix/matrix.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:pyramid/core/soft_logout_guard.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:sqflite/sqflite.dart' as sqflite_native;
|
import 'package:sqflite/sqflite.dart' as sqflite_native;
|
||||||
|
|
||||||
const _kChannel = 'pyramid_messages';
|
|
||||||
|
|
||||||
/// Port name used to locate the main isolate from background isolates.
|
/// Port name used to locate the main isolate from background isolates.
|
||||||
/// Registered by notification_service.dart when the app starts.
|
/// Registered by notification_service.dart when the app starts.
|
||||||
const kMainIsolateName = 'pyramid_main';
|
const kMainIsolateName = 'pyramid_main';
|
||||||
@@ -71,7 +68,7 @@ void notificationEngineMain() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[NOTIF-BGENGINE] handler error: $e');
|
debugPrint('[NOTIF-BGENGINE] handler error: $e');
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
@@ -115,7 +112,7 @@ Future<bool> _bgDecryptAndShow(String? roomId, String? eventId, int? notifId) as
|
|||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[NOTIF-BGENGINE] decryptAndShow error: $e');
|
debugPrint('[NOTIF-BGENGINE] decryptAndShow error: $e');
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
// Let the async key-backup upload settle before closing the DB — otherwise
|
// Let the async key-backup upload settle before closing the DB — otherwise
|
||||||
@@ -142,10 +139,10 @@ Future<bool> _bgSendReply(String? roomId, String? text) async {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
await room.sendTextEvent(text.trim());
|
await room.sendTextEvent(text.trim());
|
||||||
print('[NOTIF-BGENGINE] encrypted reply sent to $roomId');
|
debugPrint('[NOTIF-BGENGINE] encrypted reply sent to $roomId');
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[NOTIF-BGENGINE] sendReply error: $e');
|
debugPrint('[NOTIF-BGENGINE] sendReply error: $e');
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setString('notif_pending_reply_room', roomId);
|
await prefs.setString('notif_pending_reply_room', roomId);
|
||||||
await prefs.setString('notif_pending_reply_text', text);
|
await prefs.setString('notif_pending_reply_text', text);
|
||||||
@@ -167,7 +164,7 @@ Future<void> registerBgEngineHandle() async {
|
|||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setInt('bg_engine_handle', handle.toRawHandle());
|
await prefs.setInt('bg_engine_handle', handle.toRawHandle());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[NOTIF-BGENGINE] registerBgEngineHandle failed: $e');
|
debugPrint('[NOTIF-BGENGINE] registerBgEngineHandle failed: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,6 +202,12 @@ Future<Client?> _buildClient() async {
|
|||||||
database: sdkDb,
|
database: sdkDb,
|
||||||
nativeImplementations: NativeImplementationsDummy(),
|
nativeImplementations: NativeImplementationsDummy(),
|
||||||
logLevel: Level.error,
|
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
|
await client
|
||||||
@@ -216,7 +219,7 @@ Future<Client?> _buildClient() async {
|
|||||||
|
|
||||||
return client;
|
return client;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[NOTIF-BGENGINE] _buildClient failed: $e');
|
debugPrint('[NOTIF-BGENGINE] _buildClient failed: $e');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,8 +248,8 @@ Future<void> handleBackgroundNotificationResponse(
|
|||||||
NotificationResponse details) async {
|
NotificationResponse details) async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
print('[NOTIF-BG] handleBackgroundNotificationResponse fired');
|
debugPrint('[NOTIF-BG] handleBackgroundNotificationResponse fired');
|
||||||
print('[NOTIF-BG] actionId=${details.actionId} payload=${details.payload} input=${details.input} id=${details.id}');
|
debugPrint('[NOTIF-BG] actionId=${details.actionId} payload=${details.payload} input=${details.input} id=${details.id}');
|
||||||
|
|
||||||
// ── Fast path: main isolate is running ───────────────────────────────────
|
// ── Fast path: main isolate is running ───────────────────────────────────
|
||||||
// If the Pyramid UI process is alive (app is backgrounded but not killed),
|
// If the Pyramid UI process is alive (app is backgrounded but not killed),
|
||||||
@@ -254,7 +257,7 @@ Future<void> handleBackgroundNotificationResponse(
|
|||||||
// any new engine and works silently without bringing the app to foreground.
|
// any new engine and works silently without bringing the app to foreground.
|
||||||
final sendPort = IsolateNameServer.lookupPortByName(kMainIsolateName);
|
final sendPort = IsolateNameServer.lookupPortByName(kMainIsolateName);
|
||||||
if (sendPort != null) {
|
if (sendPort != null) {
|
||||||
print('[NOTIF-BG] main isolate alive → forwarding via IsolateNameServer');
|
debugPrint('[NOTIF-BG] main isolate alive → forwarding via IsolateNameServer');
|
||||||
sendPort.send(<String, dynamic>{
|
sendPort.send(<String, dynamic>{
|
||||||
'actionId': details.actionId,
|
'actionId': details.actionId,
|
||||||
'payload': details.payload,
|
'payload': details.payload,
|
||||||
@@ -268,7 +271,7 @@ Future<void> handleBackgroundNotificationResponse(
|
|||||||
// Main isolate is gone. Dismiss the notification and, for reply actions,
|
// Main isolate is gone. Dismiss the notification and, for reply actions,
|
||||||
// persist the text in SharedPreferences so _sendPendingReply picks it up
|
// persist the text in SharedPreferences so _sendPendingReply picks it up
|
||||||
// the next time the app opens (via getInitialReply cold-start flow).
|
// the next time the app opens (via getInitialReply cold-start flow).
|
||||||
print('[NOTIF-BG] main isolate not running → SharedPreferences fallback');
|
debugPrint('[NOTIF-BG] main isolate not running → SharedPreferences fallback');
|
||||||
|
|
||||||
final plugin = FlutterLocalNotificationsPlugin();
|
final plugin = FlutterLocalNotificationsPlugin();
|
||||||
await plugin.initialize(
|
await plugin.initialize(
|
||||||
@@ -280,20 +283,20 @@ Future<void> handleBackgroundNotificationResponse(
|
|||||||
final roomId = details.payload;
|
final roomId = details.payload;
|
||||||
if (roomId != null) {
|
if (roomId != null) {
|
||||||
await plugin.cancel(_stableId(roomId));
|
await plugin.cancel(_stableId(roomId));
|
||||||
print('[NOTIF-BG] cancelled notification for room $roomId');
|
debugPrint('[NOTIF-BG] cancelled notification for room $roomId');
|
||||||
} else if (details.id != null) {
|
} else if (details.id != null) {
|
||||||
await plugin.cancel(details.id!);
|
await plugin.cancel(details.id!);
|
||||||
print('[NOTIF-BG] cancelled notification id ${details.id}');
|
debugPrint('[NOTIF-BG] cancelled notification id ${details.id}');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (details.actionId != 'reply') {
|
if (details.actionId != 'reply') {
|
||||||
print('[NOTIF-BG] not a reply action, done');
|
debugPrint('[NOTIF-BG] not a reply action, done');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final text = details.input?.trim() ?? '';
|
final text = details.input?.trim() ?? '';
|
||||||
if (text.isEmpty || roomId == null) {
|
if (text.isEmpty || roomId == null) {
|
||||||
print('[NOTIF-BG] text empty or roomId null, aborting');
|
debugPrint('[NOTIF-BG] text empty or roomId null, aborting');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,102 +304,6 @@ Future<void> handleBackgroundNotificationResponse(
|
|||||||
// _bgSendReply falls back to the prefs queue if it can't send (so the main app
|
// _bgSendReply falls back to the prefs queue if it can't send (so the main app
|
||||||
// delivers it on next launch).
|
// delivers it on next launch).
|
||||||
final sent = await _bgSendReply(roomId, text);
|
final sent = await _bgSendReply(roomId, text);
|
||||||
print('[NOTIF-BG] background reply sent=$sent room=$roomId');
|
debugPrint('[NOTIF-BG] background reply sent=$sent room=$roomId');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Notification builder ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
Future<void> _showNotif({
|
|
||||||
required String title,
|
|
||||||
required String body,
|
|
||||||
required String? roomId,
|
|
||||||
required int notifId,
|
|
||||||
}) async {
|
|
||||||
final plugin = FlutterLocalNotificationsPlugin();
|
|
||||||
await plugin.initialize(
|
|
||||||
const InitializationSettings(
|
|
||||||
android: AndroidInitializationSettings('@drawable/ic_notification')),
|
|
||||||
onDidReceiveBackgroundNotificationResponse: handleBackgroundNotificationResponse,
|
|
||||||
);
|
|
||||||
|
|
||||||
await plugin.show(
|
|
||||||
notifId, title, body,
|
|
||||||
NotificationDetails(
|
|
||||||
android: AndroidNotificationDetails(
|
|
||||||
_kChannel, 'Nachrichten',
|
|
||||||
channelDescription: 'Neue Nachrichten',
|
|
||||||
importance: Importance.high,
|
|
||||||
priority: Priority.high,
|
|
||||||
icon: '@drawable/ic_notification',
|
|
||||||
color: const Color(0xFF7B61FF),
|
|
||||||
groupKey: 'pyramid_messages',
|
|
||||||
actions: roomId != null
|
|
||||||
? const [
|
|
||||||
AndroidNotificationAction(
|
|
||||||
'reply', 'Antworten',
|
|
||||||
inputs: [AndroidNotificationActionInput(label: 'Antworten…')],
|
|
||||||
allowGeneratedReplies: true,
|
|
||||||
showsUserInterface: false, // BroadcastReceiver path — app never opens
|
|
||||||
),
|
|
||||||
AndroidNotificationAction(
|
|
||||||
'read', 'Gelesen',
|
|
||||||
showsUserInterface: false,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
: const [],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
payload: roomId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── HTTP fallback — fetch sender / room name without the SDK ──────────────────
|
|
||||||
|
|
||||||
Future<String?> _fetchTitle(
|
|
||||||
String hs, String token, String roomId, String eventId) async {
|
|
||||||
try {
|
|
||||||
final encRoom = Uri.encodeComponent(roomId);
|
|
||||||
final encEvent = Uri.encodeComponent(eventId);
|
|
||||||
|
|
||||||
final rawEvent = await _get(
|
|
||||||
'$hs/_matrix/client/v3/rooms/$encRoom/event/$encEvent', token);
|
|
||||||
if (rawEvent == null) return null;
|
|
||||||
|
|
||||||
final senderId = rawEvent['sender'] as String? ?? '';
|
|
||||||
String senderName = senderId.split(':').first.replaceFirst('@', '');
|
|
||||||
|
|
||||||
if (senderId.isNotEmpty) {
|
|
||||||
final encSender = Uri.encodeComponent(senderId);
|
|
||||||
final memberState = await _get(
|
|
||||||
'$hs/_matrix/client/v3/rooms/$encRoom/state/m.room.member/$encSender',
|
|
||||||
token);
|
|
||||||
final name = memberState?['displayname'] as String?;
|
|
||||||
if (name != null && name.isNotEmpty) senderName = name;
|
|
||||||
}
|
|
||||||
|
|
||||||
final nameState =
|
|
||||||
await _get('$hs/_matrix/client/v3/rooms/$encRoom/state/m.room.name', token);
|
|
||||||
final roomName = nameState?['name'] as String?;
|
|
||||||
|
|
||||||
return roomName?.isNotEmpty == true
|
|
||||||
? '$senderName · $roomName'
|
|
||||||
: senderName;
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Map<String, dynamic>?> _get(String url, String token) async {
|
|
||||||
try {
|
|
||||||
final req = await HttpClient()
|
|
||||||
.getUrl(Uri.parse(url))
|
|
||||||
.timeout(const Duration(seconds: 8));
|
|
||||||
req.headers.add('Authorization', 'Bearer $token');
|
|
||||||
final res = await req.close();
|
|
||||||
if (res.statusCode != 200) return null;
|
|
||||||
final raw = await res.transform(utf8.decoder).join();
|
|
||||||
return jsonDecode(raw) as Map<String, dynamic>;
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// ignore_for_file: avoid_print
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
@@ -119,7 +118,7 @@ class LiveKitCallManager with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
await client.setRoomStateWithKey(roomId, _kVoicePresenceType, userId, content);
|
await client.setRoomStateWithKey(roomId, _kVoicePresenceType, userId, content);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[VoicePresence] $e');
|
debugPrint('[VoicePresence] $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +184,7 @@ class LiveKitCallManager with ChangeNotifier {
|
|||||||
.toList(),
|
.toList(),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[LiveKit] ICE server fetch error: $e');
|
debugPrint('[LiveKit] ICE server fetch error: $e');
|
||||||
}
|
}
|
||||||
|
|
||||||
await room.connect(
|
await room.connect(
|
||||||
@@ -252,7 +251,7 @@ class LiveKitCallManager with ChangeNotifier {
|
|||||||
final devices = await rtc.navigator.mediaDevices.enumerateDevices();
|
final devices = await rtc.navigator.mediaDevices.enumerateDevices();
|
||||||
deviceIds = devices.map((d) => d.deviceId).toSet();
|
deviceIds = devices.map((d) => d.deviceId).toSet();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[LiveKit] Device prefs/enumerate error: $e');
|
debugPrint('[LiveKit] Device prefs/enumerate error: $e');
|
||||||
}
|
}
|
||||||
if (prefMic != null && !deviceIds.contains(prefMic)) prefMic = null;
|
if (prefMic != null && !deviceIds.contains(prefMic)) prefMic = null;
|
||||||
if (prefSpeaker != null && !deviceIds.contains(prefSpeaker)) prefSpeaker = null;
|
if (prefSpeaker != null && !deviceIds.contains(prefSpeaker)) prefSpeaker = null;
|
||||||
@@ -268,7 +267,7 @@ class LiveKitCallManager with ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
await rtc.Helper.selectAudioOutput(prefSpeaker);
|
await rtc.Helper.selectAudioOutput(prefSpeaker);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[LiveKit] selectAudioOutput error: $e');
|
debugPrint('[LiveKit] selectAudioOutput error: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,13 +281,13 @@ class LiveKitCallManager with ChangeNotifier {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[LiveKit] Failed to enable camera: $e');
|
debugPrint('[LiveKit] Failed to enable camera: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_startStatsLogging();
|
_startStatsLogging();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[VOIP] Start call error: $e');
|
debugPrint('[VOIP] Start call error: $e');
|
||||||
isConnecting = false;
|
isConnecting = false;
|
||||||
error = e.toString();
|
error = e.toString();
|
||||||
_cleanUpAfterDisconnect();
|
_cleanUpAfterDisconnect();
|
||||||
@@ -325,7 +324,7 @@ class LiveKitCallManager with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setSubscribeQuality(String key) async {
|
Future<void> setSubscribeQuality(String key) async {
|
||||||
print('[LiveKit] Viewer changing subscribe quality to: $key');
|
debugPrint('[LiveKit] Viewer changing subscribe quality to: $key');
|
||||||
_subscribeQualityKey = key;
|
_subscribeQualityKey = key;
|
||||||
if (_room == null) { notifyListeners(); return; }
|
if (_room == null) { notifyListeners(); return; }
|
||||||
|
|
||||||
@@ -333,7 +332,7 @@ class LiveKitCallManager with ChangeNotifier {
|
|||||||
for (final p in _room!.remoteParticipants.values) {
|
for (final p in _room!.remoteParticipants.values) {
|
||||||
for (final pub in p.videoTrackPublications) {
|
for (final pub in p.videoTrackPublications) {
|
||||||
if (pub.kind == TrackType.VIDEO) {
|
if (pub.kind == TrackType.VIDEO) {
|
||||||
print('[LiveKit] Setting subscription quality for ${p.identity} to $quality');
|
debugPrint('[LiveKit] Setting subscription quality for ${p.identity} to $quality');
|
||||||
pub.setVideoQuality(quality);
|
pub.setVideoQuality(quality);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -387,13 +386,13 @@ class LiveKitCallManager with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
isCameraOff = false;
|
isCameraOff = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[LiveKit] toggleCamera ON error: $e');
|
debugPrint('[LiveKit] toggleCamera ON error: $e');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
await _room?.localParticipant?.setCameraEnabled(false);
|
await _room?.localParticipant?.setCameraEnabled(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[LiveKit] toggleCamera OFF error: $e');
|
debugPrint('[LiveKit] toggleCamera OFF error: $e');
|
||||||
}
|
}
|
||||||
isCameraOff = true;
|
isCameraOff = true;
|
||||||
}
|
}
|
||||||
@@ -417,7 +416,7 @@ class LiveKitCallManager with ChangeNotifier {
|
|||||||
isScreenSharing = true;
|
isScreenSharing = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[ScreenShare] Toggle error: $e');
|
debugPrint('[ScreenShare] Toggle error: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -455,7 +454,7 @@ class LiveKitCallManager with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
unawaited(_applyFpsToAllSimulcastLayers(track));
|
unawaited(_applyFpsToAllSimulcastLayers(track));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[ScreenShare] ERROR: $e');
|
debugPrint('[ScreenShare] ERROR: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,7 +530,7 @@ class LiveKitCallManager with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> changeQuality(String qualityKey) async {
|
Future<void> changeQuality(String qualityKey) async {
|
||||||
print('[LiveKit] Streamer changing publish quality to: $qualityKey');
|
debugPrint('[LiveKit] Streamer changing publish quality to: $qualityKey');
|
||||||
_localQualityKey = qualityKey;
|
_localQualityKey = qualityKey;
|
||||||
final params = qualityPresets[qualityKey];
|
final params = qualityPresets[qualityKey];
|
||||||
if (params == null) { notifyListeners(); return; }
|
if (params == null) { notifyListeners(); return; }
|
||||||
@@ -549,7 +548,7 @@ class LiveKitCallManager with ChangeNotifier {
|
|||||||
cameraCaptureOptions: CameraCaptureOptions(params: params),
|
cameraCaptureOptions: CameraCaptureOptions(params: params),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[LiveKit] Camera restart error: $e');
|
debugPrint('[LiveKit] Camera restart error: $e');
|
||||||
isCameraOff = true;
|
isCameraOff = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -573,7 +572,7 @@ class LiveKitCallManager with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
await participant.publishAudioTrack(track);
|
await participant.publishAudioTrack(track);
|
||||||
if (isMuted) await participant.setMicrophoneEnabled(false);
|
if (isMuted) await participant.setMicrophoneEnabled(false);
|
||||||
} catch (e) { print('[Audio] error: $e'); }
|
} catch (e) { debugPrint('[Audio] error: $e'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
void setRemoteVolume(double volume) {
|
void setRemoteVolume(double volume) {
|
||||||
@@ -586,8 +585,6 @@ class LiveKitCallManager with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _applyNoiseSuppression(bool enabled) {}
|
|
||||||
|
|
||||||
void _startStatsLogging() {
|
void _startStatsLogging() {
|
||||||
_stopStatsLogging();
|
_stopStatsLogging();
|
||||||
_statsTimer = Timer.periodic(const Duration(seconds: 5), (_) => _logStats());
|
_statsTimer = Timer.periodic(const Duration(seconds: 5), (_) => _logStats());
|
||||||
@@ -601,6 +598,6 @@ class LiveKitCallManager with ChangeNotifier {
|
|||||||
Future<void> _logStats() async {
|
Future<void> _logStats() async {
|
||||||
if (_room == null) return;
|
if (_room == null) return;
|
||||||
// Basic stats logging...
|
// Basic stats logging...
|
||||||
print('[Stats] SubQuality: $_subscribeQualityKey | PubQuality: $_localQualityKey');
|
debugPrint('[Stats] SubQuality: $_subscribeQualityKey | PubQuality: $_localQualityKey');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
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:path_provider/path_provider.dart';
|
||||||
import 'package:sqflite/sqflite.dart' as sqflite_native;
|
import 'package:sqflite/sqflite.dart' as sqflite_native;
|
||||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
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';
|
import 'package:pyramid/core/voip_manager.dart';
|
||||||
|
|
||||||
final matrixClientProvider = FutureProvider<Client>((ref) async {
|
final matrixClientProvider = FutureProvider<Client>((ref) async {
|
||||||
@@ -36,7 +39,7 @@ final matrixClientProvider = FutureProvider<Client>((ref) async {
|
|||||||
await db.execute('PRAGMA journal_mode=WAL');
|
await db.execute('PRAGMA journal_mode=WAL');
|
||||||
await db.execute('PRAGMA busy_timeout = 5000');
|
await db.execute('PRAGMA busy_timeout = 5000');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (kDebugMode) print('Could not set PRAGMAs: $e');
|
if (kDebugMode) debugPrint('Could not set PRAGMAs: $e');
|
||||||
}
|
}
|
||||||
|
|
||||||
final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db);
|
final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db);
|
||||||
@@ -57,17 +60,33 @@ final matrixClientProvider = FutureProvider<Client>((ref) async {
|
|||||||
KeyVerificationMethod.qrShow,
|
KeyVerificationMethod.qrShow,
|
||||||
},
|
},
|
||||||
logLevel: kReleaseMode ? Level.warning : Level.verbose,
|
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(
|
nativeImplementations: NativeImplementationsIsolate(
|
||||||
compute,
|
compute,
|
||||||
vodozemacInit: () => vod.init(),
|
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(
|
await client.init(
|
||||||
waitForFirstSync: false,
|
waitForFirstSync: false,
|
||||||
waitUntilLoadCompletedLoaded: false,
|
waitUntilLoadCompletedLoaded: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Re-use or create singleton instance
|
// Re-use or create singleton instance
|
||||||
try {
|
try {
|
||||||
PyramidVoipManager.instance.client;
|
PyramidVoipManager.instance.client;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:io';
|
|||||||
import 'dart:isolate';
|
import 'dart:isolate';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
@@ -72,61 +73,61 @@ Future<void> _initAndroid(WidgetRef ref) async {
|
|||||||
IsolateNameServer.registerPortWithName(
|
IsolateNameServer.registerPortWithName(
|
||||||
_mainIsolatePort!.sendPort, kMainIsolateName);
|
_mainIsolatePort!.sendPort, kMainIsolateName);
|
||||||
_mainIsolatePort!.listen((msg) => _onIsolateMessage(msg, ref));
|
_mainIsolatePort!.listen((msg) => _onIsolateMessage(msg, ref));
|
||||||
print('[NOTIF] IsolateNameServer port registered: $kMainIsolateName');
|
debugPrint('[NOTIF] IsolateNameServer port registered: $kMainIsolateName');
|
||||||
|
|
||||||
await _androidPlugin.initialize(
|
await _androidPlugin.initialize(
|
||||||
const InitializationSettings(
|
const InitializationSettings(
|
||||||
android: AndroidInitializationSettings('@drawable/ic_notification'),
|
android: AndroidInitializationSettings('@drawable/ic_notification'),
|
||||||
),
|
),
|
||||||
onDidReceiveNotificationResponse: (details) {
|
onDidReceiveNotificationResponse: (details) {
|
||||||
print('[NOTIF-FG] onDidReceiveNotificationResponse fired');
|
debugPrint('[NOTIF-FG] onDidReceiveNotificationResponse fired');
|
||||||
print('[NOTIF-FG] actionId=${details.actionId} payload=${details.payload} input=${details.input}');
|
debugPrint('[NOTIF-FG] actionId=${details.actionId} payload=${details.payload} input=${details.input}');
|
||||||
if (details.actionId == 'reply') {
|
if (details.actionId == 'reply') {
|
||||||
final text = details.input?.trim() ?? '';
|
final text = details.input?.trim() ?? '';
|
||||||
final roomId = details.payload;
|
final roomId = details.payload;
|
||||||
print('[NOTIF-FG] reply: text="$text" roomId=$roomId');
|
debugPrint('[NOTIF-FG] reply: text="$text" roomId=$roomId');
|
||||||
if (text.isNotEmpty && roomId != null && roomId.isNotEmpty) {
|
if (text.isNotEmpty && roomId != null && roomId.isNotEmpty) {
|
||||||
_androidPlugin.cancel(_stableRoomId(roomId));
|
_androidPlugin.cancel(_stableRoomId(roomId));
|
||||||
ref.read(matrixClientProvider.future).then((client) async {
|
ref.read(matrixClientProvider.future).then((client) async {
|
||||||
final room = client.getRoomById(roomId);
|
final room = client.getRoomById(roomId);
|
||||||
print('[NOTIF-FG] getRoomById → ${room != null ? 'found' : 'null'}');
|
debugPrint('[NOTIF-FG] getRoomById → ${room != null ? 'found' : 'null'}');
|
||||||
if (room != null) {
|
if (room != null) {
|
||||||
try {
|
try {
|
||||||
await room.sendTextEvent(text);
|
await room.sendTextEvent(text);
|
||||||
print('[NOTIF-FG] sendTextEvent OK');
|
debugPrint('[NOTIF-FG] sendTextEvent OK');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[NOTIF-FG] sendTextEvent failed: $e — storing in prefs');
|
debugPrint('[NOTIF-FG] sendTextEvent failed: $e — storing in prefs');
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setString('notif_pending_reply_room', roomId);
|
await prefs.setString('notif_pending_reply_room', roomId);
|
||||||
await prefs.setString('notif_pending_reply_text', text);
|
await prefs.setString('notif_pending_reply_text', text);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
print('[NOTIF-FG] room null — storing in prefs, waiting for sync');
|
debugPrint('[NOTIF-FG] room null — storing in prefs, waiting for sync');
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setString('notif_pending_reply_room', roomId);
|
await prefs.setString('notif_pending_reply_room', roomId);
|
||||||
await prefs.setString('notif_pending_reply_text', text);
|
await prefs.setString('notif_pending_reply_text', text);
|
||||||
try {
|
try {
|
||||||
await client.onSync.stream.first;
|
await client.onSync.stream.first;
|
||||||
print('[NOTIF-FG] sync arrived — retrying _sendPendingReply');
|
debugPrint('[NOTIF-FG] sync arrived — retrying _sendPendingReply');
|
||||||
await _sendPendingReply(ref);
|
await _sendPendingReply(ref);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[NOTIF-FG] sync/send error: $e');
|
debugPrint('[NOTIF-FG] sync/send error: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
print('[NOTIF-FG] reply skipped: text empty or roomId null');
|
debugPrint('[NOTIF-FG] reply skipped: text empty or roomId null');
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (details.actionId == 'read') {
|
if (details.actionId == 'read') {
|
||||||
final roomId = details.payload;
|
final roomId = details.payload;
|
||||||
if (roomId != null) _androidPlugin.cancel(_stableRoomId(roomId));
|
if (roomId != null) _androidPlugin.cancel(_stableRoomId(roomId));
|
||||||
print('[NOTIF-FG] read action — dismissed');
|
debugPrint('[NOTIF-FG] read action — dismissed');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final roomId = details.payload;
|
final roomId = details.payload;
|
||||||
print('[NOTIF-FG] tap → opening room $roomId');
|
debugPrint('[NOTIF-FG] tap → opening room $roomId');
|
||||||
if (roomId != null && roomId.isNotEmpty) {
|
if (roomId != null && roomId.isNotEmpty) {
|
||||||
ref.read(activeRoomIdProvider.notifier).state = roomId;
|
ref.read(activeRoomIdProvider.notifier).state = roomId;
|
||||||
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
|
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||||
@@ -145,7 +146,7 @@ Future<void> _initAndroid(WidgetRef ref) async {
|
|||||||
final args = call.arguments;
|
final args = call.arguments;
|
||||||
if (call.method == 'openRoom') {
|
if (call.method == 'openRoom') {
|
||||||
final roomId = (args is Map ? args['room_id'] : null) as String?;
|
final roomId = (args is Map ? args['room_id'] : null) as String?;
|
||||||
print('[NOTIF-FG] openRoom: $roomId');
|
debugPrint('[NOTIF-FG] openRoom: $roomId');
|
||||||
if (roomId != null && roomId.isNotEmpty) {
|
if (roomId != null && roomId.isNotEmpty) {
|
||||||
ref.read(activeRoomIdProvider.notifier).state = roomId;
|
ref.read(activeRoomIdProvider.notifier).state = roomId;
|
||||||
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
|
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||||
@@ -156,7 +157,7 @@ Future<void> _initAndroid(WidgetRef ref) async {
|
|||||||
// MethodChannel — no Activity was opened.
|
// MethodChannel — no Activity was opened.
|
||||||
final roomId = (args is Map ? args['room_id'] : null) as String?;
|
final roomId = (args is Map ? args['room_id'] : null) as String?;
|
||||||
final text = (args is Map ? args['text'] : null) as String?;
|
final text = (args is Map ? args['text'] : null) as String?;
|
||||||
print('[NOTIF-FG] replyFromNotification: room=$roomId text=$text');
|
debugPrint('[NOTIF-FG] replyFromNotification: room=$roomId text=$text');
|
||||||
if (roomId != null && roomId.isNotEmpty && text != null && text.isNotEmpty) {
|
if (roomId != null && roomId.isNotEmpty && text != null && text.isNotEmpty) {
|
||||||
_androidPlugin.cancel(_stableRoomId(roomId));
|
_androidPlugin.cancel(_stableRoomId(roomId));
|
||||||
Future<void> storePending() async {
|
Future<void> storePending() async {
|
||||||
@@ -170,9 +171,9 @@ Future<void> _initAndroid(WidgetRef ref) async {
|
|||||||
final room = client.getRoomById(roomId);
|
final room = client.getRoomById(roomId);
|
||||||
if (room != null) {
|
if (room != null) {
|
||||||
await room.sendTextEvent(text);
|
await room.sendTextEvent(text);
|
||||||
print('[NOTIF-FG] replyFromNotification: sent OK');
|
debugPrint('[NOTIF-FG] replyFromNotification: sent OK');
|
||||||
} else {
|
} else {
|
||||||
print('[NOTIF-FG] replyFromNotification: room null, storing in prefs');
|
debugPrint('[NOTIF-FG] replyFromNotification: room null, storing in prefs');
|
||||||
await storePending();
|
await storePending();
|
||||||
// Retry on the next sync — room list is populated after first sync.
|
// Retry on the next sync — room list is populated after first sync.
|
||||||
client.onSync.stream.first
|
client.onSync.stream.first
|
||||||
@@ -180,7 +181,7 @@ Future<void> _initAndroid(WidgetRef ref) async {
|
|||||||
.catchError((_) {});
|
.catchError((_) {});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[NOTIF-FG] replyFromNotification error: $e — storing in prefs');
|
debugPrint('[NOTIF-FG] replyFromNotification error: $e — storing in prefs');
|
||||||
await storePending();
|
await storePending();
|
||||||
// Resume-based retry (checkPendingReply in app.dart) will pick this up.
|
// Resume-based retry (checkPendingReply in app.dart) will pick this up.
|
||||||
}
|
}
|
||||||
@@ -225,7 +226,7 @@ Future<void> _initAndroid(WidgetRef ref) async {
|
|||||||
if (reply is Map) {
|
if (reply is Map) {
|
||||||
final roomId = reply['room_id'] as String?;
|
final roomId = reply['room_id'] as String?;
|
||||||
final text = reply['text'] as String?;
|
final text = reply['text'] as String?;
|
||||||
print('[NOTIF-REPLY] getInitialReply: roomId=$roomId text=$text');
|
debugPrint('[NOTIF-REPLY] getInitialReply: roomId=$roomId text=$text');
|
||||||
if (roomId != null && roomId.isNotEmpty && text != null && text.isNotEmpty) {
|
if (roomId != null && roomId.isNotEmpty && text != null && text.isNotEmpty) {
|
||||||
// Persist so _sendPendingReply sends it after the first sync.
|
// Persist so _sendPendingReply sends it after the first sync.
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
@@ -302,7 +303,7 @@ void _onIsolateMessage(dynamic message, WidgetRef ref) {
|
|||||||
final input = message['input'] as String?;
|
final input = message['input'] as String?;
|
||||||
final id = message['id'] as int?;
|
final id = message['id'] as int?;
|
||||||
|
|
||||||
print('[NOTIF-ISOLATE] action=$actionId payload=$payload input=$input');
|
debugPrint('[NOTIF-ISOLATE] action=$actionId payload=$payload input=$input');
|
||||||
|
|
||||||
switch (actionId) {
|
switch (actionId) {
|
||||||
case 'reply':
|
case 'reply':
|
||||||
@@ -315,9 +316,9 @@ void _onIsolateMessage(dynamic message, WidgetRef ref) {
|
|||||||
if (room != null) {
|
if (room != null) {
|
||||||
try {
|
try {
|
||||||
await room.sendTextEvent(text);
|
await room.sendTextEvent(text);
|
||||||
print('[NOTIF-ISOLATE] reply sent OK');
|
debugPrint('[NOTIF-ISOLATE] reply sent OK');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[NOTIF-ISOLATE] sendTextEvent failed: $e — storing in prefs');
|
debugPrint('[NOTIF-ISOLATE] sendTextEvent failed: $e — storing in prefs');
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setString('notif_pending_reply_room', roomId);
|
await prefs.setString('notif_pending_reply_room', roomId);
|
||||||
await prefs.setString('notif_pending_reply_text', text);
|
await prefs.setString('notif_pending_reply_text', text);
|
||||||
@@ -326,7 +327,7 @@ void _onIsolateMessage(dynamic message, WidgetRef ref) {
|
|||||||
.catchError((_) {});
|
.catchError((_) {});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
print('[NOTIF-ISOLATE] room not in memory — storing in prefs');
|
debugPrint('[NOTIF-ISOLATE] room not in memory — storing in prefs');
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setString('notif_pending_reply_room', roomId);
|
await prefs.setString('notif_pending_reply_room', roomId);
|
||||||
await prefs.setString('notif_pending_reply_text', text);
|
await prefs.setString('notif_pending_reply_text', text);
|
||||||
@@ -342,14 +343,14 @@ void _onIsolateMessage(dynamic message, WidgetRef ref) {
|
|||||||
} else if (id != null) {
|
} else if (id != null) {
|
||||||
_androidPlugin.cancel(id);
|
_androidPlugin.cancel(id);
|
||||||
}
|
}
|
||||||
print('[NOTIF-ISOLATE] read → notification dismissed');
|
debugPrint('[NOTIF-ISOLATE] read → notification dismissed');
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Plain notification tap → navigate to room.
|
// Plain notification tap → navigate to room.
|
||||||
if (payload != null && payload.isNotEmpty) {
|
if (payload != null && payload.isNotEmpty) {
|
||||||
ref.read(activeRoomIdProvider.notifier).state = payload;
|
ref.read(activeRoomIdProvider.notifier).state = payload;
|
||||||
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
|
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||||
print('[NOTIF-ISOLATE] tap → navigated to $payload');
|
debugPrint('[NOTIF-ISOLATE] tap → navigated to $payload');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -361,16 +362,16 @@ Future<void> _sendPendingReply(WidgetRef ref) async {
|
|||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final roomId = prefs.getString('notif_pending_reply_room');
|
final roomId = prefs.getString('notif_pending_reply_room');
|
||||||
final text = prefs.getString('notif_pending_reply_text');
|
final text = prefs.getString('notif_pending_reply_text');
|
||||||
print('[NOTIF-REPLY] _sendPendingReply: roomId=$roomId text=$text');
|
debugPrint('[NOTIF-REPLY] _sendPendingReply: roomId=$roomId text=$text');
|
||||||
if (roomId == null || text == null || text.isEmpty) return;
|
if (roomId == null || text == null || text.isEmpty) return;
|
||||||
|
|
||||||
final client = await ref.read(matrixClientProvider.future);
|
final client = await ref.read(matrixClientProvider.future);
|
||||||
print('[NOTIF-REPLY] client ready, rooms=${client.rooms.length}');
|
debugPrint('[NOTIF-REPLY] client ready, rooms=${client.rooms.length}');
|
||||||
|
|
||||||
final room = client.getRoomById(roomId);
|
final room = client.getRoomById(roomId);
|
||||||
print('[NOTIF-REPLY] getRoomById → ${room != null ? 'found' : 'null'}');
|
debugPrint('[NOTIF-REPLY] getRoomById → ${room != null ? 'found' : 'null'}');
|
||||||
if (room == null) {
|
if (room == null) {
|
||||||
print('[NOTIF-REPLY] room not in memory yet, retrying after next sync');
|
debugPrint('[NOTIF-REPLY] room not in memory yet, retrying after next sync');
|
||||||
client.onSync.stream.first
|
client.onSync.stream.first
|
||||||
.then((_) => _sendPendingReply(ref))
|
.then((_) => _sendPendingReply(ref))
|
||||||
.catchError((_) {});
|
.catchError((_) {});
|
||||||
@@ -378,11 +379,11 @@ Future<void> _sendPendingReply(WidgetRef ref) async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await room.sendTextEvent(text);
|
await room.sendTextEvent(text);
|
||||||
print('[NOTIF-REPLY] sendTextEvent OK, clearing prefs');
|
debugPrint('[NOTIF-REPLY] sendTextEvent OK, clearing prefs');
|
||||||
await prefs.remove('notif_pending_reply_room');
|
await prefs.remove('notif_pending_reply_room');
|
||||||
await prefs.remove('notif_pending_reply_text');
|
await prefs.remove('notif_pending_reply_text');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[NOTIF-REPLY] error: $e — prefs kept for retry');
|
debugPrint('[NOTIF-REPLY] error: $e — prefs kept for retry');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,7 +460,7 @@ Future<void> _decryptAndUpdateNotification(
|
|||||||
.timeout(const Duration(seconds: 5));
|
.timeout(const Duration(seconds: 5));
|
||||||
var room = client.getRoomById(roomId);
|
var room = client.getRoomById(roomId);
|
||||||
if (room == null) {
|
if (room == null) {
|
||||||
print('[NOTIF] decryptAndUpdate: room $roomId not found');
|
debugPrint('[NOTIF] decryptAndUpdate: room $roomId not found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,7 +470,7 @@ Future<void> _decryptAndUpdateNotification(
|
|||||||
final bool alreadyHave =
|
final bool alreadyHave =
|
||||||
eventId == null || event?.eventId == eventId;
|
eventId == null || event?.eventId == eventId;
|
||||||
if (!alreadyHave || event == null) {
|
if (!alreadyHave || event == null) {
|
||||||
print('[NOTIF] decryptAndUpdate: waiting for sync to deliver $eventId');
|
debugPrint('[NOTIF] decryptAndUpdate: waiting for sync to deliver $eventId');
|
||||||
await client.onSync.stream.first.timeout(const Duration(seconds: 12));
|
await client.onSync.stream.first.timeout(const Duration(seconds: 12));
|
||||||
room = client.getRoomById(roomId);
|
room = client.getRoomById(roomId);
|
||||||
if (room == null) return;
|
if (room == null) return;
|
||||||
@@ -477,7 +478,7 @@ Future<void> _decryptAndUpdateNotification(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event == null) {
|
if (event == null) {
|
||||||
print('[NOTIF] decryptAndUpdate: event still null after sync');
|
debugPrint('[NOTIF] decryptAndUpdate: event still null after sync');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.type != EventTypes.Message) return;
|
if (event.type != EventTypes.Message) return;
|
||||||
@@ -486,7 +487,7 @@ Future<void> _decryptAndUpdateNotification(
|
|||||||
final rawBody = event.body;
|
final rawBody = event.body;
|
||||||
// If decryption failed the body starts with "** Unable to decrypt"
|
// If decryption failed the body starts with "** Unable to decrypt"
|
||||||
if (rawBody.isEmpty || rawBody.startsWith('**')) {
|
if (rawBody.isEmpty || rawBody.startsWith('**')) {
|
||||||
print('[NOTIF] decryptAndUpdate: decryption failed, keeping placeholder');
|
debugPrint('[NOTIF] decryptAndUpdate: decryption failed, keeping placeholder');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,9 +508,9 @@ Future<void> _decryptAndUpdateNotification(
|
|||||||
'roomId': roomId,
|
'roomId': roomId,
|
||||||
'notifId': notifId ?? _stableRoomId(roomId),
|
'notifId': notifId ?? _stableRoomId(roomId),
|
||||||
});
|
});
|
||||||
print('[NOTIF] decryptAndUpdate: updated notification → "$title: $notifBody"');
|
debugPrint('[NOTIF] decryptAndUpdate: updated notification → "$title: $notifBody"');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[NOTIF] decryptAndUpdate error: $e');
|
debugPrint('[NOTIF] decryptAndUpdate error: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,7 +534,7 @@ Future<void> showMessageNotification({
|
|||||||
'notifId': _stableRoomId(roomId),
|
'notifId': _stableRoomId(roomId),
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[NOTIF] showNativeNotification failed: $e');
|
debugPrint('[NOTIF] showNativeNotification failed: $e');
|
||||||
}
|
}
|
||||||
} else if (Platform.isWindows) {
|
} else if (Platform.isWindows) {
|
||||||
// Close the previous notification for this room before showing a new one.
|
// Close the previous notification for this room before showing a new one.
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
);
|
||||||
|
}
|
||||||
+14
-12
@@ -1,7 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
||||||
import 'package:matrix/matrix.dart';
|
import 'package:matrix/matrix.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
@@ -54,7 +53,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
|||||||
await remoteRenderer.initialize();
|
await remoteRenderer.initialize();
|
||||||
_renderersInitialized = true;
|
_renderersInitialized = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[VOIP] Renderer init error: $e');
|
debugPrint('[VOIP] Renderer init error: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,14 +79,14 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
|||||||
merged['iceServers'] = [...existing, ...extra];
|
merged['iceServers'] = [...existing, ...extra];
|
||||||
configuration = merged;
|
configuration = merged;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[VOIP] ICE server merge error: $e');
|
debugPrint('[VOIP] ICE server merge error: $e');
|
||||||
}
|
}
|
||||||
return rtc.createPeerConnection(configuration, constraints);
|
return rtc.createPeerConnection(configuration, constraints);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> handleNewCall(CallSession call) async {
|
Future<void> handleNewCall(CallSession call) async {
|
||||||
print('[VOIP] Incoming call ${call.callId} (type: ${call.type})');
|
debugPrint('[VOIP] Incoming call ${call.callId} (type: ${call.type})');
|
||||||
await _ensureRenderers();
|
await _ensureRenderers();
|
||||||
currentCall = call;
|
currentCall = call;
|
||||||
_isCameraMuted = call.type == CallType.kVoice;
|
_isCameraMuted = call.type == CallType.kVoice;
|
||||||
@@ -99,12 +98,12 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
|||||||
Timer? _renderRefreshTimer;
|
Timer? _renderRefreshTimer;
|
||||||
void _setupCallListeners(CallSession call) {
|
void _setupCallListeners(CallSession call) {
|
||||||
call.onCallStateChanged.stream.listen((state) {
|
call.onCallStateChanged.stream.listen((state) {
|
||||||
print('[VOIP] State: $state');
|
debugPrint('[VOIP] State: $state');
|
||||||
_updateInternalStates();
|
_updateInternalStates();
|
||||||
|
|
||||||
if (state == CallState.kConnected) {
|
if (state == CallState.kConnected) {
|
||||||
if (!_isCameraMuted) {
|
if (!_isCameraMuted) {
|
||||||
print('[VOIP] Ensuring camera is unmuted on connect');
|
debugPrint('[VOIP] Ensuring camera is unmuted on connect');
|
||||||
call.setLocalVideoMuted(false);
|
call.setLocalVideoMuted(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -204,7 +203,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
|||||||
bool changed = false;
|
bool changed = false;
|
||||||
if (remote != null && remote.getVideoTracks().isNotEmpty) {
|
if (remote != null && remote.getVideoTracks().isNotEmpty) {
|
||||||
if (remoteRenderer.srcObject?.id != remote.id) {
|
if (remoteRenderer.srcObject?.id != remote.id) {
|
||||||
print('[VOIP] Assigning Remote Renderer: ${remote.id} (${remote.getVideoTracks().length} video tracks)');
|
debugPrint('[VOIP] Assigning Remote Renderer: ${remote.id} (${remote.getVideoTracks().length} video tracks)');
|
||||||
remoteRenderer.srcObject = remote;
|
remoteRenderer.srcObject = remote;
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
@@ -215,7 +214,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
|||||||
|
|
||||||
if (local != null && local.getVideoTracks().isNotEmpty) {
|
if (local != null && local.getVideoTracks().isNotEmpty) {
|
||||||
if (localRenderer.srcObject?.id != local.id) {
|
if (localRenderer.srcObject?.id != local.id) {
|
||||||
print('[VOIP] Assigning Local Renderer: ${local.id} (${local.getVideoTracks().length} video tracks)');
|
debugPrint('[VOIP] Assigning Local Renderer: ${local.id} (${local.getVideoTracks().length} video tracks)');
|
||||||
localRenderer.srcObject = local;
|
localRenderer.srcObject = local;
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
@@ -226,7 +225,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
|||||||
|
|
||||||
if (changed) notifyListeners();
|
if (changed) notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[VOIP] Error in _updateRenderers: $e');
|
debugPrint('[VOIP] Error in _updateRenderers: $e');
|
||||||
} finally {
|
} finally {
|
||||||
_isUpdating = false;
|
_isUpdating = false;
|
||||||
}
|
}
|
||||||
@@ -301,7 +300,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
|||||||
await action();
|
await action();
|
||||||
await Future.delayed(const Duration(milliseconds: 1000));
|
await Future.delayed(const Duration(milliseconds: 1000));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[VOIP] Action error: $e');
|
debugPrint('[VOIP] Action error: $e');
|
||||||
}
|
}
|
||||||
_updateInternalStates();
|
_updateInternalStates();
|
||||||
_updateRenderers();
|
_updateRenderers();
|
||||||
@@ -330,7 +329,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
|||||||
await _updateRenderers();
|
await _updateRenderers();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[VOIP] toggleCamera error: $e');
|
debugPrint('[VOIP] toggleCamera error: $e');
|
||||||
_isCameraMuted = !_isCameraMuted;
|
_isCameraMuted = !_isCameraMuted;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -379,7 +378,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
|||||||
await _ensureRenderers();
|
await _ensureRenderers();
|
||||||
_isCameraMuted = !video;
|
_isCameraMuted = !video;
|
||||||
|
|
||||||
print('[VOIP] Starting call in room $roomId (video=$video)');
|
debugPrint('[VOIP] Starting call in room $roomId (video=$video)');
|
||||||
final call = await voip.inviteToCall(room, video ? CallType.kVideo : CallType.kVoice);
|
final call = await voip.inviteToCall(room, video ? CallType.kVideo : CallType.kVoice);
|
||||||
currentCall = call;
|
currentCall = call;
|
||||||
|
|
||||||
@@ -529,7 +528,10 @@ class MediaDevicesWrapper extends MediaDevices {
|
|||||||
set ondevicechange(Function(dynamic event)? callback) => _delegate.ondevicechange = callback;
|
set ondevicechange(Function(dynamic event)? callback) => _delegate.ondevicechange = callback;
|
||||||
@override
|
@override
|
||||||
Function(dynamic event)? get ondevicechange => _delegate.ondevicechange;
|
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
|
@override
|
||||||
|
// ignore: deprecated_member_use
|
||||||
Future<List<dynamic>> getSources() => _delegate.getSources();
|
Future<List<dynamic>> getSources() => _delegate.getSources();
|
||||||
@override
|
@override
|
||||||
Future<MediaDeviceInfo> selectAudioOutput([AudioOutputOptions? options]) => _delegate.selectAudioOutput(options);
|
Future<MediaDeviceInfo> selectAudioOutput([AudioOutputOptions? options]) => _delegate.selectAudioOutput(options);
|
||||||
|
|||||||
@@ -3,6 +3,15 @@ import 'package:matrix/encryption.dart';
|
|||||||
import 'package:matrix/matrix.dart';
|
import 'package:matrix/matrix.dart';
|
||||||
import 'package:pyramid/core/theme.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(
|
Future<void> showBootstrapDialog(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Client client, {
|
Client client, {
|
||||||
|
|||||||
@@ -76,6 +76,12 @@ class LoginNotifier extends StateNotifier<LoginFormState> {
|
|||||||
identifier: mx.AuthenticationUserIdentifier(user: username),
|
identifier: mx.AuthenticationUserIdentifier(user: username),
|
||||||
password: password,
|
password: password,
|
||||||
initialDeviceDisplayName: deviceName ?? 'Pyramid',
|
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));
|
if (Platform.isAndroid) unawaited(initFcm(client));
|
||||||
@@ -104,6 +110,11 @@ final isLoggedInProvider = StreamProvider<bool>((ref) async* {
|
|||||||
final client = await ref.watch(matrixClientProvider.future);
|
final client = await ref.watch(matrixClientProvider.future);
|
||||||
yield client.isLogged();
|
yield client.isLogged();
|
||||||
await for (final loginState in client.onLoginStateChanged.stream) {
|
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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -333,7 +333,9 @@ class _ControlBtnState extends State<_ControlBtn> {
|
|||||||
duration: pt.durationFast,
|
duration: pt.durationFast,
|
||||||
height: 32,
|
height: 32,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: widget.active ? (widget.danger ? pt.danger : pt.accent) : pt.bg3,
|
color: widget.active
|
||||||
|
? (widget.danger ? pt.danger : pt.accent)
|
||||||
|
: (_hovered ? pt.bgHover : pt.bg3),
|
||||||
borderRadius: BorderRadius.circular(pt.rSm),
|
borderRadius: BorderRadius.circular(pt.rSm),
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ class VoiceChannelView extends ConsumerWidget {
|
|||||||
List<_ParticipantData> _getVoipParticipants(PyramidVoipManager voip) {
|
List<_ParticipantData> _getVoipParticipants(PyramidVoipManager voip) {
|
||||||
final participants = <_ParticipantData>[];
|
final participants = <_ParticipantData>[];
|
||||||
|
|
||||||
double _ar(rtc.RTCVideoRenderer r) {
|
double ar(rtc.RTCVideoRenderer r) {
|
||||||
final w = r.videoWidth.toDouble();
|
final w = r.videoWidth.toDouble();
|
||||||
final h = r.videoHeight.toDouble();
|
final h = r.videoHeight.toDouble();
|
||||||
return (w > 0 && h > 0) ? w / h : 16.0 / 9.0;
|
return (w > 0 && h > 0) ? w / h : 16.0 / 9.0;
|
||||||
@@ -162,7 +162,7 @@ class VoiceChannelView extends ConsumerWidget {
|
|||||||
hasVideo: hasRemoteVideo,
|
hasVideo: hasRemoteVideo,
|
||||||
isScreenShare: isRemoteScreen,
|
isScreenShare: isRemoteScreen,
|
||||||
avatarColor: _avatarColors[1],
|
avatarColor: _avatarColors[1],
|
||||||
aspectRatio: hasRemoteVideo ? _ar(voip.remoteRenderer) : 16.0 / 9.0,
|
aspectRatio: hasRemoteVideo ? ar(voip.remoteRenderer) : 16.0 / 9.0,
|
||||||
));
|
));
|
||||||
|
|
||||||
// Local
|
// Local
|
||||||
@@ -176,7 +176,7 @@ class VoiceChannelView extends ConsumerWidget {
|
|||||||
hasVideo: hasLocalVideo,
|
hasVideo: hasLocalVideo,
|
||||||
isScreenShare: isLocalScreen,
|
isScreenShare: isLocalScreen,
|
||||||
avatarColor: _avatarColors[0],
|
avatarColor: _avatarColors[0],
|
||||||
aspectRatio: hasLocalVideo ? _ar(voip.localRenderer) : 16.0 / 9.0,
|
aspectRatio: hasLocalVideo ? ar(voip.localRenderer) : 16.0 / 9.0,
|
||||||
));
|
));
|
||||||
|
|
||||||
return participants;
|
return participants;
|
||||||
@@ -689,7 +689,7 @@ class _ParticipantStrip extends StatelessWidget {
|
|||||||
return ListView.separated(
|
return ListView.separated(
|
||||||
scrollDirection: isVertical ? Axis.vertical : Axis.horizontal,
|
scrollDirection: isVertical ? Axis.vertical : Axis.horizontal,
|
||||||
itemCount: participants.length,
|
itemCount: participants.length,
|
||||||
separatorBuilder: (_, __) => const SizedBox(width: 12, height: 12),
|
separatorBuilder: (_, _) => const SizedBox(width: 12, height: 12),
|
||||||
itemBuilder: (context, i) => SizedBox(
|
itemBuilder: (context, i) => SizedBox(
|
||||||
width: isVertical ? double.infinity : 101 * participants[i].aspectRatio,
|
width: isVertical ? double.infinity : 101 * participants[i].aspectRatio,
|
||||||
height: 101,
|
height: 101,
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ class AttachmentDialog extends StatefulWidget {
|
|||||||
barrierLabel: 'Anhang',
|
barrierLabel: 'Anhang',
|
||||||
barrierColor: Colors.black54,
|
barrierColor: Colors.black54,
|
||||||
transitionDuration: const Duration(milliseconds: 180),
|
transitionDuration: const Duration(milliseconds: 180),
|
||||||
pageBuilder: (_, __, ___) =>
|
pageBuilder: (_, _, _) =>
|
||||||
AttachmentDialog(files: files, room: room, replyTo: replyTo),
|
AttachmentDialog(files: files, room: room, replyTo: replyTo),
|
||||||
transitionBuilder: (_, anim, __, child) {
|
transitionBuilder: (_, anim, _, child) {
|
||||||
final curved =
|
final curved =
|
||||||
CurvedAnimation(parent: anim, curve: Curves.easeOutBack);
|
CurvedAnimation(parent: anim, curve: Curves.easeOutBack);
|
||||||
return FadeTransition(
|
return FadeTransition(
|
||||||
@@ -184,7 +184,7 @@ class _AttachmentDialogState extends State<AttachmentDialog> {
|
|||||||
Switch.adaptive(
|
Switch.adaptive(
|
||||||
value: !_compress,
|
value: !_compress,
|
||||||
onChanged: (v) => setState(() => _compress = !v),
|
onChanged: (v) => setState(() => _compress = !v),
|
||||||
activeColor: pt.accent,
|
activeThumbColor: pt.accent,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -321,7 +321,7 @@ class _FilePreviewState extends State<_FilePreview> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _formatSize(int bytes) {
|
String _formatSize(int bytes) {
|
||||||
if (bytes < 1024) return '${bytes} B';
|
if (bytes < 1024) return '$bytes B';
|
||||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:typed_data';
|
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|||||||
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'&'), '&')
|
||||||
|
.replaceAll(RegExp(r'<'), '<')
|
||||||
|
.replaceAll(RegExp(r'>'), '>')
|
||||||
|
.replaceAll(RegExp(r' '), ' ')
|
||||||
|
.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
@@ -538,7 +538,7 @@ class _GifStickerPickerState extends State<GifStickerPicker>
|
|||||||
child: Image.network(
|
child: Image.network(
|
||||||
thumbUrl,
|
thumbUrl,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
errorBuilder: (_, __, ___) => Container(color: _pt!.bg2),
|
errorBuilder: (_, _, _) => Container(color: _pt!.bg2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -587,7 +587,7 @@ class _StickerCellState extends State<_StickerCell> {
|
|||||||
Image.network(
|
Image.network(
|
||||||
widget.piUrl,
|
widget.piUrl,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
errorBuilder: (_, __, ___) => Container(color: pt.bg2),
|
errorBuilder: (_, _, _) => Container(color: pt.bg2),
|
||||||
),
|
),
|
||||||
if (showOverlay)
|
if (showOverlay)
|
||||||
Positioned(
|
Positioned(
|
||||||
@@ -666,7 +666,7 @@ class _GifCell extends StatelessWidget {
|
|||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
Image.network(imageUrl, fit: BoxFit.cover,
|
Image.network(imageUrl, fit: BoxFit.cover,
|
||||||
errorBuilder: (_, __, ___) => Container(color: PyramidTheme.of(context).bg3)),
|
errorBuilder: (_, _, _) => Container(color: PyramidTheme.of(context).bg3)),
|
||||||
if (isHovered || isFavorite)
|
if (isHovered || isFavorite)
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 4, right: 4,
|
top: 4, right: 4,
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ class _AArtwork extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(size * 0.3),
|
borderRadius: BorderRadius.circular(size * 0.3),
|
||||||
gradient: LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [c1, c2]),
|
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(
|
Widget build(BuildContext context) => Container(
|
||||||
width: 56, height: 56,
|
width: 56, height: 56,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withOpacity(0.10),
|
color: Colors.white.withValues(alpha: 0.10),
|
||||||
shape: BoxShape.circle,
|
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),
|
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())),
|
milliseconds: (dur.inMilliseconds * (d.localPosition.dx / c.maxWidth).clamp(0.0, 1.0)).round())),
|
||||||
child: SizedBox(height: 16, child: Stack(alignment: Alignment.centerLeft, children: [
|
child: SizedBox(height: 16, child: Stack(alignment: Alignment.centerLeft, children: [
|
||||||
Container(height: 3, decoration: BoxDecoration(
|
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,
|
FractionallySizedBox(widthFactor: progress, child: Container(height: 3,
|
||||||
decoration: BoxDecoration(color: accent, borderRadius: BorderRadius.circular(2)))),
|
decoration: BoxDecoration(color: accent, borderRadius: BorderRadius.circular(2)))),
|
||||||
Positioned(
|
Positioned(
|
||||||
left: c.maxWidth * progress.clamp(0.0, 1.0) - 5,
|
left: c.maxWidth * progress.clamp(0.0, 1.0) - 5,
|
||||||
child: Container(width: 10, height: 10,
|
child: Container(width: 10, height: 10,
|
||||||
decoration: BoxDecoration(color: accent, shape: BoxShape.circle,
|
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,
|
onTap: onTap,
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
child: SizedBox(width: 32, height: 32,
|
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),
|
color: const Color(0xF014141A),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
side: BorderSide(color: Colors.white.withOpacity(0.1)),
|
side: BorderSide(color: Colors.white.withValues(alpha: 0.1)),
|
||||||
),
|
),
|
||||||
itemBuilder: (_) => [PopupMenuItem<void>(
|
itemBuilder: (_) => [PopupMenuItem<void>(
|
||||||
enabled: false, padding: EdgeInsets.zero,
|
enabled: false, padding: EdgeInsets.zero,
|
||||||
@@ -761,7 +761,7 @@ class _VSettingsButton extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
)],
|
)],
|
||||||
child: SizedBox(width: 32, height: 32,
|
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)
|
if (isSpeed)
|
||||||
...widget.speeds.map((s) {
|
...widget.speeds.map((s) {
|
||||||
final lbl = '${s == s.toInt() ? s.toInt() : s}x';
|
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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll(''', "'")
|
||||||
|
.replaceAll(''', "'")
|
||||||
|
.replaceAll(' ', ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-2533
File diff suppressed because it is too large
Load Diff
@@ -132,16 +132,16 @@ class _ProfileRow extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _InitialAvatar extends StatelessWidget {
|
class _InitialAvatar extends StatelessWidget {
|
||||||
|
static const double _size = 32;
|
||||||
final String name;
|
final String name;
|
||||||
final double radius;
|
final double radius;
|
||||||
final double size;
|
const _InitialAvatar({required this.name, required this.radius});
|
||||||
const _InitialAvatar({required this.name, required this.radius, this.size = 32});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
width: size,
|
width: _size,
|
||||||
height: size,
|
height: _size,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: const LinearGradient(
|
gradient: const LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
@@ -155,7 +155,7 @@ class _InitialAvatar extends StatelessWidget {
|
|||||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: size * 0.44,
|
fontSize: _size * 0.44,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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
@@ -7,8 +7,8 @@ import 'package:image/image.dart' as img;
|
|||||||
|
|
||||||
// ── Windows constants ────────────────────────────────────────────────────────
|
// ── Windows constants ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const _CF_DIB = 8;
|
const _cfDib = 8;
|
||||||
const _CF_DIBV4 = 20;
|
const _cfDibV4 = 20;
|
||||||
|
|
||||||
// ── Native function typedefs ─────────────────────────────────────────────────
|
// ── Native function typedefs ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ Uint8List? _readClipboardImage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Try CF_DIBV4 then CF_DIB — convert DIB to PNG via the image package
|
// 2. Try CF_DIBV4 then CF_DIB — convert DIB to PNG via the image package
|
||||||
for (final fmt in [_CF_DIBV4, _CF_DIB]) {
|
for (final fmt in [_cfDibV4, _cfDib]) {
|
||||||
if (isFormatAvailable(fmt) != 0) {
|
if (isFormatAvailable(fmt) != 0) {
|
||||||
final dibBytes = _readGlobalBytes(
|
final dibBytes = _readGlobalBytes(
|
||||||
getClipboardData(fmt), globalSize, globalLock, globalUnlock);
|
getClipboardData(fmt), globalSize, globalLock, globalUnlock);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart' hide Visibility;
|
import 'package:flutter/material.dart' hide Visibility;
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:matrix/matrix.dart';
|
import 'package:matrix/matrix.dart';
|
||||||
import 'package:pyramid/core/app_state.dart';
|
import 'package:pyramid/core/app_state.dart';
|
||||||
@@ -41,7 +40,7 @@ class _CreateJoinDialogState extends ConsumerState<CreateJoinDialog> {
|
|||||||
// Discover
|
// Discover
|
||||||
final _discoverServerCtrl = TextEditingController();
|
final _discoverServerCtrl = TextEditingController();
|
||||||
final _discoverSearchCtrl = TextEditingController();
|
final _discoverSearchCtrl = TextEditingController();
|
||||||
List<PublicRoomsChunk> _publicRooms = [];
|
List<PublishedRoomsChunk> _publicRooms = [];
|
||||||
String? _nextBatch;
|
String? _nextBatch;
|
||||||
bool _discoverLoading = false;
|
bool _discoverLoading = false;
|
||||||
bool _discoverLoadingMore = false;
|
bool _discoverLoadingMore = false;
|
||||||
@@ -189,11 +188,13 @@ class _CreateJoinDialogState extends ConsumerState<CreateJoinDialog> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) setState(() {
|
if (mounted) {
|
||||||
_discoverLoading = false;
|
setState(() {
|
||||||
_discoverLoadingMore = false;
|
_discoverLoading = false;
|
||||||
_discoverError = e.toString().split('\n').first;
|
_discoverLoadingMore = false;
|
||||||
});
|
_discoverError = e.toString().split('\n').first;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -518,13 +519,12 @@ class _SpaceEditDialogState extends ConsumerState<SpaceEditDialog> {
|
|||||||
late TextEditingController _topicCtrl;
|
late TextEditingController _topicCtrl;
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
bool _saved = false;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_nameCtrl = TextEditingController(text: widget.space.getLocalizedDisplayname());
|
_nameCtrl = TextEditingController(text: widget.space.getLocalizedDisplayname());
|
||||||
_topicCtrl = TextEditingController(text: widget.space.topic ?? '');
|
_topicCtrl = TextEditingController(text: widget.space.topic);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -535,17 +535,17 @@ class _SpaceEditDialogState extends ConsumerState<SpaceEditDialog> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
setState(() { _saving = true; _error = null; _saved = false; });
|
setState(() { _saving = true; _error = null; });
|
||||||
try {
|
try {
|
||||||
final name = _nameCtrl.text.trim();
|
final name = _nameCtrl.text.trim();
|
||||||
final topic = _topicCtrl.text.trim();
|
final topic = _topicCtrl.text.trim();
|
||||||
if (name.isNotEmpty && name != widget.space.getLocalizedDisplayname()) {
|
if (name.isNotEmpty && name != widget.space.getLocalizedDisplayname()) {
|
||||||
await widget.space.setName(name);
|
await widget.space.setName(name);
|
||||||
}
|
}
|
||||||
if (topic != (widget.space.topic ?? '')) {
|
if (topic != widget.space.topic) {
|
||||||
await widget.space.setDescription(topic);
|
await widget.space.setDescription(topic);
|
||||||
}
|
}
|
||||||
if (mounted) setState(() { _saving = false; _saved = true; });
|
if (mounted) setState(() { _saving = false; });
|
||||||
await Future.delayed(const Duration(seconds: 1));
|
await Future.delayed(const Duration(seconds: 1));
|
||||||
if (mounted) Navigator.of(context).pop(true);
|
if (mounted) Navigator.of(context).pop(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -674,7 +674,7 @@ class _RoomSettingsDialogState extends ConsumerState<RoomSettingsDialog> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_nameCtrl = TextEditingController(text: widget.room.getLocalizedDisplayname());
|
_nameCtrl = TextEditingController(text: widget.room.getLocalizedDisplayname());
|
||||||
_topicCtrl = TextEditingController(text: widget.room.topic ?? '');
|
_topicCtrl = TextEditingController(text: widget.room.topic);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -691,7 +691,7 @@ class _RoomSettingsDialogState extends ConsumerState<RoomSettingsDialog> {
|
|||||||
await widget.room.setName(_nameCtrl.text.trim());
|
await widget.room.setName(_nameCtrl.text.trim());
|
||||||
}
|
}
|
||||||
final newTopic = _topicCtrl.text.trim();
|
final newTopic = _topicCtrl.text.trim();
|
||||||
if (newTopic != (widget.room.topic ?? '')) {
|
if (newTopic != widget.room.topic) {
|
||||||
await widget.room.setDescription(newTopic);
|
await widget.room.setDescription(newTopic);
|
||||||
}
|
}
|
||||||
if (mounted) Navigator.of(context).pop();
|
if (mounted) Navigator.of(context).pop();
|
||||||
@@ -911,7 +911,7 @@ class _OptionRow extends StatelessWidget {
|
|||||||
Text(label, style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500)),
|
Text(label, style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500)),
|
||||||
Text(desc, style: TextStyle(color: pt.fgDim, fontSize: 11)),
|
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 {
|
class _PublicRoomItem extends ConsumerWidget {
|
||||||
final PublicRoomsChunk room;
|
final PublishedRoomsChunk room;
|
||||||
final PyramidTheme pt;
|
final PyramidTheme pt;
|
||||||
final bool isJoining;
|
final bool isJoining;
|
||||||
final VoidCallback onJoin;
|
final VoidCallback onJoin;
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ class _MxcAvatarState extends State<MxcAvatar> {
|
|||||||
width: widget.size,
|
width: widget.size,
|
||||||
height: widget.size,
|
height: widget.size,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (_, __, ___) => widget.placeholder(context),
|
errorBuilder: (_, _, _) => widget.placeholder(context),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -194,7 +194,7 @@ class _MxcImageState extends State<MxcImage> {
|
|||||||
width: widget.width,
|
width: widget.width,
|
||||||
height: widget.height,
|
height: widget.height,
|
||||||
gaplessPlayback: true,
|
gaplessPlayback: true,
|
||||||
errorBuilder: (_, __, ___) =>
|
errorBuilder: (_, _, _) =>
|
||||||
widget.placeholder ?? const SizedBox.shrink(),
|
widget.placeholder ?? const SizedBox.shrink(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ class _PyramidLoaderState extends State<PyramidLoader>
|
|||||||
|
|
||||||
final painter = AnimatedBuilder(
|
final painter = AnimatedBuilder(
|
||||||
animation: _ctrl,
|
animation: _ctrl,
|
||||||
builder: (_, __) => CustomPaint(
|
builder: (_, _) => CustomPaint(
|
||||||
painter: _PyramidPainter(
|
painter: _PyramidPainter(
|
||||||
yaw: _yawAt(_ctrl.value),
|
yaw: _yawAt(_ctrl.value),
|
||||||
accent: accent,
|
accent: accent,
|
||||||
@@ -82,7 +82,7 @@ class _PyramidLoaderState extends State<PyramidLoader>
|
|||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
repeat: true,
|
repeat: true,
|
||||||
animate: true,
|
animate: true,
|
||||||
errorBuilder: (_, __, ___) => painter,
|
errorBuilder: (_, _, _) => painter,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -127,22 +127,10 @@ class _PyramidPainter extends CustomPainter {
|
|||||||
Offset _proj(List<double> v, double sc, double cx, double cy, double tr) =>
|
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);
|
Offset(cx + v[0] * sc, cy + (v[1] * cos(tr) + v[2] * sin(tr)) * sc);
|
||||||
|
|
||||||
double _frontness(List<List<double>> verts) {
|
Color _darken(Color c, double amount) => c.withValues(
|
||||||
final a = verts[0], b = verts[1], c = verts[2];
|
red: max(0.0, c.r * (1 - amount)),
|
||||||
final ux = b[0]-a[0], uy = b[1]-a[1], uz = b[2]-a[2];
|
green: max(0.0, c.g * (1 - amount)),
|
||||||
final vx = c[0]-a[0], vy = c[1]-a[1], vz = c[2]-a[2];
|
blue: max(0.0, c.b * (1 - amount)),
|
||||||
final nz = ux * vy - uy * vx;
|
|
||||||
final nLen = sqrt(
|
|
||||||
pow(uy * vz - uz * vy, 2) + pow(uz * vx - ux * vz, 2) + nz * nz,
|
|
||||||
);
|
|
||||||
return nLen == 0 ? 0 : max(0.0, -nz / nLen);
|
|
||||||
}
|
|
||||||
|
|
||||||
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()),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'dart:typed_data';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||||
import 'package:pyramid/core/theme.dart';
|
import 'package:pyramid/core/theme.dart';
|
||||||
@@ -43,7 +42,7 @@ class _ScreenSharePickerState extends State<ScreenSharePicker> with SingleTicker
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[Picker] Error loading sources: $e');
|
debugPrint('[Picker] Error loading sources: $e');
|
||||||
if (mounted) setState(() => _loading = false);
|
if (mounted) setState(() => _loading = false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -723,7 +723,7 @@ class _SearchThumbState extends State<_SearchThumb> {
|
|||||||
height: size,
|
height: size,
|
||||||
child: _bytes != null
|
child: _bytes != null
|
||||||
? Image.memory(_bytes!, fit: BoxFit.cover,
|
? Image.memory(_bytes!, fit: BoxFit.cover,
|
||||||
errorBuilder: (_, __, ___) => _placeholder(pt))
|
errorBuilder: (_, _, _) => _placeholder(pt))
|
||||||
: _placeholder(pt),
|
: _placeholder(pt),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-5261
File diff suppressed because it is too large
Load Diff
@@ -27,8 +27,8 @@ class ShareTargetDialog {
|
|||||||
barrierLabel: 'Teilen',
|
barrierLabel: 'Teilen',
|
||||||
barrierColor: Colors.black54,
|
barrierColor: Colors.black54,
|
||||||
transitionDuration: const Duration(milliseconds: 180),
|
transitionDuration: const Duration(milliseconds: 180),
|
||||||
pageBuilder: (_, __, ___) => _ShareTargetPicker(client: client, share: share),
|
pageBuilder: (_, _, _) => _ShareTargetPicker(client: client, share: share),
|
||||||
transitionBuilder: (_, anim, __, child) {
|
transitionBuilder: (_, anim, _, child) {
|
||||||
final curved = CurvedAnimation(parent: anim, curve: Curves.easeOutBack);
|
final curved = CurvedAnimation(parent: anim, curve: Curves.easeOutBack);
|
||||||
return FadeTransition(
|
return FadeTransition(
|
||||||
opacity: anim,
|
opacity: anim,
|
||||||
|
|||||||
Reference in New Issue
Block a user