Compare commits
104 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2413ef627 | |||
| a4f9fd70e6 | |||
| bf552ee194 | |||
| e0179ad907 | |||
| 8b36d16cdd | |||
| f8f5332d90 | |||
| ffd70caffd | |||
| 9f797279eb | |||
| dba4b64df8 | |||
| fdc16afa70 | |||
| 9950e1eb29 | |||
| 17b3798dc0 | |||
| f2b9afbfea | |||
| 9693174de9 | |||
| bca7abb206 | |||
| 3698048b26 | |||
| 3300a44c4a | |||
| 6b0ae21f2b | |||
| 41bcaf2a88 | |||
| 52b0ae975a | |||
| 6128953e28 | |||
| c8cc1480a8 | |||
| 643ba662dd | |||
| 95d9b07dbd | |||
| b131f56b72 | |||
| d22f9c2961 | |||
| b2335da2fb | |||
| b84d31ca87 | |||
| f87db183ea | |||
| 3c7eb9ddca | |||
| f64398d3c6 | |||
| cead62ccb2 | |||
| 4d16d49012 | |||
| 725f03abb0 | |||
| 15001ae80a | |||
| 1ef32c0022 | |||
| 66bf54bdff | |||
| 81ec06398f | |||
| 4477ed9101 | |||
| 825481e6fa | |||
| db4d001ef1 | |||
| af7c538e18 | |||
| 4c6efa2a7a | |||
| d3e75c36a1 | |||
| 326cc598c7 | |||
| 13b333cbec | |||
| 4d4044b888 | |||
| 5ada0e5038 | |||
| c8ff850c98 | |||
| d42beaf7ab | |||
| 343545b59e | |||
| 455742d1bf | |||
| e97a748b35 | |||
| ba97f942d6 | |||
| 88af6c016c | |||
| 78e4174658 | |||
| 925aafb2fe | |||
| fe6427bfba | |||
| a5a5c4baf5 | |||
| 7b90f20e36 | |||
| 03b84e0e0e | |||
| 7dfdbf1159 | |||
| e0ac5cb9fd | |||
| eebf8ae7dd | |||
| 566938f004 | |||
| 1630d0e389 | |||
| 99cde9a1e2 | |||
| bfe58760ee | |||
| 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": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
|
||||
@@ -45,3 +45,7 @@ app.*.map.json
|
||||
/android/app/release
|
||||
autopilot.log
|
||||
.claude/worktrees/
|
||||
*.bak
|
||||
|
||||
# Skript-Backups vor riskanten Aenderungen (bleiben lokal als Rollback)
|
||||
*.bak-*
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Bernds Entscheidungen – Richtungsfragen aus PROGRESS.md beantwortet
|
||||
|
||||
Diese Datei beantwortet die über mehrere Sessions in PROGRESS.md gesammelten
|
||||
„Fragen an Bernd". Sie ist ab jetzt **maßgeblich** – die betroffenen Punkte in
|
||||
ROADMAP.md/PROGRESS.md gelten als entschieden.
|
||||
Zuletzt aktualisiert: **2026-07-05** (Bernd + Claude am PC).
|
||||
|
||||
## 1. SQLCipher (lokale DB + Access-Token verschlüsseln) → VOR M2 priorisieren
|
||||
- **Entscheidung:** Ja. Die Verschlüsselung der lokalen `pyramid.sqlite` hat Vorrang
|
||||
vor dem M2-Refactoring. Betrifft den M1-Punkt „Sichere Speicherung von Access-Token
|
||||
& Krypto-DB".
|
||||
- Umsetzung nach `docs/SQLCIPHER_MIGRATION.md`. **Wichtig:** Die Migration bestehender
|
||||
Klartext-DBs (Utas Gerät!) ist der riskante Teil und braucht einen echten
|
||||
PC-/Gerätetest. Auf dem Pi (kein GUI) nur so weit vorbereiten, wie es OHNE GUI sicher
|
||||
testbar ist; alles Verhaltensrelevante klar als UNGETESTET markieren und erst nach
|
||||
`docs/PC_TESTPLAN.md` als erledigt abhaken. Grundsatz „Kein Datenverlust" – im Zweifel
|
||||
für den PC-Termin liegen lassen.
|
||||
|
||||
## 2. M2 – erstes Umbau-Modul → Call-Pilot zuerst
|
||||
- **Entscheidung:** Wenn M2 startet (nach SQLCipher), zuerst die Call-Schicht entwirren
|
||||
(Call-Pilot / gemeinsame Fassade über `voip_manager.dart` + `livekit_call_manager.dart`),
|
||||
wie in `docs/M2_MODULE_SCHNITT.md` vorgeschlagen. **Nicht** zuerst `settings_modal.dart`
|
||||
aufsplitten.
|
||||
- **Wichtig:** heiliger Call-Pfad → braucht zwingend echten Call-Test auf einem Gerät.
|
||||
Auf dem Pi nicht blind umbauen; Vorbereitung/Analyse ok, Umsetzung + Test am PC.
|
||||
|
||||
## 3. Dashboard-Fernzugriff (stats.html) → Heimnetz-only lassen
|
||||
- **Entscheidung:** KEIN Fernzugriff. Das Interims-Gate (nur Heimnetz/localhost) bleibt so.
|
||||
Die offene A/B-Frage (Cloudflare Access vs. DASH_TOKEN) aus
|
||||
`docs/DASHBOARD_AUTH_HARDENING.md` ist damit **geschlossen** – nicht weiter daran
|
||||
arbeiten. Unterwegs per WireGuard erreichbar.
|
||||
|
||||
## 4. Git-History / Admin-Token → vorerst NICHTS tun
|
||||
- **Entscheidung:** Kein Git-History-Rewrite und aktuell keine Token-Rotation durch den
|
||||
Autopilot. Der Admin-Token ist bereits aus dem App-Code heraus (eigener `DIAG_TOKEN`).
|
||||
Eine spätere Rotation bleibt Bernds manuelle Sache – nicht eigenmächtig anstoßen.
|
||||
|
||||
---
|
||||
|
||||
**Kurzfassung fürs Arbeiten:** SQLCipher vor M2, dann Call-Pilot als erstes M2-Modul.
|
||||
Dashboard bleibt Heimnetz-only. History unangetastet. Riskante/GUI-relevante Teile
|
||||
warten auf den PC-Termin (`docs/PC_TESTPLAN.md`); auf dem Pi nur sicher Vorbereitbares.
|
||||
+414
@@ -1078,3 +1078,417 @@
|
||||
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: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
|
||||
2026-07-03 18:16 [Write] /home/steggi/pyramid/test/soft_logout_guard_test.dart
|
||||
2026-07-03 18:17 [Write] /home/steggi/pyramid/test/power_levels_safety_test.dart
|
||||
2026-07-03 22:21 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-03 22:22 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-03 22:23 [Write] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
|
||||
2026-07-03 22:23 [Edit] /home/steggi/pyramid/docs/PC_TESTPLAN.md
|
||||
2026-07-03 22:48 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-03 22:50 [Write] /home/steggi/pyramid/test/auth_log_test.dart
|
||||
2026-07-03 22:50 [Edit] /home/steggi/pyramid/pubspec.yaml
|
||||
2026-07-03 22:51 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-03 22:52 [Write] /home/steggi/pyramid/test/settings_prefs_test.dart
|
||||
2026-07-03 22:53 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-03 22:53 [Edit] /home/steggi/pyramid/docs/PC_TESTPLAN.md
|
||||
2026-07-03 22:54 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
|
||||
2026-07-03 23:18 [Edit] /home/steggi/pyramid/test/settings_prefs_test.dart
|
||||
2026-07-03 23:18 [Edit] /home/steggi/pyramid/test/settings_prefs_test.dart
|
||||
2026-07-03 23:19 [Edit] /home/steggi/pyramid/lib/core/settings_prefs.dart
|
||||
2026-07-03 23:20 [Edit] /home/steggi/pyramid/lib/core/settings_prefs.dart
|
||||
2026-07-03 23:21 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-03 23:21 [Edit] /home/steggi/pyramid/docs/PC_TESTPLAN.md
|
||||
2026-07-03 23:22 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 03:28 [Edit] /home/steggi/pyramid/scripts/release.ps1
|
||||
2026-07-04 03:31 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 03:32 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 03:32 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||
2026-07-04 04:01 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 04:02 [Edit] /home/steggi/pyramid/lib/widgets/settings/settings_encryption.dart
|
||||
2026-07-04 04:03 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||
2026-07-04 04:03 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 04:06 [Write] /home/steggi/pyramid/docs/LIVEKIT_TOKEN_MIGRATION.md
|
||||
2026-07-04 04:06 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||
2026-07-04 04:06 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 04:07 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
|
||||
2026-07-04 04:30 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 04:30 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 04:31 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 04:32 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 04:33 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 04:33 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 04:33 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 08:42 [Write] /home/steggi/pyramid/docs/DASHBOARD_AUTH_HARDENING.md
|
||||
2026-07-04 08:42 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 08:43 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 08:43 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||
2026-07-04 09:14 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 09:14 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 09:15 [Edit] /home/steggi/pyramid/docs/DASHBOARD_AUTH_HARDENING.md
|
||||
2026-07-04 09:15 [Edit] /home/steggi/pyramid/docs/DASHBOARD_AUTH_HARDENING.md
|
||||
2026-07-04 09:16 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||
2026-07-04 09:16 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||
2026-07-04 09:17 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 09:17 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 09:18 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
|
||||
2026-07-04 09:43 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 09:43 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 09:43 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 13:46 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 13:46 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 13:50 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 13:51 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 13:51 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||
2026-07-04 13:51 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
|
||||
2026-07-04 14:17 [Write] /home/steggi/matrix/server.py
|
||||
2026-07-04 14:19 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 14:20 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 14:20 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||
2026-07-04 14:43 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 14:44 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 14:44 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 14:44 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 14:44 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 14:45 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 14:46 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 14:46 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||
2026-07-04 14:47 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
|
||||
2026-07-04 18:54 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 18:54 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 18:54 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 18:54 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 18:54 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 18:56 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 18:56 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 18:56 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||
2026-07-04 18:57 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
|
||||
2026-07-04 19:19 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 19:20 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 19:21 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 19:21 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||
2026-07-04 19:22 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
|
||||
2026-07-04 19:44 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 19:44 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 19:44 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||
2026-07-04 19:45 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
|
||||
2026-07-05 14:51 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-05 14:52 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-05 14:52 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||
2026-07-05 14:52 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||
2026-07-05 14:52 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||
2026-07-05 14:52 [Edit] /home/steggi/pyramid/docs/DASHBOARD_AUTH_HARDENING.md
|
||||
2026-07-05 14:52 [Edit] /home/steggi/pyramid/docs/DASHBOARD_AUTH_HARDENING.md
|
||||
2026-07-05 14:52 [Edit] /home/steggi/pyramid/docs/DASHBOARD_AUTH_HARDENING.md
|
||||
2026-07-05 14:53 [Edit] /home/steggi/pyramid/docs/DASHBOARD_AUTH_HARDENING.md
|
||||
2026-07-05 14:53 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-05 14:54 [Write] /home/steggi/.claude/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand-haupt.md
|
||||
2026-07-05 14:54 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
|
||||
2026-07-05 14:54 [Edit] /home/steggi/.claude/projects/-home-steggi-pyramid/memory/MEMORY.md
|
||||
2026-07-05 15:18 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-05 15:18 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-05 18:40 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-05 18:41 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-05 19:03 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-05 19:03 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-05 19:03 [Edit] /home/steggi/.claude/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand-haupt.md
|
||||
2026-07-06 11:56 [Edit] /home/steggi/pyramid/autopilot.sh
|
||||
2026-07-06 11:57 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-06 11:57 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-06 11:57 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-06 11:57 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-06 11:58 [Edit] /home/steggi/.claude/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand-haupt.md
|
||||
2026-07-06 11:58 [Edit] /home/steggi/.claude/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand-haupt.md
|
||||
2026-07-06 12:16 [Write] C:\Users\nordm\pyramid - Kopie\lib\core\app_database.dart
|
||||
2026-07-06 12:17 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_database.dart
|
||||
2026-07-06 12:18 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_database.dart
|
||||
2026-07-06 12:18 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\matrix_client.dart
|
||||
2026-07-06 12:18 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart
|
||||
2026-07-06 12:18 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart
|
||||
2026-07-06 12:18 [Edit] C:\Users\nordm\pyramid - Kopie\pubspec.yaml
|
||||
2026-07-06 12:22 [Write] C:\Users\nordm\pyramid - Kopie\test\app_database_test.dart
|
||||
2026-07-06 12:24 [Edit] C:\Users\nordm\pyramid - Kopie\test\app_database_test.dart
|
||||
2026-07-06 12:24 [Edit] C:\Users\nordm\pyramid - Kopie\test\app_database_test.dart
|
||||
2026-07-06 12:24 [Edit] C:\Users\nordm\pyramid - Kopie\test\app_database_test.dart
|
||||
2026-07-06 12:29 [Edit] C:\Users\nordm\pyramid - Kopie\docs\SQLCIPHER_MIGRATION.md
|
||||
2026-07-06 12:30 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
|
||||
2026-07-06 12:31 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||
2026-07-06 12:33 [Write] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\call_signaling_service.dart
|
||||
2026-07-06 12:33 [Write] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\voice_channel_service.dart
|
||||
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
|
||||
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
|
||||
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
|
||||
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
|
||||
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
|
||||
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
|
||||
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
|
||||
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
|
||||
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
|
||||
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
|
||||
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 12:36 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_state.dart
|
||||
2026-07-06 12:36 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_state.dart
|
||||
2026-07-06 12:36 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\matrix_client.dart
|
||||
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart
|
||||
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart
|
||||
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart
|
||||
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\mini_call_widget.dart
|
||||
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\mini_call_widget.dart
|
||||
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\mini_call_widget.dart
|
||||
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\mini_call_widget.dart
|
||||
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart
|
||||
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart
|
||||
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart
|
||||
2026-07-06 12:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart
|
||||
2026-07-06 12:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart
|
||||
2026-07-06 12:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart
|
||||
2026-07-06 12:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\mini_call_widget.dart
|
||||
2026-07-06 12:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\mini_call_widget.dart
|
||||
2026-07-06 12:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\view\chat_header.dart
|
||||
2026-07-06 12:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\view\chat_header.dart
|
||||
2026-07-06 12:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart
|
||||
2026-07-06 12:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart
|
||||
2026-07-06 12:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
|
||||
2026-07-06 12:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
|
||||
2026-07-06 12:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart
|
||||
2026-07-06 12:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\call_signaling_service.dart
|
||||
2026-07-06 12:41 [Edit] C:\Users\nordm\pyramid - Kopie\docs\M2_MODULE_SCHNITT.md
|
||||
2026-07-06 12:41 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
|
||||
2026-07-06 12:42 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||
2026-07-06 12:44 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
|
||||
2026-07-06 12:44 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
|
||||
2026-07-06 12:45 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||
2026-07-06 16:58 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
|
||||
2026-07-06 16:59 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||
2026-07-06 17:01 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart
|
||||
2026-07-06 17:01 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart
|
||||
2026-07-06 17:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\settings_prefs.dart
|
||||
2026-07-06 17:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
|
||||
2026-07-06 17:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
|
||||
2026-07-06 17:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 17:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
|
||||
2026-07-06 17:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 17:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
|
||||
2026-07-06 17:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
|
||||
2026-07-06 17:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
|
||||
2026-07-06 17:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
|
||||
2026-07-06 17:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
|
||||
2026-07-06 17:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
|
||||
2026-07-06 17:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
|
||||
2026-07-06 17:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart
|
||||
2026-07-06 17:10 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
|
||||
2026-07-06 17:10 [Edit] C:\Users\nordm\pyramid - Kopie\docs\M2_MODULE_SCHNITT.md
|
||||
2026-07-06 17:10 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||
2026-07-06 17:12 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\179608a1-16b3-4b6b-a9ed-c7969b88b37f\scratchpad\split_voice_channel.ps1
|
||||
2026-07-06 17:13 [Edit] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\179608a1-16b3-4b6b-a9ed-c7969b88b37f\scratchpad\split_voice_channel.ps1
|
||||
2026-07-06 17:15 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
|
||||
2026-07-06 17:15 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||
2026-07-06 17:16 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
|
||||
2026-07-06 17:41 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\0954ef5a-3f8c-4521-ac42-f4ee83e4873b\scratchpad\split_encryption.ps1
|
||||
2026-07-06 17:41 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart
|
||||
2026-07-06 17:44 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
|
||||
2026-07-06 17:44 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
|
||||
2026-07-06 17:45 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||
2026-07-06 17:47 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_state.dart
|
||||
2026-07-06 17:49 [Write] C:\Users\nordm\pyramid - Kopie\docs\M2_STATE_MANAGEMENT.md
|
||||
2026-07-06 17:49 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
|
||||
2026-07-06 17:49 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||
2026-07-06 17:51 [Edit] C:\Users\nordm\pyramid - Kopie\docs\M2_STATE_MANAGEMENT.md
|
||||
2026-07-06 17:51 [Edit] C:\Users\nordm\pyramid - Kopie\docs\M2_STATE_MANAGEMENT.md
|
||||
2026-07-06 17:51 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
|
||||
2026-07-06 17:53 [Edit] C:\Users\nordm\pyramid - Kopie\test\settings_prefs_test.dart
|
||||
2026-07-06 17:54 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
|
||||
2026-07-06 17:54 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||
2026-07-06 18:19 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
|
||||
2026-07-06 18:20 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||
2026-07-06 18:22 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\b2380513-3ca6-4879-b9df-23f8ba907354\scratchpad\make_plain_db.dart
|
||||
2026-07-06 18:27 [Edit] C:\Users\nordm\pyramid - Kopie\docs\SQLCIPHER_MIGRATION.md
|
||||
2026-07-06 18:27 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
|
||||
2026-07-06 18:28 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||
2026-07-06 18:29 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
|
||||
2026-07-06 18:29 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||
2026-07-06 18:48 [Write] C:\Users\nordm\pyramid - Kopie\lib\core\app_dirs.dart
|
||||
2026-07-06 18:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_database.dart
|
||||
2026-07-06 18:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_database.dart
|
||||
2026-07-06 18:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_database.dart
|
||||
2026-07-06 18:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\auth_log.dart
|
||||
2026-07-06 18:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\auth_log.dart
|
||||
2026-07-06 18:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\media_cache.dart
|
||||
2026-07-06 18:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\media_cache.dart
|
||||
2026-07-06 18:50 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\5be37250-1cea-473c-b62c-923f3d071abd\scratchpad\ui.ps1
|
||||
2026-07-06 22:04 [Write] C:\Users\nordm\pyramid - Kopie\test\live_encryption_e2e_test.dart
|
||||
2026-07-06 22:08 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_encryption_e2e_test.dart
|
||||
2026-07-06 22:08 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_encryption_e2e_test.dart
|
||||
2026-07-06 22:10 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
|
||||
2026-07-06 22:11 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||
2026-07-06 22:15 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 22:15 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 22:15 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_ui\view\quality_menu.dart
|
||||
2026-07-06 22:16 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\voice_channel_service.dart
|
||||
2026-07-06 22:16 [Write] C:\Users\nordm\pyramid - Kopie\test\streaming_presets_test.dart
|
||||
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 22:24 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\panel\rooms_panel_core.dart
|
||||
2026-07-06 22:26 [Write] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
|
||||
2026-07-06 22:26 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
|
||||
2026-07-06 22:27 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
|
||||
2026-07-06 22:29 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
|
||||
2026-07-06 22:29 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
|
||||
2026-07-06 22:29 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
|
||||
2026-07-06 22:29 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
|
||||
2026-07-06 22:31 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\6aa736cb-f5e2-471d-9167-7695c1c38655\scratchpad\win_tools.ps1
|
||||
2026-07-06 22:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\panel\rooms_list.dart
|
||||
2026-07-06 22:50 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||
2026-07-06 22:50 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
|
||||
2026-07-06 22:50 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
|
||||
2026-07-06 22:51 [Write] C:\Users\nordm\.claude-nordmann\projects\C--Users-nordm-pyramid---Kopie\memory\test-accounts-gui.md
|
||||
2026-07-06 22:51 [Write] C:\Users\nordm\.claude-nordmann\projects\C--Users-nordm-pyramid---Kopie\memory\MEMORY.md
|
||||
2026-07-06 23:13 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
|
||||
2026-07-06 23:23 [Write] C:\Users\nordm\pyramid - Kopie\tool\device_probe.dart
|
||||
2026-07-07 03:07 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
|
||||
2026-07-07 03:07 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
|
||||
2026-07-07 03:16 [Edit] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\fdefa80f-c201-47b8-8bd3-ea082af15729\scratchpad\probe154\windows\runner\main.cpp
|
||||
2026-07-07 03:19 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\fdefa80f-c201-47b8-8bd3-ea082af15729\scratchpad\mmprobe.cpp
|
||||
2026-07-07 03:23 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\fdefa80f-c201-47b8-8bd3-ea082af15729\scratchpad\probe154\lib\main.dart
|
||||
2026-07-07 03:24 [Write] C:\Users\nordm\pyramid - Kopie\tool\device_probe.dart
|
||||
2026-07-07 03:25 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
|
||||
2026-07-07 03:29 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\fdefa80f-c201-47b8-8bd3-ea082af15729\scratchpad\win_tools.ps1
|
||||
|
||||
@@ -9,6 +9,9 @@ Einsatz (u. a. Uta) – Stabilität und Datensicherheit gehen vor Feature-Tempo.
|
||||
(`C:\Users\nordm\pyramid` und `C:\Users\nordm\MatrixPi\pyramid` sind veraltete
|
||||
April-Schnappschüsse – dort NIE arbeiten.)
|
||||
|
||||
> **Bernds Entscheidungen zu den offenen Richtungsfragen stehen in `ANTWORTEN_BERND.md`
|
||||
> (zuletzt 2026-07-05) – vor dem Abarbeiten von ROADMAP-Punkten lesen, sie sind maßgeblich.**
|
||||
|
||||
## Infrastruktur
|
||||
|
||||
- Homeserver: Continuwuity auf dem Pi5 (`steggi-matrix.work`)
|
||||
|
||||
+2650
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,56 @@
|
||||
# pyramid
|
||||
# Pyramid
|
||||
|
||||
A new Flutter project.
|
||||
Pyramid ist ein eigenständiger Matrix-Client (Flutter), kein Fork von FluffyChat
|
||||
oder Element. Er läuft gegen den selbst gehosteten Homeserver `steggi-matrix.work`
|
||||
(Continuwuity) und ist bei echten Nutzern im Einsatz – Stabilität und
|
||||
Datensicherheit haben Vorrang vor Feature-Tempo.
|
||||
|
||||
## Getting Started
|
||||
## Funktionsumfang
|
||||
|
||||
This project is a starting point for a Flutter application.
|
||||
- Matrix-Chat mit Timeline, Reply, Medien, Link-Vorschauen, lokaler Volltextsuche
|
||||
über entschlüsselte Nachrichten (server-seitige Suche funktioniert bei E2EE nicht)
|
||||
- Ende-zu-Ende-Verschlüsselung (vodozemac/Olm/Megolm) inkl. Key-Backup
|
||||
- Sprach-/Video-Calls und Bildschirmübertragung über LiveKit
|
||||
(`wss://livekit.steggi-matrix.work`)
|
||||
- Push-Benachrichtigungen mit clientseitiger Entschlüsselung auch bei gekillter
|
||||
App (FCM + Sygnal, siehe `docs/NOTIFICATIONS.md`)
|
||||
- Größenverstellbares Panel-Layout (Rooms/Chat/Members) statt starrer Tabs
|
||||
|
||||
A few resources to get you started if this is your first Flutter project:
|
||||
## Build
|
||||
|
||||
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
|
||||
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
||||
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
|
||||
Voraussetzung: Flutter (stable channel, siehe `environment.sdk` in `pubspec.yaml`
|
||||
für die unterstützte Dart-Version).
|
||||
|
||||
For help getting started with Flutter development, view the
|
||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
||||
samples, guidance on mobile development, and a full API reference.
|
||||
```
|
||||
flutter pub get
|
||||
flutter run -d windows # schnellster Praxistest während der Entwicklung
|
||||
flutter analyze # muss sauber bleiben
|
||||
```
|
||||
|
||||
Unterstützte Zielplattformen: Android, Windows, Linux, iOS, macOS (Ordner
|
||||
`android/`, `windows/`, `linux/`, `ios/`, `macos/` sind Standard-Flutter-Projekte).
|
||||
Release-Build (signiert, Windows + Android): `scripts/release.ps1`.
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
```
|
||||
lib/
|
||||
core/ Matrix-Client, Auth, VoIP-Manager, Storage, App-State
|
||||
features/
|
||||
auth/ Login, Server-Auswahl
|
||||
rooms/ Raumliste, Spaces
|
||||
chat/ Timeline, Composer, Medien, Dokumentenvorschau
|
||||
call/ LiveKit-Calls, Voice-Channel, Mini-Call-Widget
|
||||
members/ Mitgliederliste/-panel
|
||||
settings/ Einstellungen
|
||||
layout/ Panel-Layout (resizable)
|
||||
widgets/ geteilte UI-Bausteine
|
||||
utils/ Hilfsfunktionen
|
||||
```
|
||||
|
||||
## Weiterführende Dokumentation
|
||||
|
||||
- `CLAUDE.md` – Arbeitsregeln und Infrastruktur für die Zusammenarbeit mit Claude
|
||||
- `ROADMAP.md` / `PROGRESS.md` – aktueller Stand und nächste Schritte
|
||||
- `docs/NOTIFICATIONS.md` – Push-Architektur (native Anzeige vs. Hintergrund-Entschlüsselung)
|
||||
- `docs/matrix-sdk/` – Hinweise zum verwendeten Matrix-SDK
|
||||
|
||||
+256
-22
@@ -8,23 +8,167 @@ Punkte abhaken (`[x]`), wenn erledigt UND in `PROGRESS.md` protokolliert.
|
||||
## M0 – Backup & Ordnung
|
||||
|
||||
- [x] Vollbackup vor dem Refactoring (2026-07-03 → `MatrixPi\backups\pyramid-kopie_backup_2026-07-03.tar.gz`)
|
||||
- [ ] Die ~140 uncommitteten Dateien sichten, in thematische Commits aufteilen, zu Gitea pushen
|
||||
- [ ] `flutter analyze` komplett sauber bekommen und sauber halten
|
||||
- [ ] `lib/features/call/` vs. `lib/features/calls/` (leer?) klären – Leiche entfernen
|
||||
- [ ] README.md durch echte Projektbeschreibung ersetzen (was ist Pyramid, wie baut man es, wo liegt was)
|
||||
- [x] Die ~140 uncommitteten Dateien sichten, in thematische Commits aufteilen, zu Gitea pushen
|
||||
(Sicherungs-Commit 25ed765 + Folge-Commits bis 74d38fd; Arbeitsbaum ist jetzt sauber)
|
||||
- [x] `flutter analyze` komplett sauber bekommen und sauber halten (nur noch 1 bewusst
|
||||
zurückgestellter Hinweis, siehe PROGRESS.md 2026-07-03)
|
||||
- [x] `lib/features/call/` vs. `lib/features/calls/` (leer?) klären – Leiche entfernen
|
||||
(geprüft: `calls/` existiert nicht mehr, nur `call/` mit den echten Dateien)
|
||||
- [x] README.md durch echte Projektbeschreibung ersetzt (74d38fd)
|
||||
- [x] **Fable-5-Qualitäts-Review aller bisherigen Autopilot-Commits (Bernds Wunsch, VOR allem anderen):**
|
||||
ERLEDIGT – siehe Abschnitt „Fable-5-Review" oben in PROGRESS.md.
|
||||
(a) alle 6 Gott-Datei-Aufteilungen unabhängig verifiziert (Zeilen-Multiset,
|
||||
part-Bezüge): korrekt, verlorene Erklärkommentare waren mit f9979e4 schon
|
||||
wiederhergestellt; (b) Uta-Logout-/Push-Fix gegen matrix-SDK 6.2.0
|
||||
verifiziert: Analyse richtig, Lücke (SDK eskaliert Refresh-Fehler zu
|
||||
logout()+clear()) war mit 891f348 (onSoftLogout-Guard) geschlossen, Guard
|
||||
heute gegenverifiziert; (c) Lint-Fixes verhaltensäquivalent (onReorderItem
|
||||
gegen Flutter-SDK geprüft, kein Off-by-One); (d) abgehakte Punkte stimmen
|
||||
mit dem Code überein. Alles weiterhin UNGETESTET (Pi) → PC-Praxistest bleibt.
|
||||
|
||||
## M1 – Sicherheit: Nie wieder aussperren / kein Datenverlust
|
||||
|
||||
- [ ] **Uta-Random-Logout untersuchen:** Ursache finden (Soft-Logout vom Server? Token
|
||||
ungültig? Fehlerpfad im Sync, der zum Logout führt?). Erst Logging an allen
|
||||
Logout-Pfaden einbauen, dann Ursache fixen. Ein unerwarteter Logout darf NIE
|
||||
Krypto-Schlüssel wegwerfen.
|
||||
- [ ] Sichere Speicherung von Access-Token & Krypto-DB prüfen (secure storage / sqlcipher korrekt?)
|
||||
- [ ] Key-Backup einrichten-Flow: Recovery-Key erzeugen, verständlich erklären, zum Sichern zwingen
|
||||
- [ ] Key-Backup wiederherstellen-Flow: nach Login auf neuem Gerät alte Nachrichten entschlüsseln
|
||||
- [ ] Logout-Schutz: Warnung mit Klartext-Folgen, wenn Key-Backup fehlt
|
||||
- [ ] Geräte-/Sessionverwaltung in den Einstellungen (Liste, Verifizieren, Abmelden)
|
||||
- [x] **Uta-Random-Logout untersuchen:** UNGETESTET (Pi) – Ursache im matrix-SDK
|
||||
gefunden + Fix committet (63e4919, 9dc83f7), siehe PROGRESS.md 2026-07-03.
|
||||
Nächster PC-Lauf MUSS den vollen Kreislauf praktisch prüfen (Login →
|
||||
Nachrichten → Logout → erneuter Login → alte Nachrichten lesbar?), erst
|
||||
dann gilt der Punkt als wirklich erledigt.
|
||||
- [x] **REGRESSION: Push-Benachrichtigungen werden nicht mehr entschlüsselt** –
|
||||
UNGETESTET (Pi), sehr wahrscheinlich DIESELBE Ursache wie der
|
||||
Uta-Random-Logout-Bug (bereits mit 9dc83f7 mitgefixt), siehe PROGRESS.md
|
||||
2026-07-03. Auf echtem Gerät prüfen: nach Neu-Login (für den Refresh-Token)
|
||||
eine Weile laufen lassen und beobachten, ob Push-Nachrichten wieder mit
|
||||
echtem Text statt nur „Neue Nachricht"-Platzhalter ankommen. Falls das
|
||||
Problem weiterhin auftritt, liegt eine zweite, unabhängige Ursache vor
|
||||
(z. B. fehlende Megolm-Schlüssel/Key-Sharing) – dann `docs/NOTIFICATIONS.md`
|
||||
+ Memory „Pyramid Push" erneut konsultieren und die dort gelisteten 3
|
||||
Fallstricke der Hintergrund-Entschlüsselung gegenprüfen.
|
||||
- [ ] **Sichere Speicherung von Access-Token & Krypto-DB: LÜCKE GEFUNDEN.**
|
||||
**Stand 2026-07-06: UMGESETZT + auf Windows praxisgetestet** – neue
|
||||
Storage-Fassade `lib/core/app_database.dart` (SQLCipher auf
|
||||
Android/Windows/Linux, Schlüssel im Keystore, Klartext-Migration mit
|
||||
Backup/Verifikation/atomarem Tausch, Push-Isolate-Marker). Bernds echte
|
||||
Windows-DB wurde erfolgreich migriert (PROGRESS.md 2026-07-06), Tests in
|
||||
`test/app_database_test.dart` fahren die Migration mit echtem SQLCipher.
|
||||
**Nachtrag 2026-07-06 (vierte PC-Session): Android-EMULATOR-Test grün**
|
||||
(API 36: Neuanlage verschlüsselt, Klartext-Migration beim ersten Start,
|
||||
Neustart mit Keystore-Schlüssel ohne Re-Migration – Details im
|
||||
Status-Kopf von `docs/SQLCIPHER_MIGRATION.md`).
|
||||
**Nachtrag 2026-07-06 (Nacht-Session): LIVE-E2E-Test mit ECHTEM Login grün**
|
||||
(`test/live_encryption_e2e_test.dart`, Test-Account pyramidtest1 auf
|
||||
steggi-matrix.work, Gate `PYRAMID_LIVE_TEST=1`): Platte nach echtem
|
||||
Login/Nachricht byte-geprüft (kein SQLite-Header, Access-Token und
|
||||
Nachrichtentext NICHT im Klartext auffindbar, Positiv-Kontrolle in
|
||||
Klartext-DB findet beides), Neustart restauriert Session aus der
|
||||
verschlüsselten DB, Logout→Re-Login→alte Nachricht lesbar, und die
|
||||
Klartext-Migration mit einer ECHTEN eingeloggten Session erhält
|
||||
Session+Daten. **Bleibt offen:**
|
||||
echtes ARM-Gerät mit eingeloggter Session + Push-Isolate-Test
|
||||
(FCM liest verschlüsselte DB, secure_storage im Background-Isolate) –
|
||||
Testplan Punkt 4 in `docs/SQLCIPHER_MIGRATION.md`;
|
||||
erst nach grünem Gerätetest Release + Utas Gerät (vorher ihr Key-Backup
|
||||
prüfen!), dann abhaken.
|
||||
`sqlcipher_flutter_libs` steht zwar in `pubspec.yaml`, wird aber nirgends
|
||||
benutzt – `matrix_client.dart` und `background_push.dart` öffnen die
|
||||
`pyramid.sqlite` (Access-Token, gepickelter Olm-Account, Nachrichten) über
|
||||
den normalen `sqflite`/`sqflite_common_ffi`-Factory, also **unverschlüsselt**
|
||||
auf der Platte. Bewusst NICHT blind auf dem Pi gefixt: eine Umstellung auf
|
||||
SQLCipher braucht eine Migration für bereits bestehende Klartext-DBs
|
||||
(Utas Gerät!) – ohne Testgerät hier zu riskant für „Kein Datenverlust".
|
||||
Siehe PROGRESS.md 2026-07-03 + Frage an Bernd. **Umsetzungsplan liegt
|
||||
fertig in `docs/SQLCIPHER_MIGRATION.md`** (2026-07-03, gegen Code und
|
||||
SDK verifiziert) – PC-Termin kann direkt damit starten.
|
||||
- [x] Key-Backup einrichten-Flow: bereits vorhanden (`bootstrap_dialog.dart`,
|
||||
wird automatisch getriggert wenn Cross-Signing fehlt, `app_shell.dart`)
|
||||
- [x] Key-Backup wiederherstellen-Flow: bereits vorhanden (`bootstrap_dialog.dart`,
|
||||
`BootstrapState.askUnlockSsss` fragt nach Recovery-Key auf neuem Gerät)
|
||||
- [x] Logout-Schutz: Warnung mit Klartext-Folgen, wenn Key-Backup fehlt (b5762ea)
|
||||
- [x] Geräte-/Sessionverwaltung in den Einstellungen: bereits vorhanden
|
||||
(`_SessionsSection` in `settings_modal.dart` – Liste, Umbenennen, Verifizieren
|
||||
per SAS/QR, Abmelden, Massen-Abmeldung mit Passwort-Reauth)
|
||||
- [x] **SICHERHEIT: Dashboard-Admin-Endpoints – ERLEDIGT: Loch geschlossen,
|
||||
Heimnetz-only ist laut Bernd der Endzustand** (Fable-5-Review (o);
|
||||
Entscheidung `ANTWORTEN_BERND.md` Nr. 3, 2026-07-05).
|
||||
`server.py` nimmt `/api/ban`, `/api/unban`, `/api/toggle-registration`,
|
||||
`/api/create-invite`, `/api/run-stats` jetzt NUR noch aus Heimnetz/localhost an
|
||||
(Socket-IP + Cloudflare-Header-Check); Fremde aus dem Internet bekommen 403.
|
||||
Headless verifiziert (öffentlich 403, Heimnetz ok, App-Endpoints unverändert),
|
||||
siehe PROGRESS.md 2026-07-04. **Bernds Dashboard läuft im Heimnetz unverändert:
|
||||
`http://192.168.178.71:8080/stats.html`.** Offen bleibt: Will Bernd die
|
||||
Admin-Buttons auch von unterwegs? Dann Weg A (Cloudflare Access, empfohlen –
|
||||
schließt auch die weiterhin öffentliche Nutzerlisten-Ansicht) oder Weg B
|
||||
(`DASH_TOKEN`), Plan + Restrisiken in `docs/DASHBOARD_AUTH_HARDENING.md`.
|
||||
Zusatz (Fable-5-Review (p), 2026-07-04): `server.py` läuft jetzt multithreaded
|
||||
mit 30-s-Socket-Timeout – ein einzelner langsamer Client kann den Server
|
||||
(und damit `/api/livekit-token`) nicht mehr blockieren; headless verifiziert.
|
||||
Zusatz (Fable-5-Review (q), 2026-07-04): Der durch (p) eingeführte
|
||||
Nebenläufigkeits-Race in den Admin-Routen (Read-Modify-Write auf `conduit.toml`,
|
||||
`send_admin`-Readback) ist mit `ADMIN_LOCK` geschlossen, das NUR die
|
||||
Admin-Mutationen serialisiert – die öffentlichen Routen bleiben nebenläufig;
|
||||
headless verifiziert (2× parallel = 2× Laufzeit, öffentlicher Pfad 11 ms).
|
||||
Zusatz (Fable-5-Review (r), 2026-07-04): drei Restbefunde gefixt –
|
||||
`registration-status`-Leser jetzt unter ADMIN_LOCK (Truncate-Write-Race),
|
||||
negative/übergroße `Content-Length` überall abgelehnt (kein Thread-Hänger,
|
||||
ban/unban 4-KB-Deckel), Einladungs-Token per `secrets` statt `random`.
|
||||
Zusatz (Fable-5-Review (s), 2026-07-04): ban/unban-Body wird jetzt VOR dem
|
||||
ADMIN_LOCK gelesen (hängender Body-Read konnte den Lock 30 s festhalten),
|
||||
und die öffentlichen Routen antworten bei internen Fehlern generisch statt
|
||||
mit Exception-Text (Info-Leak); Details ins Journal. Headless verifiziert.
|
||||
Zusatz (Fable-5-Review (t), 2026-07-04): auch der öffentliche GET-Leser
|
||||
`registration-status` antwortet bei internen Fehlern jetzt generisch
|
||||
statt mit `str(e)` (hätte den `conduit.toml`-Pfad geleakt); headless
|
||||
verifiziert inkl. 500-Beweis.
|
||||
Zusatz (Fable-5-Review (u), 2026-07-04): (t)-Fix gegengelesen – korrekt,
|
||||
kein Befund; die server.py-Härtungskette ist damit KONVERGIERT (alle
|
||||
verbliebenen `str(e)`-Antworten LAN-gegated, absichtlich).
|
||||
**Abschluss (2026-07-05, `ANTWORTEN_BERND.md` Nr. 3): KEIN Fernzugriff
|
||||
gewünscht – die A/B-Frage ist GESCHLOSSEN, das Heimnetz-Gate ist der
|
||||
Endzustand (unterwegs nutzt Bernd WireGuard). Die Restrisiken des Gates
|
||||
(öffentliche Nutzerlisten-Ansicht, theoretisches LAN-CSRF) sind bewusst
|
||||
akzeptiert und in `docs/DASHBOARD_AUTH_HARDENING.md` dokumentiert.
|
||||
Nicht weiter daran arbeiten.**
|
||||
- [x] **LiveKit-Token-Route server-seitig gebaut + verifiziert** (Fable-5-Review (n),
|
||||
PROGRESS.md 2026-07-04). `POST /api/livekit-token` läuft live in `server.py`
|
||||
(whoami-Auth, stdlib-HS256-Minting aus `livekit.yaml`), headless geprüft
|
||||
(401/400/413/200, JWT-Signatur unabhängig gegen `livekit.yaml` gültig, Identität =
|
||||
geprüfte user_id). **Offen:** Client-Umstellung (`livekit_token.dart`/
|
||||
`livekit_call_manager.dart`) + Secret-Rotation – beides PC/Gerät (heiliger
|
||||
Call-Pfad), siehe `docs/LIVEKIT_TOKEN_MIGRATION.md`.
|
||||
- [ ] **SICHERHEIT: geleakte Secrets rotieren + aus dem Client holen** (Fable-5-Review (k),
|
||||
PROGRESS.md 2026-07-04). Das Gitea-Repo ist öffentlich (`private:false`), und es lagen
|
||||
vier aktive Secrets drin – teils schon bereinigt (release.ps1, Admin-Doku), aber:
|
||||
- **Rotieren (Pflicht, sie sind bereits geleakt):** Matrix-Server-Admin-Token
|
||||
`J5lax…` (noch gültig, voller Admin!), Gitea-Release-Token, LiveKit-`apiKey`/
|
||||
`apiSecret`, Giphy-Key. Alte jeweils widerrufen.
|
||||
- **LiveKit-Token server-seitig minten:** kleiner Token-Endpoint auf dem Pi, damit
|
||||
`apiSecret` aus `lib/core/livekit_token.dart` verschwindet (App holt nur das JWT).
|
||||
**Umsetzungsplan liegt fertig in `docs/LIVEKIT_TOKEN_MIGRATION.md`** (2026-07-04,
|
||||
gegen Code + Pi-Konfig geschrieben) – braucht echten Call-Test auf einem Gerät.
|
||||
- **E2EE-Diagnose-Upload: ERLEDIGT (2026-07-04).** Der Dashboard-Server auf dem Pi
|
||||
(`/home/steggi/matrix/server.py`) benutzte für `/api/e2ee-diagnostics` denselben
|
||||
String wie den Matrix-Admin-Token – deshalb steckte er im Client. Jetzt hat das
|
||||
Diagnose-Endpoint einen eigenen, eng begrenzten Token (`DIAG_TOKEN`, hängt nur an
|
||||
den Log an, keine Admin-Rechte); `settings_encryption.dart` benutzt diesen. Der
|
||||
**Admin-Token ist damit nicht mehr im App-Code** (per `grep` bestätigt). Server +
|
||||
Client umgestellt und verifiziert (neuer Token 200, alter Admin-Token 403). Der
|
||||
Admin-Token selbst muss trotzdem noch rotiert werden (steht oben, Bernds Sache).
|
||||
- Optional: Git-History-Rewrite (Bernds Entscheidung, Repo ist auch Backup).
|
||||
- **Stand 2026-07-05 (`ANTWORTEN_BERND.md` Nr. 4): Rotation und
|
||||
History-Rewrite ruhen BEWUSST – beides bleibt Bernds manuelle Sache,
|
||||
NICHT vom Autopilot anstoßen oder nachfragen. Der Punkt bleibt nur als
|
||||
Merkposten offen; Autopilot-bearbeitbar ist hier nichts mehr.**
|
||||
- **ACHTUNG bei „Repo einfach privat schalten":** Der In-App-Updater
|
||||
(`lib/core/update_checker.dart`) holt Releases **anonym** über die öffentliche
|
||||
Gitea-API (`/api/v1/repos/steggi/pyramid/releases`). Repo privat = Utas App
|
||||
findet keine Updates mehr (und ein Fix ließe sich nicht mehr per Update
|
||||
verteilen – Henne-Ei). Erst Updater-Strategie klären, dann Sichtbarkeit ändern.
|
||||
- [ ] Härtetest dokumentieren: Login → Nachrichten → Logout → Login neu → alles noch lesbar
|
||||
(gehört zum ausstehenden PC-Praxistest der beiden Bugfixes oben;
|
||||
kompletter Ablauf inkl. aller aufgelaufenen UNGETESTET-Punkte steht
|
||||
jetzt in `docs/PC_TESTPLAN.md` – dort abarbeiten und abhaken)
|
||||
**Teilstand 2026-07-06 (Autopilot, siehe PC_TESTPLAN):** flutter test
|
||||
grün (29), Session über 3 Starts + DB-Migration stabil eingeloggt,
|
||||
E2EE-Empfang live belegt, auth_log unauffällig; App läuft seit 12:43
|
||||
für die Langzeit-Beobachtung weiter. **Offen (Klick/Bernd):**
|
||||
Senden, Logout-Warnung, Re-Login mit Lesbarkeits-Check.
|
||||
|
||||
## M2 – Das große Refactoring (erst nach M0/M1!)
|
||||
|
||||
@@ -32,22 +176,100 @@ Ziel: gleiche Funktionen, saubere **modulare** Struktur – als Fundament für a
|
||||
Leitprinzip (Bernd): austauschbare Bausteine – jedes Modul muss sich komplett neu schreiben
|
||||
lassen, ohne dass andere Module angefasst werden müssen.
|
||||
|
||||
- [ ] Ist-Analyse schreiben: Architektur-Schwächen auflisten (Call-Logik verteilt auf
|
||||
`livekit_call_manager.dart`, `voip_manager.dart`, `voice_channel.dart`?, State-Management
|
||||
uneinheitlich?, Gott-Dateien?, tote Widgets?) → als Refactoring-Plan in PROGRESS.md
|
||||
- [ ] Modul-Schnitt definieren: Module (auth, chat, rooms, calls, streaming, push, storage, …)
|
||||
mit je einer schmalen öffentlichen Schnittstelle; Abhängigkeiten nur über Interfaces,
|
||||
keine Quer-Importe zwischen Feature-Modulen
|
||||
- [x] Ist-Analyse geschrieben (PROGRESS.md 2026-07-03): Gott-Datei `settings_modal.dart`
|
||||
(5541 Zeilen/50+ Klassen), Call-Schicht ohne gemeinsame Fassade (4 Module
|
||||
importieren `voice_channel.dart` direkt), `app_state.dart` als Kopplungspunkt
|
||||
aller Features, keine gemeinsame Storage-Schnittstelle (12 direkte
|
||||
`SharedPreferences`-Zugriffe), toter Code (`message_bubble.dart`) + Duplikate
|
||||
(`_formatTime`/`_formatDate` 3-fach). 8 Modul-Kandidaten dokumentiert.
|
||||
- [x] Modul-Schnitt definiert (`docs/M2_MODULE_SCHNITT.md`, PROGRESS.md 2026-07-03):
|
||||
„Calls" ist architektonisch zwei getrennte Module (`call_signaling` =
|
||||
Matrix-1:1-VoIP, `voice_channel` = LiveKit-SFU-Kanäle) + `call_ui`;
|
||||
außerdem `settings` (aufgesplittet), `verification` (neu, aus Settings
|
||||
herausgelöst), `storage` (Ausbau `settings_prefs.dart`), `auth`, `push`,
|
||||
`chat_timeline`/`rooms`. Umsetzungsreihenfolge vorgeschlagen, wartet auf
|
||||
Bernds Go für den Call-Pilot (Frage in PROGRESS.md).
|
||||
- [ ] Call-Schicht entwirren: EINE klare Zuständigkeit pro Klasse (Signalisierung /
|
||||
LiveKit-Verbindung / UI-State sauber getrennt)
|
||||
**Teilschritt erledigt (2026-07-06, M2-Pilot laut Entscheidung 2):**
|
||||
Module `call_signaling` und `voice_channel` mit Fassaden-Interfaces +
|
||||
Providern existieren (`lib/features/call_signaling/`,
|
||||
`lib/features/voice_channel/`), alle Konsumenten gehen nur noch über
|
||||
`callSignalingProvider`/`voiceChannelProvider` – kein UI-Import der
|
||||
Implementierungen mehr (Details/Abweichungen: Status-Kopf in
|
||||
`docs/M2_MODULE_SCHNITT.md`). App startet, 29 Tests grün, Sync/E2EE
|
||||
laufen. **UNGETESTET:** echter 1:1-Call und Voice-Channel-Join per
|
||||
Klick (braucht Gerät bzw. interaktiven Test) – deshalb nicht abgehakt.
|
||||
**Weitere Teilschritte erledigt (2026-07-06, zweite PC-Session):**
|
||||
`call_ui` ist jetzt ein eigenes Modul (`lib/features/call_ui/`,
|
||||
b2335da) und die voice_*-Prefs laufen über die `VoicePrefs`-Fassade
|
||||
in `core/settings_prefs.dart` statt direktem SharedPreferences-Zugriff
|
||||
(d22f9c2) – beides verhaltensgleich, analyze/Tests grün, App-Start
|
||||
geprüft. **Bewusst zurückgestellt bis Bernds Klick-Test des Piloten
|
||||
(PC_TESTPLAN Abschnitt 8) grün ist:** UI-State aus den Managern
|
||||
herauslösen – kein zweiter ungetesteter Logik-Umbau auf dem heiligen
|
||||
Call-Pfad.
|
||||
- [ ] Einheitliches State-Management (Riverpod konsequent, keine Misch-Patterns)
|
||||
- [ ] Toten Code & Duplikate entfernen, Ordnerstruktur vereinheitlichen
|
||||
**Ist-Analyse + Umbauplan liegen in `docs/M2_STATE_MANAGEMENT.md`**
|
||||
(2026-07-06): Riverpod ist schon dominant (~50 Provider); 4
|
||||
Misch-Patterns identifiziert, Schrittfolge definiert. Schritte 1–2
|
||||
(Call-Manager-UI-State, Voice-Provider-Umzug) GESPERRT bis Bernds
|
||||
Klick-Test (PC_TESTPLAN Abschnitt 8); MediaCache-Umstellung
|
||||
zurückgestellt (hängt an der Chat-Timeline, Korrektur im Doc).
|
||||
Push bleibt bewusst wie er ist (Isolate-Zwang). Toter
|
||||
`incomingCallProvider` bereits entfernt (41bcaf2).
|
||||
- [x] Toten Code & Duplikate entfernen, Ordnerstruktur vereinheitlichen
|
||||
(PROGRESS.md 2026-07-03: `message_bubble.dart` tot entfernt, doppelte
|
||||
`_formatTime` in `message_group.dart` zusammengeführt; alle 6 in der
|
||||
Ist-Analyse gefundenen Gott-Dateien per `part`/`part of` in
|
||||
thematische Dateien aufgeteilt – `widgets/settings_modal.dart`
|
||||
(5541→270 Zeilen, 12 Teile unter `widgets/settings/`),
|
||||
`features/chat/message_group.dart` (2775→30 Zeilen, 7 Teile unter
|
||||
`features/chat/message/`), `features/rooms/rooms_panel.dart`
|
||||
(2555→31 Zeilen, 8 Teile unter `features/rooms/panel/`),
|
||||
`features/spaces/space_admin_dialog.dart` (2278→19 Zeilen, 6 Teile
|
||||
unter `features/spaces/admin/`), `features/chat/chat_view.dart`
|
||||
(2116→33 Zeilen, 6 Teile unter `features/chat/view/`),
|
||||
`features/chat/document_viewer.dart` (1773→20 Zeilen, 5 Teile unter
|
||||
`features/chat/document/`) – jeweils verifiziert per automatisiertem
|
||||
Blockvergleich gegen das Original und `flutter analyze`, siehe
|
||||
PROGRESS.md-Einträge. `features/call/voice_channel.dart` (1123 Zeilen)
|
||||
und `layout/app_shell.dart` (1073 Zeilen) waren zunächst bewusst NICHT
|
||||
mit angefasst – beide hängen eng mit der damals unentschiedenen
|
||||
Call-Fassade bzw. dem Bootstrap/Login-Pfad zusammen.
|
||||
**Nachtrag 2026-07-06:** Nach dem Call-Piloten ist
|
||||
`call_ui/voice_channel.dart` jetzt ebenfalls geteilt (5 part-Dateien
|
||||
unter `call_ui/view/`, zeichenidentisch verifiziert, 95d9b07);
|
||||
`app_shell.dart` bleibt wegen des Bootstrap/Login-Pfads (heilig)
|
||||
weiter unangetastet. Ebenso `settings/settings_encryption.dart`
|
||||
(1470 Zeilen, größte verbliebene Teildatei) in 4 part-Dateien unter
|
||||
`settings/encryption/` geteilt (zeichenidentisch verifiziert,
|
||||
6128953) – Vorbereitung für das spätere verification-Modul
|
||||
(Schnitt-Doc Schritt 3), Code selbst unangetastet.
|
||||
UNGETESTET (Pi) im Sinne von „nicht in echter UI gesehen" – nächster
|
||||
Windows-Lauf sollte alle 6 betroffenen Bereiche einmal durchklicken
|
||||
(zusätzlich zu den 5 unten: eine PDF/Text/Archiv/Bild-Datei im Chat
|
||||
öffnen, Vollbild-PDF-Viewer inkl. Thumbnail-Leiste und Speichern-Button
|
||||
prüfen), siehe „Fragen an Bernd"/Nächster-Schritt in den jeweiligen
|
||||
PROGRESS.md-Einträgen)
|
||||
- [ ] Nach JEDEM Schritt: App läuft, Kernflows getestet, Commit
|
||||
|
||||
## M3 – Calls: Logik, UI & Audio (Discord als Referenz)
|
||||
|
||||
- [ ] Voice-Channel-Logik robust machen: Beitreten/Verlassen/Wechseln ohne Hänger,
|
||||
klarer Zustand wer drin ist, sauberes Verhalten bei Verbindungsabbruch
|
||||
**Teilfortschritt 2026-07-06 (Nacht, mit 2 echten Test-Accounts durchgeklickt):**
|
||||
Standalone-Voice-Channel in der Raumliste war per Klick gar nicht
|
||||
beitretbar → gefixt (`_toggleVoiceChannel` in rooms_list.dart, wie
|
||||
Space-Liste). Zustands-Härtung in livekit_call_manager.dart:
|
||||
`_callGeneration` verhindert Geisterverbindungen bei Auflegen/Wechsel
|
||||
während `connect`; Fehlerpfade setzen `error` sichtbar statt still zu
|
||||
verschwinden; rooms_panel_core räumt `activeVoiceRoomIdProvider` auf,
|
||||
wenn der Service inaktiv wird, + Fehler-SnackBar. REAL getestet:
|
||||
Beitreten (beide), Teilnehmerliste, bidirektionaler Screenshare,
|
||||
Live-Qualitätswechsel, sauberes Verlassen. **UNGETESTET:** echtes
|
||||
2-Peer-Audio (beide auf 1 PC), Netz-Abbruch mitten im Call (nur im
|
||||
Code abgesichert). Nicht abgehakt.
|
||||
- [ ] Call-UI aufräumen: aufgeräumter, intuitiver, konsistent (Mini-Call-Widget, Vollansicht)
|
||||
- [ ] **Windows: Audiogeräte-Erkennung reparieren** (Mikrofon/Ausgabe erkennen und in den
|
||||
Einstellungen auswählbar machen – geht aktuell gar nicht)
|
||||
@@ -61,7 +283,19 @@ lassen, ohne dass andere Module angefasst werden müssen.
|
||||
|
||||
- [ ] **Framerate verbessern:** Ursache messen (Encoder? Auflösung? Simulcast-Settings?
|
||||
Software- statt Hardware-Encoding?), dann gezielt fixen
|
||||
**Teilfortschritt 2026-07-06 (Nacht, `docs/STREAMING_60FPS.md`, ffd70ca):**
|
||||
Vier Ruckel-Ursachen adressiert – FPS-Deckel (jetzt hd60/fhd60 mit
|
||||
echten 60 FPS), zu niedrige Bitrate (5/8 Mbit/s), Capture blieb auf
|
||||
1080p/15 (Preset wird jetzt bis in ScreenShareCaptureOptions
|
||||
durchgereicht), degradationPreference (60-FPS-Modi
|
||||
maintainFramerate, kein Simulcast). contentHint='motion' in
|
||||
flutter_webrtc 1.4.1 aus Dart nicht setzbar (dokumentiert). REAL
|
||||
getestet: Umschalten auf Full HD 60fps live, Screenshare läuft weiter.
|
||||
**UNGETESTET:** subjektives Ruckel-Empfinden + realer 60-FPS-RTP
|
||||
(Sender/Empfänger auf 1 PC = nicht beurteilbar), braucht 2 Geräte+Netz.
|
||||
- [ ] Auflösung/Qualität wählbar für den **Streamer** (z. B. 720p/1080p + FPS-Wahl)
|
||||
**2026-07-06:** FPS-Wahl umgesetzt – SENDEN-Menü hat jetzt „HD 60fps"
|
||||
und „Full HD 60fps" zusätzlich zu sd/hd/fhd/4k (ffd70ca).
|
||||
- [ ] Qualität wählbar für den **Empfänger** (LiveKit-Simulcast-Layer auswählen)
|
||||
- [ ] Sauberes Degradieren bei schwacher Bandbreite statt Ruckel-Chaos
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
|
||||
|
||||
<application
|
||||
android:label="pyramid"
|
||||
android:label="Pyramid"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<service
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||
android.builtInKotlin=false
|
||||
# This newDsl flag was added automatically by Flutter migrator
|
||||
android.newDsl=false
|
||||
|
||||
Regular → Executable
+62
-9
@@ -1,19 +1,72 @@
|
||||
#!/bin/bash
|
||||
# Pyramid-Autopilot (Pi/Linux): startet Claude-Sessions in Endlosschleife.
|
||||
# Läuft eine Session ins 5h-Limit, wird alle 20 Minuten neu versucht.
|
||||
# Start (am besten in tmux): tmux new -s pyramid './autopilot.sh'
|
||||
# Wieder reinschauen: tmux attach -t pyramid
|
||||
# Stoppen: Strg+C in der tmux-Session
|
||||
# Laeuft eine Session ins Limit, wird alle 20 Minuten neu versucht.
|
||||
# Start (am besten in tmux): tmux new -d -s pyramid ./autopilot.sh
|
||||
# Wieder reinschauen: tmux attach -t pyramid (verlassen: Strg+B, dann D)
|
||||
# PAUSIEREN: touch ~/pyramid/PAUSE (greift vor der naechsten Session)
|
||||
# FORTSETZEN: rm ~/pyramid/PAUSE
|
||||
# GANZ STOPPEN: tmux kill-session -t pyramid
|
||||
cd "$(dirname "$0")"
|
||||
export PATH="$HOME/.local/bin:$HOME/flutter/bin:$PATH"
|
||||
|
||||
PROMPT="Lies CLAUDE.md und arbeite ROADMAP.md Punkt für Punkt weiter ab. \
|
||||
Halte dich strikt an den Arbeitszyklus (PROGRESS.md pflegen, kleine Commits + Push). \
|
||||
Arbeite so viele Punkte ab wie möglich."
|
||||
# --- NUR EIN Konto: das nordmann-Konto (Profil ~/.claude = Standard-Profil) ---
|
||||
# Bewusst KEINE Konto-Rotation (Bernd, 2026-07-06): der Pyramid-Autopilot arbeitet
|
||||
# ausschliesslich mit dem nordmann-Konto. Das Standard-Profil liegt unter
|
||||
# ~/.claude / ~/.claude.json -> CLAUDE_CONFIG_DIR NICHT setzen (sonst sucht Claude
|
||||
# die Config faelschlich unter ~/.claude/.claude.json).
|
||||
# (Die E-Mail-Adresse des Kontos steht absichtlich NICHT hier: das Gitea-Repo
|
||||
# ist oeffentlich, PII gehoert nicht hinein.)
|
||||
apply_account() { unset CLAUDE_CONFIG_DIR; }
|
||||
ACCOUNT_NAME="nordmann"
|
||||
|
||||
# --- Matrix-Statusberichte (Raum "🔺 Pyramid Autopilot", Token vom Gatus-Bot) ---
|
||||
MATRIX_URL="http://127.0.0.1:6167"
|
||||
MATRIX_ROOM="!sYfOwD4Dw4jBDTlNmL:steggi-matrix.work"
|
||||
MATRIX_TOKEN="$(grep -s '^MATRIX_TOKEN=' "$HOME/gatus/.env" | cut -d= -f2-)"
|
||||
|
||||
notify_matrix() {
|
||||
[ -z "$MATRIX_TOKEN" ] && return 0
|
||||
local body
|
||||
body=$(python3 -c 'import json,sys; print(json.dumps({"msgtype":"m.text","body":sys.stdin.read().strip()}))' <<< "$1")
|
||||
curl -s -m 10 -X PUT \
|
||||
"$MATRIX_URL/_matrix/client/v3/rooms/$MATRIX_ROOM/send/m.room.message/autopilot$(date +%s%N)" \
|
||||
-H "Authorization: Bearer $MATRIX_TOKEN" -H "Content-Type: application/json" \
|
||||
-d "$body" > /dev/null || true
|
||||
}
|
||||
|
||||
PROMPT="Lies CLAUDE.md. Arbeite in dieser Reihenfolge: (1) Review-Pass: Gehe die in ROADMAP.md/PROGRESS.md als erledigt markierten Punkte durch, prüfe den zugehörigen Code kritisch und überarbeite/verbessere ihn, wo nötig. Führe in PROGRESS.md einen Abschnitt 'Fable-5-Review' und hake dort jeden geprüften Punkt ab, damit bereits reviewte Punkte nicht erneut geprüft werden. (2) Wenn alle erledigten Punkte reviewt sind, arbeite ROADMAP.md Punkt für Punkt weiter ab. Halte dich strikt an den Arbeitszyklus (PROGRESS.md pflegen, kleine Commits + Push). Arbeite so viel ab wie möglich."
|
||||
|
||||
while true; do
|
||||
echo "===== $(date '+%F %T') Neue Claude-Session =====" | tee -a autopilot.log
|
||||
claude -p "$PROMPT" --dangerously-skip-permissions 2>&1 | tee -a autopilot.log
|
||||
if [ -f PAUSE ]; then
|
||||
echo "===== $(date '+%F %T') PAUSE aktiv (rm PAUSE zum Fortsetzen) - pruefe wieder in 10 Minuten ====="
|
||||
sleep 600
|
||||
continue
|
||||
fi
|
||||
apply_account
|
||||
echo "===== $(date '+%F %T') Neue Claude-Session (Konto $ACCOUNT_NAME) =====" | tee -a autopilot.log
|
||||
BEFORE=$(git rev-parse HEAD 2>/dev/null)
|
||||
SESSION_OUT=$(mktemp)
|
||||
claude -p "$PROMPT" --model claude-fable-5 --dangerously-skip-permissions 2>&1 | tee -a autopilot.log "$SESSION_OUT"
|
||||
AFTER=$(git rev-parse HEAD 2>/dev/null)
|
||||
if [ -n "$BEFORE" ] && [ "$BEFORE" != "$AFTER" ]; then
|
||||
COMMITS=$(git log --oneline --no-decorate "$BEFORE..$AFTER" | head -15)
|
||||
NEXT=$(grep -m1 -A3 "Offen/Nächster Schritt" PROGRESS.md)
|
||||
notify_matrix "🔺 Pyramid-Autopilot $(date '+%H:%M') – Session fertig.
|
||||
|
||||
Neue Commits:
|
||||
$COMMITS
|
||||
|
||||
$NEXT"
|
||||
fi
|
||||
# Limit erkannt? -> 20 Minuten warten (KEINE Konto-Rotation mehr)
|
||||
if grep -qiE "hit your.*limit|usage limit|session limit" "$SESSION_OUT"; then
|
||||
rm -f "$SESSION_OUT"
|
||||
echo "===== $(date '+%F %T') nordmann-Konto am Limit - naechster Versuch in 20 Minuten =====" | tee -a autopilot.log
|
||||
sleep 1200
|
||||
continue
|
||||
fi
|
||||
rm -f "$SESSION_OUT"
|
||||
|
||||
echo "===== $(date '+%F %T') Session beendet - Neustart in 20 Minuten (Strg+C zum Stoppen) =====" | tee -a autopilot.log
|
||||
sleep 1200
|
||||
done
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
# Dashboard-Server absichern: Admin-Endpoints haben KEINE Authentifizierung
|
||||
|
||||
**Status (ABGESCHLOSSEN 2026-07-05): Das Loch ist mit dem Heimnetz-Gate
|
||||
(„Weg C", siehe unten) geschlossen, und Bernd hat entschieden
|
||||
(`ANTWORTEN_BERND.md` Nr. 3): KEIN Fernzugriff – das Gate ist der ENDZUSTAND.**
|
||||
Die fünf Admin-Endpoints akzeptieren nur noch Anfragen aus dem
|
||||
Heimnetz/localhost; alles, was über den Cloudflare-Tunnel kommt, bekommt 403 mit
|
||||
einer Erklärung (die das Dashboard per `alert()` anzeigt). Bernds Dashboard
|
||||
funktioniert im Heimnetz unverändert unter
|
||||
`http://192.168.178.71:8080/stats.html`; unterwegs kommt Bernd per WireGuard ins
|
||||
Heimnetz. Die A/B-Frage (Cloudflare Access vs. `DASH_TOKEN`) ist damit
|
||||
GESCHLOSSEN – die Optionen unten bleiben nur als Archiv stehen, falls sich der
|
||||
Bedarf je ändert. Die unten dokumentierten Restrisiken des Gates sind bewusst
|
||||
akzeptiert. **Nicht weiter an diesem Thema arbeiten.**
|
||||
|
||||
## Heimnetz-Gate („Weg C", aktiv seit 2026-07-04, seit 2026-07-05 Endzustand)
|
||||
|
||||
In `server.py` (Konstante `ADMIN_PATHS` + Helfer `lan_denied_reason`): eine
|
||||
Admin-POST-Anfrage wird nur angenommen, wenn (1) **kein** `Cf-Connecting-Ip`/
|
||||
`Cf-Ray`-Header vorhanden ist (= sie kam nicht durch den Cloudflare-Tunnel) und
|
||||
(2) die **Socket-Absender-IP** (nicht spoofbar) `127.0.0.1`/`::1`/`192.168.*`
|
||||
ist. Abgelehnte Versuche landen größenbegrenzt (5 MB-Deckel) in
|
||||
`security_alerts.log` und damit in der Dashboard-Anzeige. Headless verifiziert:
|
||||
öffentliche URL → 403 (ban/toggle), Heimnetz-IP und localhost → App-Validierung
|
||||
erreicht (400 bei leerem Nutzer), gespoofter Cf-Header aus dem LAN → 403,
|
||||
`GET /` öffentlich → 200, `/api/livekit-token` (401 bei ungültigem Token) und
|
||||
`/api/e2ee-diagnostics` (403 bei falschem Token) unverändert öffentlich
|
||||
erreichbar (die App braucht sie, eigene Auth vorhanden).
|
||||
|
||||
**Bekannte Restrisiken des Gates (Bernd bekannt und bewusst akzeptiert,
|
||||
Entscheidung 2026-07-05):**
|
||||
- Die **öffentliche Ansicht bleibt offen** und zeigt Nutzerliste + Präsenz
|
||||
(Informations-Preisgabe). Weg A würde auch das schließen.
|
||||
- **CSRF aus dem Heimnetz:** Eine bösartige Webseite in Bernds Browser könnte
|
||||
theoretisch POSTs an `http://192.168.178.71:8080` schicken (moderne
|
||||
Chrome-Versionen blocken Public→Private-Requests, Firefox nicht vollständig).
|
||||
Erst ein Token/Access-Login (Weg A/B) schließt das sauber.
|
||||
- Wer **im Heimnetz** ist, kann weiterhin ohne Anmeldung administrieren.
|
||||
|
||||
## Problem (belegt, nicht vermutet)
|
||||
|
||||
Der Dashboard-Server `/home/steggi/matrix/server.py` (läuft als
|
||||
`matrix-stats.service` auf `0.0.0.0:8080`) ist über Cloudflare unter
|
||||
`https://dashboard.steggi-matrix.work` **öffentlich aus dem Internet erreichbar**.
|
||||
Getestet: `GET /` liefert 200 (stats.html), und die **zustandsändernden POST-Endpoints
|
||||
antworten mit der App-eigenen Validierung – ohne jede Authentifizierung**:
|
||||
|
||||
```
|
||||
$ curl -X POST https://dashboard.steggi-matrix.work/api/ban -d '{"user":""}'
|
||||
{"error": "Kein Nutzer angegeben"} # HTTP 400 – App-Antwort, KEIN Login davor
|
||||
```
|
||||
|
||||
Damit kann **jeder Fremde im Internet**, der den Hostnamen kennt, ohne Anmeldung:
|
||||
|
||||
- `/api/ban` / `/api/unban` – **jeden Matrix-Nutzer sperren/entsperren** (auch Uta!)
|
||||
→ Aussperrung / Denial-of-Service gegen echte Nutzer.
|
||||
- `/api/toggle-registration` und `/api/create-invite` – **offene Registrierung am
|
||||
Homeserver aktivieren** (setzt `allow_registration = true` +
|
||||
`yes_i_am_very_very_sure…`) → der Homeserver wird zur Spam-/Missbrauchsschleuder.
|
||||
- `/api/run-stats` – Stats-Skript auslösen (harmlos, aber ebenfalls ungeschützt).
|
||||
|
||||
**Der Hostname ist nicht wirklich geheim:** Cloudflare stellt TLS-Zertifikate aus,
|
||||
die in den öffentlichen Certificate-Transparency-Logs landen – jede Subdomain von
|
||||
`steggi-matrix.work` ist per CT-Log-Scan auffindbar. Die bisherige „Sicherheit"
|
||||
beruht also allein auf Verborgenheit eines Namens, der praktisch entdeckbar ist.
|
||||
|
||||
Bereits **korrekt abgesichert** (zum Vergleich, nicht betroffen):
|
||||
- `/api/e2ee-diagnostics` – eigener `DIAG_TOKEN` (Bearer).
|
||||
- `/api/livekit-token` – Matrix-Access-Token per `whoami` gegen Continuwuity.
|
||||
|
||||
Nur die vier Admin-/Stats-Endpoints oben sind offen.
|
||||
|
||||
## Wie das Dashboard heute aufruft
|
||||
|
||||
`stats.html` ruft alle Aktionen als **Same-Origin-`fetch` ohne Auth-Header** auf
|
||||
(`toggleRegistration`, `createInvite`, `refreshStats`, `doUserAction` →
|
||||
`/api/ban` /`/api/unban`). Es gibt keine Session, kein Cookie, keinen Token –
|
||||
deshalb bricht **jede** hinzugefügte Authentifizierung das Dashboard so lange,
|
||||
bis `stats.html` den Token mitschickt. Genau darum ist das eine Bernd-Entscheidung
|
||||
und kein stiller Fix.
|
||||
|
||||
## Option A (empfohlen, robust): Cloudflare Access vor die Subdomain
|
||||
|
||||
Am saubersten wird das Dashboard **komplett** durch Cloudflare Access geschützt
|
||||
(Zero-Trust-Login vor der Subdomain), statt in `server.py` Auth nachzubauen:
|
||||
|
||||
1. Im Cloudflare-Zero-Trust-Dashboard eine **Access-Application** für
|
||||
`dashboard.steggi-matrix.work` anlegen.
|
||||
2. Policy: nur Bernds E-Mail (`bernd.steckmeister@gmail.com`) per One-Time-PIN
|
||||
oder Google-Login zulassen.
|
||||
3. Fertig – `server.py` und `stats.html` bleiben **unverändert**, das Dashboard
|
||||
funktioniert für Bernd wie bisher (nach dem Access-Login), aber Fremde kommen
|
||||
gar nicht erst an die Endpoints.
|
||||
|
||||
Vorteil: kein Secret im Code, kein Umbau der HTML-Fetches, kein Aussperr-Risiko
|
||||
durch vergessene Tokens. Nachteil: Bernd muss die Access-App einmal im
|
||||
Cloudflare-Panel klicken (kein Code, aber sein Konto nötig). **Das ist der
|
||||
Königsweg – Option B nur, falls Access nicht gewünscht ist.**
|
||||
|
||||
## Option B (Fallback, rein in server.py): eigener Dashboard-Token
|
||||
|
||||
Wenn Access nicht gewünscht ist, ein **neuer, zufälliger `DASH_TOKEN`** (getrennt
|
||||
vom geleakten Admin-Token `J5lax…` und vom `DIAG_TOKEN`!), den die vier
|
||||
Admin-/Stats-Endpoints als `Authorization: Bearer <DASH_TOKEN>` verlangen:
|
||||
|
||||
```python
|
||||
# server.py, zu den anderen Token-Konstanten:
|
||||
DASH_TOKEN = "<neu erzeugen: python3 -c 'import secrets;print(\"dash-\"+secrets.token_urlsafe(24))'>"
|
||||
|
||||
def _dash_authed(self):
|
||||
return self.headers.get("Authorization", "") == "Bearer " + DASH_TOKEN
|
||||
```
|
||||
|
||||
In `do_POST` **vor** der Behandlung von `/api/ban`, `/api/unban`,
|
||||
`/api/toggle-registration`, `/api/create-invite`, `/api/run-stats` einfügen:
|
||||
|
||||
```python
|
||||
if self.path in ("/api/ban", "/api/unban", "/api/toggle-registration",
|
||||
"/api/create-invite", "/api/run-stats") and not self._dash_authed():
|
||||
self.send_error(403)
|
||||
return
|
||||
```
|
||||
|
||||
`GET /` und `/api/registration-status` bleiben öffentlich (nur Lesen), damit die
|
||||
Stats-Seite ohne Token sichtbar bleibt.
|
||||
|
||||
**`stats.html`** darf den Token **NIEMALS fest eingebettet** ausliefern (die Seite
|
||||
ist öffentlich – der Token läge sonst für jeden offen). Stattdessen einmal im
|
||||
Browser abfragen und in `localStorage` halten:
|
||||
|
||||
```javascript
|
||||
function dashToken() {
|
||||
var t = localStorage.getItem('dashToken');
|
||||
if (!t) { t = prompt('Dashboard-Token:'); if (t) localStorage.setItem('dashToken', t); }
|
||||
return t || '';
|
||||
}
|
||||
// und bei JEDEM Aktions-fetch:
|
||||
fetch('/api/ban', { method:'POST',
|
||||
headers: {'Content-Type':'application/json', 'Authorization':'Bearer ' + dashToken()},
|
||||
body: JSON.stringify({user:u}) })
|
||||
```
|
||||
|
||||
(betrifft `toggleRegistration`, `createInvite`, `refreshStats`, `doUserAction`).
|
||||
Bei 403 den gecachten Token verwerfen (`localStorage.removeItem('dashToken')`) und
|
||||
neu fragen. Bernd gibt den Token so genau **einmal pro Browser** ein.
|
||||
|
||||
## Rollout / Testplan
|
||||
|
||||
**Option A:** Access-App klicken → von einem fremden Netz/Inkognito prüfen, dass
|
||||
`https://dashboard.steggi-matrix.work/` zum Access-Login umleitet statt die Seite
|
||||
zu zeigen; dann als Bernd einloggen und alle Buttons testen. Kein Code-Deploy.
|
||||
|
||||
**Option B (headless auf dem Pi verifizierbar):**
|
||||
1. `server.py` + `stats.html` ändern, Dienst-Neustart (Prozess killen,
|
||||
`Restart=always` lädt neu – siehe PROGRESS 2026-07-04 zur polkit-Falle).
|
||||
2. `curl -X POST .../api/ban -d '{"user":"x"}'` **ohne** Bearer → **403** erwartet.
|
||||
3. Mit `Authorization: Bearer <DASH_TOKEN>` + leerem Nutzer → 400 (App-Validierung
|
||||
erreicht) → beweist: Token akzeptiert, Auth greift.
|
||||
4. Im Browser: Dashboard öffnen, Token eingeben, je ein Button testen
|
||||
(Registrierung toggeln + sofort zurück, Stats aktualisieren; Ban/Unban an
|
||||
einem **Test-Nutzer**, nicht an Uta).
|
||||
|
||||
## Warum erst nur der Plan kam – und dann doch das Interims-Gate
|
||||
|
||||
Eine Token-/Access-Absicherung (A/B) macht die Dashboard-Buttons überall
|
||||
funktionslos, bis Bernd aktiv etwas tut (Token eingeben bzw. Access-App
|
||||
einrichten) – das mitten in einer autonomen Session zu deployen verletzt die
|
||||
CLAUDE.md-Leitplanke „kein Aussperren". Das *Wie* der Anmeldung bleibt deshalb
|
||||
Bernds Richtungsentscheidung. Das später ergänzte Interims-Gate umgeht dieses
|
||||
Dilemma: Es braucht keinerlei Aktion von Bernd (Heimnetz-Nutzung läuft
|
||||
unverändert weiter, inkl. spontanem Spam-Bann von zu Hause), sperrt aber
|
||||
Fremde aus dem Internet sofort aus. Einzige Einschränkung: Admin-Buttons über
|
||||
die öffentliche URL gehen nicht – die 403-Meldung im Dashboard erklärt das und
|
||||
nennt die Heimnetz-URL. Mit Bernds Entscheidung vom 2026-07-05 (Heimnetz-only,
|
||||
unterwegs WireGuard) ist genau dieser Zustand der gewollte Endzustand. Rückbau
|
||||
(falls je nötig) = die Gate-Zeilen in `server.py` entfernen
|
||||
(Backup: `server.py.bak-20260704-dashgate`).
|
||||
@@ -0,0 +1,143 @@
|
||||
# LiveKit-Token server-seitig minten (apiSecret aus dem Client entfernen)
|
||||
|
||||
**Status:** geplant, NICHT umgesetzt (braucht echten Call-Test auf einem Gerät –
|
||||
Calls sind laut CLAUDE.md „heilig"). Dieser Plan ist gegen den echten Code und die
|
||||
echte Pi-Konfiguration geschrieben (2026-07-04), damit ein PC-Termin direkt starten
|
||||
kann. Analog zu `docs/SQLCIPHER_MIGRATION.md`.
|
||||
|
||||
## Problem
|
||||
|
||||
`lib/core/livekit_token.dart` mintet die LiveKit-JWTs **im Client**. Dafür muss das
|
||||
LiveKit-`apiSecret` im App-Code liegen (`livekit_token.dart:6`) – d. h. jeder
|
||||
App-Nutzer (auch Uta) trägt das SFU-Secret im Gerät. Wer es hat, kann sich für
|
||||
**jeden** Raum und **jede** Identität ein Beitritts-Token ausstellen. Das ist ein
|
||||
Architekturproblem, kein bloßer Repo-Leak: Selbst nach einem History-Rewrite bliebe
|
||||
das Secret im ausgelieferten Binary.
|
||||
|
||||
Aktueller Fluss (`lib/core/livekit_call_manager.dart:154`):
|
||||
|
||||
```dart
|
||||
final token = LiveKitTokenGenerator.generate(
|
||||
roomName: roomName,
|
||||
identity: identity,
|
||||
displayName: identity,
|
||||
ttlSeconds: 21600, // 6 h
|
||||
);
|
||||
```
|
||||
|
||||
Erzeugtes JWT (HS256, `livekit_token.dart:14-33`): Grants `video` mit
|
||||
`roomJoin/room/canPublish/canSubscribe/canPublishData`, `metadata` = Anzeigename,
|
||||
`iss` = apiKey (`LKMatrixPi`), `sub` = identity, `exp` = jetzt+ttl.
|
||||
|
||||
## Ziel
|
||||
|
||||
Das `apiSecret` liegt **nur noch auf dem Pi**. Der Client holt sich für einen
|
||||
konkreten Raum ein fertiges JWT von einem kleinen Token-Endpoint und übergibt es
|
||||
unverändert an `Room.connect`.
|
||||
|
||||
## Server-Seite (Pi)
|
||||
|
||||
Es gibt bereits einen schlanken Dashboard-Server ohne Fremd-Abhängigkeiten:
|
||||
`/home/steggi/matrix/server.py` (stdlib `http.server`, läuft als
|
||||
`matrix-stats.service` auf `0.0.0.0:8080`, nach außen `dashboard.steggi-matrix.work`).
|
||||
Der Token-Endpoint kann dort als weitere Route `POST /api/livekit-token` andocken –
|
||||
kein neuer Dienst nötig. Das LiveKit-`apiSecret` steht schon auf dem Pi in
|
||||
`/home/steggi/matrix/livekit.yaml` (`keys: LKMatrixPi: <secret>`); der Server liest
|
||||
es von dort, statt es erneut zu hinterlegen (eine Quelle der Wahrheit).
|
||||
|
||||
**Wichtig – Authentifizierung:** Der Endpoint darf NICHT anonym Tokens ausstellen
|
||||
(sonst ist nichts gewonnen). Der Client schickt seinen **Matrix-Access-Token** mit;
|
||||
der Server prüft ihn gegen Continuwuity und leitet daraus die Identität ab:
|
||||
|
||||
1. `POST /api/livekit-token`, Body `{ "room": "<roomName>", "matrix_token": "<access_token>" }`.
|
||||
2. Server ruft `GET http://127.0.0.1:6167/_matrix/client/v3/account/whoami`
|
||||
mit `Authorization: Bearer <matrix_token>` auf → liefert `user_id`
|
||||
(401 → Endpoint gibt 401 zurück, kein Token).
|
||||
3. `identity` = `user_id` (NICHT vom Client wählbar – verhindert Identitäts-Spoofing).
|
||||
4. Optional, aber empfohlen: Raummitgliedschaft prüfen (`/_matrix/client/v3/rooms/
|
||||
{roomId}/joined_members` oder der Matrix-Raum, zu dem der Voice-Channel gehört) –
|
||||
nur ausstellen, wenn der Nutzer wirklich Mitglied ist. Für Phase 1 reicht die
|
||||
whoami-Authentifizierung; Mitgliedschaftsprüfung als Härtung nachziehen.
|
||||
5. Server mintet das LiveKit-JWT **mit denselben Grants wie heute** und gibt
|
||||
`{ "token": "<jwt>", "url": "wss://livekit.steggi-matrix.work" }` zurück.
|
||||
|
||||
**JWT-Minting ohne Fremd-Paket (stdlib genügt):** LiveKit-Tokens sind HS256-JWTs.
|
||||
Mit `hmac`/`hashlib`/`base64`/`json` aus der stdlib:
|
||||
|
||||
```python
|
||||
import base64, hmac, hashlib, json, time
|
||||
|
||||
def _b64url(b): return base64.urlsafe_b64encode(b).rstrip(b"=")
|
||||
|
||||
def mint_livekit(api_key, api_secret, identity, room, name, ttl=21600):
|
||||
now = int(time.time())
|
||||
header = {"alg": "HS256", "typ": "JWT"}
|
||||
payload = {
|
||||
"iss": api_key, "sub": identity, "name": name,
|
||||
"nbf": now, "exp": now + ttl, "metadata": name,
|
||||
"video": {"roomJoin": True, "room": room,
|
||||
"canPublish": True, "canSubscribe": True,
|
||||
"canPublishData": True},
|
||||
}
|
||||
segs = [_b64url(json.dumps(header, separators=(",", ":")).encode()),
|
||||
_b64url(json.dumps(payload, separators=(",", ":")).encode())]
|
||||
signing_input = b".".join(segs)
|
||||
sig = hmac.new(api_secret.encode(), signing_input, hashlib.sha256).digest()
|
||||
return (signing_input + b"." + _b64url(sig)).decode()
|
||||
```
|
||||
|
||||
Verifikation lokal auf dem Pi (kein Gerät nötig): geminetes Token gegen den
|
||||
laufenden LiveKit prüfen, z. B. per LiveKit-CLI `lk` oder indem man das JWT auf
|
||||
jwt.io-Art dekodiert und Claims/Signatur vergleicht. Zusätzlich Gegentest mit dem
|
||||
**alten** Client-Pfad: dasselbe Token muss `Room.connect` akzeptieren.
|
||||
|
||||
## Client-Seite
|
||||
|
||||
- `lib/core/livekit_token.dart`: `apiKey`/`apiSecret` **entfernen**. Die Klasse wird
|
||||
entweder gelöscht oder zu einem dünnen HTTP-Client
|
||||
`Future<String> fetchLiveKitToken({required String room, required String matrixToken})`,
|
||||
der `POST https://dashboard.steggi-matrix.work/api/livekit-token` aufruft und das
|
||||
`token`-Feld zurückgibt. `http`-Paket ist schon Abhängigkeit (siehe
|
||||
`update_checker.dart`).
|
||||
- `lib/core/livekit_call_manager.dart:154`: `LiveKitTokenGenerator.generate(...)`
|
||||
→ `await fetchLiveKitToken(room: roomName, matrixToken: matrixClient.accessToken)`.
|
||||
Der Manager hat den Matrix-Client bereits (`_matrixClient`, Zeile 149) – der
|
||||
Access-Token ist also verfügbar. `identity`/`displayName` werden nicht mehr vom
|
||||
Client bestimmt (Server leitet Identität aus whoami ab); den bisherigen
|
||||
`identity`-Parameter entsprechend zurückbauen.
|
||||
- Fehlerpfade: Netzfehler / 401 / 403 sauber als Call-Fehler anzeigen
|
||||
(`error = ...; notifyListeners()`), NICHT still schlucken – sonst „Call verbindet
|
||||
nicht" ohne Hinweis. TTL bleibt 21600 s (6 h), Erneuerung wie bisher beim
|
||||
Neu-Beitreten.
|
||||
|
||||
## Rotation (Pflicht, danach)
|
||||
|
||||
Das alte `apiSecret` `rYUT2PRa…` ist geleakt (Client + Git-History). Nach der
|
||||
Umstellung:
|
||||
|
||||
1. Neues `apiSecret` in `/home/steggi/matrix/livekit.yaml` (`keys:`) setzen.
|
||||
2. LiveKit-Container neu starten
|
||||
(`docker compose -f /home/steggi/matrix/docker-compose.yml restart livekit`).
|
||||
3. `server.py` liest das Secret aus `livekit.yaml` – kein zweiter Ort zu pflegen.
|
||||
4. Alte Clients (Utas installierte APK) minten mit dem alten Secret → deren
|
||||
selbst-gemintete Tokens werden nach der Rotation abgelehnt. Deshalb Rotation
|
||||
**zusammen mit einem neuen Release** ausrollen, das den neuen Fetch-Pfad nutzt.
|
||||
|
||||
## Reihenfolge / Testplan (PC + Gerät)
|
||||
|
||||
1. `server.py`-Route bauen, lokal auf dem Pi verifizieren (whoami-Auth greift,
|
||||
geminetes Token dekodiert korrekt, `Room.connect` akzeptiert es).
|
||||
2. Client umstellen, `flutter analyze` sauber, `flutter test` grün.
|
||||
3. **Auf echtem Gerät** (Calls heilig, kein Headless-Test möglich): Voice-Channel
|
||||
beitreten, Audio/Video/Screenshare, Verlassen/Wieder-Beitreten, zweiter
|
||||
Teilnehmer. Erst wenn das steht: Secret rotieren + Release.
|
||||
4. Danach ROADMAP-M0-Punkt „LiveKit-Token server-seitig minten" abhaken und den
|
||||
Rotations-Teil des Secret-Punkts als erledigt markieren.
|
||||
|
||||
## Warum nicht jetzt (auf dem Pi) gemacht
|
||||
|
||||
Die Server-Route ließe sich hier bauen und headless testen – aber der zugehörige
|
||||
Client-Umbau tauscht den **heiligen Call-Pfad** aus und ist auf diesem Pi ohne
|
||||
GUI/Gerät nicht praktisch prüfbar. Einen halben, nicht end-to-end verifizierbaren
|
||||
Umbau am Call-Pfad zu stapeln verbietet die CLAUDE.md-Leitplanke („keine
|
||||
Verhaltensänderung ohne Not", Calls heilig). Deshalb hier nur der fertige Plan.
|
||||
@@ -0,0 +1,192 @@
|
||||
# M2 – Modul-Schnitt (Entwurf)
|
||||
|
||||
> **STATUS 2026-07-06: Pilot (Schritt 1) UMGESETZT.** Die Module
|
||||
> `call_signaling` (`lib/features/call_signaling/`: `CallSignalingService`
|
||||
> + `callSignalingProvider` + Implementierung `voip_manager.dart`) und
|
||||
> `voice_channel` (`lib/features/voice_channel/`: `VoiceChannelService` +
|
||||
> `voiceChannelProvider` + Implementierung `livekit_call_manager.dart`)
|
||||
> existieren; ALLE Konsumenten (Shell, Call-UI, Rooms-Panel, Chat-Header,
|
||||
> Settings) gehen nur noch über die Provider/Interfaces, kein direkter
|
||||
> Implementierungs-Import mehr (einzige dokumentierte Ausnahme:
|
||||
> Kompositions-Punkt `matrix_client.dart`). Bewusste Abweichungen vom
|
||||
> Entwurf unten (Verhaltensgleichheit vor Schönheit, heiliger Call-Pfad):
|
||||
> ChangeNotifier statt Streams, Methodennamen wie im Bestand
|
||||
> (`startCall`/`hangUp` statt `join`/`leave`), `CallSession`/LiveKit-`Room`
|
||||
> bleiben in den Schnittstellen sichtbar, weil die UI direkt rendert.
|
||||
> Echter Call-/Voice-Channel-Test steht aus (Gerät bzw. Klick-Test) –
|
||||
> siehe PROGRESS.md 2026-07-06. Schritte 2–5 unten sind weiter offen.
|
||||
>
|
||||
> **Nachtrag 2026-07-06 (zweite PC-Session):** `call_ui` ist jetzt ein
|
||||
> eigenes Modul (`lib/features/call_ui/`, reine Umbenennung von
|
||||
> `features/call/`; konsumiert weiterhin nur die zwei Service-Interfaces).
|
||||
> Außerdem ist der storage-Teilschritt für die Call-Module erledigt: die
|
||||
> voice_*-Prefs (Geräte + Ausgabelautstärke) laufen über die neue
|
||||
> `VoicePrefs`-Kapsel in `core/settings_prefs.dart` (Punkt 6 unten) –
|
||||
> kein direkter SharedPreferences-Zugriff mehr in `voip_manager.dart`/
|
||||
> `livekit_call_manager.dart`/`settings_voice.dart`, Keys unverändert.
|
||||
> „UI-State aus den Managern herauslösen" wartet bewusst auf Bernds
|
||||
> Klick-Test des Piloten (heiliger Call-Pfad).
|
||||
|
||||
Stand: 2026-07-03. Reine Schnittstellen-Planung, noch kein Umbau. Grundlage:
|
||||
Ist-Analyse in `PROGRESS.md` (Eintrag „M2: Ist-Analyse der Architektur
|
||||
geschrieben"). Leitprinzip (Bernd, `CLAUDE.md`): jedes Modul muss sich in
|
||||
einem Jahr komplett neu schreiben lassen, ohne andere Module anzufassen.
|
||||
Andere Module dürfen daher nur die hier genannte öffentliche Schnittstelle
|
||||
importieren, nie interne Dateien.
|
||||
|
||||
Korrektur zur Ist-Analyse: der Import von `rooms_provider.dart` in
|
||||
`app_state.dart` ist **kein** toter Altlast-Import, wie dort vermutet –
|
||||
`voiceParticipantsProvider` (Zeile 160) nutzt `roomListProvider` daraus, um
|
||||
bei jedem Sync neu zu bauen. Der Eintrag in PROGRESS.md wird beim nächsten
|
||||
Durchgang korrigiert.
|
||||
|
||||
## Wichtige Erkenntnis vor dem Schnitt: „Calls" ist eigentlich ZWEI Features
|
||||
|
||||
Beim genaueren Lesen von `voip_manager.dart` und `livekit_call_manager.dart`
|
||||
zeigt sich: das sind keine zwei Hälften derselben Sache, sondern zwei
|
||||
unabhängige Call-Mechanismen, die zufällig beide „Call" heißen:
|
||||
|
||||
- **`PyramidVoipManager`** – natives Matrix-VoIP (`m.call.*`-Events über das
|
||||
matrix-SDK, direkte 1:1-WebRTC-Peer-Connection, kein SFU). Vermutlich für
|
||||
spontane 1:1-Anrufe gedacht.
|
||||
- **`LiveKitCallManager`** – SFU-basierte, dauerhafte Voice-Channels (LiveKit-
|
||||
Server-Raum, Presence über Matrix-State-Events, Screensharing, Mehrpersonen).
|
||||
Das ist der Discord-artige Voice-Channel aus M3/M4.
|
||||
|
||||
Beide Module sollten **getrennt** bleiben, nicht zu einem „calls"-Modul
|
||||
verschmolzen werden. Nur die UI (`call_ui`) darf beide kennen, falls sie
|
||||
gemeinsam dargestellt werden müssen (z. B. „du bist in einem Voice-Channel,
|
||||
jemand ruft dich per 1:1-VoIP an").
|
||||
|
||||
## Modul-Liste mit öffentlicher Schnittstelle (Entwurf)
|
||||
|
||||
### 1. `call_signaling` (Matrix-1:1-VoIP)
|
||||
**Ersetzt:** `core/voip_manager.dart`
|
||||
**Öffentliche Schnittstelle (Ausschnitt):**
|
||||
```dart
|
||||
abstract class CallSignalingService {
|
||||
Stream<CallSession?> get currentCallStream;
|
||||
bool get isMicMuted;
|
||||
bool get isCameraMuted;
|
||||
Future<void> startCall(String roomId, {bool video});
|
||||
Future<void> hangup();
|
||||
Future<void> toggleMic();
|
||||
Future<void> toggleCamera();
|
||||
Future<void> toggleScreenSharing(BuildContext? context);
|
||||
RTCVideoRenderer get localRenderer; // bleibt vorerst WebRTC-typisiert,
|
||||
RTCVideoRenderer get remoteRenderer; // da UI direkt rendert
|
||||
}
|
||||
```
|
||||
Interna (ICE-Server-Merge, `MediaDevicesWrapper`, Geräte-Prefs aus
|
||||
`SharedPreferences`) bleiben privat im Modul. Geräte-Prefs sollten künftig
|
||||
über die `storage`-Fassade laufen statt direktem `SharedPreferences`-Zugriff
|
||||
(aktuell Zeile 260, 447).
|
||||
|
||||
### 2. `voice_channel` (LiveKit-SFU, Discord-artige Kanäle)
|
||||
**Ersetzt:** `core/livekit_call_manager.dart`
|
||||
**Öffentliche Schnittstelle (Ausschnitt):**
|
||||
```dart
|
||||
abstract class VoiceChannelService {
|
||||
Stream<VoiceChannelState> get stateStream; // isActive, isMuted, participants…
|
||||
Future<void> join({required String roomName, required String matrixRoomId, ...});
|
||||
Future<void> leave();
|
||||
Future<void> toggleMute();
|
||||
Future<void> toggleDeafen();
|
||||
Future<void> toggleCamera();
|
||||
Future<void> toggleScreenShare();
|
||||
Future<void> setPublishQuality(String key); // Streamer-Seite (M4)
|
||||
Future<void> setSubscribeQuality(String key); // Empfänger-Seite (M4)
|
||||
Future<void> setOutputVolume(double v);
|
||||
}
|
||||
```
|
||||
Presence-Writing (`_writePresence`, Matrix-State-Event
|
||||
`io.pyramid.voice.presence`) bleibt intern, aber nutzt eine injizierte
|
||||
`Client`-Referenz statt globalem Singleton-Zugriff – macht das Modul für
|
||||
Tests austauschbar.
|
||||
|
||||
### 3. `call_ui`
|
||||
**Ersetzt:** `features/call/voice_channel.dart`, `features/call/mini_call_widget.dart`
|
||||
Konsumiert ausschließlich `CallSignalingService` + `VoiceChannelService` über
|
||||
Riverpod-Provider (`callSignalingProvider`, `voiceChannelProvider`). Keine
|
||||
direkten Importe von `core/voip_manager.dart`/`core/livekit_call_manager.dart`
|
||||
mehr – heute importieren `chat_view.dart`, `matrix_client.dart` und
|
||||
`settings_modal.dart` diese Dateien direkt, das entfällt.
|
||||
|
||||
### 4. `settings` (aufgesplittet)
|
||||
Kein eigenes fachliches Modul, sondern reine Aggregations-UI. Jede Sektion
|
||||
wird eine eigene Datei/Klasse mit einer schmalen `Widget build(...)`-Fläche,
|
||||
die ihrerseits nur die Provider ihres Fachmoduls konsumiert (z. B.
|
||||
`_VoiceSection` nutzt künftig `voiceChannelProvider`/`callSignalingProvider`
|
||||
statt der Manager direkt). Vorschlag für Dateien:
|
||||
`settings/profile_section.dart`, `settings/notifications_section.dart`,
|
||||
`settings/appearance_section.dart`, `settings/sessions_section.dart`,
|
||||
`settings/account_section.dart`, `settings/voice_section.dart`,
|
||||
`settings/about_section.dart`.
|
||||
|
||||
### 5. `verification` (neu, aus Settings herausgelöst)
|
||||
**Quelle:** `widgets/settings_modal.dart` Zeile 2921–4545 (SAS-Dialog,
|
||||
QR-Anzeige, Emoji-Vergleich, Recovery-Key-Flow).
|
||||
```dart
|
||||
abstract class VerificationService {
|
||||
Future<void> startSasVerification(DeviceKeys device);
|
||||
Future<void> confirmSas();
|
||||
Future<void> declineSas();
|
||||
Stream<VerificationStep> get stepStream;
|
||||
}
|
||||
```
|
||||
Eigenständig, weil SAS/QR-Verifikation künftig auch außerhalb der Settings
|
||||
gebraucht werden könnte (z. B. beim Login auf einem neuen Gerät –
|
||||
`bootstrap_dialog.dart` macht heute schon etwas Ähnliches separat).
|
||||
|
||||
### 6. `storage` (Fassade, kein Rewrite)
|
||||
**Ausbau von:** `core/settings_prefs.dart`
|
||||
```dart
|
||||
abstract class Prefs {
|
||||
Future<bool> getBool(String key, {bool fallback});
|
||||
Future<void> setBool(String key, bool value);
|
||||
// … String, double, StringSet analog zu BoolPref/StringSetPref
|
||||
}
|
||||
```
|
||||
Ziel ist NICHT, `SharedPreferences` zu ersetzen, sondern die bereits
|
||||
vorhandene `BoolPref`/`StringSetPref`-Abstraktion zur Pflicht zu machen.
|
||||
Die 12 identifizierten Direktzugriffsstellen (u. a. `voip_manager.dart:260`,
|
||||
`livekit_call_manager.dart:246`, `settings_modal.dart:1804/1860/1870`) werden
|
||||
schrittweise umgestellt – **nicht** in diesem Schritt, sondern sobald das
|
||||
jeweilige Modul (z. B. `call_signaling`) ohnehin angefasst wird.
|
||||
Krypto-/Access-Token-Speicherung (`flutter_secure_storage`,
|
||||
`matrix_client.dart`) bleibt bewusst außen vor – hängt an der noch offenen
|
||||
SQLCipher-Frage (M1) und wird dort separat behandelt, nicht hier.
|
||||
|
||||
### 7. `auth`
|
||||
**Dateien:** `features/auth/login_notifier.dart`, `login_page.dart`,
|
||||
`server_page.dart`, `bootstrap_dialog.dart` (Bootstrap-Teil, Verifikations-Teil
|
||||
wandert ggf. zu `verification`, s. o.). Schnittstelle bleibt der bereits
|
||||
vorhandene `matrixClientProvider`/`login_notifier`-Zustandsautomat – heute
|
||||
schon relativ sauber gekapselt, kein akuter Handlungsbedarf.
|
||||
|
||||
### 8. `push`
|
||||
**Dateien:** `core/background_push.dart`, `core/fcm_push_service.dart`,
|
||||
`core/notification_service.dart`. Bereits über eigene Dateien getrennt
|
||||
(nativ vs. Flutter-Engine, siehe Memory „Pyramid Push"). Einzige Lücke:
|
||||
eigener `sqflite`-Zugriff in `background_push.dart` statt gemeinsamer
|
||||
DB-Schicht – bewusst so gewollt (Background-Isolate-Zwang), daher **kein**
|
||||
Refactoring-Kandidat, nur dokumentieren.
|
||||
|
||||
### 9. `chat_timeline` / `rooms`
|
||||
Niedrigste Priorität für den Pilot-Umbau, da laut Ist-Analyse strukturell
|
||||
am wenigsten verwoben (Hauptproblem dort ist Dateigröße + Duplikate, nicht
|
||||
Modul-Kopplung). Wird nach dem Call-Pilot angegangen.
|
||||
|
||||
## Reihenfolge-Vorschlag für die Umsetzung
|
||||
|
||||
1. **Pilot: `call_signaling` + `voice_channel` + `call_ui`** (Punkt „Call-Schicht
|
||||
entwirren" in ROADMAP.md) – kleinster klar abgegrenzter Schnitt, deckt
|
||||
gleich zwei ROADMAP-Punkte ab (Modul-Schnitt beweisen + Call-Entwirrung).
|
||||
2. `settings_modal.dart` aufsplitten (reine Datei-Teilung, geringes Risiko,
|
||||
keine Verhaltensänderung).
|
||||
3. `verification` aus Settings herauslösen.
|
||||
4. `storage`-Fassade schrittweise erzwingen, immer im Zuge anderer Umbauten.
|
||||
5. `chat_timeline`/`rooms` zuletzt.
|
||||
|
||||
**Bernd-Entscheidung nötig:** siehe Frage in PROGRESS.md – ob Punkt 1
|
||||
(Call-Pilot) so wie hier vorgeschlagen als Nächstes umgesetzt werden soll.
|
||||
@@ -0,0 +1,95 @@
|
||||
# M2 – Einheitliches State-Management: Ist-Analyse + Umbauplan
|
||||
|
||||
> **STATUS 2026-07-06: reine Analyse, noch KEIN Umbau.** Grundlage für den
|
||||
> ROADMAP-M2-Punkt „Einheitliches State-Management (Riverpod konsequent,
|
||||
> keine Misch-Patterns)". Die Schritte 1–2 unten betreffen den heiligen
|
||||
> Call-Pfad und sind GESPERRT, bis Bernds Klick-Test des Call-Piloten
|
||||
> (`docs/PC_TESTPLAN.md` Abschnitt 8) grün ist – vorher nur Schritt 3.
|
||||
|
||||
## Ist-Stand (repo-weit erhoben, 2026-07-06)
|
||||
|
||||
Riverpod ist bereits das dominante Muster: rund 50 Provider –
|
||||
`StateProvider` für UI-Zustand, `StreamProvider` auf `client.onSync`,
|
||||
`FutureProvider` (Client, Update-Info), `StateNotifierProvider` für die
|
||||
Prefs-Notifier (`BoolPref`/`StringSetPref` in `core/settings_prefs.dart`).
|
||||
`ValueNotifier`/eigene `StreamController` gibt es NICHT. Lokales
|
||||
`setState` in Widgets für rein ephemeren Zustand (Hover, Dialog-Schritte,
|
||||
Textfelder) ist normales Flutter und zählt hier bewusst NICHT als
|
||||
Misch-Pattern.
|
||||
|
||||
Gefundene Misch-Patterns, nach Gewicht:
|
||||
|
||||
1. **Call-Schicht: Singletons + ChangeNotifier hinter den Fassaden.**
|
||||
`LiveKitCallManager.instance` (static final Singleton,
|
||||
`features/voice_channel/livekit_call_manager.dart:17`) und
|
||||
`PyramidVoipManager._instance` (nullable static,
|
||||
`features/call_signaling/voip_manager.dart:17`; konstruiert im
|
||||
Kompositions-Punkt `core/matrix_client.dart:66–71`). Die Modul-Provider
|
||||
(`voiceChannelProvider`/`callSignalingProvider`) geben nur die
|
||||
Singletons zurück. Das ist die im Pilot BEWUSST gewählte Zwischenform
|
||||
(Verhaltensgleichheit vor Schönheit). Eigentliches Problem: UI-Zustand
|
||||
(Mute-Flags, Teilnehmer, Renderer, Qualitätswahl) lebt mutierbar IN den
|
||||
Managern und wird per `notifyListeners()` grob-granular publiziert.
|
||||
2. **Voice-UI-Zustand außerhalb des Moduls:** `activeVoiceRoomIdProvider`
|
||||
und `voiceParticipantsProvider` liegen in `core/app_state.dart`
|
||||
(Kopplungspunkt laut Ist-Analyse), werden aber von Rooms-Panel und
|
||||
Mini-Call-Widget gesetzt/gelesen und gehören fachlich ins
|
||||
`voice_channel`-Modul (Join-Zustand + Presence sind Modulsache).
|
||||
3. **`MediaCache.instance`** (`core/media_cache.dart:19`): reines
|
||||
Singleton, kein UI-Zustand, nur Cache. Nutzer sind `widgets/mxc_image.dart`
|
||||
(7 Stellen, Widgets – leicht umstellbar) UND
|
||||
`features/chat/message/message_media.dart` (7 Stellen, darunter der
|
||||
statische Wrapper `_DocBytesCache` für Dokument-Vorschauen, dessen
|
||||
Aufrufer keinen `ref`-Zugang haben). Eine Umstellung müsste `ref`/die
|
||||
Cache-Instanz durch die statischen Helfer der Chat-Timeline fädeln –
|
||||
und genau dieser Code (Download-/Entschlüsselungs-Fehlerpfade) ist
|
||||
laut PC_TESTPLAN Abschnitt 3 der am wenigsten geprüfte Teil der App.
|
||||
4. **Push/Notifications:** modul-globale Mutable-Zustände in
|
||||
`core/notification_service.dart` (`_windowsNotifs`-Map, Plugin-Globals)
|
||||
und direkte `SharedPreferences`-Zugriffe in `background_push.dart`/
|
||||
`fcm_push_service.dart`. Das ist dem Background-Isolate-Zwang
|
||||
geschuldet (siehe Schnitt-Doc Punkt 8 + Memory „Pyramid Push") –
|
||||
**bewusst KEIN Umbau-Kandidat**, nur hier dokumentiert.
|
||||
|
||||
Bereits erledigt im Zuge dieser Analyse: der seit dem Call-Piloten tote
|
||||
`incomingCallProvider` ist aus `app_state.dart` entfernt (41bcaf2).
|
||||
|
||||
## Zielbild
|
||||
|
||||
- `core/app_state.dart` enthält nur noch ECHTEN App-weiten UI-Zustand
|
||||
(Theme, Layout/Panels, Navigation, Toast, Space-Auswahl) – kein
|
||||
Feature-Fachzustand.
|
||||
- Feature-Zustand lebt im jeweiligen Modul hinter dessen Provider;
|
||||
andere Module lesen ihn NUR über die öffentliche Schnittstelle.
|
||||
- Kein `.instance`-Zugriff über Modulgrenzen; Konstruktion ausschließlich
|
||||
im Kompositions-Punkt bzw. im Provider des Moduls.
|
||||
- ChangeNotifier ist als Übergangsform ok; Endzustand der Call-Module ist
|
||||
ein immutables Zustandsobjekt (z. B. `VoiceChannelState`) pro Modul,
|
||||
das der Provider publiziert, plus Kommando-Methoden auf dem Service
|
||||
(entspricht dem Schnitt-Doc-Entwurf, dort als Stream skizziert).
|
||||
|
||||
## Schrittfolge (Reihenfolge = Priorität)
|
||||
|
||||
1. **[GESPERRT bis Klick-Test] UI-State aus den Managern herauslösen:**
|
||||
je Call-Modul ein immutables Zustandsobjekt + `Notifier`/
|
||||
`StateNotifier`, Manager wird interne Implementierung ohne
|
||||
UI-Zustand. Das ist der bereits in PROGRESS.md zurückgestellte
|
||||
Logik-Umbau – NICHT vorher anfangen (heiliger Call-Pfad, ein
|
||||
ungetesteter Umbau liegt schon auf dem Stapel).
|
||||
2. **[GESPERRT bis Klick-Test] `activeVoiceRoomIdProvider` +
|
||||
`voiceParticipantsProvider` ins `voice_channel`-Modul umziehen**
|
||||
(zusammen mit `kVoicePresenceType`). Mechanisch klein, aber
|
||||
Call-Pfad-nah – sinnvoll im selben Zug wie Schritt 1.
|
||||
3. **[ZURÜCKGESTELLT] `MediaCache` hinter `Provider<MediaCache>`:**
|
||||
Erst als „risikoarm, jederzeit" eingestuft – das stimmte NICHT
|
||||
(Korrektur 2026-07-06, siehe Ist-Stand Punkt 3: auch die Chat-Timeline
|
||||
hängt dran, inkl. statischer Helfer ohne `ref`). Gehört in den Zug des
|
||||
`chat_timeline`-Moduls (Schnitt-Doc Punkt 9) bzw. frühestens nach dem
|
||||
Klick-Test von PC_TESTPLAN Abschnitt 3 – nicht als Lückenfüller.
|
||||
4. **[kein Umbau] Push/Notifications:** bleibt wie es ist
|
||||
(Isolate-Zwang); nur bei ohnehin anstehenden Push-Arbeiten die
|
||||
Prefs-Zugriffe auf die `Prefs`-Fassade ziehen (Schnitt-Doc Punkt 6).
|
||||
|
||||
Abhak-Kriterium für den ROADMAP-Punkt: Schritte 1–3 umgesetzt und die
|
||||
Kernflows (Login, Raum, Nachricht, Voice-Join) nach jedem Schritt
|
||||
praktisch geprüft; Punkt 4 braucht nur den Doku-Vermerk (steht hiermit).
|
||||
@@ -0,0 +1,174 @@
|
||||
# PC-Testplan – alle aufgelaufenen UNGETESTET(Pi)-Punkte
|
||||
|
||||
Stand: 2026-07-03; Autopilot-Ergebnisse vom **2026-07-06** eingetragen.
|
||||
Alle Pi-Sessions seit dem 2026-07-03 konnten nur
|
||||
`flutter analyze` + automatisierte Textvergleiche fahren (kein GUI auf dem Pi).
|
||||
Dieser Plan konsolidiert JEDEN ausstehenden Praxistest aus PROGRESS.md an einer
|
||||
Stelle. Beim nächsten Windows-Lauf von oben nach unten abarbeiten, Ergebnis je
|
||||
Punkt hier eintragen (✅/❌ + Datum), Befunde als eigene PROGRESS-Einträge.
|
||||
|
||||
Schnellster Weg: `flutter run -d windows`.
|
||||
|
||||
> **Grenze des Autopilot-Laufs 2026-07-06:** Der Autopilot kann bauen,
|
||||
> testen und die App starten/beenden, aber NICHT in der UI klicken. Alles
|
||||
> Klick-Interaktive (Abschnitte 2–8, Logout/Re-Login in Abschnitt 1) braucht
|
||||
> Bernd am PC – die App-Starts heute liefen mit Bernds bestehender Session.
|
||||
|
||||
## 0. Vorab (1 Minute)
|
||||
|
||||
- [x] `flutter test` – ✅ 2026-07-06: **32 Tests grün** (24 Bestand + 5 neue
|
||||
SQLCipher-Tests aus `test/app_database_test.dart` + 3 neue
|
||||
VoicePrefs-Vertragstests in `test/settings_prefs_test.dart`),
|
||||
mehrfach gelaufen (vor/nach jedem Umbau des Tages).
|
||||
|
||||
## 1. Härtetest Login/Krypto (M1 – PFLICHT, zuerst!)
|
||||
|
||||
Deckt die Fixes 63e4919, 9dc83f7, 891f348 ab (Uta-Random-Logout + Guard).
|
||||
Seit 2026-07-06 zusätzlich relevant: SQLCipher-Migration ist auf diesem PC
|
||||
gelaufen – die lokale DB ist jetzt verschlüsselt (PROGRESS.md 2026-07-06).
|
||||
|
||||
- [ ] Login mit Test-Account → Nachrichten in verschlüsseltem Raum senden/lesen
|
||||
(Teil-Evidenz ✅ 2026-07-06: Bestands-Session blieb über 3 App-Starts
|
||||
und die DB-Migration hinweg eingeloggt; App hat live to_device-Events
|
||||
von @uta entschlüsselt und Room-Keys forwardet → Lesen/E2EE belegt.
|
||||
Aktives SENDEN + frischer Login noch offen → Klick-Test Bernd)
|
||||
- [x] `auth_log.txt` (App-Support-Verzeichnis) prüfen: ✅ 2026-07-06 –
|
||||
LoginState-Wechsel werden mitgeschrieben (je Start `loggedIn`), dazu
|
||||
der DB-Migrations-Eintrag; keine unerwarteten Warnungen, kein
|
||||
Soft-Logout-Eintrag.
|
||||
- [ ] Logout (Warnung bei fehlendem Key-Backup erscheint? b5762ea) – Klick-Test
|
||||
- [ ] Erneuter Login → **alte verschlüsselte Nachrichten noch lesbar?** – Klick-Test
|
||||
- [x] Mehrere Stunden laufen lassen: kein Random-Logout, kein
|
||||
Login-Screen-Flackern (isLoggedInProvider-Änderung), `auth_log.txt`
|
||||
zeigt ggf. „Soft-Logout durch Token-Refresh abgewendet"
|
||||
(ERFÜLLT 2026-07-07 03:06: Fenster 22:49–03:06 = >4 h am Stück ohne
|
||||
Logout, sechs echte Netzausfälle kumulativ überstanden; ein
|
||||
Login-Screen-Flackern wäre als `LoginState`-Wechsel im auth_log
|
||||
sichtbar – keiner vorhanden. Historie: Zwischenstand 2026-07-06 16:57: Der 12:43-Lauf endete
|
||||
schon ~13:03 (letzter DB-Write; keine Absturzspur in Event-Log/WER,
|
||||
kein Logout-Eintrag – vermutlich Fenster geschlossen). Im 20-min-
|
||||
Fenster KEIN Random-Logout. Session überlebte danach den 4. Start
|
||||
mit verschlüsselter DB (auth_log 16:57:46 `loggedIn`, keine erneute
|
||||
Migration). Danach zwei geplante Neustarts für die Refactoring-
|
||||
Praxistests (17:08 VoicePrefs/call_ui, 17:14 voice_channel-Split),
|
||||
jeweils sauberer `loggedIn`, die Fenster dazwischen ohne Auffälligkeit
|
||||
(16:57–17:08 ≈ 50 min sauber). **Check 17:37 (dritte PC-Session):**
|
||||
Fenster 17:14–17:42 sauber – kein Logout-/Fehler-Eintrag, DB-Writes
|
||||
liefen durchgehend; Lauf um 17:42 vom Autopilot beendet (Rebuild für
|
||||
den settings_encryption-Split). Beobachtung läuft seit 17:43 mit dem
|
||||
aktuellen Code (auth_log 17:43:14 `loggedIn`, keine Migration).
|
||||
**Check 18:19 (vierte PC-Session), Fenster 17:43–18:19:** kein Logout,
|
||||
und obendrauf der bisher wertvollste Positiv-Befund – ein ECHTER
|
||||
Netzausfall 18:09:57–18:16:39 (~7 min, „Client has not connection to
|
||||
the server" alle 3 s im auth_log) wurde sauber überstanden: kein
|
||||
Logout, keine Eskalation, Sync erholte sich von selbst (WAL-Write
|
||||
18:18:13 nach der letzten Fehlerzeile). Genau der Fehlerpfad, den die
|
||||
Fixes 63e4919/9dc83f7/891f348 absichern sollen. **Check 18:29:**
|
||||
zweiter, kurzer Netz-Blip 18:25:56 (~40 s) ebenfalls selbst erholt
|
||||
(WAL-Write 18:28:38 danach), wieder kein Logout. Beobachtung läuft
|
||||
weiter (Lauf seit 17:43, nächstes Fenster ab 18:29); erst nach einem
|
||||
echten Mehr-Stunden-Fenster abhaken.
|
||||
**Check 23:12 (Nacht-Session), Fenster 18:29–22:49:** dritter
|
||||
Netzausfall 18:41:32–18:46:03 (~4,5 min, wieder „Client has not
|
||||
connection") ebenfalls selbst erholt, KEIN Logout, keine Eskalation;
|
||||
danach bis zum geplanten Stopp (Rebuild für die Nacht-Session-Fixes)
|
||||
keine weiteren Einträge. Neustart 22:49:22 `loggedIn`, KEINE
|
||||
Re-Migration → 8. Start in Folge mit verschlüsselter DB sauber.
|
||||
Fenster 22:49–23:12 leer, WAL-Writes laufen (23:12:33). Beobachtung
|
||||
läuft weiter, nächstes Fenster ab 23:12.
|
||||
**Check 03:06 (2026-07-07, Nacht-Session 2), Fenster 23:12–03:06:**
|
||||
DREI weitere Netz-Blips, alle selbst erholt, KEIN Logout: 01:32:17–
|
||||
01:32:20 (~3 s), 02:01:53–02:06:36 (~4,7 min), 02:33:21–02:36:37
|
||||
(~3,3 min; „Client has not connection" alle 3 s). WAL-Write 03:05:43
|
||||
NACH der letzten Fehlerzeile, Prozess (PID 47412, seit 22:49) lebt.
|
||||
Gegenprobe: die parallel laufende pyramidtest1-Instanz (profile1)
|
||||
loggt DIESELBEN Fenster → echter Netz-/Server-Ausfall, nicht
|
||||
app-spezifisch; auch sie ohne Logout erholt. Server beim Check
|
||||
wieder erreichbar (HTTP 200). Beobachtung läuft weiter, nächstes
|
||||
Fenster ab 03:06.
|
||||
Kumulativ: 2026-07-06 12:26 – 2026-07-07 03:06 über 8 Starts, sechs
|
||||
echte Netzausfälle (~7 min / ~40 s / ~4,5 min / ~3 s / ~4,7 min /
|
||||
~3,3 min) – ohne einen einzigen Random-Logout. Das aktuelle Fenster
|
||||
22:49–03:06 ist erstmals ein echtes Mehr-Stunden-Fenster (>4 h) am
|
||||
Stück.)
|
||||
- [ ] Ergebnis als „Härtetest"-Eintrag in PROGRESS.md dokumentieren
|
||||
(= ROADMAP-M1-Punkt „Härtetest dokumentieren" abhaken)
|
||||
- [ ] Android: nach Neu-Login beobachten, ob Push-Inhalte wieder echt
|
||||
entschlüsselt ankommen (statt „Neue Nachricht"-Platzhalter). Falls nicht:
|
||||
zweite Ursache, siehe `docs/NOTIFICATIONS.md` + Memory „Pyramid Push"
|
||||
- [ ] Danach Uta: einmal aus- und wieder einloggen (erst dann hat ihre
|
||||
Session einen Refresh-Token!)
|
||||
|
||||
## 2. Settings-Split (5d77238, 12 Teildateien)
|
||||
|
||||
Alle Reiter öffnen und benutzen: Profil, Benachrichtigungen, Erscheinungsbild,
|
||||
Voice, Tastaturkürzel, Privatsphäre, Sessions, Verschlüsselung, Account, Über.
|
||||
|
||||
- [ ] Verschlüsselung (HEILIG): Recovery-Key anzeigen, Megolm-Export UND
|
||||
-Import (Passphrase), SAS/QR-Verifizierung starten
|
||||
- [ ] Sessions: Gerät umbenennen, Einzel-Logout, Massen-Logout-Dialog
|
||||
(Passwort-Reauth) nur bis zur Abfrage
|
||||
- [ ] Account: Passwort-ändern-Dialog öffnen, Account-löschen-Dialog öffnen
|
||||
(NICHT bestätigen)
|
||||
- [ ] Logout-Warnungs-Text bei fehlendem Backup sichtbar (settings_about/shared)
|
||||
|
||||
## 3. Chat-Timeline-Split (5bf45a9) + tote-Code-Bereinigung (6400fb6)
|
||||
|
||||
Einen Chat mit allen Nachrichtentypen durchgehen:
|
||||
|
||||
- [ ] Text mit Link-Vorschau, Bild, Video, Audio, Datei-Anhang
|
||||
- [ ] Download-/Entschlüsselungs-Fehlerpfade in `message_media.dart` gezielt
|
||||
ansehen (am wenigsten geprüfter Teil)
|
||||
- [ ] Reaktionen setzen/entfernen, Hover-Aktionsleiste, Antworten/Zitat
|
||||
- [ ] Datums-Trenner, „Neue Nachrichten"-Divider
|
||||
- [ ] Gescheiterte Nachricht: bleibt mit Retry/Löschen stehen, Retry geht
|
||||
- [ ] Zeitanzeige bei aufeinanderfolgenden Nachrichten + Hover-Uhrzeit bei
|
||||
Fortsetzungsnachrichten (`_formatMessageTime`-Zusammenführung)
|
||||
|
||||
## 4. Raumlisten-Split (a863fa3) + Reorder-Fix (401bd6a)
|
||||
|
||||
- [ ] Space wechseln, Raum beitreten/verlassen
|
||||
- [ ] **Drag&Drop-Umsortieren: landet der Raum EXAKT an der Zielposition?**
|
||||
(onReorder→onReorderItem, Index-Korrektur jetzt im Framework – Off-by-One
|
||||
wäre hier sichtbar; nach oben UND nach unten ziehen testen)
|
||||
- [ ] Voice-Channel-Teilnehmerliste in der Raumliste
|
||||
- [ ] Einladung annehmen/ablehnen, Ungelesen-Badge, DM-Avatare
|
||||
|
||||
## 5. Space-Admin-Split (0c7d359)
|
||||
|
||||
An einem TEST-Space, nicht an Utas echtem:
|
||||
|
||||
- [ ] Übersicht: Sichtbarkeit umschalten, Banner ändern (Danger-Zone nur ansehen)
|
||||
- [ ] Mitglieder: einladen, Rolle ändern (Selbst-Aussperr-Schutz
|
||||
`updatePowerLevelsSafely` greift?), kicken
|
||||
- [ ] Berechtigungen ändern, Kanäle: erstellen + bestehenden Raum hinzufügen
|
||||
|
||||
## 6. Chat-View-Split (31e4001)
|
||||
|
||||
- [ ] Chat-Header (normal + DM mit ausklappendem Header/Presence)
|
||||
- [ ] Tippanzeige, Offline-/Reconnect-Banner (Netz kurz trennen)
|
||||
- [ ] Datei per Drag&Drop in den Chat ziehen (Overlay + Senden)
|
||||
|
||||
## 7. Dokument-Viewer-Split (daf4cf3) + Matrix4-Fix (401bd6a)
|
||||
|
||||
- [ ] PDF inline im Chat, Vollbild-Viewer: **Zoom per Buttons/Strg+Mausrad**
|
||||
(translateByDouble/scaleByDouble-Umbau – zoomt es zentriert wie vorher?),
|
||||
Seiten-Navigation, Thumbnail-Leiste, Speichern-Button
|
||||
- [ ] Text/Markdown/SVG-Vorschau, Office-Text-Extraktion
|
||||
- [ ] ZIP/TAR-Archiv-Browser, PSD-Thumbnail
|
||||
- [ ] Bild im Chat öffnen (Fallback-Pfade)
|
||||
|
||||
## 8. Kleinkram aus den Lint-Commits
|
||||
|
||||
- [ ] Mini-Call-Widget: Buttons zeigen jetzt Hover-Farbe (9dc1175, gewollt) –
|
||||
sieht das ok aus?
|
||||
- [ ] Anhang-Dialog + Raum-erstellen-Dialog: Switches (activeThumbColor) sehen
|
||||
aus wie vorher
|
||||
- [ ] Voice-Channel beitreten/verlassen (Kernflow laut CLAUDE.md, von den
|
||||
Splits nicht berührt, aber Pflicht nach jedem Refactoring)
|
||||
|
||||
## Danach
|
||||
|
||||
- [ ] Alle ✅ hier eintragen, PROGRESS.md-Eintrag „PC-Praxistest" schreiben,
|
||||
erst DANN neue riskante Punkte anfangen (Call-Fassade, SQLCipher –
|
||||
siehe `docs/SQLCIPHER_MIGRATION.md`)
|
||||
+4
-2
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
# SQLCipher-Migration der `pyramid.sqlite` – Umsetzungsplan (PC-Termin)
|
||||
|
||||
> **STATUS 2026-07-06: UMGESETZT (`lib/core/app_database.dart`) und auf
|
||||
> Windows mit Bernds echter 14-MB-DB praxisgetestet** (Migration gelaufen,
|
||||
> 23 Tabellen verifiziert, Login/E2EE danach intakt, Neustart öffnet die
|
||||
> verschlüsselte DB, `flutter test` deckt Migration/Fehlpfade mit echtem
|
||||
> SQLCipher ab).
|
||||
> **Android-Emulator-Test 2026-07-06 (API 36, x86_64, Debug-APK) GRÜN:**
|
||||
> (a) frische Installation legt die DB direkt verschlüsselt an
|
||||
> (Header = Zufallsbytes, per run-as verifiziert), kein Cipher-Fallback im
|
||||
> auth_log; (b) Update-Szenario nach `pm clear` + untergeschobener
|
||||
> Klartext-DB (synthetisch, 2 Tabellen/550 Zeilen): Migration lief beim
|
||||
> ersten Start („2 Tabellen verifiziert" im auth_log), `.premigration`
|
||||
> liegt da, DB danach verschlüsselt; (c) Neustart öffnet mit dem
|
||||
> Keystore-Schlüssel OHNE Re-Migration, App rendert normal den
|
||||
> Login-Screen. Damit sind openCipherOnAndroid, sqlcipher_export und
|
||||
> flutter_secure_storage auf Android (Haupt-Isolate) real belegt.
|
||||
> **Offen bleibt (deshalb Punkt 4 NICHT voll abgehakt):** echtes
|
||||
> ARM-Gerät, Migration einer ECHTEN eingeloggten Session auf Android und
|
||||
> der Push-Test (FCM-Isolate liest verschlüsselte DB, secure_storage im
|
||||
> Background-Isolate) – braucht ein Testgerät mit eingeloggtem Account.
|
||||
> Erst danach Release und Uta. **Abweichung vom Plan unten:** Die Annahme „keine Desktop-Binaries"
|
||||
> ist seit sqlcipher_flutter_libs 0.6.x überholt – das Plugin baut SQLCipher
|
||||
> auf Windows/Linux als `sqlite3.dll`/`libsqlite3.so` mit (im Build-Output
|
||||
> verifiziert). Deshalb ist Phase 1 NICHT Android-only: Windows/Linux
|
||||
> verschlüsseln ebenfalls, ein Test-Override ist unnötig. iOS/macOS bleiben
|
||||
> Klartext (SQLCipher-Pod kollidiert mit firebase_messaging, kein Release-Ziel).
|
||||
|
||||
## Für Bernd in einfach
|
||||
|
||||
Die Datenbank der App (Nachrichten, Zugangs-Token, Verschlüsselungs-Schlüssel)
|
||||
liegt heute **unverschlüsselt** auf dem Gerät – geschützt nur durch die
|
||||
Android-App-Sandbox. Ein Angreifer bräuchte Root oder physischen Zugriff mit
|
||||
Entwickler-Tools, um sie zu lesen. Dieser Plan verschlüsselt die Datei selbst.
|
||||
Der riskante Teil ist nicht die Verschlüsselung, sondern die **einmalige
|
||||
Umwandlung bestehender Installationen** (Utas Handy!) – deshalb: erst am PC
|
||||
mit Testdaten, dann eigenes Gerät, dann erst Release.
|
||||
|
||||
**Offene Entscheidung (Frage in PROGRESS.md):** Priorität vor/nach M2?
|
||||
|
||||
## Ist-Zustand (verifiziert im Code)
|
||||
|
||||
| Stelle | Datei | Factory heute |
|
||||
|---|---|---|
|
||||
| Haupt-App Android/iOS | `lib/core/matrix_client.dart:24` | `sqflite_native.databaseFactory` (System-SQLite, **kann kein SQLCipher**) |
|
||||
| Haupt-App Desktop | `lib/core/matrix_client.dart:27` | `databaseFactoryFfi` (gebündeltes sqlite3) |
|
||||
| Push-Isolate (nur Android) | `lib/core/background_push.dart` `_buildClient()` | `sqflite_native.databaseFactory` |
|
||||
|
||||
- `sqlcipher_flutter_libs ^0.6.8` ist bereits in `pubspec.yaml`, wird nirgends benutzt.
|
||||
- `flutter_secure_storage ^10` ist bereits da (→ Schlüsselablage im Android Keystore).
|
||||
- Beide Prozesse öffnen **dieselbe Datei** (`pyramid.sqlite`, WAL-Modus,
|
||||
`busy_timeout 5000`).
|
||||
|
||||
## Zielarchitektur
|
||||
|
||||
1. **Ein gemeinsamer DB-Öffner** `lib/core/app_database.dart` (neues Storage-Modul
|
||||
im Sinne von M2): kapselt Factory-Wahl, PRAGMAs, Schlüsselbeschaffung und
|
||||
Migration. `matrix_client.dart` und `background_push.dart` rufen NUR noch
|
||||
diese Fassade – damit verschwindet auch die heutige Code-Duplikation der
|
||||
PRAGMA-Blöcke.
|
||||
2. **Android:** nicht mehr `sqflite_native`, sondern `databaseFactoryFfi` mit
|
||||
SQLCipher-Lib:
|
||||
```dart
|
||||
import 'package:sqlite3/open.dart';
|
||||
import 'package:sqlcipher_flutter_libs/sqlcipher_flutter_libs.dart';
|
||||
open.overrideFor(OperatingSystem.android, openCipherOnAndroid);
|
||||
```
|
||||
**Achtung Push-Isolate:** `databaseFactoryFfi` spawnt ein eigenes Isolate –
|
||||
im Firebase-Background-Isolate ist das genau die Fallenklasse, an der schon
|
||||
`NativeImplementationsIsolate` scheiterte (Kommentar in
|
||||
`background_push.dart`). Dort deshalb `databaseFactoryFfiNoIsolate` benutzen.
|
||||
3. **Schlüssel:** 32 Byte kryptografisch zufällig (`Random.secure()`), hex-kodiert
|
||||
in `flutter_secure_storage` unter `db_cipher_key`. Beide Prozesse lesen
|
||||
denselben Eintrag. Schlüssel wird bei ERSTER Nutzung erzeugt; existiert schon
|
||||
eine verschlüsselte DB, aber kein Schlüssel im Storage → harter Fehler mit
|
||||
Klartext-Meldung, NIEMALS still neue DB anlegen (Datenverlust-Regel!).
|
||||
4. **Öffnen:** `PRAGMA key = "x'<hex>'"` als allererstes Statement
|
||||
(`OpenDatabaseOptions(onConfigure: ...)`), danach wie bisher WAL + busy_timeout.
|
||||
5. **Windows/Linux-Desktop:** `sqlcipher_flutter_libs` liefert dort KEINE
|
||||
Binaries. Phase 1 = nur Android verschlüsseln (dort liegt das echte Risiko,
|
||||
dort sind die echten Nutzer); Desktop bleibt vorerst Klartext, klar im Code
|
||||
kommentiert. Desktop-SQLCipher später als eigener Punkt (eigene DLL bündeln).
|
||||
|
||||
## Migration bestehender Klartext-DBs (der heikle Teil)
|
||||
|
||||
Reihenfolge beim App-Start, VOR `MatrixSdkDatabase.init`, VOR jedem Push-Zugriff:
|
||||
|
||||
1. Erkennung: Datei beginnt mit `SQLite format 3\0` → Klartext → Migration nötig.
|
||||
(Verschlüsselte SQLCipher-Dateien haben keinen lesbaren Header.)
|
||||
2. WAL eindampfen: Klartext-DB kurz normal öffnen,
|
||||
`PRAGMA wal_checkpoint(TRUNCATE)`, schließen – sonst verlieren wir Daten,
|
||||
die noch in `pyramid.sqlite-wal` liegen.
|
||||
3. **Backup:** `pyramid.sqlite` → `pyramid.sqlite.premigration` kopieren
|
||||
(Bytes, nicht rename). Bleibt bis zum verifizierten Erfolg liegen.
|
||||
4. Export in NEUE Datei (Original bleibt unangetastet):
|
||||
```sql
|
||||
ATTACH DATABASE 'pyramid.enc.sqlite' AS enc KEY "x'<hex>'";
|
||||
SELECT sqlcipher_export('enc');
|
||||
DETACH DATABASE enc;
|
||||
```
|
||||
(läuft über die SQLCipher-FFI-Verbindung auf der Klartext-DB)
|
||||
5. Verifikation: `pyramid.enc.sqlite` mit Schlüssel öffnen,
|
||||
`PRAGMA integrity_check` == ok UND Zeilenzahl einer Kerntabelle
|
||||
(z. B. `box_inbound_group_session`, Name gegen matrix-6.2.0
|
||||
`matrix_sdk_database.dart:139` verifiziert) identisch mit Original. Schlägt IRGENDWAS
|
||||
fehl → `pyramid.enc.sqlite` löschen, App startet normal mit Klartext-DB
|
||||
weiter, Fehler in `auth_log.txt`. **Kein Zustand darf die Klartext-DB
|
||||
beschädigt zurücklassen.**
|
||||
6. Tausch: Original + `-wal`/`-shm` löschen, `pyramid.enc.sqlite` →
|
||||
`pyramid.sqlite` (rename, gleiche Partition = atomar).
|
||||
7. `.premigration`-Backup erst nach N erfolgreichen Tagen / manuell löschen
|
||||
(Entscheidung Bernd: sonst liegt weiter eine Klartext-Kopie herum –
|
||||
Kompromiss: nach 7 Tagen beim Start automatisch löschen).
|
||||
|
||||
### Race mit dem Push-Isolate
|
||||
|
||||
Kommt ein FCM-Push, während die Haupt-App migriert, würde das Isolate die DB
|
||||
mittendrin öffnen. Lösung: Marker-Datei `pyramid.sqlite.migrating` vor Schritt 2
|
||||
anlegen, nach Schritt 6 löschen; `_buildClient()` im Push-Pfad bricht bei
|
||||
vorhandenem Marker sofort ab (Notification zeigt dann einmalig den
|
||||
„Neue Nachricht"-Platzhalter – akzeptabel). Zusätzlich erkennt das Isolate
|
||||
selbst Klartext vs. verschlüsselt am Datei-Header und wählt PRAGMA key
|
||||
entsprechend – es migriert aber NIE selbst.
|
||||
|
||||
## Testplan (PC-Termin, in dieser Reihenfolge)
|
||||
|
||||
1. Windows-Build mit Test-Account: Migration Klartext→SQLCipher durchlaufen
|
||||
lassen (Desktop kriegt fürs TESTEN die FFI-SQLCipher-Variante per lokalem
|
||||
Override, auch wenn Phase 1 sie im Release nicht aktiviert).
|
||||
2. Kernflow danach: Login erhalten? Alte verschlüsselte Nachrichten lesbar?
|
||||
Logout → Neu-Login → immer noch lesbar? (`docs/PC_TESTPLAN.md` Abschnitt 1)
|
||||
3. Fehlerpfade erzwingen: Migration mit vollem Datenträger / Kill mitten in
|
||||
Schritt 4 → App muss mit unversehrter Klartext-DB weiterlaufen.
|
||||
4. Android-Testgerät (eigenes, NICHT Uta): frische Installation (Neuanlage
|
||||
verschlüsselt), dann Update-Szenario (bestehende Klartext-DB migriert),
|
||||
dann Push-Test (Isolate liest verschlüsselte DB; secure_storage-Zugriff
|
||||
aus dem Background-Isolate explizit verifizieren – bekanntes Risiko).
|
||||
5. Erst wenn 1–4 grün: Release, Uta informieren (ihr Gerät migriert beim
|
||||
ersten Start automatisch; vorher ihr Key-Backup verifizieren!).
|
||||
|
||||
## Bewusst NICHT im Scope
|
||||
|
||||
- `auth_log.txt`, `SharedPreferences`, Media-Cache bleiben Klartext (keine
|
||||
Geheimnisse bzw. nur Cache; einzeln bewerten, falls Bernd es wünscht).
|
||||
- Passphrase-basierter Schlüssel (Nutzer-Eingabe): bewusst nein – Schlüssel
|
||||
liegt im Keystore, gleiche Vertrauensstufe wie der Rest der App-Daten,
|
||||
aber Datei-Exfiltration allein reicht dann nicht mehr.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Streaming: hohe Auflösung + 60 FPS (M4)
|
||||
|
||||
**Ziel (Bernd):** Bildschirm-Streams ruckeln aktuell. Es soll eine hohe Streaming-Einstellung
|
||||
geben – hohe Auflösung UND hohe FPS, idealerweise **60 FPS**, flüssig.
|
||||
|
||||
## Ist-Zustand (im Code gefunden, 2026-07-06)
|
||||
|
||||
Datei `lib/features/voice_channel/livekit_call_manager.dart`:
|
||||
- `qualityPresets` (~Z. 21): Auflösungs-Presets `sd`(360p) / `hd`(720p) / `fhd`(1080p) / `4k`(2160p).
|
||||
- `_screenShareEncodings` (~Z. 28): **FPS hart gedeckelt** – `sd`=10, `hd`=15, `fhd`=20, `4k`=30.
|
||||
**Es gibt KEIN 60-FPS-Preset.** Bitraten für hohe FPS zu niedrig (fhd nur 2,5 Mbit/s @20,
|
||||
4k nur 4 Mbit/s @30).
|
||||
- `startScreenShare` (~Z. 453): erstellt `ScreenShareCaptureOptions(maxFrameRate: …)` und
|
||||
published mit `simulcast: true`, `degradationPreference: DegradationPreference.balanced`.
|
||||
- **`contentHint` wird NICHT gesetzt** → der Encoder weiß nicht, dass es bewegtes Video ist,
|
||||
und priorisiert Detail/Auflösung statt Framerate → Ruckeln.
|
||||
- `_applyFpsToAllSimulcastLayers` (~Z. 485) hebt die FPS nachträglich auf allen Simulcast-Layern an.
|
||||
|
||||
UI `lib/features/call_ui/view/quality_menu.dart` (~Z. 21): „SENDEN"-Menü listet sd/hd/fhd/4k.
|
||||
|
||||
## Wahrscheinliche Ruckel-Ursachen
|
||||
1. `maxFramerate` hart auf 20–30 begrenzt (nie 60).
|
||||
2. Zu niedrige Bitrate für hohe FPS → Encoder verwirft/verwischt Frames.
|
||||
3. `contentHint` nicht auf `motion` → Encoder opfert FPS für Detail.
|
||||
4. `degradationPreference: balanced` → unter Last sinkt eher die FPS als die Auflösung.
|
||||
|
||||
## Aufgabe
|
||||
1. **Neue High-FPS-Presets** für die Send-Qualität, z. B.:
|
||||
- `hd60` → 720p, `VideoEncoding(maxBitrate ~4–5 Mbit/s, maxFramerate 60)`
|
||||
- `fhd60` → 1080p, `VideoEncoding(maxBitrate ~6–8 Mbit/s, maxFramerate 60)`
|
||||
Sowohl in `qualityPresets` (Auflösung) als auch `_screenShareEncodings` (Bitrate/FPS) eintragen
|
||||
und im „SENDEN"-Menü in `quality_menu.dart` als „HD 60fps" / „Full HD 60fps" ergänzen.
|
||||
2. **`contentHint = 'motion'`** auf dem Screenshare-Track setzen (flutter_webrtc/LiveKit-Feld) –
|
||||
wichtigster Einzelhebel gegen Ruckeln bei Bewegung.
|
||||
3. Für die 60-FPS-Modi **`degradationPreference: DegradationPreference.maintainFramerate`**
|
||||
statt `balanced` (unter Last lieber Auflösung senken als FPS).
|
||||
4. Sicherstellen, dass 60 wirklich durchgereicht wird – auch in `ScreenShareCaptureOptions.maxFrameRate`
|
||||
und `_applyFpsToAllSimulcastLayers`.
|
||||
5. Für den flüssigen 60-FPS-Modus **`simulcast: false` erwägen** (volle Bitrate auf einen Layer),
|
||||
A/B gegen simulcast=true testen.
|
||||
|
||||
## Testen (mit den Test-Accounts)
|
||||
- Streamen als pyramidtest1, zuschauen als pyramidtest2 (zwei Logins/Fenster).
|
||||
- Prüfen, dass `sender.parameters.encodings` real `maxFramerate == 60` und die hohe `maxBitrate` tragen.
|
||||
- Subjektiv: flüssiger? Bei Bewegung kein Ruckeln?
|
||||
- EHRLICH protokollieren, was wirklich getestet wurde. Echtes flüssiges 60-FPS-Empfinden ist ggf.
|
||||
nur mit echtem Gerät/Netz voll beurteilbar → dann klar als UNGETESTET markieren.
|
||||
|
||||
## Nicht vergessen
|
||||
`flutter analyze` sauber, kleiner Commit + Push, PROGRESS.md + ROADMAP (M4) pflegen.
|
||||
@@ -70,7 +70,7 @@ Continuwuity hat einen speziellen Admin-Raum. Als Server-Admin kannst du dort Be
|
||||
# Als curl (Admin-Token erforderlich):
|
||||
# Admin-Room-ID herausfinden:
|
||||
curl -s "https://steggi-matrix.work/_matrix/client/v3/directory/room/%23admins%3Asteggi-matrix.work" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
-H "Authorization: Bearer <ADMIN_TOKEN>"
|
||||
# → {"room_id": "!adminRoomId:steggi-matrix.work", ...}
|
||||
```
|
||||
|
||||
@@ -84,22 +84,22 @@ curl -s "https://steggi-matrix.work/_matrix/client/v3/directory/room/%23admins%3
|
||||
|
||||
# User zu Server-Admin machen
|
||||
curl -X POST "https://steggi-matrix.work/_conduwuit/admin/v1/make_user_admin" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
|
||||
-H "Authorization: Bearer <ADMIN_TOKEN>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"user_id": "@todesneutron:steggi-matrix.work"}'
|
||||
|
||||
# Server-Status abfragen
|
||||
curl "https://steggi-matrix.work/_conduwuit/server/version" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
-H "Authorization: Bearer <ADMIN_TOKEN>"
|
||||
|
||||
# Alternativ — Standard Matrix-Whoami (prüft ob Token gültig):
|
||||
curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
-H "Authorization: Bearer <ADMIN_TOKEN>"
|
||||
# → {"user_id": "@todesneutron:steggi-matrix.work", "is_guest": false}
|
||||
|
||||
# Prüfen ob User Server-Admin ist (Conduwuit-spezifisch):
|
||||
curl "https://steggi-matrix.work/_conduwuit/admin/v1/users/@todesneutron:steggi-matrix.work" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
-H "Authorization: Bearer <ADMIN_TOKEN>"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -112,7 +112,7 @@ Als Server-Admin kannst du via Matrix Client API Power Levels in jedem Raum setz
|
||||
```bash
|
||||
# Schritt 1: Aktuelles Power-Level-Event lesen
|
||||
ROOM_ID="!raumId:steggi-matrix.work"
|
||||
TOKEN="J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
TOKEN="<ADMIN_TOKEN>"
|
||||
SERVER="https://steggi-matrix.work"
|
||||
|
||||
curl "${SERVER}/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.power_levels/" \
|
||||
@@ -172,7 +172,7 @@ await client.setRoomStateWithKey(
|
||||
```bash
|
||||
# Testen ob restricted join rules funktionieren:
|
||||
ROOM_ID="!testRaum:steggi-matrix.work"
|
||||
TOKEN="J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
TOKEN="<ADMIN_TOKEN>"
|
||||
SPACE_ID="!spaceId:steggi-matrix.work"
|
||||
|
||||
curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.join_rules/" \
|
||||
@@ -230,7 +230,7 @@ try {
|
||||
```bash
|
||||
# 1. Prüfen: Welchen Power Level hat mein User in diesem Raum?
|
||||
ROOM_ID="!raumId:steggi-matrix.work"
|
||||
TOKEN="J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
TOKEN="<ADMIN_TOKEN>"
|
||||
|
||||
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.power_levels/" \
|
||||
-H "Authorization: Bearer ${TOKEN}"
|
||||
@@ -269,7 +269,7 @@ curl "https://steggi-matrix.work/_matrix/client/v3/capabilities" \
|
||||
```bash
|
||||
# Prüfen ob User Server-Admin ist:
|
||||
curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
-H "Authorization: Bearer <ADMIN_TOKEN>"
|
||||
|
||||
# Auf Pi (SSH): User-Datenbank direkt abfragen (Conduwuit/Continuwuity nutzt RocksDB)
|
||||
# Alternativ: Über Admin-Room-Befehl
|
||||
@@ -278,7 +278,7 @@ curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \
|
||||
|
||||
# Über HTTP (Conduwuit Admin-Endpoint):
|
||||
curl -X POST "https://steggi-matrix.work/_conduwuit/admin/v1/make_user_admin" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
|
||||
-H "Authorization: Bearer <ADMIN_TOKEN>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"user_id": "@todesneutron:steggi-matrix.work"}'
|
||||
```
|
||||
@@ -292,19 +292,19 @@ Da kein Synapse-Admin-API existiert, nutze die Standard-Matrix-Client-API mit Ad
|
||||
```bash
|
||||
# Raum beitreten (als Admin, auch wenn invite-only):
|
||||
curl -X POST "https://steggi-matrix.work/_matrix/client/v3/join/!roomId%3Asteggi-matrix.work" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
|
||||
-H "Authorization: Bearer <ADMIN_TOKEN>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{}'
|
||||
|
||||
# User in Raum einladen:
|
||||
curl -X POST "https://steggi-matrix.work/_matrix/client/v3/rooms/!roomId%3Asteggi-matrix.work/invite" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
|
||||
-H "Authorization: Bearer <ADMIN_TOKEN>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"user_id": "@user:steggi-matrix.work"}'
|
||||
|
||||
# Space-Child-Relation setzen (Channel einem Space hinzufügen):
|
||||
curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/!spaceId%3Asteggi-matrix.work/state/m.space.child/!channelId%3Asteggi-matrix.work/" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
|
||||
-H "Authorization: Bearer <ADMIN_TOKEN>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"via": ["steggi-matrix.work"],
|
||||
@@ -313,7 +313,7 @@ curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/!spaceId%3Astegg
|
||||
|
||||
# Space-Parent-Relation setzen (im Channel-Raum):
|
||||
curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/!channelId%3Asteggi-matrix.work/state/m.space.parent/!spaceId%3Asteggi-matrix.work/" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
|
||||
-H "Authorization: Bearer <ADMIN_TOKEN>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"via": ["steggi-matrix.work"],
|
||||
@@ -322,11 +322,11 @@ curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/!channelId%3Aste
|
||||
|
||||
# Alle Mitglieder eines Raums auflisten:
|
||||
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/!roomId%3Asteggi-matrix.work/members" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
-H "Authorization: Bearer <ADMIN_TOKEN>"
|
||||
|
||||
# Raum verlassen (als User, nicht löschen):
|
||||
curl -X POST "https://steggi-matrix.work/_matrix/client/v3/rooms/!roomId%3Asteggi-matrix.work/leave" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
|
||||
-H "Authorization: Bearer <ADMIN_TOKEN>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{}'
|
||||
```
|
||||
@@ -419,15 +419,15 @@ Wenn du M_FORBIDDEN bekommst:
|
||||
```bash
|
||||
# 1. Token gültig?
|
||||
curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
-H "Authorization: Bearer <ADMIN_TOKEN>"
|
||||
|
||||
# 2. PL im betroffenen Raum prüfen:
|
||||
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/!ROOM_ID/state/m.room.power_levels/" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
-H "Authorization: Bearer <ADMIN_TOKEN>"
|
||||
|
||||
# 3. Mitglied des Raums?
|
||||
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/!ROOM_ID/state/m.room.member/%40todesneutron%3Asteggi-matrix.work/" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
-H "Authorization: Bearer <ADMIN_TOKEN>"
|
||||
|
||||
# 4. Was genau schlägt fehl? (Antwort-Body lesen!)
|
||||
# M_FORBIDDEN + "Power level too low" → PL erhöhen
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:sqflite/sqflite.dart' as sqflite_native;
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import 'package:sqlcipher_flutter_libs/sqlcipher_flutter_libs.dart';
|
||||
import 'package:sqlite3/open.dart';
|
||||
import 'package:pyramid/core/app_dirs.dart';
|
||||
import 'package:pyramid/core/auth_log.dart';
|
||||
|
||||
/// Läuft im DB-Isolate der FFI-Factory (muss deshalb top-level sein):
|
||||
/// Auf Android liefert das sqlcipher_flutter_libs-Plugin die Bibliothek als
|
||||
/// `libsqlcipher.so` – dem sqlite3-Paket muss man das dort sagen. Auf
|
||||
/// Windows/Linux bündelt dasselbe Plugin SQLCipher bereits UNTER DEM
|
||||
/// STANDARDNAMEN (`sqlite3.dll` neben der exe, verifiziert im Build-Output),
|
||||
/// der Default-Loader findet sie also ohne Override.
|
||||
void _sqlcipherFfiInit() {
|
||||
open.overrideFor(OperatingSystem.android, openCipherOnAndroid);
|
||||
}
|
||||
|
||||
/// Gemeinsamer Öffner für die `pyramid.sqlite` – der eine Storage-Baustein,
|
||||
/// hinter dem Factory-Wahl, Verschlüsselung und Migration stecken (Plan:
|
||||
/// `docs/SQLCIPHER_MIGRATION.md`, Modularitäts-Leitsatz aus CLAUDE.md).
|
||||
///
|
||||
/// Kapselt an EINER Stelle, was vorher in `matrix_client.dart` und
|
||||
/// `background_push.dart` dupliziert war:
|
||||
/// * Factory-Wahl pro Plattform (FFI + SQLCipher wo verfügbar),
|
||||
/// * Schlüsselbeschaffung: 32 zufällige Bytes, hex-kodiert im
|
||||
/// Plattform-Keystore (`flutter_secure_storage`, Eintrag `db_cipher_key`),
|
||||
/// * einmalige Migration bestehender Klartext-DBs nach SQLCipher,
|
||||
/// * die PRAGMAs (WAL, busy_timeout).
|
||||
///
|
||||
/// Oberste Regel (CLAUDE.md „Kein Datenverlust"): KEIN Codepfad darf eine
|
||||
/// bestehende DB beschädigen oder still eine leere neue anlegen. Im Zweifel
|
||||
/// läuft die App unverschlüsselt weiter und der Fehler steht im auth_log.
|
||||
class AppDatabase {
|
||||
AppDatabase._();
|
||||
|
||||
static const _cipherKeyStorageKey = 'db_cipher_key';
|
||||
|
||||
/// Klartext-Backup `*.premigration` nach so vielen Tagen automatisch
|
||||
/// aufräumen (Kompromiss aus dem Migrationsplan: Rollback-Fenster vs.
|
||||
/// dauerhaft herumliegende Klartext-Kopie).
|
||||
static const _premigrationMaxAge = Duration(days: 7);
|
||||
|
||||
/// Plattformen, auf denen sqlcipher_flutter_libs die Cipher-Bibliothek
|
||||
/// mitliefert. iOS/macOS bleiben bewusst beim nativen sqflite-Plugin
|
||||
/// (Klartext): der SQLCipher-Pod kollidiert dort mit Paketen, die das
|
||||
/// System-sqlite3 linken (z. B. firebase_messaging, siehe Paket-README),
|
||||
/// und iOS ist kein Release-Ziel von Pyramid.
|
||||
static bool get _isCipherPlatform =>
|
||||
!kIsWeb && (Platform.isAndroid || Platform.isWindows || Platform.isLinux);
|
||||
|
||||
static Future<String> _dbPath() async {
|
||||
final appDir = await appSupportDir();
|
||||
return p.join(appDir.path, 'pyramid.sqlite');
|
||||
}
|
||||
|
||||
// ── Öffnen: Haupt-App ────────────────────────────────────────────────────
|
||||
|
||||
/// Öffnet die Datenbank für die Haupt-App. Nur dieser Pfad migriert eine
|
||||
/// bestehende Klartext-DB (das Push-Isolate migriert NIE selbst).
|
||||
static Future<Database> open() async {
|
||||
final dbPath = await _dbPath();
|
||||
|
||||
if (!_isCipherPlatform) {
|
||||
// iOS/macOS: unverändert das native sqflite-Plugin, Klartext.
|
||||
final db = await sqflite_native.databaseFactory.openDatabase(dbPath);
|
||||
await _applyPragmas(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
// Lade-Workaround für Android 6 (Paket-README) – im Main-Isolate
|
||||
// ausführen, BEVOR irgendein Isolate sqlite benutzt.
|
||||
try {
|
||||
await applyWorkaroundToOpenSqlCipherOnOldAndroidVersions();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
final factory = createDatabaseFactoryFfi(ffiInit: _sqlcipherFfiInit);
|
||||
|
||||
await _recoverFromInterruptedSwap(dbPath);
|
||||
await _clearStaleMarker(dbPath);
|
||||
|
||||
final dbFile = File(dbPath);
|
||||
final exists = await dbFile.exists();
|
||||
final isPlaintext = exists && await isPlaintextSqlite(dbFile);
|
||||
|
||||
if (!await _cipherAvailable(factory)) {
|
||||
// Sicherheitsnetz: sollte auf Cipher-Plattformen nie passieren
|
||||
// (Bibliothek wird als Plugin mitgebaut). Eine VERSCHLÜSSELTE DB ohne
|
||||
// Cipher-Lib ist ein harter Fehler – niemals still neu anlegen.
|
||||
if (exists && !isPlaintext) {
|
||||
throw StateError(
|
||||
'pyramid.sqlite ist verschlüsselt, aber die SQLCipher-Bibliothek '
|
||||
'fehlt in diesem Build. Kein Zugriff möglich – Daten bleiben '
|
||||
'unangetastet. Bitte App-Build prüfen (sqlcipher_flutter_libs).',
|
||||
);
|
||||
}
|
||||
await AuthLog.write(
|
||||
'[DB] SQLCipher nicht verfügbar – Datenbank bleibt unverschlüsselt.',
|
||||
);
|
||||
final db = await factory.openDatabase(dbPath);
|
||||
await _applyPragmas(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
if (exists && !isPlaintext) {
|
||||
// Bereits verschlüsselt: der Schlüssel MUSS im Keystore liegen.
|
||||
final key = await _readStoredKey();
|
||||
if (key == null || key.isEmpty) {
|
||||
throw StateError(
|
||||
'pyramid.sqlite ist verschlüsselt, aber im Schlüsselspeicher fehlt '
|
||||
'"$_cipherKeyStorageKey". Es wird KEINE neue Datenbank angelegt, '
|
||||
'damit keine Daten verloren gehen. Schlüsselspeicher prüfen '
|
||||
'(flutter_secure_storage) oder Backup einspielen.',
|
||||
);
|
||||
}
|
||||
final db = await _openWithKey(factory, dbPath, key);
|
||||
await _applyPragmas(db);
|
||||
await _cleanupOldBackup(dbPath);
|
||||
return db;
|
||||
}
|
||||
|
||||
final key = await _getOrCreateKey();
|
||||
|
||||
if (exists && isPlaintext) {
|
||||
final migrated = await migratePlaintextToEncrypted(
|
||||
factory: factory,
|
||||
dbPath: dbPath,
|
||||
hexKey: key,
|
||||
);
|
||||
if (!migrated) {
|
||||
// Migration fehlgeschlagen → Klartext-DB ist unversehrt, App läuft
|
||||
// wie bisher weiter (Fehlerdetails stehen im auth_log).
|
||||
final db = await factory.openDatabase(dbPath);
|
||||
await _applyPragmas(db);
|
||||
return db;
|
||||
}
|
||||
}
|
||||
|
||||
// Frische Installation (legt verschlüsselt neu an) oder frisch migriert.
|
||||
final db = await _openWithKey(factory, dbPath, key);
|
||||
await _applyPragmas(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
// ── Öffnen: Android-Push-Isolate ─────────────────────────────────────────
|
||||
|
||||
/// Öffnet die Datenbank für das Push-/Hintergrund-Isolate. Gibt `null`
|
||||
/// zurück, wenn gerade kein sicherer Zugriff möglich ist (Migration läuft,
|
||||
/// DB fehlt, Schlüssel fehlt) – die Notification zeigt dann einmalig den
|
||||
/// nativen „Neue Nachricht"-Platzhalter, das ist der akzeptierte Preis.
|
||||
/// Dieser Pfad migriert NIE und legt NIE eine neue Datei an.
|
||||
static Future<Database?> openForPush() async {
|
||||
final dbPath = await _dbPath();
|
||||
|
||||
if (await File('$dbPath.migrating').exists()) {
|
||||
debugPrint('[DB] Migration läuft – Push-Zugriff übersprungen.');
|
||||
return null;
|
||||
}
|
||||
|
||||
final dbFile = File(dbPath);
|
||||
if (!await dbFile.exists()) return null;
|
||||
|
||||
if (!_isCipherPlatform) {
|
||||
final db = await sqflite_native.databaseFactory.openDatabase(dbPath);
|
||||
await _applyPragmas(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
// noIsolate: databaseFactoryFfi würde ein Unter-Isolate spawnen – genau
|
||||
// die Fallenklasse, an der im Firebase-Background-Isolate schon
|
||||
// NativeImplementationsIsolate scheiterte (siehe background_push.dart).
|
||||
final factory =
|
||||
createDatabaseFactoryFfi(ffiInit: _sqlcipherFfiInit, noIsolate: true);
|
||||
|
||||
if (await isPlaintextSqlite(dbFile)) {
|
||||
// Noch nicht migriert (macht beim nächsten Start die Haupt-App).
|
||||
final db = await factory.openDatabase(dbPath);
|
||||
await _applyPragmas(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
final key = await _readStoredKey();
|
||||
if (key == null || key.isEmpty) {
|
||||
// Bekanntes Risiko aus dem Plan: secure_storage aus dem
|
||||
// Background-Isolate. Niemals raten oder neu anlegen – nur aussteigen.
|
||||
debugPrint('[DB] Kein Cipher-Schlüssel im Push-Isolate lesbar.');
|
||||
return null;
|
||||
}
|
||||
final db = await _openWithKey(factory, dbPath, key);
|
||||
await _applyPragmas(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
// ── Migration Klartext → SQLCipher ───────────────────────────────────────
|
||||
|
||||
/// Wandelt eine bestehende Klartext-DB in eine SQLCipher-DB um – der heikle
|
||||
/// Teil des Plans. Reihenfolge exakt wie in `docs/SQLCIPHER_MIGRATION.md`:
|
||||
/// Marker → WAL-Checkpoint → Byte-Backup → Export in NEUE Datei →
|
||||
/// Verifikation (integrity_check + Zeilenzahlen ALLER Tabellen) → atomarer
|
||||
/// Tausch. Schlägt irgendetwas fehl, bleibt die Klartext-DB unangetastet
|
||||
/// und die Funktion gibt `false` zurück.
|
||||
@visibleForTesting
|
||||
static Future<bool> migratePlaintextToEncrypted({
|
||||
required DatabaseFactory factory,
|
||||
required String dbPath,
|
||||
required String hexKey,
|
||||
}) async {
|
||||
final marker = File('$dbPath.migrating');
|
||||
final encPath = '$dbPath.enc';
|
||||
final backupPath = '$dbPath.premigration';
|
||||
try {
|
||||
// Marker zuerst: hält das Push-Isolate fern (Race aus dem Plan).
|
||||
await marker.writeAsString(DateTime.now().toIso8601String());
|
||||
|
||||
// 1. WAL eindampfen – sonst fehlen im Export Daten, die noch in
|
||||
// pyramid.sqlite-wal liegen.
|
||||
var db = await factory.openDatabase(dbPath);
|
||||
try {
|
||||
await db.rawQuery('PRAGMA wal_checkpoint(TRUNCATE)');
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
|
||||
// 2. Byte-Backup (Kopie, kein rename) – bleibt bis zum verifizierten
|
||||
// Erfolg + Aufräumfrist liegen.
|
||||
await File(dbPath).copy(backupPath);
|
||||
|
||||
// 3. Export in NEUE Datei; das Original wird nie beschrieben.
|
||||
final encFile = File(encPath);
|
||||
if (await encFile.exists()) await encFile.delete();
|
||||
final Map<String, int> sourceCounts;
|
||||
db = await factory.openDatabase(dbPath);
|
||||
try {
|
||||
sourceCounts = await _tableRowCounts(db);
|
||||
await db.rawQuery(
|
||||
'ATTACH DATABASE \'${_escapeSqlString(encPath)}\' AS enc '
|
||||
'KEY "x\'$hexKey\'"',
|
||||
);
|
||||
await db.rawQuery("SELECT sqlcipher_export('enc')");
|
||||
await db.rawQuery('DETACH DATABASE enc');
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
|
||||
// 4. Verifikation der NEUEN Datei, bevor irgendetwas getauscht wird.
|
||||
final encDb = await _openWithKey(factory, encPath, hexKey);
|
||||
try {
|
||||
final integrity = await encDb.rawQuery('PRAGMA integrity_check');
|
||||
final integrityOk = integrity.length == 1 &&
|
||||
integrity.first.values.first.toString() == 'ok';
|
||||
if (!integrityOk) {
|
||||
throw StateError('integrity_check fehlgeschlagen: $integrity');
|
||||
}
|
||||
final encCounts = await _tableRowCounts(encDb);
|
||||
for (final entry in sourceCounts.entries) {
|
||||
if (encCounts[entry.key] != entry.value) {
|
||||
throw StateError(
|
||||
'Zeilenzahl weicht ab in "${entry.key}": '
|
||||
'${entry.value} (Original) != ${encCounts[entry.key]} (Export)',
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await encDb.close();
|
||||
}
|
||||
|
||||
// 5. Tausch. rename auf derselben Partition ist atomar; für das
|
||||
// Zeitfenster zwischen delete und rename existiert das Byte-Backup
|
||||
// (Wiederanlauf repariert das, siehe _recoverFromInterruptedSwap).
|
||||
for (final suffix in ['-wal', '-shm']) {
|
||||
final sidecar = File('$dbPath$suffix');
|
||||
if (await sidecar.exists()) await sidecar.delete();
|
||||
}
|
||||
await File(dbPath).delete();
|
||||
await File(encPath).rename(dbPath);
|
||||
|
||||
await AuthLog.write(
|
||||
'[DB] Klartext-DB erfolgreich nach SQLCipher migriert '
|
||||
'(${sourceCounts.length} Tabellen verifiziert).',
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
// Aufräumen: halbfertigen Export löschen, Original bleibt maßgeblich.
|
||||
try {
|
||||
final encFile = File(encPath);
|
||||
if (await encFile.exists()) await encFile.delete();
|
||||
} catch (_) {}
|
||||
await AuthLog.write(
|
||||
'[DB] SQLCipher-Migration fehlgeschlagen – Klartext-DB bleibt '
|
||||
'unangetastet: $e',
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
try {
|
||||
if (await marker.exists()) await marker.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Absturz exakt zwischen „Original gelöscht" und „Export umbenannt":
|
||||
/// dann fehlt `pyramid.sqlite`, aber das Byte-Backup existiert. Backup
|
||||
/// zurückkopieren (die Migration läuft danach einfach erneut). Ein evtl.
|
||||
/// liegengebliebener Export ist nicht als vollständig beweisbar → löschen.
|
||||
static Future<void> _recoverFromInterruptedSwap(String dbPath) async {
|
||||
final backup = File('$dbPath.premigration');
|
||||
if (await File(dbPath).exists() || !await backup.exists()) return;
|
||||
try {
|
||||
final enc = File('$dbPath.enc');
|
||||
if (await enc.exists()) await enc.delete();
|
||||
await backup.copy(dbPath);
|
||||
await AuthLog.write(
|
||||
'[DB] Unterbrochene Migration erkannt – Klartext-Backup '
|
||||
'wiederhergestellt, Migration läuft erneut.',
|
||||
);
|
||||
} catch (e) {
|
||||
await AuthLog.write('[DB] Wiederherstellung aus .premigration fehlgeschlagen: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Marker ohne laufende Migration ist ein Absturz-Überbleibsel – beim
|
||||
/// Start der Haupt-App (es gibt nur eine) gefahrlos entfernbar.
|
||||
static Future<void> _clearStaleMarker(String dbPath) async {
|
||||
try {
|
||||
final marker = File('$dbPath.migrating');
|
||||
if (await marker.exists()) {
|
||||
await marker.delete();
|
||||
await AuthLog.write('[DB] Verwaisten Migrations-Marker entfernt.');
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static Future<void> _cleanupOldBackup(String dbPath) async {
|
||||
try {
|
||||
final backup = File('$dbPath.premigration');
|
||||
if (!await backup.exists()) return;
|
||||
final age = DateTime.now().difference((await backup.stat()).modified);
|
||||
if (age > _premigrationMaxAge) {
|
||||
await backup.delete();
|
||||
await AuthLog.write(
|
||||
'[DB] Klartext-Backup .premigration nach '
|
||||
'${_premigrationMaxAge.inDays} Tagen gelöscht.',
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ── Bausteine ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Klartext-SQLite-Dateien beginnen mit dem 16-Byte-Header
|
||||
/// `SQLite format 3\0`; SQLCipher-Dateien haben keinen lesbaren Header.
|
||||
/// Leere/winzige Dateien zählen als Klartext (die Migration exportiert sie
|
||||
/// dann schlicht leer).
|
||||
@visibleForTesting
|
||||
static Future<bool> isPlaintextSqlite(File file) async {
|
||||
final raf = await file.open();
|
||||
try {
|
||||
final header = await raf.read(16);
|
||||
if (header.length < 16) return true;
|
||||
// 16-Byte-Magic: "SQLite format 3" gefolgt von einem NUL-Byte.
|
||||
const magic = 'SQLite format 3';
|
||||
for (var i = 0; i < magic.length; i++) {
|
||||
if (header[i] != magic.codeUnitAt(i)) return false;
|
||||
}
|
||||
return header[15] == 0x00;
|
||||
} finally {
|
||||
await raf.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Öffnet eine Datei mit Schlüssel. `PRAGMA key` MUSS das allererste
|
||||
/// Statement der Verbindung sein → onConfigure. Der sqlite_master-Zugriff
|
||||
/// danach lässt einen falschen/fehlenden Schlüssel sofort hier scheitern
|
||||
/// statt irgendwann später mitten im Betrieb.
|
||||
static Future<Database> _openWithKey(
|
||||
DatabaseFactory factory,
|
||||
String path,
|
||||
String hexKey,
|
||||
) async {
|
||||
final db = await factory.openDatabase(
|
||||
path,
|
||||
options: OpenDatabaseOptions(
|
||||
onConfigure: (db) async {
|
||||
await db.rawQuery('PRAGMA key = "x\'$hexKey\'"');
|
||||
},
|
||||
),
|
||||
);
|
||||
try {
|
||||
await db.rawQuery('SELECT count(*) FROM sqlite_master');
|
||||
} catch (e) {
|
||||
await db.close();
|
||||
rethrow;
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
static Future<Database> openEncrypted(
|
||||
DatabaseFactory factory,
|
||||
String path,
|
||||
String hexKey,
|
||||
) =>
|
||||
_openWithKey(factory, path, hexKey);
|
||||
|
||||
/// `PRAGMA cipher_version` liefert bei normalem sqlite3 KEINE Zeile – der
|
||||
/// vom Paket-README empfohlene Wächter dagegen, versehentlich unverschlüsselt
|
||||
/// weiterzulaufen, weil die falsche Bibliothek geladen wurde.
|
||||
static Future<bool> _cipherAvailable(DatabaseFactory factory) async {
|
||||
final db = await factory.openDatabase(inMemoryDatabasePath);
|
||||
try {
|
||||
final rows = await db.rawQuery('PRAGMA cipher_version');
|
||||
if (rows.isEmpty) return false;
|
||||
final value = rows.first.values.first;
|
||||
return value != null && value.toString().isNotEmpty;
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Wie bisher in matrix_client.dart/background_push.dart: WAL gegen
|
||||
/// "database is locked" zwischen Haupt-App und Push-Isolate.
|
||||
static Future<void> _applyPragmas(Database db) async {
|
||||
try {
|
||||
await db.execute('PRAGMA journal_mode=WAL');
|
||||
await db.execute('PRAGMA busy_timeout = 5000');
|
||||
} catch (e) {
|
||||
if (kDebugMode) debugPrint('Could not set PRAGMAs: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<String?> _readStoredKey() =>
|
||||
const FlutterSecureStorage().read(key: _cipherKeyStorageKey);
|
||||
|
||||
/// Erzeugt beim allerersten Mal 32 kryptografisch zufällige Bytes und legt
|
||||
/// sie hex-kodiert im Keystore ab. Der Read-Back danach ist Pflicht: würde
|
||||
/// das Schreiben still scheitern und wir trotzdem damit verschlüsseln,
|
||||
/// wäre die DB beim nächsten Start unlesbar (Totalverlust).
|
||||
static Future<String> _getOrCreateKey() async {
|
||||
const storage = FlutterSecureStorage();
|
||||
final existing = await storage.read(key: _cipherKeyStorageKey);
|
||||
if (existing != null && existing.isNotEmpty) return existing;
|
||||
|
||||
final rnd = Random.secure();
|
||||
final key = List.generate(
|
||||
32,
|
||||
(_) => rnd.nextInt(256).toRadixString(16).padLeft(2, '0'),
|
||||
).join();
|
||||
await storage.write(key: _cipherKeyStorageKey, value: key);
|
||||
final readBack = await storage.read(key: _cipherKeyStorageKey);
|
||||
if (readBack != key) {
|
||||
throw StateError(
|
||||
'Schlüsselspeicher-Verifikation fehlgeschlagen: '
|
||||
'"$_cipherKeyStorageKey" konnte nicht zuverlässig abgelegt werden. '
|
||||
'Es wird NICHT verschlüsselt (sonst wäre die DB später unlesbar).',
|
||||
);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
static Future<Map<String, int>> _tableRowCounts(Database db) async {
|
||||
final tables = await db.rawQuery(
|
||||
"SELECT name FROM sqlite_master "
|
||||
"WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
|
||||
);
|
||||
final result = <String, int>{};
|
||||
for (final row in tables) {
|
||||
final name = row['name'] as String;
|
||||
final count = await db.rawQuery('SELECT count(*) AS c FROM "$name"');
|
||||
result[name] = (count.first['c'] as num).toInt();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// SQL-String-Literal: einfache Anführungszeichen verdoppeln (der Pfad
|
||||
/// kommt aus dem App-Verzeichnis, aber sicher ist sicher).
|
||||
static String _escapeSqlString(String value) => value.replaceAll("'", "''");
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
/// Basisverzeichnis für alle lokalen App-Daten (pyramid.sqlite, auth_log.txt,
|
||||
/// Media-Cache).
|
||||
///
|
||||
/// Normalfall: das Application-Support-Verzeichnis der Plattform – exakt wie
|
||||
/// bisher, es ändert sich NICHTS.
|
||||
///
|
||||
/// Nur auf dem Desktop kann die Umgebungsvariable `PYRAMID_PROFILE_DIR` ein
|
||||
/// abweichendes Verzeichnis erzwingen. Zweck: Testläufe mit den
|
||||
/// Autopilot-Testkonten in einem eigenen Profil, parallel zur echten
|
||||
/// Session desselben Windows-Nutzers (die echte Session sieht die Variable
|
||||
/// nie und bleibt unberührt). Bewusst NICHT umgeleitet werden
|
||||
/// shared_preferences und flutter_secure_storage – deren Plugins lösen ihre
|
||||
/// Pfade selbst auf; ein Testprofil teilt sich also den DB-Schlüssel
|
||||
/// (`db_cipher_key`) und die UI-Prefs mit dem Hauptprofil. Für Tests ist das
|
||||
/// unkritisch (der Schlüssel wird nur GELESEN, nie überschrieben), echte
|
||||
/// Zweitkonten bräuchten mehr.
|
||||
Future<Directory> appSupportDir() async {
|
||||
final override = Platform.isWindows || Platform.isLinux || Platform.isMacOS
|
||||
? Platform.environment['PYRAMID_PROFILE_DIR']
|
||||
: null;
|
||||
if (override != null && override.trim().isNotEmpty) {
|
||||
final dir = Directory(override.trim());
|
||||
await dir.create(recursive: true);
|
||||
return dir;
|
||||
}
|
||||
return getApplicationSupportDirectory();
|
||||
}
|
||||
+3
-12
@@ -3,8 +3,6 @@ import 'package:matrix/matrix.dart';
|
||||
import 'package:pyramid/core/settings_prefs.dart';
|
||||
import 'package:pyramid/core/theme.dart';
|
||||
import 'package:pyramid/core/matrix_client.dart';
|
||||
import 'package:pyramid/core/livekit_call_manager.dart';
|
||||
import 'package:pyramid/core/voip_manager.dart';
|
||||
import 'package:pyramid/features/rooms/rooms_provider.dart';
|
||||
|
||||
// ─── Theme state ──────────────────────────────────────────────────────────────
|
||||
@@ -64,16 +62,9 @@ enum ModalKind { none, settings, search }
|
||||
final activeModalProvider = StateProvider<ModalKind>((ref) => ModalKind.none);
|
||||
|
||||
// ─── Call state ───────────────────────────────────────────────────────────────
|
||||
|
||||
final callStateProvider = ChangeNotifierProvider<LiveKitCallManager>((ref) {
|
||||
return LiveKitCallManager.instance;
|
||||
});
|
||||
|
||||
final voipStateProvider = ChangeNotifierProvider<PyramidVoipManager>((ref) {
|
||||
return PyramidVoipManager.instance;
|
||||
});
|
||||
|
||||
final incomingCallProvider = StateProvider<CallSession?>((ref) => null);
|
||||
// Die Call-Provider leben seit dem M2-Call-Pilot in ihren Modulen:
|
||||
// `callSignalingProvider` (features/call_signaling/call_signaling_service.dart)
|
||||
// und `voiceChannelProvider` (features/voice_channel/voice_channel_service.dart).
|
||||
|
||||
// ─── View mode (chat / voice) ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:pyramid/core/app_dirs.dart';
|
||||
|
||||
/// Schreibt Login-/Logout-relevante Ereignisse in eine Datei, die einen
|
||||
/// App-Neustart übersteht. Zweck: den Uta-Random-Logout-Bug nachvollziehen
|
||||
/// können, auch wenn die App zwischen dem Logout und dem nächsten Blick ins
|
||||
/// Log neu gestartet wurde (siehe ROADMAP M1).
|
||||
class AuthLog {
|
||||
static const _maxLines = 500;
|
||||
static File? _file;
|
||||
|
||||
static Future<File> _ensureFile() async {
|
||||
final existing = _file;
|
||||
if (existing != null) return existing;
|
||||
final dir = await appSupportDir();
|
||||
final file = File(p.join(dir.path, 'auth_log.txt'));
|
||||
return _file = file;
|
||||
}
|
||||
|
||||
static Future<void> write(String message) async {
|
||||
try {
|
||||
final file = await _ensureFile();
|
||||
await file.writeAsString(
|
||||
'${DateTime.now().toIso8601String()} $message\n',
|
||||
mode: FileMode.append,
|
||||
flush: true,
|
||||
);
|
||||
final lines = await file.readAsLines();
|
||||
if (lines.length > _maxLines) {
|
||||
await file.writeAsString(
|
||||
'${lines.sublist(lines.length - _maxLines).join('\n')}\n',
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
// Logging darf selbst nie einen Fehlerpfad (oder gar einen Logout) auslösen.
|
||||
}
|
||||
}
|
||||
|
||||
static Future<String> read() async {
|
||||
try {
|
||||
final file = await _ensureFile();
|
||||
if (!await file.exists()) return '(noch keine Einträge)';
|
||||
return await file.readAsString();
|
||||
} catch (e) {
|
||||
return 'Log konnte nicht gelesen werden: $e';
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
-130
@@ -1,6 +1,4 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
@@ -9,12 +7,9 @@ import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod;
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:pyramid/core/app_database.dart';
|
||||
import 'package:pyramid/core/soft_logout_guard.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:sqflite/sqflite.dart' as sqflite_native;
|
||||
|
||||
const _kChannel = 'pyramid_messages';
|
||||
|
||||
/// Port name used to locate the main isolate from background isolates.
|
||||
/// Registered by notification_service.dart when the app starts.
|
||||
@@ -71,7 +66,7 @@ void notificationEngineMain() {
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
print('[NOTIF-BGENGINE] handler error: $e');
|
||||
debugPrint('[NOTIF-BGENGINE] handler error: $e');
|
||||
}
|
||||
return false;
|
||||
});
|
||||
@@ -115,7 +110,7 @@ Future<bool> _bgDecryptAndShow(String? roomId, String? eventId, int? notifId) as
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
print('[NOTIF-BGENGINE] decryptAndShow error: $e');
|
||||
debugPrint('[NOTIF-BGENGINE] decryptAndShow error: $e');
|
||||
return false;
|
||||
} finally {
|
||||
// Let the async key-backup upload settle before closing the DB — otherwise
|
||||
@@ -142,10 +137,10 @@ Future<bool> _bgSendReply(String? roomId, String? text) async {
|
||||
return false;
|
||||
}
|
||||
await room.sendTextEvent(text.trim());
|
||||
print('[NOTIF-BGENGINE] encrypted reply sent to $roomId');
|
||||
debugPrint('[NOTIF-BGENGINE] encrypted reply sent to $roomId');
|
||||
return true;
|
||||
} catch (e) {
|
||||
print('[NOTIF-BGENGINE] sendReply error: $e');
|
||||
debugPrint('[NOTIF-BGENGINE] sendReply error: $e');
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('notif_pending_reply_room', roomId);
|
||||
await prefs.setString('notif_pending_reply_text', text);
|
||||
@@ -167,7 +162,7 @@ Future<void> registerBgEngineHandle() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt('bg_engine_handle', handle.toRawHandle());
|
||||
} catch (e) {
|
||||
print('[NOTIF-BGENGINE] registerBgEngineHandle failed: $e');
|
||||
debugPrint('[NOTIF-BGENGINE] registerBgEngineHandle failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,18 +180,13 @@ Future<Client?> _buildClient() async {
|
||||
await vod.init();
|
||||
} catch (_) {}
|
||||
|
||||
final appDir = await getApplicationSupportDirectory();
|
||||
final dbPath = p.join(appDir.path, 'pyramid.sqlite');
|
||||
|
||||
final db = await sqflite_native.databaseFactory
|
||||
.openDatabase(dbPath)
|
||||
.timeout(const Duration(seconds: 5));
|
||||
|
||||
// Prevent "database is locked" if the main process still has the DB open.
|
||||
try {
|
||||
await db.execute('PRAGMA busy_timeout = 5000');
|
||||
await db.execute('PRAGMA journal_mode = WAL');
|
||||
} catch (_) {}
|
||||
// Gemeinsame Storage-Fassade: erkennt Klartext vs. SQLCipher am
|
||||
// Datei-Header, migriert aber NIE selbst (docs/SQLCIPHER_MIGRATION.md).
|
||||
// null = gerade kein sicherer Zugriff (Migration läuft / DB oder
|
||||
// Schlüssel fehlt) → Notification behält den nativen Platzhalter.
|
||||
final db =
|
||||
await AppDatabase.openForPush().timeout(const Duration(seconds: 10));
|
||||
if (db == null) return null;
|
||||
|
||||
final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db);
|
||||
|
||||
@@ -205,6 +195,12 @@ Future<Client?> _buildClient() async {
|
||||
database: sdkDb,
|
||||
nativeImplementations: NativeImplementationsDummy(),
|
||||
logLevel: Level.error,
|
||||
// Kritisch: getEventByPushNotification() ruft als Erstes
|
||||
// ensureNotSoftLoggedOut() auf. Ohne eigenen Handler würde ein
|
||||
// fehlgeschlagener Token-Refresh AUS DEM HINTERGRUND-ISOLATE heraus
|
||||
// logout()+clear() auf der geteilten pyramid.sqlite auslösen und die
|
||||
// Session der gesamten App zerstören. Siehe soft_logout_guard.dart.
|
||||
onSoftLogout: guardedSoftLogoutRefresh,
|
||||
);
|
||||
|
||||
await client
|
||||
@@ -216,7 +212,7 @@ Future<Client?> _buildClient() async {
|
||||
|
||||
return client;
|
||||
} catch (e) {
|
||||
print('[NOTIF-BGENGINE] _buildClient failed: $e');
|
||||
debugPrint('[NOTIF-BGENGINE] _buildClient failed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -245,8 +241,8 @@ Future<void> handleBackgroundNotificationResponse(
|
||||
NotificationResponse details) async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
print('[NOTIF-BG] handleBackgroundNotificationResponse fired');
|
||||
print('[NOTIF-BG] actionId=${details.actionId} payload=${details.payload} input=${details.input} id=${details.id}');
|
||||
debugPrint('[NOTIF-BG] handleBackgroundNotificationResponse fired');
|
||||
debugPrint('[NOTIF-BG] actionId=${details.actionId} payload=${details.payload} input=${details.input} id=${details.id}');
|
||||
|
||||
// ── Fast path: main isolate is running ───────────────────────────────────
|
||||
// If the Pyramid UI process is alive (app is backgrounded but not killed),
|
||||
@@ -254,7 +250,7 @@ Future<void> handleBackgroundNotificationResponse(
|
||||
// any new engine and works silently without bringing the app to foreground.
|
||||
final sendPort = IsolateNameServer.lookupPortByName(kMainIsolateName);
|
||||
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>{
|
||||
'actionId': details.actionId,
|
||||
'payload': details.payload,
|
||||
@@ -268,7 +264,7 @@ Future<void> handleBackgroundNotificationResponse(
|
||||
// Main isolate is gone. Dismiss the notification and, for reply actions,
|
||||
// persist the text in SharedPreferences so _sendPendingReply picks it up
|
||||
// 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();
|
||||
await plugin.initialize(
|
||||
@@ -280,20 +276,20 @@ Future<void> handleBackgroundNotificationResponse(
|
||||
final roomId = details.payload;
|
||||
if (roomId != null) {
|
||||
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) {
|
||||
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') {
|
||||
print('[NOTIF-BG] not a reply action, done');
|
||||
debugPrint('[NOTIF-BG] not a reply action, done');
|
||||
return;
|
||||
}
|
||||
|
||||
final text = details.input?.trim() ?? '';
|
||||
if (text.isEmpty || roomId == null) {
|
||||
print('[NOTIF-BG] text empty or roomId null, aborting');
|
||||
debugPrint('[NOTIF-BG] text empty or roomId null, aborting');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -301,102 +297,6 @@ Future<void> handleBackgroundNotificationResponse(
|
||||
// _bgSendReply falls back to the prefs queue if it can't send (so the main app
|
||||
// delivers it on next launch).
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
+26
-31
@@ -1,4 +1,4 @@
|
||||
import 'dart:io';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -6,38 +6,17 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod;
|
||||
import 'package:matrix/encryption/utils/key_verification.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqflite/sqflite.dart' as sqflite_native;
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import 'package:pyramid/core/voip_manager.dart';
|
||||
import 'package:pyramid/core/app_database.dart';
|
||||
import 'package:pyramid/core/auth_log.dart';
|
||||
import 'package:pyramid/core/soft_logout_guard.dart';
|
||||
// Kompositions-Punkt: Einzige Stelle außerhalb des call_signaling-Moduls,
|
||||
// die die Implementierung (statt der Fassade) kennen darf – sie erzeugt sie.
|
||||
import 'package:pyramid/features/call_signaling/voip_manager.dart';
|
||||
|
||||
final matrixClientProvider = FutureProvider<Client>((ref) async {
|
||||
final DatabaseFactory factory;
|
||||
|
||||
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
|
||||
// Use the native sqflite plugin — Android/iOS have system SQLite built-in,
|
||||
// no .so loading required.
|
||||
factory = sqflite_native.databaseFactory;
|
||||
} else if (!kIsWeb) {
|
||||
sqfliteFfiInit();
|
||||
factory = databaseFactoryFfi;
|
||||
} else {
|
||||
throw UnsupportedError('Web not supported');
|
||||
}
|
||||
|
||||
final appDir = await getApplicationSupportDirectory();
|
||||
final dbPath = p.join(appDir.path, 'pyramid.sqlite');
|
||||
|
||||
final db = await factory.openDatabase(dbPath);
|
||||
|
||||
// Optimization for Windows/FFI to prevent "database is locked" errors
|
||||
try {
|
||||
await db.execute('PRAGMA journal_mode=WAL');
|
||||
await db.execute('PRAGMA busy_timeout = 5000');
|
||||
} catch (e) {
|
||||
if (kDebugMode) print('Could not set PRAGMAs: $e');
|
||||
}
|
||||
// Factory-Wahl, Verschlüsselung (SQLCipher), Migration und PRAGMAs stecken
|
||||
// komplett in der Storage-Fassade — siehe docs/SQLCIPHER_MIGRATION.md.
|
||||
final db = await AppDatabase.open();
|
||||
|
||||
final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db);
|
||||
|
||||
@@ -57,12 +36,28 @@ final matrixClientProvider = FutureProvider<Client>((ref) async {
|
||||
KeyVerificationMethod.qrShow,
|
||||
},
|
||||
logLevel: kReleaseMode ? Level.warning : Level.verbose,
|
||||
// Ohne eigenen Handler eskaliert das SDK jeden fehlgeschlagenen
|
||||
// Token-Refresh (auch bloße Netzwerkfehler!) zu logout()+clear() —
|
||||
// Session und Krypto-Schlüssel wären weg. Siehe soft_logout_guard.dart.
|
||||
onSoftLogout: guardedSoftLogoutRefresh,
|
||||
nativeImplementations: NativeImplementationsIsolate(
|
||||
compute,
|
||||
vodozemacInit: () => vod.init(),
|
||||
),
|
||||
);
|
||||
|
||||
// Persistiert Warnungen/Fehler des SDK sowie jeden Login-Status-Wechsel,
|
||||
// damit ein unerwarteter Logout (z. B. Soft-Logout vom Server) auch nach
|
||||
// einem App-Neustart noch nachvollziehbar ist (ROADMAP M1, Uta-Logout-Bug).
|
||||
Logs().onLog = (event) {
|
||||
if (event.level.index <= Level.warning.index) {
|
||||
unawaited(AuthLog.write('[SDK ${event.level.name}] ${event.toFormattedString()}'));
|
||||
}
|
||||
};
|
||||
client.onLoginStateChanged.stream.listen((state) {
|
||||
unawaited(AuthLog.write('LoginState -> ${state.name}'));
|
||||
});
|
||||
|
||||
await client.init(
|
||||
waitForFirstSync: false,
|
||||
waitUntilLoadCompletedLoaded: false,
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:pyramid/core/app_dirs.dart';
|
||||
|
||||
/// Shared, bounded LRU cache for media bytes (chat images, document previews,
|
||||
/// avatars). Eviction is driven by a total byte budget plus a hard entry cap,
|
||||
@@ -89,7 +89,7 @@ class MediaCache {
|
||||
}
|
||||
|
||||
Future<Directory> _initDiskDir() async {
|
||||
final support = await getApplicationSupportDirectory();
|
||||
final support = await appSupportDir();
|
||||
final dir = Directory(
|
||||
'${support.path}${Platform.pathSeparator}media_cache');
|
||||
await dir.create(recursive: true);
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -72,61 +73,61 @@ Future<void> _initAndroid(WidgetRef ref) async {
|
||||
IsolateNameServer.registerPortWithName(
|
||||
_mainIsolatePort!.sendPort, kMainIsolateName);
|
||||
_mainIsolatePort!.listen((msg) => _onIsolateMessage(msg, ref));
|
||||
print('[NOTIF] IsolateNameServer port registered: $kMainIsolateName');
|
||||
debugPrint('[NOTIF] IsolateNameServer port registered: $kMainIsolateName');
|
||||
|
||||
await _androidPlugin.initialize(
|
||||
const InitializationSettings(
|
||||
android: AndroidInitializationSettings('@drawable/ic_notification'),
|
||||
),
|
||||
onDidReceiveNotificationResponse: (details) {
|
||||
print('[NOTIF-FG] onDidReceiveNotificationResponse fired');
|
||||
print('[NOTIF-FG] actionId=${details.actionId} payload=${details.payload} input=${details.input}');
|
||||
debugPrint('[NOTIF-FG] onDidReceiveNotificationResponse fired');
|
||||
debugPrint('[NOTIF-FG] actionId=${details.actionId} payload=${details.payload} input=${details.input}');
|
||||
if (details.actionId == 'reply') {
|
||||
final text = details.input?.trim() ?? '';
|
||||
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) {
|
||||
_androidPlugin.cancel(_stableRoomId(roomId));
|
||||
ref.read(matrixClientProvider.future).then((client) async {
|
||||
final room = client.getRoomById(roomId);
|
||||
print('[NOTIF-FG] getRoomById → ${room != null ? 'found' : 'null'}');
|
||||
debugPrint('[NOTIF-FG] getRoomById → ${room != null ? 'found' : 'null'}');
|
||||
if (room != null) {
|
||||
try {
|
||||
await room.sendTextEvent(text);
|
||||
print('[NOTIF-FG] sendTextEvent OK');
|
||||
debugPrint('[NOTIF-FG] sendTextEvent OK');
|
||||
} 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();
|
||||
await prefs.setString('notif_pending_reply_room', roomId);
|
||||
await prefs.setString('notif_pending_reply_text', text);
|
||||
}
|
||||
} 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();
|
||||
await prefs.setString('notif_pending_reply_room', roomId);
|
||||
await prefs.setString('notif_pending_reply_text', text);
|
||||
try {
|
||||
await client.onSync.stream.first;
|
||||
print('[NOTIF-FG] sync arrived — retrying _sendPendingReply');
|
||||
debugPrint('[NOTIF-FG] sync arrived — retrying _sendPendingReply');
|
||||
await _sendPendingReply(ref);
|
||||
} catch (e) {
|
||||
print('[NOTIF-FG] sync/send error: $e');
|
||||
debugPrint('[NOTIF-FG] sync/send error: $e');
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
print('[NOTIF-FG] reply skipped: text empty or roomId null');
|
||||
debugPrint('[NOTIF-FG] reply skipped: text empty or roomId null');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (details.actionId == 'read') {
|
||||
final roomId = details.payload;
|
||||
if (roomId != null) _androidPlugin.cancel(_stableRoomId(roomId));
|
||||
print('[NOTIF-FG] read action — dismissed');
|
||||
debugPrint('[NOTIF-FG] read action — dismissed');
|
||||
return;
|
||||
}
|
||||
final roomId = details.payload;
|
||||
print('[NOTIF-FG] tap → opening room $roomId');
|
||||
debugPrint('[NOTIF-FG] tap → opening room $roomId');
|
||||
if (roomId != null && roomId.isNotEmpty) {
|
||||
ref.read(activeRoomIdProvider.notifier).state = roomId;
|
||||
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||
@@ -145,7 +146,7 @@ Future<void> _initAndroid(WidgetRef ref) async {
|
||||
final args = call.arguments;
|
||||
if (call.method == 'openRoom') {
|
||||
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) {
|
||||
ref.read(activeRoomIdProvider.notifier).state = roomId;
|
||||
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||
@@ -156,7 +157,7 @@ Future<void> _initAndroid(WidgetRef ref) async {
|
||||
// MethodChannel — no Activity was opened.
|
||||
final roomId = (args is Map ? args['room_id'] : 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) {
|
||||
_androidPlugin.cancel(_stableRoomId(roomId));
|
||||
Future<void> storePending() async {
|
||||
@@ -170,9 +171,9 @@ Future<void> _initAndroid(WidgetRef ref) async {
|
||||
final room = client.getRoomById(roomId);
|
||||
if (room != null) {
|
||||
await room.sendTextEvent(text);
|
||||
print('[NOTIF-FG] replyFromNotification: sent OK');
|
||||
debugPrint('[NOTIF-FG] replyFromNotification: sent OK');
|
||||
} else {
|
||||
print('[NOTIF-FG] replyFromNotification: room null, storing in prefs');
|
||||
debugPrint('[NOTIF-FG] replyFromNotification: room null, storing in prefs');
|
||||
await storePending();
|
||||
// Retry on the next sync — room list is populated after first sync.
|
||||
client.onSync.stream.first
|
||||
@@ -180,7 +181,7 @@ Future<void> _initAndroid(WidgetRef ref) async {
|
||||
.catchError((_) {});
|
||||
}
|
||||
} catch (e) {
|
||||
print('[NOTIF-FG] replyFromNotification error: $e — storing in prefs');
|
||||
debugPrint('[NOTIF-FG] replyFromNotification error: $e — storing in prefs');
|
||||
await storePending();
|
||||
// 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) {
|
||||
final roomId = reply['room_id'] 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) {
|
||||
// Persist so _sendPendingReply sends it after the first sync.
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -302,7 +303,7 @@ void _onIsolateMessage(dynamic message, WidgetRef ref) {
|
||||
final input = message['input'] as String?;
|
||||
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) {
|
||||
case 'reply':
|
||||
@@ -315,9 +316,9 @@ void _onIsolateMessage(dynamic message, WidgetRef ref) {
|
||||
if (room != null) {
|
||||
try {
|
||||
await room.sendTextEvent(text);
|
||||
print('[NOTIF-ISOLATE] reply sent OK');
|
||||
debugPrint('[NOTIF-ISOLATE] reply sent OK');
|
||||
} 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();
|
||||
await prefs.setString('notif_pending_reply_room', roomId);
|
||||
await prefs.setString('notif_pending_reply_text', text);
|
||||
@@ -326,7 +327,7 @@ void _onIsolateMessage(dynamic message, WidgetRef ref) {
|
||||
.catchError((_) {});
|
||||
}
|
||||
} 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();
|
||||
await prefs.setString('notif_pending_reply_room', roomId);
|
||||
await prefs.setString('notif_pending_reply_text', text);
|
||||
@@ -342,14 +343,14 @@ void _onIsolateMessage(dynamic message, WidgetRef ref) {
|
||||
} else if (id != null) {
|
||||
_androidPlugin.cancel(id);
|
||||
}
|
||||
print('[NOTIF-ISOLATE] read → notification dismissed');
|
||||
debugPrint('[NOTIF-ISOLATE] read → notification dismissed');
|
||||
|
||||
default:
|
||||
// Plain notification tap → navigate to room.
|
||||
if (payload != null && payload.isNotEmpty) {
|
||||
ref.read(activeRoomIdProvider.notifier).state = payload;
|
||||
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 roomId = prefs.getString('notif_pending_reply_room');
|
||||
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;
|
||||
|
||||
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);
|
||||
print('[NOTIF-REPLY] getRoomById → ${room != null ? 'found' : 'null'}');
|
||||
debugPrint('[NOTIF-REPLY] getRoomById → ${room != null ? 'found' : '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
|
||||
.then((_) => _sendPendingReply(ref))
|
||||
.catchError((_) {});
|
||||
@@ -378,11 +379,11 @@ Future<void> _sendPendingReply(WidgetRef ref) async {
|
||||
}
|
||||
|
||||
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_text');
|
||||
} 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));
|
||||
var room = client.getRoomById(roomId);
|
||||
if (room == null) {
|
||||
print('[NOTIF] decryptAndUpdate: room $roomId not found');
|
||||
debugPrint('[NOTIF] decryptAndUpdate: room $roomId not found');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -469,7 +470,7 @@ Future<void> _decryptAndUpdateNotification(
|
||||
final bool alreadyHave =
|
||||
eventId == null || event?.eventId == eventId;
|
||||
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));
|
||||
room = client.getRoomById(roomId);
|
||||
if (room == null) return;
|
||||
@@ -477,7 +478,7 @@ Future<void> _decryptAndUpdateNotification(
|
||||
}
|
||||
|
||||
if (event == null) {
|
||||
print('[NOTIF] decryptAndUpdate: event still null after sync');
|
||||
debugPrint('[NOTIF] decryptAndUpdate: event still null after sync');
|
||||
return;
|
||||
}
|
||||
if (event.type != EventTypes.Message) return;
|
||||
@@ -486,7 +487,7 @@ Future<void> _decryptAndUpdateNotification(
|
||||
final rawBody = event.body;
|
||||
// If decryption failed the body starts with "** Unable to decrypt"
|
||||
if (rawBody.isEmpty || rawBody.startsWith('**')) {
|
||||
print('[NOTIF] decryptAndUpdate: decryption failed, keeping placeholder');
|
||||
debugPrint('[NOTIF] decryptAndUpdate: decryption failed, keeping placeholder');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -507,9 +508,9 @@ Future<void> _decryptAndUpdateNotification(
|
||||
'roomId': roomId,
|
||||
'notifId': notifId ?? _stableRoomId(roomId),
|
||||
});
|
||||
print('[NOTIF] decryptAndUpdate: updated notification → "$title: $notifBody"');
|
||||
debugPrint('[NOTIF] decryptAndUpdate: updated notification → "$title: $notifBody"');
|
||||
} catch (e) {
|
||||
print('[NOTIF] decryptAndUpdate error: $e');
|
||||
debugPrint('[NOTIF] decryptAndUpdate error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -533,7 +534,7 @@ Future<void> showMessageNotification({
|
||||
'notifId': _stableRoomId(roomId),
|
||||
});
|
||||
} catch (e) {
|
||||
print('[NOTIF] showNativeNotification failed: $e');
|
||||
debugPrint('[NOTIF] showNativeNotification failed: $e');
|
||||
}
|
||||
} else if (Platform.isWindows) {
|
||||
// Close the previous notification for this room before showing a new one.
|
||||
|
||||
@@ -7,6 +7,9 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class BoolPref extends StateNotifier<bool> {
|
||||
final String _key;
|
||||
// Guards against the load race: if set()/toggle() ran before _load's read
|
||||
// lands, the stale persisted value must not overwrite the user's choice.
|
||||
bool _userSet = false;
|
||||
|
||||
BoolPref(this._key, bool defaultValue) : super(defaultValue) {
|
||||
_load(defaultValue);
|
||||
@@ -14,10 +17,11 @@ class BoolPref extends StateNotifier<bool> {
|
||||
|
||||
Future<void> _load(bool def) async {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
if (mounted) state = p.getBool(_key) ?? def;
|
||||
if (mounted && !_userSet) state = p.getBool(_key) ?? def;
|
||||
}
|
||||
|
||||
Future<void> set(bool v) async {
|
||||
_userSet = true;
|
||||
state = v;
|
||||
final p = await SharedPreferences.getInstance();
|
||||
await p.setBool(_key, v);
|
||||
@@ -30,9 +34,10 @@ class BoolPref extends StateNotifier<bool> {
|
||||
|
||||
class StringSetPref extends StateNotifier<Set<String>> {
|
||||
final String _key;
|
||||
late final Future<void> _loaded;
|
||||
|
||||
StringSetPref(this._key) : super({}) {
|
||||
_load();
|
||||
_loaded = _load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
@@ -42,14 +47,18 @@ class StringSetPref extends StateNotifier<Set<String>> {
|
||||
}
|
||||
|
||||
Future<void> add(String v) async {
|
||||
if (state.contains(v)) return;
|
||||
// Read-modify-write: ohne abgeschlossenes Laden würde hier der halbleere
|
||||
// Start-State zurückgeschrieben und persistierte Einträge gingen verloren.
|
||||
await _loaded;
|
||||
if (!mounted || state.contains(v)) return;
|
||||
state = {...state, v};
|
||||
final p = await SharedPreferences.getInstance();
|
||||
await p.setStringList(_key, state.toList());
|
||||
}
|
||||
|
||||
Future<void> remove(String v) async {
|
||||
if (!state.contains(v)) return;
|
||||
await _loaded;
|
||||
if (!mounted || !state.contains(v)) return;
|
||||
state = {...state}..remove(v);
|
||||
final p = await SharedPreferences.getInstance();
|
||||
await p.setStringList(_key, state.toList());
|
||||
@@ -81,6 +90,56 @@ final privacyReadReceiptsProvider =
|
||||
final privacyTypingProvider =
|
||||
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('privacy_typing', true));
|
||||
|
||||
// ── Voice preferences ────────────────────────────────────────────────────────
|
||||
// Geschrieben von der Settings-Voice-Sektion, gelesen von den Call-Modulen
|
||||
// (call_signaling, voice_channel). Die historischen voice_*-Keys bleiben
|
||||
// unverändert, damit bestehende Nutzer-Einstellungen erhalten bleiben.
|
||||
|
||||
class VoicePrefs {
|
||||
VoicePrefs._();
|
||||
|
||||
static const _kMic = 'voice_mic_device';
|
||||
static const _kSpeaker = 'voice_speaker_device';
|
||||
static const _kCamera = 'voice_camera_device';
|
||||
static const _kOutputVolume = 'voice_output_volume';
|
||||
|
||||
static Future<double> loadOutputVolume() async {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
return p.getDouble(_kOutputVolume) ?? 1.0;
|
||||
}
|
||||
|
||||
static Future<void> saveOutputVolume(double v) async {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
await p.setDouble(_kOutputVolume, v);
|
||||
}
|
||||
|
||||
/// Gewählte Geräte-IDs; null = kein Pref gesetzt (System-Standard nutzen).
|
||||
static Future<({String? mic, String? speaker, String? camera})>
|
||||
loadDevices() async {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
return (
|
||||
mic: p.getString(_kMic),
|
||||
speaker: p.getString(_kSpeaker),
|
||||
camera: p.getString(_kCamera),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> saveMic(String? deviceId) => _saveDevice(_kMic, deviceId);
|
||||
static Future<void> saveSpeaker(String? deviceId) =>
|
||||
_saveDevice(_kSpeaker, deviceId);
|
||||
static Future<void> saveCamera(String? deviceId) =>
|
||||
_saveDevice(_kCamera, deviceId);
|
||||
|
||||
static Future<void> _saveDevice(String key, String? deviceId) async {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
if (deviceId == null || deviceId.isEmpty) {
|
||||
await p.remove(key);
|
||||
} else {
|
||||
await p.setString(key, deviceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Theme persistence helpers ────────────────────────────────────────────────
|
||||
|
||||
Future<void> saveThemePrefs({
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:pyramid/core/auth_log.dart';
|
||||
|
||||
/// Wendet einen Soft-Logout durch Token-Refresh ab, ohne JE in einen
|
||||
/// destruktiven Logout zu eskalieren.
|
||||
///
|
||||
/// Hintergrund: Ohne gesetztes `Client.onSoftLogout` behandelt das matrix-SDK
|
||||
/// einen fehlgeschlagenen Refresh so: catch → `logout()` → `finally clear()` —
|
||||
/// die lokale Session inkl. Krypto-Schlüssel wird also selbst dann zerstört,
|
||||
/// wenn der Refresh nur an einem transienten Netzwerkfehler scheiterte
|
||||
/// (Gerät offline im Moment des Token-Ablaufs) oder am Rotations-Race mit dem
|
||||
/// Push-Hintergrund-Isolate (beide teilen sich die DB; refreshAccessToken()
|
||||
/// liest den Refresh-Token bei jedem Versuch frisch aus der DB, deshalb heilt
|
||||
/// ein Retry dieses Race). Wirft dieser Handler nicht, ruft das SDK auch kein
|
||||
/// `logout()` auf (Client._handleSoftLogout, matrix 6.2.0).
|
||||
///
|
||||
/// Regel aus CLAUDE.md: Fehlerpfade dürfen NIE in einem stillen Logout enden.
|
||||
/// Schlägt der Refresh dauerhaft fehl, bleibt der Client im Zustand
|
||||
/// soft-logged-out; der nächste Sync/API-Aufruf stößt den Refresh erneut an.
|
||||
Future<void> guardedSoftLogoutRefresh(Client client) async {
|
||||
for (var attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
await client.refreshAccessToken();
|
||||
await AuthLog.write(
|
||||
'Soft-Logout durch Token-Refresh abgewendet (Versuch $attempt)',
|
||||
);
|
||||
return;
|
||||
} catch (e) {
|
||||
await AuthLog.write(
|
||||
'Soft-Logout: Token-Refresh fehlgeschlagen (Versuch $attempt/3): $e',
|
||||
);
|
||||
if (attempt < 3) {
|
||||
await Future.delayed(Duration(seconds: 2 * attempt));
|
||||
}
|
||||
}
|
||||
}
|
||||
await AuthLog.write(
|
||||
'Soft-Logout: Refresh dauerhaft fehlgeschlagen – Session wird NICHT '
|
||||
'zerstört, nächster Sync/API-Aufruf versucht es erneut.',
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,15 @@ import 'package:matrix/encryption.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:pyramid/core/theme.dart';
|
||||
|
||||
/// Ob Cross-Signing/Key-Backup fehlt oder nicht entsperrt ist — wenn ja, würde
|
||||
/// ein Logout den Zugriff auf ältere verschlüsselte Nachrichten kosten.
|
||||
Future<bool> isKeyBackupMissing(Client client) async {
|
||||
final enc = client.encryption;
|
||||
if (enc == null) return true;
|
||||
if (!enc.crossSigning.enabled) return true;
|
||||
return !(await enc.keyManager.isCached().catchError((_) => false));
|
||||
}
|
||||
|
||||
Future<void> showBootstrapDialog(
|
||||
BuildContext context,
|
||||
Client client, {
|
||||
|
||||
@@ -76,6 +76,12 @@ class LoginNotifier extends StateNotifier<LoginFormState> {
|
||||
identifier: mx.AuthenticationUserIdentifier(user: username),
|
||||
password: password,
|
||||
initialDeviceDisplayName: deviceName ?? 'Pyramid',
|
||||
// Ohne Refresh-Token eskaliert das SDK jeden Soft-Logout (z. B. nach
|
||||
// Ablauf des Access-Tokens) sofort zu einem echten, destruktiven
|
||||
// Logout, weil `refreshAccessToken()` sonst mangels Refresh-Token
|
||||
// fehlschlägt (siehe matrix-Paket Client._handleSoftLogout). Das ist
|
||||
// der wahrscheinlichste Auslöser des Uta-Random-Logout-Bugs.
|
||||
refreshToken: true,
|
||||
);
|
||||
|
||||
if (Platform.isAndroid) unawaited(initFcm(client));
|
||||
@@ -104,6 +110,11 @@ final isLoggedInProvider = StreamProvider<bool>((ref) async* {
|
||||
final client = await ref.watch(matrixClientProvider.future);
|
||||
yield client.isLogged();
|
||||
await for (final loginState in client.onLoginStateChanged.stream) {
|
||||
yield loginState == mx.LoginState.loggedIn;
|
||||
// softLoggedOut ist KEIN Logout: Die Session existiert lokal weiter, das
|
||||
// SDK versucht gerade einen stillen Token-Refresh. Würde hier `false`
|
||||
// geliefert, spränge die UI bei jedem Routine-Refresh kurz auf den
|
||||
// Login-Screen – für Nutzer nicht von einem „Random Logout" zu
|
||||
// unterscheiden. Nur ein echtes loggedOut zählt.
|
||||
yield loginState != mx.LoginState.loggedOut;
|
||||
}
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:pyramid/features/call_signaling/voip_manager.dart';
|
||||
|
||||
/// Öffentliche Schnittstelle des Moduls `call_signaling` – natives
|
||||
/// Matrix-1:1-VoIP (`m.call.*`-Events, direkte WebRTC-Peer-Connection,
|
||||
/// kein SFU). Schnitt nach `docs/M2_MODULE_SCHNITT.md`.
|
||||
///
|
||||
/// Andere Module (UI, Settings, Shell) konsumieren AUSSCHLIESSLICH diese
|
||||
/// Schnittstelle über [callSignalingProvider] – nie `voip_manager.dart`
|
||||
/// direkt. Einzige Ausnahme ist der Kompositions-Punkt in
|
||||
/// `matrix_client.dart`, der die Implementierung konstruiert.
|
||||
///
|
||||
/// Bewusste Abweichungen vom Entwurf im Doc (Verhaltensgleichheit vor
|
||||
/// Schönheit, heiliger Call-Pfad):
|
||||
/// * ChangeNotifier statt Streams – die gesamte UI hängt heute an
|
||||
/// `ref.watch(...)` mit notifyListeners; ein Stream-Umbau wäre eine
|
||||
/// Verhaltensänderung ohne Not.
|
||||
/// * [currentCall] bleibt als matrix-SDK-`CallSession` sichtbar (die UI
|
||||
/// braucht state/room/answer/reject), ebenso die WebRTC-Renderer –
|
||||
/// das Doc sieht Letzteres ausdrücklich vor.
|
||||
abstract class CallSignalingService extends ChangeNotifier {
|
||||
/// Der laufende 1:1-Call (null = keiner). UI liest state/room und
|
||||
/// beantwortet eingehende Calls über [safeAction] + answer()/reject().
|
||||
CallSession? get currentCall;
|
||||
|
||||
rtc.RTCVideoRenderer get localRenderer;
|
||||
rtc.RTCVideoRenderer get remoteRenderer;
|
||||
|
||||
bool get isMicMuted;
|
||||
bool get isCameraMuted;
|
||||
bool get isScreensharing;
|
||||
bool get isRemoteScreensharing;
|
||||
bool get isDeafened;
|
||||
|
||||
/// Sende-Qualität ('sd' | 'hd' | 'fhd' | '4k').
|
||||
String get currentQualityKey;
|
||||
|
||||
Future<void> startCall(String roomId, {bool video});
|
||||
Future<void> hangup();
|
||||
Future<void> toggleMic();
|
||||
Future<void> toggleCamera();
|
||||
Future<void> toggleDeafen();
|
||||
|
||||
/// Auf Windows öffnet ein [context] den Quellen-Picker fürs Screensharing.
|
||||
Future<void> toggleScreenSharing([BuildContext? context]);
|
||||
|
||||
Future<void> changeQuality(String key);
|
||||
|
||||
/// Wendet die in den Einstellungen gewählte Ausgabelautstärke auf den
|
||||
/// Remote-Stream an. Ohne [value] wird der Pref-Wert gelesen.
|
||||
Future<void> applyOutputVolume([double? value]);
|
||||
|
||||
/// Führt eine Call-Aktion (answer/reject/…) fehlertolerant aus und stößt
|
||||
/// danach die interne Zustands-/Renderer-Aktualisierung an.
|
||||
Future<void> safeAction(Future<dynamic> Function() action);
|
||||
}
|
||||
|
||||
/// Einziger Zugriffsweg der UI auf das 1:1-VoIP-Modul. Die Instanz wird beim
|
||||
/// Client-Aufbau in `matrix_client.dart` erzeugt (existiert also, sobald die
|
||||
/// eingeloggte Shell steht).
|
||||
final callSignalingProvider =
|
||||
ChangeNotifierProvider<CallSignalingService>((ref) {
|
||||
return PyramidVoipManager.instance;
|
||||
});
|
||||
@@ -1,23 +1,30 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:webrtc_interface/webrtc_interface.dart';
|
||||
import 'package:pyramid/core/ice_servers.dart';
|
||||
import 'package:pyramid/core/settings_prefs.dart';
|
||||
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
||||
import 'package:pyramid/widgets/screen_share_picker.dart';
|
||||
|
||||
class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
||||
/// Implementierung von [CallSignalingService] (Modul `call_signaling`).
|
||||
/// Wird ausschließlich vom Kompositions-Punkt (`matrix_client.dart`)
|
||||
/// konstruiert; alle anderen gehen über `callSignalingProvider`.
|
||||
class PyramidVoipManager extends CallSignalingService
|
||||
implements WebRTCDelegate {
|
||||
static PyramidVoipManager? _instance;
|
||||
static PyramidVoipManager get instance => _instance!;
|
||||
|
||||
final Client client;
|
||||
late final VoIP voip;
|
||||
@override
|
||||
CallSession? currentCall;
|
||||
|
||||
@override
|
||||
final rtc.RTCVideoRenderer localRenderer = rtc.RTCVideoRenderer();
|
||||
@override
|
||||
final rtc.RTCVideoRenderer remoteRenderer = rtc.RTCVideoRenderer();
|
||||
bool _renderersInitialized = false;
|
||||
|
||||
@@ -30,11 +37,17 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
||||
String? _selectedSourceId;
|
||||
String _currentQualityKey = 'hd';
|
||||
|
||||
@override
|
||||
bool get isMicMuted => _isMicMuted;
|
||||
@override
|
||||
bool get isCameraMuted => _isCameraMuted;
|
||||
@override
|
||||
bool get isScreensharing => _isScreensharing;
|
||||
@override
|
||||
bool get isRemoteScreensharing => _isRemoteScreensharing;
|
||||
@override
|
||||
bool get isDeafened => _isDeafened;
|
||||
@override
|
||||
String get currentQualityKey => _currentQualityKey;
|
||||
|
||||
PyramidVoipManager(this.client) {
|
||||
@@ -54,7 +67,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
||||
await remoteRenderer.initialize();
|
||||
_renderersInitialized = true;
|
||||
} catch (e) {
|
||||
print('[VOIP] Renderer init error: $e');
|
||||
debugPrint('[VOIP] Renderer init error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,14 +93,14 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
||||
merged['iceServers'] = [...existing, ...extra];
|
||||
configuration = merged;
|
||||
} catch (e) {
|
||||
print('[VOIP] ICE server merge error: $e');
|
||||
debugPrint('[VOIP] ICE server merge error: $e');
|
||||
}
|
||||
return rtc.createPeerConnection(configuration, constraints);
|
||||
}
|
||||
|
||||
@override
|
||||
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();
|
||||
currentCall = call;
|
||||
_isCameraMuted = call.type == CallType.kVoice;
|
||||
@@ -99,12 +112,12 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
||||
Timer? _renderRefreshTimer;
|
||||
void _setupCallListeners(CallSession call) {
|
||||
call.onCallStateChanged.stream.listen((state) {
|
||||
print('[VOIP] State: $state');
|
||||
debugPrint('[VOIP] State: $state');
|
||||
_updateInternalStates();
|
||||
|
||||
if (state == CallState.kConnected) {
|
||||
if (!_isCameraMuted) {
|
||||
print('[VOIP] Ensuring camera is unmuted on connect');
|
||||
debugPrint('[VOIP] Ensuring camera is unmuted on connect');
|
||||
call.setLocalVideoMuted(false);
|
||||
}
|
||||
}
|
||||
@@ -204,7 +217,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
||||
bool changed = false;
|
||||
if (remote != null && remote.getVideoTracks().isNotEmpty) {
|
||||
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;
|
||||
changed = true;
|
||||
}
|
||||
@@ -215,7 +228,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
||||
|
||||
if (local != null && local.getVideoTracks().isNotEmpty) {
|
||||
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;
|
||||
changed = true;
|
||||
}
|
||||
@@ -226,7 +239,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
||||
|
||||
if (changed) notifyListeners();
|
||||
} catch (e) {
|
||||
print('[VOIP] Error in _updateRenderers: $e');
|
||||
debugPrint('[VOIP] Error in _updateRenderers: $e');
|
||||
} finally {
|
||||
_isUpdating = false;
|
||||
}
|
||||
@@ -254,12 +267,12 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
||||
|
||||
/// Wendet die in den Einstellungen gewählte Ausgabelautstärke auf den
|
||||
/// Remote-Audio-Stream an. Ohne [value] wird der Pref-Wert gelesen.
|
||||
@override
|
||||
Future<void> applyOutputVolume([double? value]) async {
|
||||
var v = value;
|
||||
if (v == null) {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
v = prefs.getDouble('voice_output_volume') ?? 1.0;
|
||||
v = await VoicePrefs.loadOutputVolume();
|
||||
} catch (_) {
|
||||
v = 1.0;
|
||||
}
|
||||
@@ -296,17 +309,19 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
||||
@override
|
||||
Future<void> registerListeners(CallSession session) async {}
|
||||
|
||||
@override
|
||||
Future<void> safeAction(Future<dynamic> Function() action) async {
|
||||
try {
|
||||
await action();
|
||||
await Future.delayed(const Duration(milliseconds: 1000));
|
||||
} catch (e) {
|
||||
print('[VOIP] Action error: $e');
|
||||
debugPrint('[VOIP] Action error: $e');
|
||||
}
|
||||
_updateInternalStates();
|
||||
_updateRenderers();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> toggleMic() async {
|
||||
if (currentCall == null) return;
|
||||
_isMicMuted = !_isMicMuted;
|
||||
@@ -319,6 +334,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> toggleCamera() async {
|
||||
if (currentCall == null) return;
|
||||
_isCameraMuted = !_isCameraMuted;
|
||||
@@ -330,7 +346,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
||||
await _updateRenderers();
|
||||
}
|
||||
} catch (e) {
|
||||
print('[VOIP] toggleCamera error: $e');
|
||||
debugPrint('[VOIP] toggleCamera error: $e');
|
||||
_isCameraMuted = !_isCameraMuted;
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -338,6 +354,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
||||
await _updateRenderers();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> toggleScreenSharing([BuildContext? context]) async {
|
||||
if (currentCall == null) return;
|
||||
final call = currentCall!;
|
||||
@@ -360,11 +377,13 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> toggleDeafen() async {
|
||||
_isDeafened = !_isDeafened;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> hangup() async {
|
||||
if (currentCall == null) return;
|
||||
await safeAction(() async {
|
||||
@@ -373,13 +392,14 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> startCall(String roomId, {bool video = false}) async {
|
||||
final room = client.getRoomById(roomId);
|
||||
if (room == null) return;
|
||||
await _ensureRenderers();
|
||||
_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);
|
||||
currentCall = call;
|
||||
|
||||
@@ -395,6 +415,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> changeQuality(String key) async {
|
||||
_currentQualityKey = key;
|
||||
if (currentCall == null) {
|
||||
@@ -445,10 +466,10 @@ class MediaDevicesWrapper extends MediaDevices {
|
||||
// In den Einstellungen gewählte Geräte (voice_*_device Prefs) anwenden —
|
||||
// nur wenn kein Screenshare-Source gesetzt ist (sonst nicht anfassen).
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final mic = prefs.getString('voice_mic_device');
|
||||
final cam = prefs.getString('voice_camera_device');
|
||||
final speaker = prefs.getString('voice_speaker_device');
|
||||
final devicePrefs = await VoicePrefs.loadDevices();
|
||||
final mic = devicePrefs.mic;
|
||||
final cam = devicePrefs.camera;
|
||||
final speaker = devicePrefs.speaker;
|
||||
if (mic != null &&
|
||||
improvedConstraints['audio'] != null &&
|
||||
improvedConstraints['audio'] != false) {
|
||||
@@ -529,7 +550,10 @@ class MediaDevicesWrapper extends MediaDevices {
|
||||
set ondevicechange(Function(dynamic event)? callback) => _delegate.ondevicechange = callback;
|
||||
@override
|
||||
Function(dynamic event)? get ondevicechange => _delegate.ondevicechange;
|
||||
// Erzwungener Override der abstrakten MediaDevices-Klasse, die getSources()
|
||||
// selbst als deprecated markiert (Ersatz: enumerateDevices()) aber weiterhin verlangt.
|
||||
@override
|
||||
// ignore: deprecated_member_use
|
||||
Future<List<dynamic>> getSources() => _delegate.getSources();
|
||||
@override
|
||||
Future<MediaDeviceInfo> selectAudioOutput([AudioOutputOptions? options]) => _delegate.selectAudioOutput(options);
|
||||
+11
-9
@@ -3,14 +3,14 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:pyramid/core/app_state.dart';
|
||||
import 'package:pyramid/core/theme.dart';
|
||||
import 'package:pyramid/core/livekit_call_manager.dart';
|
||||
import 'package:pyramid/core/voip_manager.dart';
|
||||
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
||||
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
|
||||
import 'package:pyramid/widgets/screen_share_picker.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
||||
import 'package:livekit_client/livekit_client.dart';
|
||||
|
||||
class MiniCallWidget extends ConsumerStatefulWidget {
|
||||
final dynamic call; // Can be LiveKitCallManager or PyramidVoipManager
|
||||
final dynamic call; // CallSignalingService (1:1) oder VoiceChannelService
|
||||
const MiniCallWidget({super.key, required this.call});
|
||||
|
||||
@override
|
||||
@@ -45,7 +45,7 @@ class _MiniCallWidgetState extends ConsumerState<MiniCallWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = PyramidTheme.of(context);
|
||||
final isVoip = widget.call is PyramidVoipManager;
|
||||
final isVoip = widget.call is CallSignalingService;
|
||||
final call = widget.call;
|
||||
|
||||
final String displayName = isVoip
|
||||
@@ -95,7 +95,7 @@ class _MiniCallWidgetState extends ConsumerState<MiniCallWidget> {
|
||||
isStreaming ? call.localRenderer : call.remoteRenderer,
|
||||
objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
|
||||
)
|
||||
: _LiveKitPreview(manager: call as LiveKitCallManager),
|
||||
: _LiveKitPreview(manager: call as VoiceChannelService),
|
||||
),
|
||||
|
||||
// Top bar of preview
|
||||
@@ -259,9 +259,9 @@ class _MiniCallWidgetState extends ConsumerState<MiniCallWidget> {
|
||||
pt: pt,
|
||||
onTap: () async {
|
||||
if (isVoip) {
|
||||
(call as PyramidVoipManager).toggleScreenSharing(context);
|
||||
(call as CallSignalingService).toggleScreenSharing(context);
|
||||
} else {
|
||||
final lk = call as LiveKitCallManager;
|
||||
final lk = call as VoiceChannelService;
|
||||
if (lk.isScreenSharing) {
|
||||
await lk.stopScreenShare();
|
||||
} else {
|
||||
@@ -333,7 +333,9 @@ class _ControlBtnState extends State<_ControlBtn> {
|
||||
duration: pt.durationFast,
|
||||
height: 32,
|
||||
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),
|
||||
),
|
||||
child: Icon(
|
||||
@@ -349,7 +351,7 @@ class _ControlBtnState extends State<_ControlBtn> {
|
||||
}
|
||||
|
||||
class _LiveKitPreview extends StatelessWidget {
|
||||
final LiveKitCallManager manager;
|
||||
final VoiceChannelService manager;
|
||||
const _LiveKitPreview({required this.manager});
|
||||
|
||||
@override
|
||||
@@ -0,0 +1,173 @@
|
||||
part of '../voice_channel.dart';
|
||||
|
||||
class _BigAvatar extends StatelessWidget {
|
||||
final _ParticipantData data;
|
||||
final PyramidTheme pt;
|
||||
const _BigAvatar({required this.data, required this.pt});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Container(
|
||||
width: 100, height: 100,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(colors: [data.avatarColor, data.avatarColor.withAlpha(150)], begin: Alignment.topLeft, end: Alignment.bottomRight),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(child: Text(data.name.isNotEmpty ? data.name[0].toUpperCase() : '?', style: const TextStyle(color: Colors.white, fontSize: 40, fontWeight: FontWeight.bold))),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SmallAvatar extends StatelessWidget {
|
||||
final _ParticipantData data;
|
||||
final PyramidTheme pt;
|
||||
const _SmallAvatar({required this.data, required this.pt});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Container(
|
||||
width: 44, height: 44,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(colors: [data.avatarColor, data.avatarColor.withAlpha(150)], begin: Alignment.topLeft, end: Alignment.bottomRight),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(child: Text(data.name.isNotEmpty ? data.name[0].toUpperCase() : '?', style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold))),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CallControls extends StatelessWidget {
|
||||
final bool isMuted;
|
||||
final bool isDeafened;
|
||||
final bool isCameraOff;
|
||||
final bool isScreenSharing;
|
||||
final VoidCallback onToggleMic;
|
||||
final VoidCallback onToggleDeafen;
|
||||
final VoidCallback onToggleCam;
|
||||
final VoidCallback onToggleScreen;
|
||||
final VoidCallback onHangUp;
|
||||
final PyramidTheme pt;
|
||||
|
||||
const _CallControls({
|
||||
required this.isMuted,
|
||||
required this.isDeafened,
|
||||
required this.isCameraOff,
|
||||
required this.isScreenSharing,
|
||||
required this.onToggleMic,
|
||||
required this.onToggleDeafen,
|
||||
required this.onToggleCam,
|
||||
required this.onToggleScreen,
|
||||
required this.onHangUp,
|
||||
required this.pt,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
|
||||
decoration: BoxDecoration(color: pt.bg1, border: Border(top: BorderSide(color: pt.border))),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_VoiceCtrlBtn(icon: isMuted ? Icons.mic_off_rounded : Icons.mic_rounded, danger: isMuted, pt: pt, onTap: onToggleMic),
|
||||
const SizedBox(width: 16),
|
||||
_VoiceCtrlBtn(icon: isDeafened ? Icons.headset_off_rounded : Icons.headphones_rounded, danger: isDeafened, pt: pt, onTap: onToggleDeafen),
|
||||
const SizedBox(width: 16),
|
||||
_VoiceCtrlBtn(icon: isCameraOff ? Icons.videocam_off_rounded : Icons.videocam_rounded, active: !isCameraOff, pt: pt, onTap: onToggleCam),
|
||||
const SizedBox(width: 16),
|
||||
_VoiceCtrlBtn(icon: Icons.screen_share_rounded, active: isScreenSharing, pt: pt, onTap: onToggleScreen),
|
||||
const SizedBox(width: 24),
|
||||
Container(width: 1, height: 32, color: pt.border),
|
||||
const SizedBox(width: 24),
|
||||
_VoiceCtrlBtn(icon: Icons.call_end_rounded, isDanger: true, pt: pt, onTap: onHangUp),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NoCall extends StatelessWidget {
|
||||
final PyramidTheme pt;
|
||||
final WidgetRef ref;
|
||||
const _NoCall({required this.pt, required this.ref});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: pt.bg1,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
height: 52,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: pt.border))),
|
||||
child: Row(
|
||||
children: [
|
||||
PyrIconBtn(
|
||||
icon: const Icon(Icons.menu_rounded),
|
||||
tooltip: 'Toggle sidebar',
|
||||
onPressed: () => ref.read(railCollapsedProvider.notifier).update((s) => !s),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(Icons.headphones_rounded, size: 16, color: pt.fgMuted),
|
||||
const SizedBox(width: 8),
|
||||
Text('No active call', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('𐃏', style: TextStyle(fontSize: 48, color: pt.fgDim.withAlpha(100))),
|
||||
const SizedBox(height: 12),
|
||||
Text('Join a voice room to start a call', style: TextStyle(color: pt.fgMuted, fontSize: 14)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FullscreenVideoPage extends StatelessWidget {
|
||||
final _ParticipantData data;
|
||||
const _FullscreenVideoPage({required this.data});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(children: [
|
||||
Positioned.fill(
|
||||
child: data.videoWidget != null
|
||||
? _MirrorWrapper(
|
||||
isLocal: data.id == 'local' || (data.originalParticipant is LocalParticipant),
|
||||
isScreenShare: data.isScreenShare,
|
||||
child: data.videoWidget!,
|
||||
)
|
||||
: _BigAvatar(data: data, pt: PyramidTheme.of(context)),
|
||||
),
|
||||
Positioned(
|
||||
top: 0, right: 0,
|
||||
child: SafeArea(
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.fullscreen_exit_rounded, color: Colors.white, size: 28),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
part of '../voice_channel.dart';
|
||||
|
||||
class _UnifiedCallView extends StatefulWidget {
|
||||
final List<_ParticipantData> participants;
|
||||
final bool isLiveKit;
|
||||
final String displayName;
|
||||
final bool isConnecting;
|
||||
final VoidCallback onHangUp;
|
||||
final VoidCallback onMinimize;
|
||||
final VoidCallback onToggleMic;
|
||||
final VoidCallback onToggleDeafen;
|
||||
final VoidCallback onToggleCam;
|
||||
final VoidCallback onToggleScreen;
|
||||
final bool isMuted;
|
||||
final bool isDeafened;
|
||||
final bool isCameraOff;
|
||||
final bool isScreenSharing;
|
||||
final Function(Offset)? onQualityMenu;
|
||||
final PyramidTheme pt;
|
||||
|
||||
const _UnifiedCallView({
|
||||
required this.participants,
|
||||
required this.isLiveKit,
|
||||
required this.displayName,
|
||||
required this.isConnecting,
|
||||
required this.onHangUp,
|
||||
required this.onMinimize,
|
||||
required this.onToggleMic,
|
||||
required this.onToggleDeafen,
|
||||
required this.onToggleCam,
|
||||
required this.onToggleScreen,
|
||||
required this.isMuted,
|
||||
required this.isDeafened,
|
||||
required this.isCameraOff,
|
||||
required this.isScreenSharing,
|
||||
this.onQualityMenu,
|
||||
required this.pt,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_UnifiedCallView> createState() => _UnifiedCallViewState();
|
||||
}
|
||||
|
||||
class _UnifiedCallViewState extends State<_UnifiedCallView> {
|
||||
String? _heroId;
|
||||
bool _isGridView = false;
|
||||
bool _stripCollapsed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_autoSelectHero();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_UnifiedCallView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
_autoSelectHero();
|
||||
}
|
||||
|
||||
void _autoSelectHero() {
|
||||
if (_isGridView) return;
|
||||
final screenshare = widget.participants.firstWhereOrNull((p) => p.isScreenShare);
|
||||
if (screenshare != null && (_heroId == null || !widget.participants.any((p) => p.id == _heroId && p.hasVideo))) {
|
||||
_heroId = screenshare.id;
|
||||
} else if (_heroId == null && widget.participants.isNotEmpty) {
|
||||
final firstVideo = widget.participants.firstWhereOrNull((p) => p.hasVideo);
|
||||
if (firstVideo != null) _heroId = firstVideo.id;
|
||||
}
|
||||
}
|
||||
|
||||
List<_ParticipantData> _sorted(List<_ParticipantData> all) {
|
||||
int rank(_ParticipantData p) {
|
||||
if (p.isScreenShare) return 0;
|
||||
if (p.hasVideo) return 1;
|
||||
if (p.isSpeaking) return 2;
|
||||
return 3;
|
||||
}
|
||||
return [...all]..sort((a, b) => rank(a).compareTo(rank(b)));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
final all = _sorted(widget.participants);
|
||||
final hero = all.firstWhereOrNull((p) => p.id == _heroId) ?? (all.isNotEmpty ? all.first : null);
|
||||
|
||||
if (widget.isConnecting) {
|
||||
return Scaffold(
|
||||
backgroundColor: pt.bg1,
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const PyramidLoader(size: 80),
|
||||
const SizedBox(height: 20),
|
||||
Text('Verbinde…',
|
||||
style: TextStyle(color: pt.fgMuted, fontSize: 14)),
|
||||
const SizedBox(height: 16),
|
||||
TextButton.icon(
|
||||
style: TextButton.styleFrom(foregroundColor: pt.danger),
|
||||
onPressed: widget.onHangUp,
|
||||
icon: const Icon(Icons.call_end_rounded, size: 16),
|
||||
label: const Text('Abbrechen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: pt.bg1,
|
||||
body: Column(
|
||||
children: [
|
||||
_CallHeader(
|
||||
displayName: widget.displayName,
|
||||
isGridView: _isGridView,
|
||||
onToggleView: () => setState(() => _isGridView = !_isGridView),
|
||||
onMinimize: widget.onMinimize,
|
||||
pt: pt,
|
||||
),
|
||||
Expanded(
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: _isGridView || hero == null
|
||||
? _ParticipantGrid(participants: all, pt: pt, onSelect: (p) => setState(() { _heroId = p.id; _isGridView = false; }))
|
||||
: _HeroLayout(
|
||||
hero: hero,
|
||||
others: all.where((p) => p.id != hero.id).toList(),
|
||||
stripCollapsed: _stripCollapsed,
|
||||
onToggleStrip: () => setState(() => _stripCollapsed = !_stripCollapsed),
|
||||
onSetStripCollapsed: (v) => setState(() => _stripCollapsed = v),
|
||||
onSelectOther: (p) => setState(() => _heroId = p.id),
|
||||
onQualityMenu: widget.onQualityMenu,
|
||||
pt: pt,
|
||||
),
|
||||
),
|
||||
),
|
||||
_CallControls(
|
||||
isMuted: widget.isMuted,
|
||||
isDeafened: widget.isDeafened,
|
||||
isCameraOff: widget.isCameraOff,
|
||||
isScreenSharing: widget.isScreenSharing,
|
||||
onToggleMic: widget.onToggleMic,
|
||||
onToggleDeafen: widget.onToggleDeafen,
|
||||
onToggleCam: widget.onToggleCam,
|
||||
onToggleScreen: widget.onToggleScreen,
|
||||
onHangUp: widget.onHangUp,
|
||||
pt: pt,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CallHeader extends StatelessWidget {
|
||||
final String displayName;
|
||||
final bool isGridView;
|
||||
final VoidCallback onToggleView;
|
||||
final VoidCallback onMinimize;
|
||||
final PyramidTheme pt;
|
||||
|
||||
const _CallHeader({required this.displayName, required this.isGridView, required this.onToggleView, required this.onMinimize, required this.pt});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 52,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: pt.border))),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.headphones_rounded, size: 16, color: pt.fgMuted),
|
||||
const SizedBox(width: 8),
|
||||
Text(displayName, style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(width: 8),
|
||||
_LiveBadge(pt: pt),
|
||||
const Spacer(),
|
||||
PyrIconBtn(
|
||||
icon: Icon(isGridView ? Icons.grid_view_rounded : Icons.featured_video_rounded, color: isGridView ? pt.accent : pt.fg),
|
||||
tooltip: isGridView ? 'Hero mode' : 'Grid mode',
|
||||
onPressed: onToggleView,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
PyrIconBtn(
|
||||
icon: Icon(Icons.close_fullscreen_rounded, color: pt.fg),
|
||||
tooltip: 'Minimieren',
|
||||
onPressed: onMinimize,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LiveBadge extends StatelessWidget {
|
||||
final PyramidTheme pt;
|
||||
const _LiveBadge({required this.pt});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(999), color: pt.online.withAlpha(30)),
|
||||
child: Row(children: [
|
||||
Container(width: 6, height: 6, decoration: BoxDecoration(color: pt.online, shape: BoxShape.circle)),
|
||||
const SizedBox(width: 4),
|
||||
Text('Live', style: TextStyle(color: pt.online, fontSize: 11, fontWeight: FontWeight.w600)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
part of '../voice_channel.dart';
|
||||
|
||||
class _ParticipantGrid extends StatelessWidget {
|
||||
final List<_ParticipantData> participants;
|
||||
final PyramidTheme pt;
|
||||
final Function(_ParticipantData) onSelect;
|
||||
|
||||
const _ParticipantGrid({required this.participants, required this.pt, required this.onSelect});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(builder: (context, box) {
|
||||
final cols = box.maxWidth > 900 ? 4 : (box.maxWidth > 600 ? 3 : 2);
|
||||
const spacing = 12.0;
|
||||
final tileWidth = (box.maxWidth - spacing * (cols - 1) - 24) / cols;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Wrap(
|
||||
spacing: spacing,
|
||||
runSpacing: spacing,
|
||||
children: participants.map((p) => SizedBox(
|
||||
width: tileWidth,
|
||||
height: tileWidth / p.aspectRatio,
|
||||
child: _ParticipantTile(data: p, pt: pt, onTap: () => onSelect(p)),
|
||||
)).toList(),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _HeroLayout extends StatelessWidget {
|
||||
final _ParticipantData hero;
|
||||
final List<_ParticipantData> others;
|
||||
final bool stripCollapsed;
|
||||
final VoidCallback onToggleStrip;
|
||||
final Function(bool) onSetStripCollapsed;
|
||||
final Function(_ParticipantData) onSelectOther;
|
||||
final Function(Offset)? onQualityMenu;
|
||||
final PyramidTheme pt;
|
||||
|
||||
const _HeroLayout({
|
||||
required this.hero,
|
||||
required this.others,
|
||||
required this.stripCollapsed,
|
||||
required this.onToggleStrip,
|
||||
required this.onSetStripCollapsed,
|
||||
required this.onSelectOther,
|
||||
this.onQualityMenu,
|
||||
required this.pt,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(builder: (context, box) {
|
||||
final isLandscape = box.maxWidth > box.maxHeight && box.maxWidth > 600;
|
||||
|
||||
final heroView = _HeroSection(
|
||||
data: hero,
|
||||
onQualityMenu: onQualityMenu,
|
||||
pt: pt,
|
||||
);
|
||||
|
||||
final strip = _ParticipantStrip(
|
||||
participants: others,
|
||||
isVertical: isLandscape,
|
||||
onSelect: onSelectOther,
|
||||
pt: pt,
|
||||
);
|
||||
|
||||
if (isLandscape) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: GestureDetector(
|
||||
onHorizontalDragEnd: (details) {
|
||||
if (details.primaryVelocity! < -300) onSetStripCollapsed(false);
|
||||
if (details.primaryVelocity! > 300) onSetStripCollapsed(true);
|
||||
},
|
||||
child: heroView,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!stripCollapsed)
|
||||
SizedBox(width: 200, child: Padding(padding: const EdgeInsets.only(right: 12, top: 12, bottom: 12), child: strip)),
|
||||
_CollapseHandle(isVertical: true, collapsed: stripCollapsed, onToggle: onToggleStrip, pt: pt),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: GestureDetector(
|
||||
onVerticalDragEnd: (details) {
|
||||
if (details.primaryVelocity! < -300) onSetStripCollapsed(false);
|
||||
if (details.primaryVelocity! > 300) onSetStripCollapsed(true);
|
||||
},
|
||||
child: heroView,
|
||||
),
|
||||
),
|
||||
),
|
||||
_CollapseHandle(isVertical: false, collapsed: stripCollapsed, onToggle: onToggleStrip, pt: pt),
|
||||
if (!stripCollapsed)
|
||||
SizedBox(height: 120, child: Padding(padding: const EdgeInsets.only(bottom: 12, left: 12, right: 12), child: strip)),
|
||||
],
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _HeroSection extends StatefulWidget {
|
||||
final _ParticipantData data;
|
||||
final Function(Offset)? onQualityMenu;
|
||||
final PyramidTheme pt;
|
||||
|
||||
const _HeroSection({required this.data, this.onQualityMenu, required this.pt});
|
||||
|
||||
@override
|
||||
State<_HeroSection> createState() => _HeroSectionState();
|
||||
}
|
||||
|
||||
class _HeroSectionState extends State<_HeroSection> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() => _hovered = !_hovered),
|
||||
child: Center(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(pt.rLg),
|
||||
child: Container(
|
||||
color: Colors.black,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: widget.data.videoWidget != null
|
||||
? _MirrorWrapper(
|
||||
isLocal: widget.data.id == 'local' || (widget.data.originalParticipant is LocalParticipant),
|
||||
isScreenShare: widget.data.isScreenShare,
|
||||
child: widget.data.videoWidget!,
|
||||
)
|
||||
: _BigAvatar(data: widget.data, pt: pt),
|
||||
),
|
||||
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
top: _hovered ? 0 : -60, left: 0, right: 0,
|
||||
child: _HeroTopBar(data: widget.data, pt: pt),
|
||||
),
|
||||
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
bottom: _hovered ? 0 : -60, left: 0, right: 0,
|
||||
child: _HeroBottomBar(
|
||||
data: widget.data,
|
||||
onQualityMenu: widget.onQualityMenu,
|
||||
pt: pt,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HeroTopBar extends StatelessWidget {
|
||||
final _ParticipantData data;
|
||||
final PyramidTheme pt;
|
||||
const _HeroTopBar({required this.data, required this.pt});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Colors.black54, Colors.transparent]),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (data.isScreenShare) _LivePill(pt: pt),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
data.isScreenShare ? "${data.name} präsentiert" : data.name,
|
||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HeroBottomBar extends StatelessWidget {
|
||||
final _ParticipantData data;
|
||||
final Function(Offset)? onQualityMenu;
|
||||
final PyramidTheme pt;
|
||||
const _HeroBottomBar({required this.data, this.onQualityMenu, required this.pt});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(begin: Alignment.bottomCenter, end: Alignment.topCenter, colors: [Colors.black54, Colors.transparent]),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
if (onQualityMenu != null)
|
||||
_HeroActionBtn(icon: Icons.settings_rounded, onTapDown: (d) => onQualityMenu!(d.globalPosition)),
|
||||
const SizedBox(width: 8),
|
||||
_HeroActionBtn(
|
||||
icon: Icons.fullscreen_rounded,
|
||||
onTap: () => Navigator.of(context).push(MaterialPageRoute(
|
||||
fullscreenDialog: true,
|
||||
builder: (_) => _FullscreenVideoPage(data: data),
|
||||
)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HeroActionBtn extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final VoidCallback? onTap;
|
||||
final Function(TapDownDetails)? onTapDown;
|
||||
const _HeroActionBtn({required this.icon, this.onTap, this.onTapDown});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
onTapDown: onTapDown,
|
||||
child: Container(
|
||||
width: 36, height: 36,
|
||||
decoration: BoxDecoration(color: Colors.white24, borderRadius: BorderRadius.circular(8)),
|
||||
child: Icon(icon, color: Colors.white, size: 20),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LivePill extends StatelessWidget {
|
||||
final PyramidTheme pt;
|
||||
const _LivePill({required this.pt});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(color: const Color(0xFFEF4444), borderRadius: BorderRadius.circular(4)),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Container(width: 4, height: 4, decoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle)),
|
||||
const SizedBox(width: 4),
|
||||
const Text('LIVE', style: TextStyle(color: Colors.white, fontSize: 9, fontWeight: FontWeight.w800, letterSpacing: 0.5)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ParticipantStrip extends StatelessWidget {
|
||||
final List<_ParticipantData> participants;
|
||||
final bool isVertical;
|
||||
final Function(_ParticipantData) onSelect;
|
||||
final PyramidTheme pt;
|
||||
|
||||
const _ParticipantStrip({required this.participants, required this.isVertical, required this.onSelect, required this.pt});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView.separated(
|
||||
scrollDirection: isVertical ? Axis.vertical : Axis.horizontal,
|
||||
itemCount: participants.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 12, height: 12),
|
||||
itemBuilder: (context, i) => SizedBox(
|
||||
width: isVertical ? double.infinity : 101 * participants[i].aspectRatio,
|
||||
height: 101,
|
||||
child: _ParticipantTile(data: participants[i], pt: pt, onTap: () => onSelect(participants[i])),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ParticipantTile extends StatefulWidget {
|
||||
final _ParticipantData data;
|
||||
final PyramidTheme pt;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ParticipantTile({required this.data, required this.pt, required this.onTap});
|
||||
|
||||
@override
|
||||
State<_ParticipantTile> createState() => _ParticipantTileState();
|
||||
}
|
||||
|
||||
class _ParticipantTileState extends State<_ParticipantTile> with SingleTickerProviderStateMixin {
|
||||
late AnimationController _speakingCtrl;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_speakingCtrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 800))..repeat(reverse: true);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_speakingCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
final data = widget.data;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedBuilder(
|
||||
animation: _speakingCtrl,
|
||||
builder: (context, _) {
|
||||
final isSpeaking = data.isSpeaking;
|
||||
final pulse = isSpeaking ? _speakingCtrl.value : 0.0;
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg2,
|
||||
borderRadius: BorderRadius.circular(pt.rBase),
|
||||
border: Border.all(
|
||||
color: isSpeaking
|
||||
? pt.accent.withAlpha((150 + pulse * 105).toInt())
|
||||
: pt.border,
|
||||
width: isSpeaking ? 2 : 1,
|
||||
),
|
||||
boxShadow: isSpeaking
|
||||
? [BoxShadow(color: pt.accent.withAlpha((20 + pulse * 40).toInt()), blurRadius: 4 + pulse * 8, spreadRadius: pulse * 2)]
|
||||
: null,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(pt.rBase - 1),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: data.videoWidget != null
|
||||
? _MirrorWrapper(
|
||||
isLocal: data.id == 'local' || (data.originalParticipant is LocalParticipant),
|
||||
isScreenShare: data.isScreenShare,
|
||||
child: data.videoWidget!,
|
||||
)
|
||||
: _SmallAvatar(data: data, pt: pt),
|
||||
),
|
||||
|
||||
Positioned(
|
||||
left: 6, bottom: 6, right: 6,
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(color: Colors.black54, borderRadius: BorderRadius.circular(4)),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (data.isMuted) ...[Icon(Icons.mic_off_rounded, size: 10, color: pt.danger), const SizedBox(width: 4)],
|
||||
Flexible(child: Text(data.name, style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w500), overflow: TextOverflow.ellipsis)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (data.isScreenShare) Positioned(top: 6, left: 6, child: _LivePill(pt: pt)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CollapseHandle extends StatelessWidget {
|
||||
final bool isVertical;
|
||||
final bool collapsed;
|
||||
final VoidCallback onToggle;
|
||||
final PyramidTheme pt;
|
||||
|
||||
const _CollapseHandle({required this.isVertical, required this.collapsed, required this.onToggle, required this.pt});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onToggle,
|
||||
child: Container(
|
||||
width: isVertical ? 24 : double.infinity,
|
||||
height: isVertical ? double.infinity : 24,
|
||||
color: Colors.transparent,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
isVertical
|
||||
? (collapsed ? Icons.chevron_left_rounded : Icons.chevron_right_rounded)
|
||||
: (collapsed ? Icons.keyboard_arrow_up_rounded : Icons.keyboard_arrow_down_rounded),
|
||||
color: pt.fgDim, size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
part of '../voice_channel.dart';
|
||||
|
||||
void _handleLiveKitScreenShare(BuildContext context, VoiceChannelService call) async {
|
||||
if (call.isScreenSharing) {
|
||||
await call.stopScreenShare();
|
||||
} else {
|
||||
final source = await ScreenSharePicker.show(context);
|
||||
if (source != null) await call.startScreenShare(source.id);
|
||||
}
|
||||
}
|
||||
|
||||
void _showLiveKitQualityMenu(BuildContext context, VoiceChannelService call, Offset pos) {
|
||||
final pt = PyramidTheme.of(context);
|
||||
showMenu(
|
||||
context: context,
|
||||
position: RelativeRect.fromLTRB(pos.dx - 210, pos.dy - 350, pos.dx, pos.dy),
|
||||
color: pt.bg2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(pt.rBase)),
|
||||
items: <PopupMenuEntry>[
|
||||
const PopupMenuItem(enabled: false, height: 28, child: Text('SENDEN', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Colors.grey))),
|
||||
for (final q in [
|
||||
('sd', 'SD', '360p'),
|
||||
('hd', 'HD', '720p'),
|
||||
('hd60', 'HD 60fps', '720p·60'),
|
||||
('fhd', 'Full HD', '1080p'),
|
||||
('fhd60', 'Full HD 60fps', '1080p·60'),
|
||||
('4k', '4K', '2160p'),
|
||||
])
|
||||
PopupMenuItem(onTap: () => call.changeQuality(q.$1), child: _QualityItem(label: q.$2, hint: q.$3, active: call.currentQualityKey == q.$1, pt: pt)),
|
||||
const PopupMenuDivider(),
|
||||
const PopupMenuItem(enabled: false, height: 28, child: Text('EMPFANGEN', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Colors.grey))),
|
||||
for (final q in [('auto', 'Auto', ''), ('high', 'Hoch', 'FHD'), ('medium', 'Mittel', 'HD'), ('low', 'Niedrig', 'SD')])
|
||||
PopupMenuItem(onTap: () => call.setSubscribeQuality(q.$1), child: _QualityItem(label: q.$2, hint: q.$3, active: call.subscribeQualityKey == q.$1, pt: pt)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _showVoipQualityMenu(BuildContext context, CallSignalingService voip, Offset pos) {
|
||||
final pt = PyramidTheme.of(context);
|
||||
showMenu(
|
||||
context: context,
|
||||
position: RelativeRect.fromLTRB(pos.dx - 210, pos.dy - 200, pos.dx, pos.dy),
|
||||
color: pt.bg2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(pt.rBase)),
|
||||
items: <PopupMenuEntry>[
|
||||
const PopupMenuItem(enabled: false, height: 28, child: Text('SENDEN', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: Colors.grey))),
|
||||
for (final q in [('sd', 'SD', '360p'), ('hd', 'HD', '720p'), ('fhd', 'Full HD', '1080p'), ('4k', '4K', '2160p')])
|
||||
PopupMenuItem(onTap: () => voip.changeQuality(q.$1), child: _QualityItem(label: q.$2, hint: q.$3, active: voip.currentQualityKey == q.$1, pt: pt)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _QualityItem extends StatelessWidget {
|
||||
final String label;
|
||||
final String? hint;
|
||||
final bool active;
|
||||
final PyramidTheme pt;
|
||||
const _QualityItem({required this.label, required this.active, required this.pt, this.hint});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(children: [
|
||||
if (active) Icon(Icons.check_rounded, size: 16, color: pt.accent) else const SizedBox(width: 16),
|
||||
const SizedBox(width: 8),
|
||||
Text(label, style: TextStyle(color: active ? pt.accent : pt.fg, fontSize: 13, fontWeight: active ? FontWeight.bold : FontWeight.normal)),
|
||||
if (hint != null && hint!.isNotEmpty) ...[const Spacer(), Text(hint!, style: TextStyle(color: pt.fgMuted, fontSize: 11))],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class _VoiceCtrlBtn extends StatefulWidget {
|
||||
final IconData icon;
|
||||
final PyramidTheme pt;
|
||||
final bool active;
|
||||
final bool danger;
|
||||
final bool isDanger;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _VoiceCtrlBtn({required this.icon, required this.pt, this.onTap, this.active = false, this.danger = false, this.isDanger = false});
|
||||
|
||||
@override
|
||||
State<_VoiceCtrlBtn> createState() => _VoiceCtrlBtnState();
|
||||
}
|
||||
|
||||
class _VoiceCtrlBtnState extends State<_VoiceCtrlBtn> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
Color bg;
|
||||
Color iconColor;
|
||||
if (widget.isDanger) { bg = pt.danger; iconColor = Colors.white; }
|
||||
else if (widget.danger) { bg = pt.danger.withAlpha(30); iconColor = pt.danger; }
|
||||
else if (widget.active) { bg = pt.accent.withAlpha(30); iconColor = pt.accent; }
|
||||
else { bg = _hovered ? pt.bgHover : pt.bg3; iconColor = pt.fg; }
|
||||
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
width: 52, height: 52,
|
||||
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(26)),
|
||||
child: Center(child: Icon(widget.icon, size: 20, color: iconColor)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MirrorWrapper extends StatelessWidget {
|
||||
final Widget child;
|
||||
final bool isLocal;
|
||||
final bool isScreenShare;
|
||||
|
||||
const _MirrorWrapper({required this.child, required this.isLocal, required this.isScreenShare});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isLocal && !isScreenShare) {
|
||||
return Transform(
|
||||
alignment: Alignment.center,
|
||||
transform: Matrix4.identity()..rotateY(3.14159),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
return child;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
part of '../voice_channel.dart';
|
||||
|
||||
// Unified participant data to handle both LiveKit and VoIP
|
||||
class _ParticipantData {
|
||||
final String id;
|
||||
final String name;
|
||||
final Widget? videoWidget;
|
||||
final bool isSpeaking;
|
||||
final bool isMuted;
|
||||
final bool hasVideo;
|
||||
final bool isScreenShare;
|
||||
final Color avatarColor;
|
||||
final dynamic originalParticipant;
|
||||
final double aspectRatio;
|
||||
|
||||
const _ParticipantData({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.videoWidget,
|
||||
this.isSpeaking = false,
|
||||
this.isMuted = false,
|
||||
this.hasVideo = false,
|
||||
this.isScreenShare = false,
|
||||
this.avatarColor = Colors.grey,
|
||||
this.originalParticipant,
|
||||
this.aspectRatio = 16.0 / 9.0,
|
||||
});
|
||||
}
|
||||
|
||||
class VoiceChannelView extends ConsumerWidget {
|
||||
const VoiceChannelView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final pt = PyramidTheme.of(context);
|
||||
final call = ref.watch(voiceChannelProvider);
|
||||
final voip = ref.watch(callSignalingProvider);
|
||||
|
||||
if (voip.currentCall != null && voip.currentCall!.state != CallState.kEnded) {
|
||||
return _UnifiedCallView(
|
||||
participants: _getVoipParticipants(voip),
|
||||
isLiveKit: false,
|
||||
displayName: voip.currentCall!.room.getLocalizedDisplayname(),
|
||||
isConnecting: false,
|
||||
onHangUp: () {
|
||||
voip.hangup();
|
||||
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||
},
|
||||
onMinimize: () =>
|
||||
ref.read(viewModeProvider.notifier).state = ViewMode.chat,
|
||||
onToggleMic: voip.toggleMic,
|
||||
onToggleDeafen: voip.toggleDeafen,
|
||||
onToggleCam: voip.toggleCamera,
|
||||
onToggleScreen: () => voip.toggleScreenSharing(context),
|
||||
isMuted: voip.isMicMuted,
|
||||
isDeafened: voip.isDeafened,
|
||||
isCameraOff: voip.isCameraMuted,
|
||||
isScreenSharing: voip.isScreensharing,
|
||||
onQualityMenu: (pos) => _showVoipQualityMenu(context, voip, pos),
|
||||
pt: pt,
|
||||
);
|
||||
}
|
||||
|
||||
if (!call.isActive) {
|
||||
return _NoCall(pt: pt, ref: ref);
|
||||
}
|
||||
|
||||
return _UnifiedCallView(
|
||||
participants: _getLiveKitParticipants(call),
|
||||
isLiveKit: true,
|
||||
displayName: call.displayName,
|
||||
isConnecting: call.isConnecting,
|
||||
onHangUp: () {
|
||||
call.hangUp();
|
||||
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||
},
|
||||
onMinimize: () =>
|
||||
ref.read(viewModeProvider.notifier).state = ViewMode.chat,
|
||||
onToggleMic: call.toggleMute,
|
||||
onToggleDeafen: call.toggleDeafen,
|
||||
onToggleCam: call.toggleCamera,
|
||||
onToggleScreen: () => _handleLiveKitScreenShare(context, call),
|
||||
isMuted: call.isMuted,
|
||||
isDeafened: call.isDeafened,
|
||||
isCameraOff: call.isCameraOff,
|
||||
isScreenSharing: call.isScreenSharing,
|
||||
onQualityMenu: (pos) => _showLiveKitQualityMenu(context, call, pos),
|
||||
pt: pt,
|
||||
);
|
||||
}
|
||||
|
||||
List<_ParticipantData> _getLiveKitParticipants(VoiceChannelService call) {
|
||||
final room = call.room;
|
||||
if (room == null) return [];
|
||||
final all = <Participant>[
|
||||
if (room.localParticipant != null) room.localParticipant!,
|
||||
...room.remoteParticipants.values,
|
||||
];
|
||||
|
||||
return all.mapIndexed((i, p) {
|
||||
final screenPub = p.videoTrackPublications.firstWhereOrNull((v) => v.source == TrackSource.screenShareVideo);
|
||||
final camPub = p.videoTrackPublications.firstWhereOrNull((v) => v.source == TrackSource.camera);
|
||||
final videoTrack = (screenPub?.track ?? camPub?.track) as VideoTrack?;
|
||||
final isScreen = screenPub != null;
|
||||
|
||||
double ar = 16.0 / 9.0;
|
||||
if (videoTrack != null) {
|
||||
try {
|
||||
final dim = (videoTrack as dynamic).currentDimensions ?? (videoTrack as dynamic).dimensions;
|
||||
if (dim != null) {
|
||||
final w = (dim.width as num?)?.toDouble() ?? 0;
|
||||
final h = (dim.height as num?)?.toDouble() ?? 0;
|
||||
if (w > 0 && h > 0) ar = w / h;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return _ParticipantData(
|
||||
id: p.identity,
|
||||
name: p.identity == room.localParticipant?.identity ? 'Du' : p.identity,
|
||||
videoWidget: videoTrack != null ? VideoTrackRenderer(videoTrack, fit: VideoViewFit.contain) : null,
|
||||
isSpeaking: p.isSpeaking,
|
||||
isMuted: !p.isMicrophoneEnabled(),
|
||||
hasVideo: videoTrack != null,
|
||||
isScreenShare: isScreen,
|
||||
avatarColor: _avatarColors[i % _avatarColors.length],
|
||||
originalParticipant: p,
|
||||
aspectRatio: ar,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
List<_ParticipantData> _getVoipParticipants(CallSignalingService voip) {
|
||||
final participants = <_ParticipantData>[];
|
||||
|
||||
double ar(rtc.RTCVideoRenderer r) {
|
||||
final w = r.videoWidth.toDouble();
|
||||
final h = r.videoHeight.toDouble();
|
||||
return (w > 0 && h > 0) ? w / h : 16.0 / 9.0;
|
||||
}
|
||||
|
||||
// Remote
|
||||
final isRemoteScreen = voip.isRemoteScreensharing;
|
||||
final hasRemoteVideo = voip.remoteRenderer.srcObject != null && voip.remoteRenderer.srcObject!.getVideoTracks().isNotEmpty;
|
||||
participants.add(_ParticipantData(
|
||||
id: 'remote',
|
||||
name: voip.currentCall?.room.getLocalizedDisplayname() ?? 'Remote',
|
||||
videoWidget: hasRemoteVideo ? rtc.RTCVideoView(voip.remoteRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null,
|
||||
isMuted: false,
|
||||
hasVideo: hasRemoteVideo,
|
||||
isScreenShare: isRemoteScreen,
|
||||
avatarColor: _avatarColors[1],
|
||||
aspectRatio: hasRemoteVideo ? ar(voip.remoteRenderer) : 16.0 / 9.0,
|
||||
));
|
||||
|
||||
// Local
|
||||
final isLocalScreen = voip.isScreensharing;
|
||||
final hasLocalVideo = voip.localRenderer.srcObject != null && voip.localRenderer.srcObject!.getVideoTracks().isNotEmpty;
|
||||
participants.add(_ParticipantData(
|
||||
id: 'local',
|
||||
name: 'Du',
|
||||
videoWidget: hasLocalVideo ? rtc.RTCVideoView(voip.localRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null,
|
||||
isMuted: voip.isMicMuted,
|
||||
hasVideo: hasLocalVideo,
|
||||
isScreenShare: isLocalScreen,
|
||||
avatarColor: _avatarColors[0],
|
||||
aspectRatio: hasLocalVideo ? ar(voip.localRenderer) : 16.0 / 9.0,
|
||||
));
|
||||
|
||||
return participants;
|
||||
}
|
||||
|
||||
static const _avatarColors = [
|
||||
Color(0xFFFF6B9D), Color(0xFF06B6D4), Color(0xFFA855F7),
|
||||
Color(0xFF10B981), Color(0xFFF59E0B), Color(0xFFEF4444),
|
||||
Color(0xFF3B82F6), Color(0xFF84CC16),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:livekit_client/livekit_client.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:pyramid/core/app_state.dart';
|
||||
import 'package:pyramid/widgets/screen_share_picker.dart';
|
||||
import 'package:pyramid/core/theme.dart';
|
||||
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
||||
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
||||
import 'package:pyramid/widgets/hover_region.dart';
|
||||
import 'package:pyramid/widgets/pyramid_loader.dart';
|
||||
|
||||
part 'view/voice_channel_core.dart';
|
||||
part 'view/call_view.dart';
|
||||
part 'view/participant_layout.dart';
|
||||
part 'view/avatars_controls.dart';
|
||||
part 'view/quality_menu.dart';
|
||||
@@ -30,9 +30,9 @@ class AttachmentDialog extends StatefulWidget {
|
||||
barrierLabel: 'Anhang',
|
||||
barrierColor: Colors.black54,
|
||||
transitionDuration: const Duration(milliseconds: 180),
|
||||
pageBuilder: (_, __, ___) =>
|
||||
pageBuilder: (_, _, _) =>
|
||||
AttachmentDialog(files: files, room: room, replyTo: replyTo),
|
||||
transitionBuilder: (_, anim, __, child) {
|
||||
transitionBuilder: (_, anim, _, child) {
|
||||
final curved =
|
||||
CurvedAnimation(parent: anim, curve: Curves.easeOutBack);
|
||||
return FadeTransition(
|
||||
@@ -184,7 +184,7 @@ class _AttachmentDialogState extends State<AttachmentDialog> {
|
||||
Switch.adaptive(
|
||||
value: !_compress,
|
||||
onChanged: (v) => setState(() => _compress = !v),
|
||||
activeColor: pt.accent,
|
||||
activeThumbColor: pt.accent,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -321,7 +321,7 @@ class _FilePreviewState extends State<_FilePreview> {
|
||||
}
|
||||
|
||||
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';
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.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(
|
||||
thumbUrl,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, __, ___) => Container(color: _pt!.bg2),
|
||||
errorBuilder: (_, _, _) => Container(color: _pt!.bg2),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -587,7 +587,7 @@ class _StickerCellState extends State<_StickerCell> {
|
||||
Image.network(
|
||||
widget.piUrl,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, __, ___) => Container(color: pt.bg2),
|
||||
errorBuilder: (_, _, _) => Container(color: pt.bg2),
|
||||
),
|
||||
if (showOverlay)
|
||||
Positioned(
|
||||
@@ -666,7 +666,7 @@ class _GifCell extends StatelessWidget {
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Image.network(imageUrl, fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Container(color: PyramidTheme.of(context).bg3)),
|
||||
errorBuilder: (_, _, _) => Container(color: PyramidTheme.of(context).bg3)),
|
||||
if (isHovered || isFavorite)
|
||||
Positioned(
|
||||
top: 4, right: 4,
|
||||
|
||||
@@ -266,7 +266,7 @@ class _AArtwork extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(size * 0.3),
|
||||
gradient: LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [c1, c2]),
|
||||
),
|
||||
child: Icon(Icons.music_note, color: Colors.white.withOpacity(0.85), size: size * 0.48),
|
||||
child: Icon(Icons.music_note, color: Colors.white.withValues(alpha: 0.85), size: size * 0.48),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -603,9 +603,9 @@ class _VCenterPlay extends StatelessWidget {
|
||||
Widget build(BuildContext context) => Container(
|
||||
width: 56, height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.10),
|
||||
color: Colors.white.withValues(alpha: 0.10),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white.withOpacity(0.18)),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.18)),
|
||||
),
|
||||
child: const Icon(Icons.play_arrow, color: Colors.white, size: 26),
|
||||
);
|
||||
@@ -682,14 +682,14 @@ class _VScrubber extends StatelessWidget {
|
||||
milliseconds: (dur.inMilliseconds * (d.localPosition.dx / c.maxWidth).clamp(0.0, 1.0)).round())),
|
||||
child: SizedBox(height: 16, child: Stack(alignment: Alignment.centerLeft, children: [
|
||||
Container(height: 3, decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.20), borderRadius: BorderRadius.circular(2))),
|
||||
color: Colors.white.withValues(alpha: 0.20), borderRadius: BorderRadius.circular(2))),
|
||||
FractionallySizedBox(widthFactor: progress, child: Container(height: 3,
|
||||
decoration: BoxDecoration(color: accent, borderRadius: BorderRadius.circular(2)))),
|
||||
Positioned(
|
||||
left: c.maxWidth * progress.clamp(0.0, 1.0) - 5,
|
||||
child: Container(width: 10, height: 10,
|
||||
decoration: BoxDecoration(color: accent, shape: BoxShape.circle,
|
||||
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.4), blurRadius: 0, spreadRadius: 2)])),
|
||||
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.4), blurRadius: 0, spreadRadius: 2)])),
|
||||
),
|
||||
])),
|
||||
));
|
||||
@@ -722,7 +722,7 @@ class _VOverlayBtn extends StatelessWidget {
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: SizedBox(width: 32, height: 32,
|
||||
child: Icon(icon, size: 17, color: Colors.white.withOpacity(0.85))),
|
||||
child: Icon(icon, size: 17, color: Colors.white.withValues(alpha: 0.85))),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -750,7 +750,7 @@ class _VSettingsButton extends StatelessWidget {
|
||||
color: const Color(0xF014141A),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
side: BorderSide(color: Colors.white.withOpacity(0.1)),
|
||||
side: BorderSide(color: Colors.white.withValues(alpha: 0.1)),
|
||||
),
|
||||
itemBuilder: (_) => [PopupMenuItem<void>(
|
||||
enabled: false, padding: EdgeInsets.zero,
|
||||
@@ -761,7 +761,7 @@ class _VSettingsButton extends StatelessWidget {
|
||||
),
|
||||
)],
|
||||
child: SizedBox(width: 32, height: 32,
|
||||
child: Icon(Icons.settings_outlined, size: 16, color: Colors.white.withOpacity(0.85))),
|
||||
child: Icon(Icons.settings_outlined, size: 16, color: Colors.white.withValues(alpha: 0.85))),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -812,7 +812,7 @@ class _VSettingsMenuState extends State<_VSettingsMenu> {
|
||||
]),
|
||||
),
|
||||
),
|
||||
Container(height: 1, color: Colors.white.withOpacity(0.08)),
|
||||
Container(height: 1, color: Colors.white.withValues(alpha: 0.08)),
|
||||
if (isSpeed)
|
||||
...widget.speeds.map((s) {
|
||||
final lbl = '${s == s.toInt() ? s.toInt() : s}x';
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
part of '../message_group.dart';
|
||||
|
||||
class _HoverActions extends StatefulWidget {
|
||||
final PyramidTheme pt;
|
||||
final Event event;
|
||||
final List<Event> events;
|
||||
final Room? room;
|
||||
final String currentUserId;
|
||||
final VoidCallback? onReply;
|
||||
final ValueChanged<bool>? onOverlayStateChanged;
|
||||
|
||||
const _HoverActions({
|
||||
required this.pt,
|
||||
required this.event,
|
||||
required this.events,
|
||||
required this.currentUserId,
|
||||
this.room,
|
||||
this.onReply,
|
||||
this.onOverlayStateChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_HoverActions> createState() => _HoverActionsState();
|
||||
}
|
||||
|
||||
class _HoverActionsState extends State<_HoverActions> {
|
||||
OverlayEntry? _reactionOverlay;
|
||||
List<String> _quickReactions = ReactionService.quickReactions;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
ReactionService.load().then((_) {
|
||||
if (mounted) setState(() => _quickReactions = ReactionService.quickReactions);
|
||||
});
|
||||
}
|
||||
|
||||
void _closeReactionOverlay() {
|
||||
_reactionOverlay?.remove();
|
||||
_reactionOverlay = null;
|
||||
widget.onOverlayStateChanged?.call(false);
|
||||
}
|
||||
|
||||
void _toggleReactionPicker() {
|
||||
if (_reactionOverlay != null) {
|
||||
_closeReactionOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
final offset = box?.localToGlobal(Offset.zero) ?? Offset.zero;
|
||||
|
||||
_reactionOverlay = OverlayEntry(builder: (ctx) {
|
||||
final screenSize = MediaQuery.sizeOf(ctx);
|
||||
const pickerW = 340.0;
|
||||
const pickerH = 380.0;
|
||||
double left = (offset.dx - pickerW / 2).clamp(8.0, screenSize.width - pickerW - 8);
|
||||
double top = (offset.dy - pickerH - 8).clamp(8.0, screenSize.height - pickerH - 8);
|
||||
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: Stack(children: [
|
||||
GestureDetector(
|
||||
onTap: _closeReactionOverlay,
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: const SizedBox.expand(),
|
||||
),
|
||||
Positioned(
|
||||
left: left,
|
||||
top: top,
|
||||
child: EmojiPicker(
|
||||
onEmojiSelected: (emoji) {
|
||||
_closeReactionOverlay();
|
||||
widget.room?.sendReaction(widget.event.eventId, emoji)
|
||||
.catchError((_) => '');
|
||||
ReactionService.recordUsed(emoji).then((_) {
|
||||
if (mounted) setState(() => _quickReactions = ReactionService.quickReactions);
|
||||
});
|
||||
},
|
||||
onClose: _closeReactionOverlay,
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
});
|
||||
widget.onOverlayStateChanged?.call(true);
|
||||
Overlay.of(context).insert(_reactionOverlay!);
|
||||
}
|
||||
|
||||
void _showMoreMenu() {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
final offset = box?.localToGlobal(Offset.zero) ?? Offset.zero;
|
||||
final size = box?.size ?? Size.zero;
|
||||
|
||||
final isOwnMessage = widget.event.senderId == widget.currentUserId;
|
||||
final text = widget.event.body;
|
||||
|
||||
// Find any image or sticker event in the group that can be saved as a sticker.
|
||||
final imageEvent = widget.events.firstWhereOrNull(
|
||||
(e) => e.messageType == 'm.image' || e.type == EventTypes.Sticker,
|
||||
);
|
||||
|
||||
// Capture messenger synchronously before showMenu's async gap so the
|
||||
// snackbar can be shown even if the widget is unmounted by then.
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
|
||||
showMenu<String>(
|
||||
context: context,
|
||||
position: RelativeRect.fromLTRB(
|
||||
offset.dx, offset.dy - size.height,
|
||||
offset.dx + size.width, offset.dy,
|
||||
),
|
||||
items: [
|
||||
PopupMenuItem(value: 'copy', child: Row(children: [
|
||||
Icon(Icons.copy_rounded, size: 16, color: widget.pt.fgMuted),
|
||||
const SizedBox(width: 10),
|
||||
Text('Kopieren', style: TextStyle(color: widget.pt.fg, fontSize: 14)),
|
||||
])),
|
||||
if (imageEvent != null)
|
||||
PopupMenuItem(value: 'save_sticker', child: Row(children: [
|
||||
Icon(Icons.sticky_note_2_outlined, size: 16, color: widget.pt.fgMuted),
|
||||
const SizedBox(width: 10),
|
||||
Text('Als Sticker speichern', style: TextStyle(color: widget.pt.fg, fontSize: 14)),
|
||||
])),
|
||||
if (isOwnMessage)
|
||||
PopupMenuItem(value: 'delete', child: Row(children: [
|
||||
Icon(Icons.delete_outline_rounded, size: 16, color: widget.pt.danger),
|
||||
const SizedBox(width: 10),
|
||||
Text('Löschen', style: TextStyle(color: widget.pt.danger, fontSize: 14)),
|
||||
])),
|
||||
PopupMenuItem(value: 'pin', child: Row(children: [
|
||||
Icon(Icons.push_pin_outlined, size: 16, color: widget.pt.fgMuted),
|
||||
const SizedBox(width: 10),
|
||||
Text('Anpinnen', style: TextStyle(color: widget.pt.fg, fontSize: 14)),
|
||||
])),
|
||||
],
|
||||
).then((value) async {
|
||||
if (value == 'copy') {
|
||||
Clipboard.setData(ClipboardData(text: text));
|
||||
} else if (value == 'save_sticker' && imageEvent != null) {
|
||||
final ok = await GifFavoriteService.addImageAsStickerFavorite(imageEvent);
|
||||
messenger?.showSnackBar(SnackBar(
|
||||
content: Text(ok ? 'Als Sticker gespeichert' : 'Fehler beim Speichern'),
|
||||
duration: const Duration(seconds: 2),
|
||||
));
|
||||
} else if (value == 'delete') {
|
||||
widget.event.redactEvent(reason: 'Vom Nutzer gelöscht')
|
||||
.catchError((_) => null);
|
||||
} else if (value == 'pin') {
|
||||
final room = widget.room;
|
||||
if (room == null) return;
|
||||
final pinned = List<String>.from(
|
||||
room.getState(EventTypes.RoomPinnedEvents)?.content['pinned'] as List? ?? [],
|
||||
);
|
||||
final eventId = widget.event.eventId;
|
||||
if (!pinned.contains(eventId)) pinned.add(eventId);
|
||||
room.setPinnedEvents(pinned).catchError((_) => '');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_reactionOverlay != null) {
|
||||
_reactionOverlay!.remove();
|
||||
_reactionOverlay = null;
|
||||
widget.onOverlayStateChanged?.call(false);
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg0,
|
||||
borderRadius: BorderRadius.circular(pt.rBase),
|
||||
border: Border.all(color: pt.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withAlpha(100),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Quick reactions
|
||||
..._quickReactions.map((emoji) => _QuickReactionBtn(
|
||||
emoji: emoji,
|
||||
pt: pt,
|
||||
onTap: () {
|
||||
widget.room?.sendReaction(widget.event.eventId, emoji)
|
||||
.catchError((_) => '');
|
||||
ReactionService.recordUsed(emoji).then((_) {
|
||||
if (mounted) setState(() => _quickReactions = ReactionService.quickReactions);
|
||||
});
|
||||
},
|
||||
)),
|
||||
Container(width: 1, height: 18, color: pt.border, margin: const EdgeInsets.symmetric(horizontal: 2)),
|
||||
_ActionBtn(
|
||||
icon: Icons.add_reaction_outlined,
|
||||
tooltip: 'Reaktion',
|
||||
pt: pt,
|
||||
accent: true,
|
||||
onTap: _toggleReactionPicker,
|
||||
),
|
||||
_ActionBtn(
|
||||
icon: Icons.reply_rounded,
|
||||
tooltip: 'Antworten',
|
||||
pt: pt,
|
||||
onTap: widget.onReply,
|
||||
),
|
||||
_ActionBtn(
|
||||
icon: Icons.more_horiz_rounded,
|
||||
tooltip: 'Mehr',
|
||||
pt: pt,
|
||||
onTap: _showMoreMenu,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActionBtn extends StatefulWidget {
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
final PyramidTheme pt;
|
||||
final bool accent;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _ActionBtn({
|
||||
required this.icon,
|
||||
required this.tooltip,
|
||||
required this.pt,
|
||||
this.accent = false,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_ActionBtn> createState() => _ActionBtnState();
|
||||
}
|
||||
|
||||
class _ActionBtnState extends State<_ActionBtn> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = _hovered
|
||||
? (widget.accent ? widget.pt.accent : widget.pt.fg)
|
||||
: widget.pt.fgMuted;
|
||||
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: Tooltip(
|
||||
message: widget.tooltip,
|
||||
waitDuration: const Duration(milliseconds: 400),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: _hovered ? widget.pt.bgHover : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(widget.pt.rSm),
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(widget.icon, size: 16, color: color),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
part of '../message_group.dart';
|
||||
|
||||
String _formatMessageTime(DateTime t) {
|
||||
final h = t.hour.toString().padLeft(2, '0');
|
||||
final m = t.minute.toString().padLeft(2, '0');
|
||||
return '$h:$m';
|
||||
}
|
||||
|
||||
// Wraps a group of consecutive messages from the same sender
|
||||
class MessageGroup extends StatefulWidget {
|
||||
final List<Event> events;
|
||||
final String currentUserId;
|
||||
final Room? room;
|
||||
final Timeline? timeline;
|
||||
final VoidCallback? onReply;
|
||||
final Set<String> selectedIds;
|
||||
final ValueChanged<String>? onToggleSelect;
|
||||
final bool showStatusIndicator;
|
||||
/// Event IDs that another member has read up to (their last-read position).
|
||||
/// A ✓✓ is rendered at any of this group's events contained here.
|
||||
final Set<String> readEventIds;
|
||||
/// When non-null and pointing to a non-last event in this group, an inline
|
||||
/// "Neue Nachrichten" divider is rendered after that event's row.
|
||||
final String? fullyReadId;
|
||||
/// Jump-to-message (from search): the row matching [jumpEventId] gets
|
||||
/// [jumpKey] attached (for ensureVisible) and [highlightEventId] is tinted.
|
||||
final String? jumpEventId;
|
||||
final GlobalKey? jumpKey;
|
||||
final String? highlightEventId;
|
||||
final bool highlightOn;
|
||||
|
||||
const MessageGroup({
|
||||
super.key,
|
||||
required this.events,
|
||||
required this.currentUserId,
|
||||
this.room,
|
||||
this.timeline,
|
||||
this.onReply,
|
||||
this.selectedIds = const {},
|
||||
this.onToggleSelect,
|
||||
this.showStatusIndicator = false,
|
||||
this.readEventIds = const {},
|
||||
this.fullyReadId,
|
||||
this.jumpEventId,
|
||||
this.jumpKey,
|
||||
this.highlightEventId,
|
||||
this.highlightOn = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MessageGroup> createState() => _MessageGroupState();
|
||||
}
|
||||
|
||||
class _MessageGroupState extends State<MessageGroup> {
|
||||
// Per-message hover state (desktop): which event row the pointer is over, and
|
||||
// which row currently has its reaction-picker overlay open.
|
||||
String? _hoveredEventId;
|
||||
String? _overlayEventId;
|
||||
double _swipeOffset = 0;
|
||||
static const _swipeThreshold = 56.0;
|
||||
bool _swipeTriggered = false;
|
||||
|
||||
bool get _isMobile => Platform.isAndroid || Platform.isIOS;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = PyramidTheme.of(context);
|
||||
final firstEvent = widget.events.first;
|
||||
final sender = firstEvent.senderFromMemoryOrFallback;
|
||||
final name = sender.calcDisplayname();
|
||||
final initial = name.isNotEmpty ? name[0].toUpperCase() : '?';
|
||||
final color = _colorFromId(firstEvent.senderId);
|
||||
final time = _formatMessageTime(firstEvent.originServerTs);
|
||||
final avatarUri = sender.avatarUrl;
|
||||
|
||||
// Build a FLAT column of rows — each message at the same indentation level.
|
||||
// First event row: avatar + name + time + content
|
||||
// Continuation rows: timestamp-slot + content
|
||||
// This prevents the nested-Row double-indent bug.
|
||||
final rows = <Widget>[];
|
||||
|
||||
// ── First event row ──────────────────────────────────
|
||||
// Each message row is INDIVIDUALLY selectable (see _selectableRow) so two
|
||||
// consecutive messages from the same sender aren't selected as one block.
|
||||
rows.add(_selectableRow(firstEvent, pt, Padding(
|
||||
padding: EdgeInsets.only(top: pt.msgGap / 2, left: 20, right: 20, bottom: 2),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Avatar
|
||||
SizedBox(
|
||||
width: 38,
|
||||
child: Column(children: [
|
||||
const SizedBox(height: 2),
|
||||
_Avatar(initial: initial, color: color, pt: pt, size: 38, avatarUri: avatarUri),
|
||||
]),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
// Name + time + message
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
Text(name, style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(width: 8),
|
||||
Text(time, style: TextStyle(color: pt.fgDim, fontSize: 11)),
|
||||
]),
|
||||
const SizedBox(height: 2),
|
||||
_MessageBody(
|
||||
event: firstEvent,
|
||||
pt: pt,
|
||||
timeline: widget.timeline,
|
||||
room: widget.room,
|
||||
currentUserId: widget.currentUserId,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)));
|
||||
if (widget.readEventIds.contains(firstEvent.eventId)) rows.add(_ReadMarker(pt: pt));
|
||||
// Inline unread divider after the first event when it's the fullyRead
|
||||
// marker and there are more events in this group.
|
||||
if (widget.fullyReadId == firstEvent.eventId && widget.events.length > 1) {
|
||||
rows.add(_InlineUnreadDivider(pt: pt));
|
||||
}
|
||||
|
||||
// ── Continuation event rows (siblings, same indentation) ──
|
||||
for (var j = 1; j < widget.events.length; j++) {
|
||||
final event = widget.events[j];
|
||||
rows.add(_selectableRow(event, pt, Padding(
|
||||
padding: const EdgeInsets.only(top: 8, left: 20, right: 20),
|
||||
child: _ContinuationMessage(
|
||||
event: event,
|
||||
pt: pt,
|
||||
timeline: widget.timeline,
|
||||
room: widget.room,
|
||||
currentUserId: widget.currentUserId,
|
||||
),
|
||||
)));
|
||||
if (widget.readEventIds.contains(event.eventId)) rows.add(_ReadMarker(pt: pt));
|
||||
// Inline divider after this event if it's the fullyRead and not the last.
|
||||
if (widget.fullyReadId == event.eventId && j < widget.events.length - 1) {
|
||||
rows.add(_InlineUnreadDivider(pt: pt));
|
||||
}
|
||||
}
|
||||
|
||||
// Sending/sent indicator on the newest own message — only when no read ✓✓
|
||||
// is already shown for it (the ✓✓ implies it was delivered + read).
|
||||
if (widget.showStatusIndicator &&
|
||||
firstEvent.senderId == widget.currentUserId &&
|
||||
widget.room != null &&
|
||||
!widget.readEventIds.contains(widget.events.last.eventId)) {
|
||||
rows.add(Padding(
|
||||
padding: const EdgeInsets.only(right: 24, bottom: 1),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: _MessageStatus(
|
||||
lastEvent: widget.events.last,
|
||||
pt: pt,
|
||||
),
|
||||
),
|
||||
));
|
||||
}
|
||||
rows.add(const SizedBox(height: 2));
|
||||
|
||||
// Hover highlight + actions are now per-row (see _selectableRow), so the
|
||||
// group is just the column of rows.
|
||||
Widget content = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: rows,
|
||||
);
|
||||
|
||||
if (!_isMobile) return content;
|
||||
|
||||
return GestureDetector(
|
||||
onHorizontalDragUpdate: (d) {
|
||||
if (d.delta.dx > 0) return; // Only allow right-to-left swipe for reply
|
||||
setState(() {
|
||||
_swipeOffset = (_swipeOffset + d.delta.dx).clamp(-_swipeThreshold, 0.0);
|
||||
if (_swipeOffset <= -_swipeThreshold && !_swipeTriggered) {
|
||||
_swipeTriggered = true;
|
||||
HapticFeedback.mediumImpact();
|
||||
}
|
||||
});
|
||||
},
|
||||
onHorizontalDragEnd: (_) {
|
||||
if (_swipeTriggered) widget.onReply?.call();
|
||||
setState(() { _swipeOffset = 0; _swipeTriggered = false; });
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
Transform.translate(
|
||||
offset: Offset(_swipeOffset, 0),
|
||||
child: content,
|
||||
),
|
||||
if (_swipeOffset < -8)
|
||||
Positioned(
|
||||
right: 12,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: Center(
|
||||
child: AnimatedOpacity(
|
||||
opacity: (-_swipeOffset / _swipeThreshold).clamp(0.0, 1.0),
|
||||
duration: const Duration(milliseconds: 60),
|
||||
child: Icon(Icons.reply_rounded, size: 20, color: pt.fgMuted),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Wraps a single message row so it can be selected independently of the rest
|
||||
// of the sender's group. Long-press enters selection mode; in selection mode a
|
||||
// full-row tap overlay toggles this message's selection.
|
||||
Widget _selectableRow(Event event, PyramidTheme pt, Widget child) {
|
||||
final selected = widget.selectedIds.contains(event.eventId);
|
||||
final inSel = widget.selectedIds.isNotEmpty;
|
||||
final showActions = !_isMobile && !inSel &&
|
||||
(_hoveredEventId == event.eventId || _overlayEventId == event.eventId);
|
||||
final isJumpTarget = widget.jumpEventId == event.eventId;
|
||||
final isHighlighted = widget.highlightEventId == event.eventId;
|
||||
|
||||
Widget row = GestureDetector(
|
||||
key: isJumpTarget ? widget.jumpKey : null,
|
||||
onLongPress: () => widget.onToggleSelect?.call(event.eventId),
|
||||
child: Stack(
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 100),
|
||||
color: selected
|
||||
? pt.accent.withAlpha(40)
|
||||
: (showActions ? pt.bg2 : Colors.transparent),
|
||||
child: child,
|
||||
),
|
||||
// Jump-from-search highlight — fades out via AnimatedOpacity.
|
||||
if (isHighlighted)
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: AnimatedOpacity(
|
||||
opacity: widget.highlightOn ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 900),
|
||||
curve: Curves.easeOut,
|
||||
child: Container(color: pt.accent.withAlpha(48)),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (selected)
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: Container(width: 3, color: pt.accent),
|
||||
),
|
||||
if (showActions)
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 32,
|
||||
child: _HoverActions(
|
||||
pt: pt,
|
||||
event: event,
|
||||
events: [event],
|
||||
room: widget.room,
|
||||
currentUserId: widget.currentUserId,
|
||||
onReply: widget.onReply,
|
||||
onOverlayStateChanged: (active) => setState(
|
||||
() => _overlayEventId = active ? event.eventId : null),
|
||||
),
|
||||
),
|
||||
if (inSel)
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => widget.onToggleSelect?.call(event.eventId),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (_isMobile) return row;
|
||||
|
||||
// Desktop: per-row hover highlight + actions.
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hoveredEventId = event.eventId),
|
||||
onExit: (_) => setState(() {
|
||||
if (_hoveredEventId == event.eventId) _hoveredEventId = null;
|
||||
}),
|
||||
child: row,
|
||||
);
|
||||
}
|
||||
|
||||
Color _colorFromId(String id) {
|
||||
const colors = [
|
||||
Color(0xFF06B6D4), Color(0xFFA855F7), Color(0xFF10B981),
|
||||
Color(0xFFF43F5E), Color(0xFF3B82F6), Color(0xFFF59E0B),
|
||||
Color(0xFFFF6B9D),
|
||||
];
|
||||
if (id.isEmpty) return colors[0];
|
||||
return colors[id.codeUnitAt(0) % colors.length];
|
||||
}
|
||||
}
|
||||
|
||||
// Shown under a message whose send failed (e.g. offline) — keeps the message
|
||||
// visible with retry / delete instead of letting it silently disappear.
|
||||
class _SendFailedRow extends StatelessWidget {
|
||||
final Event event;
|
||||
final PyramidTheme pt;
|
||||
const _SendFailedRow({required this.event, required this.pt});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 3, bottom: 1),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline_rounded, size: 13, color: pt.danger),
|
||||
const SizedBox(width: 4),
|
||||
Text('Nicht gesendet', style: TextStyle(color: pt.danger, fontSize: 12)),
|
||||
const SizedBox(width: 10),
|
||||
_link('Wiederholen', pt, () async {
|
||||
try { await event.sendAgain(); } catch (_) {}
|
||||
}),
|
||||
const SizedBox(width: 10),
|
||||
_link('Löschen', pt, () async {
|
||||
try { await event.cancelSend(); } catch (_) {}
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _link(String label, PyramidTheme pt, VoidCallback onTap) => GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: pt.accent,
|
||||
fontSize: 12,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: pt.accent,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Single message (first in group) with full avatar + name shown
|
||||
class _MessageBody extends StatelessWidget {
|
||||
final Event event;
|
||||
final PyramidTheme pt;
|
||||
final Timeline? timeline;
|
||||
final Room? room;
|
||||
final String currentUserId;
|
||||
|
||||
const _MessageBody({
|
||||
required this.event,
|
||||
required this.pt,
|
||||
this.timeline,
|
||||
this.room,
|
||||
required this.currentUserId,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_MessageContent(event: event, pt: pt, timeline: timeline),
|
||||
if (event.status.isError) _SendFailedRow(event: event, pt: pt),
|
||||
if (timeline != null) _ReactionsRow(event: event, timeline: timeline!, room: room, currentUserId: currentUserId, pt: pt),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Continuation message — no avatar, just indented text with timestamp on hover
|
||||
class _ContinuationMessage extends StatefulWidget {
|
||||
final Event event;
|
||||
final PyramidTheme pt;
|
||||
final Timeline? timeline;
|
||||
final Room? room;
|
||||
final String currentUserId;
|
||||
|
||||
const _ContinuationMessage({
|
||||
required this.event,
|
||||
required this.pt,
|
||||
this.timeline,
|
||||
this.room,
|
||||
required this.currentUserId,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_ContinuationMessage> createState() => _ContinuationMessageState();
|
||||
}
|
||||
|
||||
class _ContinuationMessageState extends State<_ContinuationMessage> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Timestamp on hover (in avatar column space)
|
||||
SizedBox(
|
||||
width: 38,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: AnimatedOpacity(
|
||||
opacity: _hovered ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 120),
|
||||
child: Text(
|
||||
_formatMessageTime(widget.event.originServerTs),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: widget.pt.fgDim,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_MessageContent(event: widget.event, pt: widget.pt, timeline: widget.timeline),
|
||||
if (widget.event.status.isError) _SendFailedRow(event: widget.event, pt: widget.pt),
|
||||
if (widget.timeline != null)
|
||||
_ReactionsRow(
|
||||
event: widget.event,
|
||||
timeline: widget.timeline!,
|
||||
room: widget.room,
|
||||
currentUserId: widget.currentUserId,
|
||||
pt: widget.pt,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
part of '../message_group.dart';
|
||||
|
||||
class _LinkPreviewData {
|
||||
final String? title;
|
||||
final String? description;
|
||||
final String? imageMxc;
|
||||
const _LinkPreviewData({this.title, this.description, this.imageMxc});
|
||||
bool get isEmpty =>
|
||||
(title == null || title!.isEmpty) &&
|
||||
(description == null || description!.isEmpty) &&
|
||||
imageMxc == null;
|
||||
}
|
||||
|
||||
class _LinkPreview extends ConsumerStatefulWidget {
|
||||
final String url;
|
||||
final PyramidTheme pt;
|
||||
const _LinkPreview({required this.url, required this.pt});
|
||||
|
||||
// url → preview (null = fetched but nothing usable). Avoids refetching on
|
||||
// every rebuild/scroll.
|
||||
static final Map<String, _LinkPreviewData?> _cache = {};
|
||||
|
||||
@override
|
||||
ConsumerState<_LinkPreview> createState() => _LinkPreviewState();
|
||||
}
|
||||
|
||||
class _LinkPreviewState extends ConsumerState<_LinkPreview> {
|
||||
_LinkPreviewData? _data;
|
||||
bool _done = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
if (_LinkPreview._cache.containsKey(widget.url)) {
|
||||
setState(() { _data = _LinkPreview._cache[widget.url]; _done = true; });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final client = await ref.read(matrixClientProvider.future);
|
||||
final hs = client.homeserver.toString();
|
||||
final enc = Uri.encodeQueryComponent(widget.url);
|
||||
final headers = {'authorization': 'Bearer ${client.accessToken}'};
|
||||
|
||||
var res = await client.httpClient.get(
|
||||
Uri.parse('$hs/_matrix/client/v1/media/preview_url?url=$enc'),
|
||||
headers: headers,
|
||||
);
|
||||
if (res.statusCode == 404) {
|
||||
// Legacy fallback for older servers.
|
||||
res = await client.httpClient.get(
|
||||
Uri.parse('$hs/_matrix/media/v3/preview_url?url=$enc'),
|
||||
headers: headers,
|
||||
);
|
||||
}
|
||||
_LinkPreviewData? data;
|
||||
if (res.statusCode == 200) {
|
||||
final json = jsonDecode(utf8.decode(res.bodyBytes)) as Map<String, dynamic>;
|
||||
data = _LinkPreviewData(
|
||||
title: (json['og:title'] as String?)?.trim(),
|
||||
description: (json['og:description'] as String?)?.trim(),
|
||||
imageMxc: json['og:image'] as String?,
|
||||
);
|
||||
if (data.isEmpty) data = null;
|
||||
}
|
||||
_LinkPreview._cache[widget.url] = data;
|
||||
if (mounted) setState(() { _data = data; _done = true; });
|
||||
} catch (_) {
|
||||
_LinkPreview._cache[widget.url] = null;
|
||||
if (mounted) setState(() { _data = null; _done = true; });
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final data = _data;
|
||||
if (!_done || data == null) return const SizedBox.shrink();
|
||||
final pt = widget.pt;
|
||||
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 6, bottom: 2),
|
||||
child: GestureDetector(
|
||||
onTap: () => launchUrl(Uri.parse(widget.url), mode: LaunchMode.externalApplication),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 380),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(pt.rBase),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg2,
|
||||
border: Border(left: BorderSide(color: pt.accent, width: 3)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (data.imageMxc != null && client != null)
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 180),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: MxcImage(
|
||||
mxcUri: data.imageMxc!,
|
||||
client: client,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 8, 10, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (data.title != null && data.title!.isNotEmpty)
|
||||
Text(
|
||||
data.title!,
|
||||
style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w600),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (data.description != null && data.description!.isNotEmpty) ...[
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
data.description!,
|
||||
style: TextStyle(color: pt.fgMuted, fontSize: 12, height: 1.35),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReplyPreview extends StatelessWidget {
|
||||
final Event replyEvent;
|
||||
final PyramidTheme pt;
|
||||
const _ReplyPreview({required this.replyEvent, required this.pt});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final senderName = replyEvent.senderFromMemoryOrFallback.calcDisplayname();
|
||||
|
||||
// Media-aware preview: show an icon + label instead of a raw filename,
|
||||
// matching WhatsApp/Discord. Falls back to stripped text for m.text.
|
||||
final (IconData? icon, String body) = _previewFor(replyEvent);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: IntrinsicHeight(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(pt.rSm),
|
||||
child: Container(
|
||||
color: pt.accentSoft,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Accent bar flush with the left edge of the tinted card.
|
||||
Container(width: 3, color: pt.accent),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 5, 10, 5),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(senderName, style: TextStyle(color: pt.accent, fontSize: 12, fontWeight: FontWeight.w600)),
|
||||
Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 13, color: pt.fgMuted),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
body,
|
||||
style: TextStyle(color: pt.fgMuted, fontSize: 12),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
(IconData?, String) _previewFor(Event e) {
|
||||
switch (e.messageType) {
|
||||
case 'm.image':
|
||||
return (Icons.image_outlined, 'Bild');
|
||||
case 'm.video':
|
||||
return (Icons.videocam_outlined, 'Video');
|
||||
case 'm.audio':
|
||||
return (Icons.mic_none_rounded, 'Sprachnachricht');
|
||||
case 'm.file':
|
||||
return (Icons.insert_drive_file_outlined, e.body.isNotEmpty ? e.body : 'Datei');
|
||||
}
|
||||
if (e.type == EventTypes.Sticker) return (Icons.emoji_emotions_outlined, 'Sticker');
|
||||
|
||||
// Plain text: strip nested reply fallback, collapse to first line.
|
||||
String body = e.body.replaceAll('\r\n', '\n');
|
||||
if (body.startsWith('> ')) {
|
||||
final lines = body.split('\n');
|
||||
var i = 0;
|
||||
while (i < lines.length && (lines[i].startsWith('> ') || lines[i].trimRight() == '>')) {
|
||||
i++;
|
||||
}
|
||||
if (i < lines.length && lines[i].trim().isEmpty) i++;
|
||||
if (i < lines.length) body = lines.skip(i).join(' ').trim();
|
||||
}
|
||||
return (null, body.split('\n').first);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,167 @@
|
||||
part of '../message_group.dart';
|
||||
|
||||
class _ReactionsRow extends StatelessWidget {
|
||||
final Event event;
|
||||
final Timeline timeline;
|
||||
final Room? room;
|
||||
final String currentUserId;
|
||||
final PyramidTheme pt;
|
||||
|
||||
const _ReactionsRow({
|
||||
required this.event,
|
||||
required this.timeline,
|
||||
required this.currentUserId,
|
||||
required this.pt,
|
||||
this.room,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final reactionEvents = event.aggregatedEvents(timeline, RelationshipTypes.reaction);
|
||||
if (reactionEvents.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
// Group by emoji key → list of senders
|
||||
final Map<String, List<String>> grouped = {};
|
||||
for (final r in reactionEvents) {
|
||||
final key = r.content.tryGetMap<String, dynamic>('m.relates_to')?['key'] as String?;
|
||||
if (key == null || key.isEmpty) continue;
|
||||
(grouped[key] ??= []).add(r.senderId);
|
||||
}
|
||||
if (grouped.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 4, bottom: 2),
|
||||
child: Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
children: grouped.entries.map((e) {
|
||||
final myReaction = e.value.contains(currentUserId);
|
||||
return _ReactionChip(
|
||||
emoji: e.key,
|
||||
count: e.value.length,
|
||||
mine: myReaction,
|
||||
pt: pt,
|
||||
onTap: () {
|
||||
if (myReaction) {
|
||||
// Find own reaction event to redact
|
||||
for (final r in reactionEvents) {
|
||||
final key = r.content.tryGetMap<String, dynamic>('m.relates_to')?['key'] as String?;
|
||||
if (key == e.key && r.senderId == currentUserId) {
|
||||
r.redactEvent().catchError((_) => null);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
room?.sendReaction(event.eventId, e.key).catchError((_) => '');
|
||||
ReactionService.recordUsed(e.key);
|
||||
}
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReactionChip extends StatefulWidget {
|
||||
final String emoji;
|
||||
final int count;
|
||||
final bool mine;
|
||||
final PyramidTheme pt;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ReactionChip({
|
||||
required this.emoji,
|
||||
required this.count,
|
||||
required this.mine,
|
||||
required this.pt,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_ReactionChip> createState() => _ReactionChipState();
|
||||
}
|
||||
|
||||
class _ReactionChipState extends State<_ReactionChip> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.mine
|
||||
? pt.accentSoft
|
||||
: (_hovered ? pt.bgHover : pt.bg2),
|
||||
borderRadius: BorderRadius.circular(pt.rBase),
|
||||
border: Border.all(
|
||||
color: widget.mine ? pt.accent : (_hovered ? pt.borderStrong : pt.border),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(widget.emoji, style: const TextStyle(fontSize: 13)),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${widget.count}',
|
||||
style: TextStyle(
|
||||
color: widget.mine ? pt.accent : pt.fgMuted,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QuickReactionBtn extends StatefulWidget {
|
||||
final String emoji;
|
||||
final PyramidTheme pt;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _QuickReactionBtn({required this.emoji, required this.pt, this.onTap});
|
||||
|
||||
@override
|
||||
State<_QuickReactionBtn> createState() => _QuickReactionBtnState();
|
||||
}
|
||||
|
||||
class _QuickReactionBtnState extends State<_QuickReactionBtn> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 100),
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: _hovered ? widget.pt.bgHover : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(widget.pt.rSm),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(widget.emoji, style: const TextStyle(fontSize: 15)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
part of '../message_group.dart';
|
||||
|
||||
class _Avatar extends ConsumerStatefulWidget {
|
||||
final String initial;
|
||||
final Color color;
|
||||
final PyramidTheme pt;
|
||||
final double size;
|
||||
final Uri? avatarUri;
|
||||
|
||||
const _Avatar({
|
||||
required this.initial,
|
||||
required this.color,
|
||||
required this.pt,
|
||||
required this.size,
|
||||
this.avatarUri,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<_Avatar> createState() => _AvatarState();
|
||||
}
|
||||
|
||||
class _AvatarState extends ConsumerState<_Avatar> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||
final radius = BorderRadius.circular(widget.size / 2);
|
||||
|
||||
Widget child;
|
||||
if (client != null && widget.avatarUri != null) {
|
||||
child = MxcAvatar(
|
||||
mxcUri: widget.avatarUri,
|
||||
client: client,
|
||||
size: widget.size,
|
||||
borderRadius: radius,
|
||||
placeholder: (_) => _InitialBox(
|
||||
initial: widget.initial,
|
||||
color: widget.color,
|
||||
size: widget.size,
|
||||
radius: radius,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
child = _InitialBox(
|
||||
initial: widget.initial,
|
||||
color: widget.color,
|
||||
size: widget.size,
|
||||
radius: radius,
|
||||
);
|
||||
}
|
||||
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOutBack,
|
||||
transform: _hovered
|
||||
? Matrix4.diagonal3Values(1.06, 1.06, 1.0)
|
||||
: Matrix4.identity(),
|
||||
transformAlignment: Alignment.center,
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InitialBox extends StatelessWidget {
|
||||
final String initial;
|
||||
final Color color;
|
||||
final double size;
|
||||
final BorderRadius radius;
|
||||
const _InitialBox({required this.initial, required this.color, required this.size, required this.radius});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(color: color, borderRadius: radius),
|
||||
child: Center(
|
||||
child: Text(initial, style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Inline "Neue Nachrichten" divider rendered inside a MessageGroup when the
|
||||
// fullyRead event is in the middle of the group (same sender, <5 min window).
|
||||
class _InlineUnreadDivider extends StatelessWidget {
|
||||
final PyramidTheme pt;
|
||||
const _InlineUnreadDivider({required this.pt});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(0, 8, 0, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Divider(color: pt.accent.withAlpha(160), height: 1)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Text(
|
||||
'Neue Nachrichten',
|
||||
style: TextStyle(color: pt.accent, fontSize: 11, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
Expanded(child: Divider(color: pt.accent.withAlpha(160), height: 1)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Date divider between message groups
|
||||
class DateDivider extends StatelessWidget {
|
||||
final DateTime date;
|
||||
|
||||
const DateDivider({super.key, required this.date});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = PyramidTheme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Divider(color: pt.border, height: 1)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text(
|
||||
_formatDate(date),
|
||||
style: TextStyle(
|
||||
color: pt.fgDim,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.06 * 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(child: Divider(color: pt.border, height: 1)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime d) {
|
||||
final now = DateTime.now();
|
||||
if (d.year == now.year && d.month == now.month && d.day == now.day) {
|
||||
return 'Heute · ${d.day}. ${_monthName(d.month)}';
|
||||
}
|
||||
final yesterday = now.subtract(const Duration(days: 1));
|
||||
if (d.year == yesterday.year && d.month == yesterday.month && d.day == yesterday.day) {
|
||||
return 'Gestern';
|
||||
}
|
||||
return '${d.day}. ${_monthName(d.month)} ${d.year}';
|
||||
}
|
||||
|
||||
String _monthName(int m) => const [
|
||||
'', 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
||||
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'
|
||||
][m];
|
||||
}
|
||||
|
||||
class _MessageStatus extends StatelessWidget {
|
||||
final Event lastEvent;
|
||||
final PyramidTheme pt;
|
||||
|
||||
const _MessageStatus({required this.lastEvent, required this.pt});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final status = lastEvent.status;
|
||||
if (status == EventStatus.error) {
|
||||
return Icon(Icons.error_outline_rounded, size: 12, color: pt.danger);
|
||||
}
|
||||
if (status == EventStatus.sending) {
|
||||
return Icon(Icons.access_time_rounded, size: 12, color: pt.fgDim);
|
||||
}
|
||||
// Sent (delivered). "Read" is shown by the per-event ✓✓ marker.
|
||||
return Icon(Icons.done_rounded, size: 13, color: pt.fgDim);
|
||||
}
|
||||
}
|
||||
|
||||
// Read receipt: a ✓✓ shown at the partner's last-read message (any sender),
|
||||
// so it moves next to their own message when they reply.
|
||||
class _ReadMarker extends StatelessWidget {
|
||||
final PyramidTheme pt;
|
||||
const _ReadMarker({required this.pt});
|
||||
|
||||
// Goldener Doppelhaken — bewusst nicht die Akzentfarbe, damit "gelesen"
|
||||
// unabhängig vom Theme sofort erkennbar ist.
|
||||
static const _gold = Color(0xFFE6B422);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.only(right: 24, top: 1, bottom: 1),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Icon(Icons.done_all_rounded, size: 13, color: _gold),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 ref.read(callSignalingProvider).startCall(room.id, video: cam);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use LiveKit for group voice rooms
|
||||
ref.read(viewModeProvider.notifier).state = ViewMode.voice;
|
||||
await ref.read(voiceChannelProvider).startCall(
|
||||
roomName: roomId,
|
||||
roomDisplayName: room.getLocalizedDisplayname(),
|
||||
identity: 'user_${DateTime.now().millisecondsSinceEpoch}',
|
||||
audioOnly: !cam,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// DM-Header, der sich per Klick aufklappt: Das Banner blendet als Hintergrund
|
||||
/// ein, Avatar und Name skalieren mit, Handle + Bio erscheinen darunter
|
||||
/// (Design nach Nutzer-Mockup, Twitter/Discord-artig).
|
||||
class _DmExpandingHeader extends ConsumerStatefulWidget {
|
||||
final Room room;
|
||||
final String roomId;
|
||||
final PyramidTheme pt;
|
||||
final bool expanded;
|
||||
final VoidCallback onToggle;
|
||||
final bool membersOpen;
|
||||
final bool pinnedOpen;
|
||||
final VoidCallback onToggleMembers;
|
||||
final VoidCallback onOpenSearch;
|
||||
final VoidCallback onTogglePinned;
|
||||
|
||||
const _DmExpandingHeader({
|
||||
super.key,
|
||||
required this.room,
|
||||
required this.roomId,
|
||||
required this.pt,
|
||||
required this.expanded,
|
||||
required this.onToggle,
|
||||
required this.membersOpen,
|
||||
required this.pinnedOpen,
|
||||
required this.onToggleMembers,
|
||||
required this.onOpenSearch,
|
||||
required this.onTogglePinned,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<_DmExpandingHeader> createState() =>
|
||||
_DmExpandingHeaderState();
|
||||
}
|
||||
|
||||
class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
|
||||
static const _animDuration = Duration(milliseconds: 280);
|
||||
static const _animCurve = Curves.easeOutCubic;
|
||||
|
||||
Profile? _profile;
|
||||
String? _bannerMxcUri;
|
||||
String? _statusMsg;
|
||||
bool _online = false;
|
||||
|
||||
String? get _partnerId => widget.room.directChatMatrixID;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Direkt laden, damit das Banner beim ersten Aufklappen schon da ist.
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_DmExpandingHeader old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.room.id != widget.room.id) {
|
||||
_profile = null;
|
||||
_bannerMxcUri = null;
|
||||
_statusMsg = null;
|
||||
_online = false;
|
||||
_load();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final userId = _partnerId;
|
||||
if (userId == null) return;
|
||||
final client = widget.room.client;
|
||||
// Race-Guard: Beim schnellen DM-Wechsel dürfen verspätete Antworten des
|
||||
// VORHERIGEN Partners das Profil des aktuellen nicht überschreiben.
|
||||
bool stillCurrent() => mounted && _partnerId == userId;
|
||||
try {
|
||||
final p = await client.getProfileFromUserId(userId);
|
||||
if (stillCurrent()) setState(() => _profile = p);
|
||||
} catch (_) {}
|
||||
try {
|
||||
final p = await client.fetchCurrentPresence(userId);
|
||||
final last = p.lastActiveTimestamp;
|
||||
final fresh = p.currentlyActive == true ||
|
||||
(last != null &&
|
||||
DateTime.now().difference(last) < const Duration(minutes: 5));
|
||||
if (stillCurrent()) {
|
||||
setState(() {
|
||||
_online = p.presence == PresenceType.online && fresh;
|
||||
_statusMsg = p.statusMsg;
|
||||
});
|
||||
}
|
||||
} catch (_) {}
|
||||
// Banner aus dem Profil-Custom-Field (io.pyramid.profile.banner_url).
|
||||
try {
|
||||
final apiUri = client.homeserver!.replace(
|
||||
path: '/_matrix/client/v3/profile/${Uri.encodeComponent(userId)}',
|
||||
);
|
||||
final resp = await client.httpClient.get(
|
||||
apiUri,
|
||||
headers: {'Authorization': 'Bearer ${client.accessToken}'},
|
||||
);
|
||||
if (resp.statusCode == 200) {
|
||||
final data = jsonDecode(resp.body) as Map<String, dynamic>;
|
||||
final url = data['io.pyramid.profile.banner_url'] as String?;
|
||||
if (url != null && stillCurrent()) {
|
||||
setState(() => _bannerMxcUri = url);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
final expanded = widget.expanded;
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
final isMobile = size.width < 900 || size.height < 500;
|
||||
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||
|
||||
final userId = _partnerId ?? '';
|
||||
final member = widget.room.unsafeGetUserFromMemoryOrFallback(userId);
|
||||
final name = _profile?.displayName ??
|
||||
member.displayName ??
|
||||
widget.room.getLocalizedDisplayname();
|
||||
final avatarUrl =
|
||||
_profile?.avatarUrl ?? member.avatarUrl ?? widget.room.avatar;
|
||||
final initial = name.isNotEmpty ? name[0].toUpperCase() : '?';
|
||||
|
||||
// Im Querformat (wenig Höhe) kompakter aufklappen.
|
||||
final expandedHeight = size.height < 500 ? 140.0 : 190.0;
|
||||
final avatarSize = expanded
|
||||
? (size.height < 500 ? 72.0 : 96.0)
|
||||
: 32.0;
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: _animDuration,
|
||||
curve: _animCurve,
|
||||
height: expanded ? expandedHeight : 52,
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg1,
|
||||
border: Border(bottom: BorderSide(color: pt.border)),
|
||||
),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// Banner als Hintergrund — blendet beim Aufklappen ein.
|
||||
if (_bannerMxcUri != null && client != null)
|
||||
Positioned.fill(
|
||||
child: AnimatedOpacity(
|
||||
opacity: expanded ? 1 : 0,
|
||||
duration: _animDuration,
|
||||
curve: Curves.easeOut,
|
||||
child: MxcImage(
|
||||
mxcUri: _bannerMxcUri!,
|
||||
client: client,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Scrim: links dunkel, damit Avatar/Text lesbar bleiben (Mockup).
|
||||
if (_bannerMxcUri != null)
|
||||
Positioned.fill(
|
||||
child: AnimatedOpacity(
|
||||
opacity: expanded ? 1 : 0,
|
||||
duration: _animDuration,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: [
|
||||
pt.bg1,
|
||||
pt.bg1.withAlpha(210),
|
||||
pt.bg1.withAlpha(0),
|
||||
],
|
||||
stops: const [0.0, 0.32, 0.72],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
if (isMobile) ...[
|
||||
GestureDetector(
|
||||
onTap: () =>
|
||||
ref.read(activeRoomIdProvider.notifier).state = null,
|
||||
child:
|
||||
Icon(Icons.arrow_back_rounded, size: 22, color: pt.fg),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
// Klickbarer Profilbereich: Avatar + Name + Details
|
||||
Expanded(
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.onToggle,
|
||||
child: Row(
|
||||
children: [
|
||||
// Avatar — skaliert mit (rund, wie im Mockup)
|
||||
AnimatedContainer(
|
||||
duration: _animDuration,
|
||||
curve: _animCurve,
|
||||
width: avatarSize,
|
||||
height: avatarSize,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: pt.bg3,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: avatarUrl != null && client != null
|
||||
? MxcImage(
|
||||
mxcUri: avatarUrl.toString(),
|
||||
client: client,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: _initialBox(initial, pt),
|
||||
)
|
||||
: _initialBox(initial, pt),
|
||||
),
|
||||
AnimatedContainer(
|
||||
duration: _animDuration,
|
||||
curve: _animCurve,
|
||||
width: expanded ? 20 : 10,
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Name skaliert mit; Status blendet daneben ein
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: AnimatedDefaultTextStyle(
|
||||
duration: _animDuration,
|
||||
curve: _animCurve,
|
||||
style: TextStyle(
|
||||
color: pt.fg,
|
||||
fontSize: expanded ? 24 : 15,
|
||||
fontWeight: expanded
|
||||
? FontWeight.w700
|
||||
: FontWeight.w600,
|
||||
),
|
||||
child: Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedOpacity(
|
||||
opacity: expanded ? 1 : 0,
|
||||
duration: _animDuration,
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.only(left: 8),
|
||||
child: Text(
|
||||
_online ? 'online' : 'offline',
|
||||
style: TextStyle(
|
||||
color: _online
|
||||
? pt.online
|
||||
: pt.fgMuted,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Eingeklappt: "zuletzt online vor…",
|
||||
// aufgeklappt: Handle + Biographie.
|
||||
AnimatedCrossFade(
|
||||
duration: _animDuration,
|
||||
sizeCurve: _animCurve,
|
||||
crossFadeState: expanded
|
||||
? CrossFadeState.showSecond
|
||||
: CrossFadeState.showFirst,
|
||||
firstChild: client != null
|
||||
? _PresenceLine(
|
||||
userId: _partnerId,
|
||||
client: client,
|
||||
pt: pt,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
secondChild: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
userId,
|
||||
style: TextStyle(
|
||||
color: pt.fgMuted, fontSize: 13),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (_statusMsg != null &&
|
||||
_statusMsg!.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_statusMsg!,
|
||||
style: TextStyle(
|
||||
color: pt.fgMuted,
|
||||
fontSize: 13,
|
||||
height: 1.35),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Aktionen — bleiben immer sichtbar
|
||||
Row(
|
||||
children: [
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.phone_rounded),
|
||||
tooltip: 'Voice call',
|
||||
onPressed: () => _startCall(cam: false),
|
||||
),
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.videocam_rounded),
|
||||
tooltip: 'Video call',
|
||||
onPressed: () => _startCall(cam: true),
|
||||
),
|
||||
if (size.width > 700)
|
||||
Container(
|
||||
width: 1,
|
||||
height: 20,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
color: pt.border,
|
||||
),
|
||||
if (size.width > 800)
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.push_pin_rounded),
|
||||
tooltip: 'Angepinnte Nachrichten',
|
||||
active: widget.pinnedOpen,
|
||||
onPressed: widget.onTogglePinned,
|
||||
),
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.search_rounded),
|
||||
tooltip: '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 ref.read(callSignalingProvider).startCall(widget.room.id, video: cam);
|
||||
}
|
||||
}
|
||||
|
||||
class _DmHeaderAvatar extends StatelessWidget {
|
||||
final Room? room;
|
||||
final String name;
|
||||
final Client client;
|
||||
|
||||
const _DmHeaderAvatar({required this.room, required this.name, required this.client});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final initial = name.isNotEmpty ? name[0].toUpperCase() : '?';
|
||||
const size = 32.0;
|
||||
return MxcAvatar(
|
||||
mxcUri: room?.avatar,
|
||||
client: client,
|
||||
size: size,
|
||||
borderRadius: BorderRadius.circular(size / 2),
|
||||
placeholder: (_) => _InitialCircle(initial: initial, size: size),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InitialCircle extends StatelessWidget {
|
||||
final String initial;
|
||||
final double size;
|
||||
const _InitialCircle({required this.initial, required this.size});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const color = Color(0xFF06B6D4);
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: const BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
child: Center(
|
||||
child: Text(initial,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PresenceLine extends StatefulWidget {
|
||||
final String? userId;
|
||||
final Client client;
|
||||
final PyramidTheme pt;
|
||||
|
||||
const _PresenceLine({required this.userId, required this.client, required this.pt});
|
||||
|
||||
@override
|
||||
State<_PresenceLine> createState() => _PresenceLineState();
|
||||
}
|
||||
|
||||
class _PresenceLineState extends State<_PresenceLine> {
|
||||
CachedPresence? _presence;
|
||||
StreamSubscription<CachedPresence>? _sub;
|
||||
// Debounce timer: delay "online" updates 4s to filter out background-sync
|
||||
// false positives (push notifications briefly wake the recipient's client).
|
||||
Timer? _onlineDebounce;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load(widget.userId);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_PresenceLine oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.userId != widget.userId) {
|
||||
_sub?.cancel();
|
||||
_sub = null;
|
||||
_onlineDebounce?.cancel();
|
||||
setState(() => _presence = null);
|
||||
_load(widget.userId);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sub?.cancel();
|
||||
_onlineDebounce?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// "online" only when the server's presence enum says online AND the last
|
||||
// activity is recent. Using currentlyActive (the old logic) reported online
|
||||
// long after the user left, because that flag lingers.
|
||||
static bool _isOnline(CachedPresence p) {
|
||||
if (p.presence != PresenceType.online) return false;
|
||||
final last = p.lastActiveTimestamp;
|
||||
return last == null || DateTime.now().difference(last).inSeconds < 90;
|
||||
}
|
||||
|
||||
void _applyPresence(CachedPresence p) {
|
||||
_onlineDebounce?.cancel();
|
||||
if (_isOnline(p)) {
|
||||
// Wait 4 seconds before committing "online" to filter single-sync spikes
|
||||
// (a backgrounded client briefly reports online when a push wakes it).
|
||||
_onlineDebounce = Timer(const Duration(seconds: 4), () {
|
||||
if (mounted) setState(() => _presence = p);
|
||||
});
|
||||
} else {
|
||||
if (mounted) setState(() => _presence = p);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _load(String? userId) async {
|
||||
if (userId == null) return;
|
||||
// Never show our own presence in a DM header
|
||||
if (userId == widget.client.userID) return;
|
||||
try {
|
||||
final p = await widget.client.fetchCurrentPresence(userId);
|
||||
if (mounted) _applyPresence(p);
|
||||
} catch (_) {}
|
||||
_sub = widget.client.onPresenceChanged.stream
|
||||
.where((p) => p.userid == userId)
|
||||
.listen((p) => _applyPresence(p));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = _presence;
|
||||
if (p == null) return const SizedBox.shrink();
|
||||
|
||||
String text;
|
||||
Color color = widget.pt.fgDim;
|
||||
if (_isOnline(p)) {
|
||||
text = 'online';
|
||||
color = const Color(0xFF3BA55D);
|
||||
} else if (p.lastActiveTimestamp != null) {
|
||||
text = 'zuletzt online ${_relativeTime(p.lastActiveTimestamp!)}';
|
||||
} else {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Text(
|
||||
text,
|
||||
style: TextStyle(color: color, fontSize: 11),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
}
|
||||
|
||||
String _relativeTime(DateTime t) {
|
||||
final diff = DateTime.now().difference(t);
|
||||
if (diff.inMinutes < 1) return 'gerade eben';
|
||||
if (diff.inMinutes < 60) return 'vor ${diff.inMinutes}min';
|
||||
if (diff.inHours < 24) return 'vor ${diff.inHours}h';
|
||||
if (diff.inDays < 7) return 'vor ${diff.inDays}d';
|
||||
if (diff.inDays < 365) return 'vor ${(diff.inDays / 7).floor()}w';
|
||||
return 'vor ${(diff.inDays / 365).floor()}j';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
part of '../chat_view.dart';
|
||||
|
||||
class _MessageList extends StatelessWidget {
|
||||
final Timeline timeline;
|
||||
final ScrollController scrollCtrl;
|
||||
final String currentUserId;
|
||||
final PyramidTheme pt;
|
||||
final ValueChanged<Event> onReply;
|
||||
final Set<String> selectedIds;
|
||||
final ValueChanged<String> onToggleSelect;
|
||||
final String? sessionFullyReadId;
|
||||
final bool sessionHasUnread;
|
||||
final bool unreadFading;
|
||||
/// Attached to the group-level _UnreadDivider so ensureVisible can scroll to it.
|
||||
final GlobalKey? unreadDividerKey;
|
||||
/// Jump-to-message target (from search): scroll to + highlight this event.
|
||||
final String? jumpEventId;
|
||||
final GlobalKey? jumpKey;
|
||||
final String? highlightEventId;
|
||||
final bool highlightOn;
|
||||
|
||||
const _MessageList({
|
||||
required this.timeline,
|
||||
required this.scrollCtrl,
|
||||
required this.currentUserId,
|
||||
required this.pt,
|
||||
required this.onReply,
|
||||
required this.selectedIds,
|
||||
required this.onToggleSelect,
|
||||
this.sessionFullyReadId,
|
||||
this.sessionHasUnread = false,
|
||||
this.unreadFading = false,
|
||||
this.unreadDividerKey,
|
||||
this.jumpEventId,
|
||||
this.jumpKey,
|
||||
this.highlightEventId,
|
||||
this.highlightOn = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Dedupe by eventId — during concurrent forward/backward pagination the
|
||||
// timeline can transiently contain the same event twice, which would
|
||||
// produce duplicate ListView keys and crash the sliver (indexOf assertion).
|
||||
final seenIds = <String>{};
|
||||
final events = timeline.events
|
||||
.where(_isVisible)
|
||||
.where((e) => seenIds.add(e.eventId))
|
||||
.toList();
|
||||
|
||||
if (events.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.forum_outlined, size: 48, color: pt.fgDim),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No messages yet',
|
||||
style: TextStyle(color: pt.fgMuted, fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final groups = _buildGroups(events);
|
||||
final hasUnread = sessionHasUnread;
|
||||
|
||||
// Resolve the effective fullyReadId: if the captured ID points to a
|
||||
// non-visible event (reaction, edit, state) walk the timeline forward
|
||||
// (toward older events) until we find the nearest visible one.
|
||||
String? fullyReadId = sessionFullyReadId;
|
||||
if (fullyReadId != null) {
|
||||
final inGroups = groups.any((g) => g.any((e) => e.eventId == fullyReadId));
|
||||
if (!inGroups) {
|
||||
final tlEvents = timeline.events; // newest-first
|
||||
final idx = tlEvents.indexWhere((e) => e.eventId == fullyReadId);
|
||||
if (idx >= 0) {
|
||||
fullyReadId = null;
|
||||
for (int i = idx; i < tlEvents.length; i++) {
|
||||
if (_isVisible(tlEvents[i])) {
|
||||
fullyReadId = tlEvents[i].eventId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fullyReadId = null; // event not even in loaded timeline
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read-receipt display — matching Element behaviour:
|
||||
// • The newest unread own group shows ✓ (sent but not read)
|
||||
// • The newest own group AT or BEFORE the partner's receipt shows ✓✓
|
||||
// • If those are the same group → just ✓✓
|
||||
final room = timeline.room;
|
||||
|
||||
// Read receipts (Element-style):
|
||||
// • ✓✓ at the single newest event another member has read (any sender) —
|
||||
// moves next to the partner's own reply when they reply/read everything.
|
||||
// • grey ✓ only on my newest message while it's still unread by them.
|
||||
// timeline.events is newest-first → lower index = newer.
|
||||
//
|
||||
// Receipts können je nach Client in `global` ODER `mainThread` stehen
|
||||
// (threaded read receipts) — beide zusammenführen, neuere ts gewinnt.
|
||||
final mergedReceipts = <String, LatestReceiptStateData>{};
|
||||
for (final source in [
|
||||
room.receiptState.global.otherUsers,
|
||||
room.receiptState.mainThread?.otherUsers ?? const {},
|
||||
]) {
|
||||
source.forEach((userId, data) {
|
||||
final existing = mergedReceipts[userId];
|
||||
if (existing == null || data.ts > existing.ts) {
|
||||
mergedReceipts[userId] = data;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
int readIndex = -1;
|
||||
int latestReceiptTs = 0;
|
||||
mergedReceipts.forEach((userId, data) {
|
||||
if (userId == currentUserId) return;
|
||||
if (data.ts > latestReceiptTs) latestReceiptTs = data.ts;
|
||||
var ri = timeline.events.indexWhere((e) => e.eventId == data.eventId);
|
||||
// Receipt kann auf ein unsichtbares Event zeigen (Reaktion, Edit) —
|
||||
// zum nächstälteren sichtbaren Event vorrücken, sonst geht der Marker
|
||||
// verloren.
|
||||
while (ri >= 0 &&
|
||||
ri < timeline.events.length &&
|
||||
!_isVisible(timeline.events[ri])) {
|
||||
ri++;
|
||||
}
|
||||
if (ri >= timeline.events.length) ri = -1;
|
||||
if (ri >= 0 && (readIndex < 0 || ri < readIndex)) readIndex = ri;
|
||||
});
|
||||
final readEventIds =
|
||||
readIndex >= 0 ? {timeline.events[readIndex].eventId} : <String>{};
|
||||
|
||||
// Newest own visible message, and whether it's still unread (newer than the
|
||||
// partner's read position, or no read position yet).
|
||||
final newestOwnIndex =
|
||||
timeline.events.indexWhere((e) => _isVisible(e) && e.senderId == currentUserId);
|
||||
var ownUnread = newestOwnIndex >= 0 && (readIndex < 0 || newestOwnIndex < readIndex);
|
||||
// Fallback: Das Receipt-Event liegt außerhalb des geladenen Fensters
|
||||
// (z.B. sehr alte Nachricht) — wenn der Receipt-Zeitstempel jünger ist als
|
||||
// meine neueste Nachricht, gilt sie trotzdem als gelesen.
|
||||
if (ownUnread &&
|
||||
readIndex < 0 &&
|
||||
latestReceiptTs >=
|
||||
timeline.events[newestOwnIndex].originServerTs.millisecondsSinceEpoch) {
|
||||
readEventIds.add(timeline.events[newestOwnIndex].eventId);
|
||||
ownUnread = false;
|
||||
}
|
||||
final lastOwnGroupKey = (ownUnread)
|
||||
? () {
|
||||
for (final g in groups) {
|
||||
if (g.first.senderId == currentUserId) return 'g:${g.first.eventId}';
|
||||
}
|
||||
return null;
|
||||
}()
|
||||
: null;
|
||||
|
||||
// Flatten groups + date dividers + unread divider into independent list items.
|
||||
// itemFullyReadIds[i] is non-null only for a group item that contains the
|
||||
// fullyRead event at a non-last position — MessageGroup renders an inline
|
||||
// divider after that event.
|
||||
final itemKeys = <String>[];
|
||||
final itemGroups = <List<Event>?>[];
|
||||
final itemDates = <DateTime?>[];
|
||||
final itemUnread = <bool>[];
|
||||
final itemFullyReadIds = <String?>[];
|
||||
|
||||
bool unreadInserted = false;
|
||||
|
||||
for (var i = 0; i < groups.length; i++) {
|
||||
final group = groups[i];
|
||||
// Use the newest event (group.last after reversal) for date comparison.
|
||||
final groupNewest = group.last.originServerTs;
|
||||
final nextGroupNewest = i < groups.length - 1 ? groups[i + 1].last.originServerTs : null;
|
||||
final isLastOfDay = nextGroupNewest == null || !_sameDay(groupNewest, nextGroupNewest);
|
||||
|
||||
// Check if this group contains the effective fullyRead event.
|
||||
final groupContainsFullyRead = fullyReadId != null &&
|
||||
group.any((e) => e.eventId == fullyReadId);
|
||||
|
||||
if (!unreadInserted && hasUnread && groupContainsFullyRead) {
|
||||
if (group.last.eventId == fullyReadId) {
|
||||
// fullyRead is the NEWEST event in the group → the whole group is
|
||||
// read. Insert a group-level divider between this group and the
|
||||
// newer (lower-index) groups.
|
||||
itemKeys.add('unread');
|
||||
itemGroups.add(null);
|
||||
itemDates.add(null);
|
||||
itemUnread.add(true);
|
||||
itemFullyReadIds.add(null);
|
||||
}
|
||||
// Whether group-level or inline, we've now handled the marker.
|
||||
unreadInserted = true;
|
||||
}
|
||||
|
||||
// Key based on oldest event (group.first) — stable identifier.
|
||||
itemKeys.add('g:${group.first.eventId}');
|
||||
itemGroups.add(group);
|
||||
itemDates.add(null);
|
||||
itemUnread.add(false);
|
||||
// Pass fullyReadId to the group only when the event is mid-group
|
||||
// (not the last event) so MessageGroup can render an inline divider.
|
||||
// groupContainsFullyRead already guarantees fullyReadId != null.
|
||||
final passedFullyReadId =
|
||||
(groupContainsFullyRead && group.last.eventId != fullyReadId)
|
||||
? fullyReadId
|
||||
: null;
|
||||
itemFullyReadIds.add(passedFullyReadId);
|
||||
|
||||
if (isLastOfDay) {
|
||||
final d = groupNewest;
|
||||
itemKeys.add('d:${d.year}-${d.month}-${d.day}');
|
||||
itemGroups.add(null);
|
||||
itemDates.add(d);
|
||||
itemUnread.add(false);
|
||||
itemFullyReadIds.add(null);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: fullyRead event is older than all loaded events — all visible
|
||||
// messages are unread. Insert divider above the oldest loaded group (top of list).
|
||||
if (!unreadInserted && hasUnread) {
|
||||
itemKeys.add('unread');
|
||||
itemGroups.add(null);
|
||||
itemDates.add(null);
|
||||
itemUnread.add(true);
|
||||
itemFullyReadIds.add(null);
|
||||
}
|
||||
|
||||
final keyIndex = <String, int>{
|
||||
for (var i = 0; i < itemKeys.length; i++) itemKeys[i]: i,
|
||||
};
|
||||
|
||||
return ListView.builder(
|
||||
controller: scrollCtrl,
|
||||
reverse: true,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
scrollCacheExtent: const ScrollCacheExtent.pixels(2500),
|
||||
itemCount: itemKeys.length,
|
||||
findChildIndexCallback: (Key key) {
|
||||
// Only return an index when the key still maps to that exact slot —
|
||||
// returning a stale/duplicate index trips the sliver indexOf assertion.
|
||||
if (key is ValueKey<String>) {
|
||||
final idx = keyIndex[key.value];
|
||||
if (idx != null && idx < itemKeys.length && itemKeys[idx] == key.value) {
|
||||
return idx;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
itemBuilder: (context, i) {
|
||||
final key = ValueKey(itemKeys[i]);
|
||||
if (itemUnread[i]) {
|
||||
// Wrap in SizedBox so the list can use the ValueKey for optimisation
|
||||
// while _UnreadDivider carries the GlobalKey for ensureVisible.
|
||||
return SizedBox(
|
||||
key: key,
|
||||
child: _UnreadDivider(key: unreadDividerKey, pt: pt, fading: unreadFading),
|
||||
);
|
||||
}
|
||||
final date = itemDates[i];
|
||||
if (date != null) return DateDivider(key: key, date: date);
|
||||
final group = itemGroups[i]!;
|
||||
final groupKey = itemKeys[i];
|
||||
// Show the sending/sent indicator on the newest own group.
|
||||
final showStatus = groupKey == lastOwnGroupKey;
|
||||
return MessageGroup(
|
||||
key: key,
|
||||
events: group,
|
||||
currentUserId: currentUserId,
|
||||
room: room,
|
||||
timeline: timeline,
|
||||
onReply: () => onReply(group.last),
|
||||
selectedIds: selectedIds,
|
||||
onToggleSelect: onToggleSelect,
|
||||
showStatusIndicator: showStatus,
|
||||
readEventIds: readEventIds,
|
||||
fullyReadId: itemFullyReadIds[i],
|
||||
jumpEventId: jumpEventId,
|
||||
jumpKey: jumpKey,
|
||||
highlightEventId: highlightEventId,
|
||||
highlightOn: highlightOn,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<List<Event>> _buildGroups(List<Event> events) {
|
||||
final groups = <List<Event>>[];
|
||||
List<Event>? current;
|
||||
|
||||
for (final event in events) {
|
||||
if (current == null ||
|
||||
current.first.senderId != event.senderId ||
|
||||
event.originServerTs.difference(current.last.originServerTs).abs() >
|
||||
const Duration(minutes: 5)) {
|
||||
current = [event];
|
||||
groups.add(current);
|
||||
} else {
|
||||
current.add(event);
|
||||
}
|
||||
}
|
||||
// The timeline is newest-first, so each group is [newest, ..., oldest].
|
||||
// Reverse each group so messages render oldest-to-newest (top to bottom).
|
||||
return groups.map((g) => g.reversed.toList()).toList();
|
||||
}
|
||||
|
||||
bool _sameDay(DateTime a, DateTime b) =>
|
||||
a.year == b.year && a.month == b.month && a.day == b.day;
|
||||
|
||||
static bool _isVisible(Event e) {
|
||||
// NOTE: error-status events are kept visible so a failed send shows a
|
||||
// "not sent" state with retry/delete instead of silently disappearing.
|
||||
if (!{
|
||||
EventTypes.Message,
|
||||
EventTypes.Sticker,
|
||||
EventTypes.Encrypted,
|
||||
}.contains(e.type)) { return false; }
|
||||
if ({
|
||||
RelationshipTypes.edit,
|
||||
RelationshipTypes.reaction,
|
||||
}.contains(e.relationshipType)) { return false; }
|
||||
if (e.type == EventTypes.Redaction) { return false; }
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class _SelectionHeader extends StatelessWidget {
|
||||
final PyramidTheme pt;
|
||||
final int count;
|
||||
final Set<String> selectedIds;
|
||||
final Room? room;
|
||||
final Timeline? timeline;
|
||||
final String currentUserId;
|
||||
final VoidCallback onClear;
|
||||
final VoidCallback onReply;
|
||||
|
||||
const _SelectionHeader({
|
||||
required this.pt,
|
||||
required this.count,
|
||||
required this.selectedIds,
|
||||
required this.room,
|
||||
required this.timeline,
|
||||
required this.currentUserId,
|
||||
required this.onClear,
|
||||
required this.onReply,
|
||||
});
|
||||
|
||||
List<Event> _resolveEvents() {
|
||||
if (timeline == null) return [];
|
||||
return selectedIds
|
||||
.map((id) {
|
||||
try { return timeline!.events.firstWhere((e) => e.eventId == id); } catch (_) { return null; }
|
||||
})
|
||||
.whereType<Event>()
|
||||
.toList();
|
||||
}
|
||||
|
||||
void _copyAll() {
|
||||
final texts = _resolveEvents().map((e) => e.body).join('\n');
|
||||
Clipboard.setData(ClipboardData(text: texts));
|
||||
}
|
||||
|
||||
void _deleteAll(BuildContext context) {
|
||||
for (final e in _resolveEvents()) {
|
||||
if (e.senderId == currentUserId) {
|
||||
e.redactEvent(reason: 'Vom Nutzer gelöscht').catchError((_) => null);
|
||||
}
|
||||
}
|
||||
onClear();
|
||||
}
|
||||
|
||||
void _pinAll() {
|
||||
if (room == null) return;
|
||||
final pinned = List<String>.from(
|
||||
room!.getState(EventTypes.RoomPinnedEvents)?.content['pinned'] as List? ?? [],
|
||||
);
|
||||
for (final e in _resolveEvents()) {
|
||||
if (!pinned.contains(e.eventId)) pinned.add(e.eventId);
|
||||
}
|
||||
room!.setPinnedEvents(pinned).catchError((_) => '');
|
||||
onClear();
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final events = _resolveEvents();
|
||||
final canDelete = events.isNotEmpty && events.every((e) => e.senderId == currentUserId);
|
||||
final isTextOnly = events.every((e) => e.messageType == 'm.text');
|
||||
|
||||
return Container(
|
||||
height: 52,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: pt.accentSoft,
|
||||
border: Border(bottom: BorderSide(color: pt.accent.withAlpha(80))),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: onClear,
|
||||
icon: Icon(Icons.close_rounded, size: 20, color: pt.accent),
|
||||
tooltip: 'Auswahl aufheben',
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'$count ausgewählt',
|
||||
style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const Spacer(),
|
||||
if (count == 1)
|
||||
_SelBtn(icon: Icons.reply_rounded, tooltip: 'Antworten', pt: pt, onTap: onReply),
|
||||
if (isTextOnly)
|
||||
_SelBtn(icon: Icons.copy_rounded, tooltip: 'Kopieren', pt: pt, onTap: _copyAll),
|
||||
_SelBtn(icon: Icons.push_pin_outlined, tooltip: 'Anpinnen', pt: pt, onTap: _pinAll),
|
||||
if (canDelete)
|
||||
_SelBtn(
|
||||
icon: Icons.delete_outline_rounded,
|
||||
tooltip: 'Löschen',
|
||||
pt: pt,
|
||||
color: pt.danger,
|
||||
onTap: () => _deleteAll(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SelBtn extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
final PyramidTheme pt;
|
||||
final Color? color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _SelBtn({required this.icon, required this.tooltip, required this.pt, required this.onTap, this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
onPressed: onTap,
|
||||
icon: Icon(icon, size: 20, color: color ?? pt.fg),
|
||||
tooltip: tooltip,
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
padding: EdgeInsets.zero,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _UnreadDivider extends StatelessWidget {
|
||||
final PyramidTheme pt;
|
||||
final bool fading;
|
||||
const _UnreadDivider({super.key, required this.pt, this.fading = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedOpacity(
|
||||
opacity: fading ? 0.0 : 1.0,
|
||||
duration: const Duration(milliseconds: 700),
|
||||
curve: Curves.easeOut,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Divider(color: pt.accent.withAlpha(160), height: 1)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Text(
|
||||
'Neue Nachrichten',
|
||||
style: TextStyle(color: pt.accent, fontSize: 11, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
Expanded(child: Divider(color: pt.accent.withAlpha(160), height: 1)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,644 @@
|
||||
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) {
|
||||
final isVoice = _isVoiceRoom(room);
|
||||
return _RoomItem(
|
||||
room: room,
|
||||
isActive: isVoice ? false : activeRoomId == room.id,
|
||||
isInVoice:
|
||||
isVoice && ref.read(activeVoiceRoomIdProvider) == room.id,
|
||||
pt: pt,
|
||||
// Voice-Räume treten dem Kanal bei (wie in der Space-Liste) –
|
||||
// vorher öffnete der Klick hier nur die Chat-Ansicht, ein
|
||||
// Standalone-Voice-Channel war per Klick gar nicht beitretbar.
|
||||
onTap: () {
|
||||
if (isVoice) {
|
||||
_toggleVoiceChannel(ref, room);
|
||||
} else {
|
||||
ref.read(activeRoomIdProvider.notifier).state = room.id;
|
||||
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||
}
|
||||
},
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Beitreten/Verlassen eines Voice-Channels aus der Raumliste – exakt das
|
||||
/// Verhalten von `joinVoiceChannel` in rooms_space_list.dart (Toggle beim
|
||||
/// aktiven Kanal, Wechsel legt vorher auf).
|
||||
Future<void> _toggleVoiceChannel(WidgetRef ref, Room room) async {
|
||||
final call = ref.read(voiceChannelProvider);
|
||||
final activeVoice = ref.read(activeVoiceRoomIdProvider);
|
||||
if (call.isVoiceChannel && activeVoice == room.id) {
|
||||
ref.read(activeVoiceRoomIdProvider.notifier).state = null;
|
||||
await call.hangUp();
|
||||
return;
|
||||
}
|
||||
if (call.isActive) await call.hangUp();
|
||||
ref.read(activeVoiceRoomIdProvider.notifier).state = room.id;
|
||||
await call.startCall(
|
||||
roomName: room.id,
|
||||
roomDisplayName: room.getLocalizedDisplayname(),
|
||||
identity: room.client.userID ?? '',
|
||||
audioOnly: true,
|
||||
voiceChannel: true,
|
||||
matrixClient: room.client,
|
||||
matrixRoomId: room.id,
|
||||
);
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatefulWidget {
|
||||
final String title;
|
||||
final int count;
|
||||
final bool isCollapsed;
|
||||
final PyramidTheme pt;
|
||||
final VoidCallback onToggle;
|
||||
final VoidCallback? onAddTap;
|
||||
|
||||
const _SectionHeader({
|
||||
required this.title,
|
||||
required this.count,
|
||||
required this.isCollapsed,
|
||||
required this.pt,
|
||||
required this.onToggle,
|
||||
this.onAddTap,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_SectionHeader> createState() => _SectionHeaderState();
|
||||
}
|
||||
|
||||
class _SectionHeaderState extends State<_SectionHeader> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 4),
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: widget.onToggle,
|
||||
child: Row(children: [
|
||||
AnimatedRotation(
|
||||
turns: widget.isCollapsed ? -0.25 : 0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOutBack,
|
||||
child: Icon(Icons.keyboard_arrow_down_rounded,
|
||||
size: 14,
|
||||
color: _hovered ? pt.fgMuted : pt.fgDim),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(widget.title.toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: _hovered ? pt.fgMuted : pt.fgDim,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.04 * 11)),
|
||||
const Spacer(),
|
||||
Text('${widget.count}',
|
||||
style: TextStyle(color: pt.fgDim, fontSize: 11)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
if (_hovered && widget.onAddTap != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.onAddTap,
|
||||
child: SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: Center(
|
||||
child: Icon(Icons.add, size: 12, color: pt.fgMuted)),
|
||||
),
|
||||
),
|
||||
] else
|
||||
const SizedBox(width: 22),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RoomItem extends ConsumerStatefulWidget {
|
||||
final Room room;
|
||||
final bool isActive;
|
||||
final bool isInVoice;
|
||||
final PyramidTheme pt;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _RoomItem({
|
||||
super.key,
|
||||
required this.room,
|
||||
required this.isActive,
|
||||
this.isInVoice = false,
|
||||
required this.pt,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<_RoomItem> createState() => _RoomItemState();
|
||||
}
|
||||
|
||||
class _RoomItemState extends ConsumerState<_RoomItem> {
|
||||
bool _hovered = false;
|
||||
|
||||
Future<void> _leaveRoom() async {
|
||||
final pt = widget.pt;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: pt.bg2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(pt.rXl),
|
||||
side: BorderSide(color: pt.border)),
|
||||
title: Text('Raum verlassen?',
|
||||
style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)),
|
||||
content: Text(
|
||||
'"${widget.room.getLocalizedDisplayname()}" verlassen?',
|
||||
style: TextStyle(color: pt.fgMuted, fontSize: 13)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: pt.danger,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8))),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Verlassen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
try {
|
||||
final id = widget.room.id;
|
||||
await widget.room.leave();
|
||||
if (mounted && ref.read(activeRoomIdProvider) == id) {
|
||||
ref.read(activeRoomIdProvider.notifier).state = null;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _leaveDm() async {
|
||||
final pt = widget.pt;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: pt.bg2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(pt.rXl),
|
||||
side: BorderSide(color: pt.border)),
|
||||
title: Text('Chat verlassen?',
|
||||
style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)),
|
||||
content: Text(
|
||||
'Unterhaltung mit "${widget.room.getLocalizedDisplayname()}" schließen?',
|
||||
style: TextStyle(color: pt.fgMuted, fontSize: 13)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: pt.danger,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8))),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Verlassen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
try {
|
||||
final id = widget.room.id;
|
||||
await widget.room.leave();
|
||||
if (mounted) {
|
||||
if (ref.read(activeRoomIdProvider) == id) {
|
||||
ref.read(activeRoomIdProvider.notifier).state = null;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _showRoomSettings() async {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (_) => RoomSettingsDialog(room: widget.room),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _inviteUser() async {
|
||||
final pt = widget.pt;
|
||||
final ctrl = TextEditingController();
|
||||
final userId = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: pt.bg2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(pt.rXl),
|
||||
side: BorderSide(color: pt.border)),
|
||||
title: Text('Nutzer einladen',
|
||||
style: TextStyle(
|
||||
color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)),
|
||||
content: TextField(
|
||||
controller: ctrl,
|
||||
autofocus: true,
|
||||
style: TextStyle(color: pt.fg, fontSize: 13),
|
||||
decoration: InputDecoration(
|
||||
hintText: '@nutzer:server',
|
||||
hintStyle: TextStyle(color: pt.fgDim, fontSize: 12),
|
||||
filled: true,
|
||||
fillColor: pt.bg3,
|
||||
isDense: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: pt.border)),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: pt.border)),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: pt.accent)),
|
||||
),
|
||||
cursorColor: pt.accent,
|
||||
onSubmitted: (v) => Navigator.pop(ctx, v.trim()),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, ctrl.text.trim()),
|
||||
child: Text('Einladen', style: TextStyle(color: pt.accent))),
|
||||
],
|
||||
),
|
||||
);
|
||||
ctrl.dispose();
|
||||
if (userId == null || userId.isEmpty || !mounted) return;
|
||||
try {
|
||||
await widget.room.invite(userId);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('Fehler: ${e.toString().split('\n').first}'),
|
||||
backgroundColor: widget.pt.danger,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Touch-friendly action menu — opened via long-press (the hover menu is not
|
||||
/// reachable on touch devices).
|
||||
void _showContextMenu() {
|
||||
final pt = widget.pt;
|
||||
final room = widget.room;
|
||||
final isDm = room.isDirectChat;
|
||||
|
||||
Widget action(IconData icon, String label, VoidCallback onTap,
|
||||
{bool danger = false}) {
|
||||
return ListTile(
|
||||
leading: Icon(icon, size: 20, color: danger ? pt.danger : pt.fgMuted),
|
||||
title: Text(label,
|
||||
style: TextStyle(
|
||||
color: danger ? pt.danger : pt.fg, fontSize: 14)),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
onTap();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: pt.bg2,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child: Text(room.getLocalizedDisplayname(),
|
||||
style: TextStyle(
|
||||
color: pt.fg,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700),
|
||||
overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
]),
|
||||
),
|
||||
Divider(height: 1, color: pt.border),
|
||||
if (!isDm) ...[
|
||||
action(Icons.person_add_outlined, 'Nutzer einladen', _inviteUser),
|
||||
action(Icons.settings_rounded, 'Einstellungen', _showRoomSettings),
|
||||
],
|
||||
action(
|
||||
Icons.exit_to_app_rounded,
|
||||
isDm ? 'Chat verlassen' : 'Raum verlassen',
|
||||
isDm ? _leaveDm : _leaveRoom,
|
||||
danger: true,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
final room = widget.room;
|
||||
final active = widget.isActive;
|
||||
final inVoice = widget.isInVoice;
|
||||
final name = room.getLocalizedDisplayname();
|
||||
final unread = room.notificationCount;
|
||||
final isDm = room.isDirectChat;
|
||||
final isVoice = _isVoiceRoom(room);
|
||||
final voiceParticipants = isVoice ? ref.watch(voiceParticipantsProvider(room.id)) : <Map<String, dynamic>>[];
|
||||
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||
|
||||
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,171 @@
|
||||
part of '../rooms_panel.dart';
|
||||
|
||||
const _kVoiceRoomType = 'io.pyramid.voice_channel';
|
||||
|
||||
const _kSpaceBannerType = 'io.pyramid.space.banner';
|
||||
|
||||
bool _isVoiceRoom(Room room) =>
|
||||
room.getState(EventTypes.RoomCreate)?.content['type'] == _kVoiceRoomType;
|
||||
|
||||
int _compareSpaceChildren(SpaceChild a, SpaceChild b) {
|
||||
final ao = a.order;
|
||||
final bo = b.order;
|
||||
if (ao.isEmpty && bo.isEmpty) return 0;
|
||||
if (ao.isEmpty) return 1;
|
||||
if (bo.isEmpty) return -1;
|
||||
return ao.compareTo(bo);
|
||||
}
|
||||
|
||||
class RoomsPanel extends ConsumerStatefulWidget {
|
||||
const RoomsPanel({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<RoomsPanel> createState() => _RoomsPanelState();
|
||||
}
|
||||
|
||||
class _RoomsPanelState extends ConsumerState<RoomsPanel> {
|
||||
final _filterCtrl = TextEditingController();
|
||||
String _filter = '';
|
||||
final Map<String, bool> _collapsed = {};
|
||||
String? _lastShownCallError;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_filterCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = PyramidTheme.of(context);
|
||||
|
||||
// Voice-Zustand mit der UI synchron halten: Bei unerwartetem Abbruch
|
||||
// oder fehlgeschlagenem Beitritt wird der Service inaktiv, aber
|
||||
// activeVoiceRoomIdProvider zeigte vorher weiter auf den Kanal – das
|
||||
// Kanal-Highlight und der eigene (optimistische) Teilnehmer-Eintrag
|
||||
// blieben hängen. Außerdem den Fehler sichtbar machen (kein stiller
|
||||
// Fehlerpfad, CLAUDE.md).
|
||||
ref.listen<VoiceChannelService>(voiceChannelProvider, (_, call) {
|
||||
if (!call.isActive && ref.read(activeVoiceRoomIdProvider) != null) {
|
||||
ref.read(activeVoiceRoomIdProvider.notifier).state = null;
|
||||
}
|
||||
final err = call.error;
|
||||
if (err == null) {
|
||||
_lastShownCallError = null;
|
||||
} else if (err != _lastShownCallError) {
|
||||
_lastShownCallError = err;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
|
||||
SnackBar(content: Text('Voice: $err')),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
final roomsAsync = ref.watch(roomListProvider);
|
||||
final activeSpaceId = ref.watch(activeSpaceIdProvider);
|
||||
final call = ref.watch(voiceChannelProvider);
|
||||
final voip = ref.watch(callSignalingProvider);
|
||||
|
||||
final isRealSpace = activeSpaceId != null &&
|
||||
activeSpaceId != 'dms' &&
|
||||
activeSpaceId != 'rooms';
|
||||
|
||||
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||
final forceJoined = ref.watch(forceJoinedSpacesProvider);
|
||||
final activeSpaceRoom =
|
||||
isRealSpace ? client?.getRoomById(activeSpaceId) : null;
|
||||
final isInvitedSpace = activeSpaceRoom?.membership == Membership.invite &&
|
||||
!forceJoined.contains(activeSpaceId);
|
||||
|
||||
return Container(
|
||||
color: pt.bg1,
|
||||
child: Column(
|
||||
children: [
|
||||
_Header(pt: pt, spaceId: activeSpaceId),
|
||||
if (!isInvitedSpace)
|
||||
_FilterInput(
|
||||
controller: _filterCtrl,
|
||||
pt: pt,
|
||||
onChanged: (v) => setState(() => _filter = v),
|
||||
),
|
||||
Expanded(
|
||||
child: isInvitedSpace
|
||||
? _SpaceInviteView(space: activeSpaceRoom!, pt: pt)
|
||||
: isRealSpace
|
||||
? _SpaceRoomsList(
|
||||
spaceId: activeSpaceId,
|
||||
filter: _filter,
|
||||
collapsed: _collapsed,
|
||||
pt: pt,
|
||||
onToggleSection: (id) => setState(() {
|
||||
_collapsed[id] = !(_collapsed[id] ?? false);
|
||||
}),
|
||||
)
|
||||
: roomsAsync.when(
|
||||
loading: () => Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: pt.accent),
|
||||
),
|
||||
),
|
||||
error: (e, _) => Center(
|
||||
child: Text('Error',
|
||||
style: TextStyle(color: pt.fgDim, fontSize: 12)),
|
||||
),
|
||||
data: (rooms) => _RoomsList(
|
||||
rooms: rooms,
|
||||
filter: _filter,
|
||||
spaceId: activeSpaceId,
|
||||
collapsed: _collapsed,
|
||||
pt: pt,
|
||||
onToggleSection: (id) => setState(() {
|
||||
_collapsed[id] = !(_collapsed[id] ?? false);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Call-Dock: immer ÜBER dem Konto-Balken, animiert ein-/ausblenden.
|
||||
// Im Mobil-Querformat (Höhe knapp) liegen Call-Balken und
|
||||
// Konto-Balken nebeneinander statt übereinander.
|
||||
Builder(builder: (context) {
|
||||
final hasVoipCall = voip.currentCall != null &&
|
||||
voip.currentCall!.state != CallState.kEnded;
|
||||
final hasCallDock = call.isActive || hasVoipCall;
|
||||
final isLandscapeCompact =
|
||||
MediaQuery.sizeOf(context).height < 500;
|
||||
|
||||
final callDock = AnimatedSize(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (call.isActive) MiniCallWidget(call: call),
|
||||
if (hasVoipCall) MiniCallWidget(call: voip),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (isLandscapeCompact && hasCallDock) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(child: callDock),
|
||||
const Expanded(child: UserPanel()),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [callDock, const UserPanel()],
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(voiceChannelProvider);
|
||||
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(voiceChannelProvider);
|
||||
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)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+13
-2534
File diff suppressed because it is too large
Load Diff
@@ -132,16 +132,16 @@ class _ProfileRow extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _InitialAvatar extends StatelessWidget {
|
||||
static const double _size = 32;
|
||||
final String name;
|
||||
final double radius;
|
||||
final double size;
|
||||
const _InitialAvatar({required this.name, required this.radius, this.size = 32});
|
||||
const _InitialAvatar({required this.name, required this.radius});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
width: _size,
|
||||
height: _size,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
@@ -155,7 +155,7 @@ class _InitialAvatar extends StatelessWidget {
|
||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: size * 0.44,
|
||||
fontSize: _size * 0.44,
|
||||
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
+121
-33
@@ -1,4 +1,3 @@
|
||||
// ignore_for_file: avoid_print
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
@@ -7,11 +6,14 @@ import 'package:livekit_client/livekit_client.dart';
|
||||
import 'package:matrix/matrix.dart' hide Room;
|
||||
import 'package:pyramid/core/ice_servers.dart';
|
||||
import 'package:pyramid/core/livekit_token.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:pyramid/core/settings_prefs.dart';
|
||||
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
|
||||
|
||||
const _kVoicePresenceType = 'io.pyramid.voice.presence';
|
||||
|
||||
class LiveKitCallManager with ChangeNotifier {
|
||||
/// Implementierung von [VoiceChannelService] (Modul `voice_channel`).
|
||||
/// Andere Module gehen ausschließlich über `voiceChannelProvider`.
|
||||
class LiveKitCallManager extends VoiceChannelService {
|
||||
static final LiveKitCallManager instance = LiveKitCallManager._();
|
||||
LiveKitCallManager._();
|
||||
|
||||
@@ -19,31 +21,51 @@ class LiveKitCallManager with ChangeNotifier {
|
||||
static final Map<String, VideoParameters> qualityPresets = {
|
||||
'sd': VideoParametersPresets.h360_169,
|
||||
'hd': VideoParametersPresets.h720_169,
|
||||
'hd60': VideoParametersPresets.h720_169,
|
||||
'fhd': VideoParametersPresets.h1080_169,
|
||||
'fhd60': VideoParametersPresets.h1080_169,
|
||||
'4k': VideoParametersPresets.h2160_169,
|
||||
};
|
||||
|
||||
static const Map<String, VideoEncoding> _screenShareEncodings = {
|
||||
/// FPS + Bitrate pro Screenshare-Preset. Die 60-FPS-Presets (M4,
|
||||
/// `docs/STREAMING_60FPS.md`) brauchen deutlich mehr Bitrate, sonst
|
||||
/// verwirft der Encoder Frames und es ruckelt trotz hoher Framerate.
|
||||
@visibleForTesting
|
||||
static const Map<String, VideoEncoding> screenShareEncodings = {
|
||||
'sd': VideoEncoding(maxBitrate: 500_000, maxFramerate: 10),
|
||||
'hd': VideoEncoding(maxBitrate: 1_500_000, maxFramerate: 15),
|
||||
'hd60': VideoEncoding(maxBitrate: 5_000_000, maxFramerate: 60),
|
||||
'fhd': VideoEncoding(maxBitrate: 2_500_000, maxFramerate: 20),
|
||||
'fhd60': VideoEncoding(maxBitrate: 8_000_000, maxFramerate: 60),
|
||||
'4k': VideoEncoding(maxBitrate: 4_000_000, maxFramerate: 30),
|
||||
};
|
||||
|
||||
/// 60-FPS-Presets bekommen beim Publish eine andere Strategie
|
||||
/// (kein Simulcast, Framerate vor Auflösung halten).
|
||||
@visibleForTesting
|
||||
static bool isHighFpsPreset(String key) => key.endsWith('60');
|
||||
|
||||
Room? _room;
|
||||
EventsListener<RoomEvent>? _roomEventListener;
|
||||
String _displayName = '';
|
||||
@override
|
||||
bool isConnecting = false;
|
||||
@override
|
||||
String? error;
|
||||
@override
|
||||
bool isMuted = false;
|
||||
@override
|
||||
bool isCameraOff = false;
|
||||
@override
|
||||
bool isScreenSharing = false;
|
||||
String _localQualityKey = 'hd';
|
||||
String _subscribeQualityKey = 'auto'; // 'auto' | 'low' | 'medium' | 'high'
|
||||
String? _currentScreenShareSourceId;
|
||||
LocalVideoTrack? _screenShareTrack;
|
||||
@override
|
||||
bool isDeafened = false;
|
||||
bool _mutedBeforeDeafen = false;
|
||||
@override
|
||||
bool isVoiceChannel = false;
|
||||
String? _matrixRoomId;
|
||||
Client? _matrixClient;
|
||||
@@ -51,12 +73,24 @@ class LiveKitCallManager with ChangeNotifier {
|
||||
|
||||
Timer? _statsTimer;
|
||||
|
||||
/// Entwertet laufende `startCall`-Verbindungsaufbauten: Wer während des
|
||||
/// (mehrere Sekunden dauernden) `connect` auflegt oder den Kanal wechselt,
|
||||
/// darf KEINE Geisterverbindung zurücklassen – vorher blieb in dem Fall
|
||||
/// das Mikro im alten Kanal offen, während die UI „nicht verbunden" zeigte.
|
||||
int _callGeneration = 0;
|
||||
|
||||
@override
|
||||
bool get isActive => _room != null || isConnecting;
|
||||
@override
|
||||
Room? get room => _room;
|
||||
@override
|
||||
String? get currentRoomId => _matrixRoomId;
|
||||
@override
|
||||
String get displayName => _displayName;
|
||||
bool get noiseSuppression => false;
|
||||
@override
|
||||
String get currentQualityKey => _localQualityKey;
|
||||
@override
|
||||
String get subscribeQualityKey => _subscribeQualityKey;
|
||||
|
||||
VideoParameters get _currentQuality =>
|
||||
@@ -65,6 +99,7 @@ class LiveKitCallManager with ChangeNotifier {
|
||||
void _onRoomChanged() => notifyListeners();
|
||||
|
||||
void _cleanUpAfterDisconnect() {
|
||||
_callGeneration++;
|
||||
_stopStatsLogging();
|
||||
_presenceHeartbeat?.cancel();
|
||||
_presenceHeartbeat = null;
|
||||
@@ -77,7 +112,9 @@ class LiveKitCallManager with ChangeNotifier {
|
||||
_room = null;
|
||||
_displayName = '';
|
||||
isConnecting = false;
|
||||
error = null;
|
||||
// `error` bewusst NICHT löschen: Ein unerwarteter Abbruch/Join-Fehler
|
||||
// muss für die UI sichtbar bleiben (kein stiller Fehlerpfad). Manuelles
|
||||
// Auflegen (hangUp) setzt error selbst zurück.
|
||||
isMuted = false;
|
||||
isCameraOff = false;
|
||||
isScreenSharing = false;
|
||||
@@ -119,10 +156,11 @@ class LiveKitCallManager with ChangeNotifier {
|
||||
}
|
||||
await client.setRoomStateWithKey(roomId, _kVoicePresenceType, userId, content);
|
||||
} catch (e) {
|
||||
print('[VoicePresence] $e');
|
||||
debugPrint('[VoicePresence] $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> startCall({
|
||||
required String roomName,
|
||||
required String roomDisplayName,
|
||||
@@ -133,6 +171,7 @@ class LiveKitCallManager with ChangeNotifier {
|
||||
String? matrixRoomId,
|
||||
}) async {
|
||||
if (isActive) return;
|
||||
final gen = ++_callGeneration;
|
||||
|
||||
final url = 'wss://livekit.steggi-matrix.work';
|
||||
|
||||
@@ -185,7 +224,7 @@ class LiveKitCallManager with ChangeNotifier {
|
||||
.toList(),
|
||||
);
|
||||
} catch (e) {
|
||||
print('[LiveKit] ICE server fetch error: $e');
|
||||
debugPrint('[LiveKit] ICE server fetch error: $e');
|
||||
}
|
||||
|
||||
await room.connect(
|
||||
@@ -194,6 +233,17 @@ class LiveKitCallManager with ChangeNotifier {
|
||||
connectOptions: ConnectOptions(rtcConfiguration: rtcConfig),
|
||||
);
|
||||
|
||||
if (gen != _callGeneration) {
|
||||
// Während des Verbindens wurde aufgelegt oder der Kanal gewechselt –
|
||||
// diese Verbindung gehört niemandem mehr: sofort wieder trennen,
|
||||
// sonst bleibt das Mikro unsichtbar im alten Kanal offen.
|
||||
room.removeListener(_onRoomChanged);
|
||||
try {
|
||||
await room.disconnect();
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
|
||||
_room = room;
|
||||
isConnecting = false;
|
||||
notifyListeners();
|
||||
@@ -234,7 +284,11 @@ class LiveKitCallManager with ChangeNotifier {
|
||||
..on<TrackUnpublishedEvent>((_) => notifyListeners())
|
||||
..on<ParticipantConnectedEvent>((_) => notifyListeners())
|
||||
..on<ParticipantDisconnectedEvent>((_) => notifyListeners())
|
||||
..on<RoomDisconnectedEvent>((_) {
|
||||
..on<RoomDisconnectedEvent>((event) {
|
||||
// Kommt nur bei UNERWARTETEN Abbrüchen an (manuelles hangUp
|
||||
// entsorgt den Listener vorher) – Grund sichtbar machen statt
|
||||
// still aus dem Kanal zu verschwinden.
|
||||
error = 'Verbindung getrennt (${event.reason?.name ?? 'unbekannt'})';
|
||||
_cleanUpAfterDisconnect();
|
||||
});
|
||||
|
||||
@@ -244,15 +298,15 @@ class LiveKitCallManager with ChangeNotifier {
|
||||
String? prefMic, prefSpeaker, prefCam;
|
||||
var deviceIds = <String>{};
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_outputVolume = prefs.getDouble('voice_output_volume') ?? 1.0;
|
||||
prefMic = prefs.getString('voice_mic_device');
|
||||
prefSpeaker = prefs.getString('voice_speaker_device');
|
||||
prefCam = prefs.getString('voice_camera_device');
|
||||
_outputVolume = await VoicePrefs.loadOutputVolume();
|
||||
final devicePrefs = await VoicePrefs.loadDevices();
|
||||
prefMic = devicePrefs.mic;
|
||||
prefSpeaker = devicePrefs.speaker;
|
||||
prefCam = devicePrefs.camera;
|
||||
final devices = await rtc.navigator.mediaDevices.enumerateDevices();
|
||||
deviceIds = devices.map((d) => d.deviceId).toSet();
|
||||
} 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 (prefSpeaker != null && !deviceIds.contains(prefSpeaker)) prefSpeaker = null;
|
||||
@@ -268,7 +322,7 @@ class LiveKitCallManager with ChangeNotifier {
|
||||
try {
|
||||
await rtc.Helper.selectAudioOutput(prefSpeaker);
|
||||
} catch (e) {
|
||||
print('[LiveKit] selectAudioOutput error: $e');
|
||||
debugPrint('[LiveKit] selectAudioOutput error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,16 +336,23 @@ class LiveKitCallManager with ChangeNotifier {
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
print('[LiveKit] Failed to enable camera: $e');
|
||||
debugPrint('[LiveKit] Failed to enable camera: $e');
|
||||
}
|
||||
}
|
||||
|
||||
_startStatsLogging();
|
||||
} catch (e) {
|
||||
print('[VOIP] Start call error: $e');
|
||||
debugPrint('[VOIP] Start call error: $e');
|
||||
if (gen != _callGeneration) {
|
||||
// Inzwischen wurde aufgelegt/neu verbunden – der Zustand gehört
|
||||
// schon dem neuen Aufbau, hier nichts mehr anfassen.
|
||||
return;
|
||||
}
|
||||
isConnecting = false;
|
||||
error = e.toString();
|
||||
_cleanUpAfterDisconnect();
|
||||
// NACH dem Aufräumen setzen, damit der Join-Fehler sichtbar bleibt.
|
||||
error = 'Beitritt fehlgeschlagen: $e';
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,6 +369,7 @@ class LiveKitCallManager with ChangeNotifier {
|
||||
double _outputVolume = 1.0;
|
||||
|
||||
/// Live-Anwendung des Lautstärke-Sliders auf alle Remote-Audio-Tracks.
|
||||
@override
|
||||
Future<void> setOutputVolume(double v) async {
|
||||
_outputVolume = v;
|
||||
final room = _room;
|
||||
@@ -324,8 +386,9 @@ class LiveKitCallManager with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setSubscribeQuality(String key) async {
|
||||
print('[LiveKit] Viewer changing subscribe quality to: $key');
|
||||
debugPrint('[LiveKit] Viewer changing subscribe quality to: $key');
|
||||
_subscribeQualityKey = key;
|
||||
if (_room == null) { notifyListeners(); return; }
|
||||
|
||||
@@ -333,7 +396,7 @@ class LiveKitCallManager with ChangeNotifier {
|
||||
for (final p in _room!.remoteParticipants.values) {
|
||||
for (final pub in p.videoTrackPublications) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -341,12 +404,14 @@ class LiveKitCallManager with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> toggleMute() async {
|
||||
isMuted = !isMuted;
|
||||
notifyListeners();
|
||||
await _room?.localParticipant?.setMicrophoneEnabled(!isMuted);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> toggleDeafen() async {
|
||||
if (!isDeafened) {
|
||||
_mutedBeforeDeafen = isMuted;
|
||||
@@ -378,6 +443,7 @@ class LiveKitCallManager with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> toggleCamera() async {
|
||||
if (isCameraOff) {
|
||||
try {
|
||||
@@ -387,19 +453,20 @@ class LiveKitCallManager with ChangeNotifier {
|
||||
);
|
||||
isCameraOff = false;
|
||||
} catch (e) {
|
||||
print('[LiveKit] toggleCamera ON error: $e');
|
||||
debugPrint('[LiveKit] toggleCamera ON error: $e');
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await _room?.localParticipant?.setCameraEnabled(false);
|
||||
} catch (e) {
|
||||
print('[LiveKit] toggleCamera OFF error: $e');
|
||||
debugPrint('[LiveKit] toggleCamera OFF error: $e');
|
||||
}
|
||||
isCameraOff = true;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> toggleScreenShare() async {
|
||||
if (isScreenSharing) {
|
||||
await stopScreenShare();
|
||||
@@ -417,24 +484,33 @@ class LiveKitCallManager with ChangeNotifier {
|
||||
isScreenSharing = true;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
print('[ScreenShare] Toggle error: $e');
|
||||
debugPrint('[ScreenShare] Toggle error: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VideoEncoding _screenShareEncoding() {
|
||||
return _screenShareEncodings[_localQualityKey] ??
|
||||
return screenShareEncodings[_localQualityKey] ??
|
||||
const VideoEncoding(maxBitrate: 1_500_000, maxFramerate: 15);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> startScreenShare(String sourceId) async {
|
||||
if (isScreenSharing) return;
|
||||
try {
|
||||
final encoding = _screenShareEncoding();
|
||||
final highFps = isHighFpsPreset(_localQualityKey);
|
||||
final track = await LocalVideoTrack.createScreenShareTrack(
|
||||
ScreenShareCaptureOptions(
|
||||
sourceId: sourceId,
|
||||
maxFrameRate: encoding.maxFramerate.toDouble(),
|
||||
// Ohne explizite params bleibt die Capture-Auflösung beim
|
||||
// LiveKit-Default (1080p/15) hängen – das Preset (Auflösung UND
|
||||
// FPS) muss bis in die Capture-Constraints durchgereicht werden.
|
||||
params: VideoParameters(
|
||||
dimensions: _currentQuality.dimensions,
|
||||
encoding: encoding,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -443,8 +519,15 @@ class LiveKitCallManager with ChangeNotifier {
|
||||
await _room!.localParticipant!.publishVideoTrack(
|
||||
track,
|
||||
publishOptions: VideoPublishOptions(
|
||||
simulcast: true,
|
||||
degradationPreference: DegradationPreference.balanced,
|
||||
// 60-FPS-Presets: EIN Layer mit voller Bitrate statt drei
|
||||
// Simulcast-Encodes (Software-Encoder!), und unter Last lieber
|
||||
// Auflösung senken als Frames verlieren. contentHint 'motion'
|
||||
// wäre ein weiterer Hebel, ist aber in flutter_webrtc 1.4.1 aus
|
||||
// Dart nicht setzbar (Pub-Cache geprüft, 2026-07-06).
|
||||
simulcast: !highFps,
|
||||
degradationPreference: highFps
|
||||
? DegradationPreference.maintainFramerate
|
||||
: DegradationPreference.balanced,
|
||||
screenShareEncoding: encoding,
|
||||
),
|
||||
);
|
||||
@@ -455,7 +538,7 @@ class LiveKitCallManager with ChangeNotifier {
|
||||
notifyListeners();
|
||||
unawaited(_applyFpsToAllSimulcastLayers(track));
|
||||
} catch (e) {
|
||||
print('[ScreenShare] ERROR: $e');
|
||||
debugPrint('[ScreenShare] ERROR: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,6 +564,7 @@ class LiveKitCallManager with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stopScreenShare() async {
|
||||
final local = _room?.localParticipant;
|
||||
if (local != null) {
|
||||
@@ -502,7 +586,10 @@ class LiveKitCallManager with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> hangUp() async {
|
||||
// Entwertet einen evtl. noch laufenden Verbindungsaufbau (s. _callGeneration).
|
||||
_callGeneration++;
|
||||
_stopStatsLogging();
|
||||
_presenceHeartbeat?.cancel();
|
||||
_presenceHeartbeat = null;
|
||||
@@ -530,8 +617,9 @@ class LiveKitCallManager with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> changeQuality(String qualityKey) async {
|
||||
print('[LiveKit] Streamer changing publish quality to: $qualityKey');
|
||||
debugPrint('[LiveKit] Streamer changing publish quality to: $qualityKey');
|
||||
_localQualityKey = qualityKey;
|
||||
final params = qualityPresets[qualityKey];
|
||||
if (params == null) { notifyListeners(); return; }
|
||||
@@ -549,7 +637,7 @@ class LiveKitCallManager with ChangeNotifier {
|
||||
cameraCaptureOptions: CameraCaptureOptions(params: params),
|
||||
);
|
||||
} catch (e) {
|
||||
print('[LiveKit] Camera restart error: $e');
|
||||
debugPrint('[LiveKit] Camera restart error: $e');
|
||||
isCameraOff = true;
|
||||
}
|
||||
}
|
||||
@@ -563,6 +651,7 @@ class LiveKitCallManager with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> changeAudioInput(String deviceId) async {
|
||||
final participant = _room?.localParticipant;
|
||||
if (participant == null) return;
|
||||
@@ -573,9 +662,10 @@ class LiveKitCallManager with ChangeNotifier {
|
||||
}
|
||||
await participant.publishAudioTrack(track);
|
||||
if (isMuted) await participant.setMicrophoneEnabled(false);
|
||||
} catch (e) { print('[Audio] error: $e'); }
|
||||
} catch (e) { debugPrint('[Audio] error: $e'); }
|
||||
}
|
||||
|
||||
@override
|
||||
void setRemoteVolume(double volume) {
|
||||
if (_room == null) return;
|
||||
final mute = volume < 0.05;
|
||||
@@ -586,8 +676,6 @@ class LiveKitCallManager with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
void _applyNoiseSuppression(bool enabled) {}
|
||||
|
||||
void _startStatsLogging() {
|
||||
_stopStatsLogging();
|
||||
_statsTimer = Timer.periodic(const Duration(seconds: 5), (_) => _logStats());
|
||||
@@ -601,6 +689,6 @@ class LiveKitCallManager with ChangeNotifier {
|
||||
Future<void> _logStats() async {
|
||||
if (_room == null) return;
|
||||
// Basic stats logging...
|
||||
print('[Stats] SubQuality: $_subscribeQualityKey | PubQuality: $_localQualityKey');
|
||||
debugPrint('[Stats] SubQuality: $_subscribeQualityKey | PubQuality: $_localQualityKey');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:livekit_client/livekit_client.dart' as lk;
|
||||
import 'package:matrix/matrix.dart' hide Room;
|
||||
import 'package:pyramid/features/voice_channel/livekit_call_manager.dart';
|
||||
|
||||
/// Öffentliche Schnittstelle des Moduls `voice_channel` – LiveKit-SFU-basierte,
|
||||
/// dauerhafte Voice-Channels (Discord-artig: Presence über Matrix-State-Events,
|
||||
/// Screensharing, Mehrpersonen). Schnitt nach `docs/M2_MODULE_SCHNITT.md`;
|
||||
/// bewusst GETRENNT vom 1:1-Modul `call_signaling`, siehe dort.
|
||||
///
|
||||
/// Andere Module konsumieren ausschließlich diese Schnittstelle über
|
||||
/// [voiceChannelProvider] – nie `livekit_call_manager.dart` direkt.
|
||||
///
|
||||
/// Bewusste Abweichungen vom Entwurf im Doc (Verhaltensgleichheit vor
|
||||
/// Schönheit): ChangeNotifier statt `stateStream`, Methodennamen wie im
|
||||
/// Bestand (`startCall`/`hangUp` statt `join`/`leave`), und [room] bleibt
|
||||
/// als LiveKit-Typ sichtbar, weil die Teilnehmer-Kacheln der UI direkt
|
||||
/// LiveKit-Tracks rendern (analog zu den WebRTC-Renderern im Signaling).
|
||||
abstract class VoiceChannelService extends ChangeNotifier {
|
||||
bool get isActive;
|
||||
bool get isConnecting;
|
||||
String? get error;
|
||||
bool get isMuted;
|
||||
bool get isCameraOff;
|
||||
bool get isScreenSharing;
|
||||
bool get isDeafened;
|
||||
bool get isVoiceChannel;
|
||||
|
||||
/// Der verbundene LiveKit-Raum (null = nicht verbunden).
|
||||
lk.Room? get room;
|
||||
|
||||
/// Matrix-Raum-ID des aktiven Voice-Channels.
|
||||
String? get currentRoomId;
|
||||
String get displayName;
|
||||
|
||||
/// Sende-Qualität ('sd' | 'hd' | 'hd60' | 'fhd' | 'fhd60' | '4k').
|
||||
/// Die *60-Presets sind für flüssiges Bildschirm-Streaming (M4).
|
||||
String get currentQualityKey;
|
||||
|
||||
/// Empfangs-Qualität ('auto' | 'low' | 'medium' | 'high').
|
||||
String get subscribeQualityKey;
|
||||
|
||||
Future<void> startCall({
|
||||
required String roomName,
|
||||
required String roomDisplayName,
|
||||
required String identity,
|
||||
bool audioOnly,
|
||||
bool voiceChannel,
|
||||
Client? matrixClient,
|
||||
String? matrixRoomId,
|
||||
});
|
||||
Future<void> hangUp();
|
||||
|
||||
Future<void> toggleMute();
|
||||
Future<void> toggleDeafen();
|
||||
Future<void> toggleCamera();
|
||||
Future<void> toggleScreenShare();
|
||||
Future<void> startScreenShare(String sourceId);
|
||||
Future<void> stopScreenShare();
|
||||
|
||||
Future<void> changeQuality(String qualityKey);
|
||||
Future<void> setSubscribeQuality(String key);
|
||||
|
||||
/// Live-Anwendung des Lautstärke-Sliders auf alle Remote-Audio-Tracks.
|
||||
Future<void> setOutputVolume(double v);
|
||||
Future<void> changeAudioInput(String deviceId);
|
||||
void setRemoteVolume(double volume);
|
||||
}
|
||||
|
||||
/// Einziger Zugriffsweg der UI auf das Voice-Channel-Modul.
|
||||
final voiceChannelProvider =
|
||||
ChangeNotifierProvider<VoiceChannelService>((ref) {
|
||||
return LiveKitCallManager.instance;
|
||||
});
|
||||
@@ -9,9 +9,10 @@ import 'package:pretty_qr_code/pretty_qr_code.dart';
|
||||
import 'package:pyramid/core/app_state.dart';
|
||||
import 'package:pyramid/core/matrix_client.dart';
|
||||
import 'package:pyramid/core/theme.dart';
|
||||
import 'package:pyramid/core/voip_manager.dart';
|
||||
import 'package:pyramid/features/auth/bootstrap_dialog.dart';
|
||||
import 'package:pyramid/features/call/voice_channel.dart';
|
||||
import 'package:pyramid/features/call_ui/voice_channel.dart';
|
||||
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
||||
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
|
||||
import 'package:pyramid/features/chat/chat_view.dart';
|
||||
import 'package:pyramid/features/members/members_panel.dart';
|
||||
import 'package:pyramid/features/rooms/rooms_panel.dart';
|
||||
@@ -160,7 +161,7 @@ class _AppShellState extends ConsumerState<AppShell>
|
||||
// notifyListeners (u.a. Render-Timer im Sekundentakt); ohne Flankenerkennung
|
||||
// würde der Nutzer permanent zurück in die Call-UI gezwungen und könnte
|
||||
// sie nie minimieren.
|
||||
ref.listen(voipStateProvider, (prev, next) {
|
||||
ref.listen(callSignalingProvider, (prev, next) {
|
||||
final wasConnected = _wasVoipConnected;
|
||||
final isConnected = next.currentCall != null &&
|
||||
next.currentCall!.state == CallState.kConnected;
|
||||
@@ -172,7 +173,7 @@ class _AppShellState extends ConsumerState<AppShell>
|
||||
}
|
||||
});
|
||||
|
||||
ref.listen(callStateProvider, (prev, next) {
|
||||
ref.listen(voiceChannelProvider, (prev, next) {
|
||||
// Only auto-switch to voice view for video/audio calls, NOT voice channels
|
||||
// (voice channels should stay in background with a mini bar)
|
||||
final wasActive = _wasLiveKitActive;
|
||||
@@ -200,7 +201,7 @@ class _AppShellState extends ConsumerState<AppShell>
|
||||
final activeModal = ref.watch(activeModalProvider);
|
||||
final membersOpen = ref.watch(membersPanelOpenProvider);
|
||||
final innerPt = PyramidTheme.of(context);
|
||||
final voip = ref.watch(voipStateProvider);
|
||||
final voip = ref.watch(callSignalingProvider);
|
||||
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
final isMobile = size.width < 900 || size.height < 500;
|
||||
@@ -332,7 +333,7 @@ class _MobileShell extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final voip = ref.watch(voipStateProvider);
|
||||
final voip = ref.watch(callSignalingProvider);
|
||||
|
||||
Widget body;
|
||||
if (viewMode == ViewMode.voice) {
|
||||
@@ -456,7 +457,7 @@ class _NotificationBlockedBanner extends ConsumerWidget {
|
||||
|
||||
class _IncomingCallOverlay extends ConsumerWidget {
|
||||
final CallSession call;
|
||||
final PyramidVoipManager voip;
|
||||
final CallSignalingService voip;
|
||||
final PyramidTheme pt;
|
||||
|
||||
const _IncomingCallOverlay({
|
||||
|
||||
@@ -7,8 +7,8 @@ import 'package:image/image.dart' as img;
|
||||
|
||||
// ── Windows constants ────────────────────────────────────────────────────────
|
||||
|
||||
const _CF_DIB = 8;
|
||||
const _CF_DIBV4 = 20;
|
||||
const _cfDib = 8;
|
||||
const _cfDibV4 = 20;
|
||||
|
||||
// ── Native function typedefs ─────────────────────────────────────────────────
|
||||
|
||||
@@ -94,7 +94,7 @@ Uint8List? _readClipboardImage() {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
final dibBytes = _readGlobalBytes(
|
||||
getClipboardData(fmt), globalSize, globalLock, globalUnlock);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart' hide Visibility;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:pyramid/core/app_state.dart';
|
||||
@@ -41,7 +40,7 @@ class _CreateJoinDialogState extends ConsumerState<CreateJoinDialog> {
|
||||
// Discover
|
||||
final _discoverServerCtrl = TextEditingController();
|
||||
final _discoverSearchCtrl = TextEditingController();
|
||||
List<PublicRoomsChunk> _publicRooms = [];
|
||||
List<PublishedRoomsChunk> _publicRooms = [];
|
||||
String? _nextBatch;
|
||||
bool _discoverLoading = false;
|
||||
bool _discoverLoadingMore = false;
|
||||
@@ -189,13 +188,15 @@ class _CreateJoinDialogState extends ConsumerState<CreateJoinDialog> {
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) setState(() {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_discoverLoading = false;
|
||||
_discoverLoadingMore = false;
|
||||
_discoverError = e.toString().split('\n').first;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onSearchChanged(String _) {
|
||||
_searchDebounce?.cancel();
|
||||
@@ -518,13 +519,12 @@ class _SpaceEditDialogState extends ConsumerState<SpaceEditDialog> {
|
||||
late TextEditingController _topicCtrl;
|
||||
bool _saving = false;
|
||||
String? _error;
|
||||
bool _saved = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nameCtrl = TextEditingController(text: widget.space.getLocalizedDisplayname());
|
||||
_topicCtrl = TextEditingController(text: widget.space.topic ?? '');
|
||||
_topicCtrl = TextEditingController(text: widget.space.topic);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -535,17 +535,17 @@ class _SpaceEditDialogState extends ConsumerState<SpaceEditDialog> {
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
setState(() { _saving = true; _error = null; _saved = false; });
|
||||
setState(() { _saving = true; _error = null; });
|
||||
try {
|
||||
final name = _nameCtrl.text.trim();
|
||||
final topic = _topicCtrl.text.trim();
|
||||
if (name.isNotEmpty && name != widget.space.getLocalizedDisplayname()) {
|
||||
await widget.space.setName(name);
|
||||
}
|
||||
if (topic != (widget.space.topic ?? '')) {
|
||||
if (topic != widget.space.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));
|
||||
if (mounted) Navigator.of(context).pop(true);
|
||||
} catch (e) {
|
||||
@@ -674,7 +674,7 @@ class _RoomSettingsDialogState extends ConsumerState<RoomSettingsDialog> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nameCtrl = TextEditingController(text: widget.room.getLocalizedDisplayname());
|
||||
_topicCtrl = TextEditingController(text: widget.room.topic ?? '');
|
||||
_topicCtrl = TextEditingController(text: widget.room.topic);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -691,7 +691,7 @@ class _RoomSettingsDialogState extends ConsumerState<RoomSettingsDialog> {
|
||||
await widget.room.setName(_nameCtrl.text.trim());
|
||||
}
|
||||
final newTopic = _topicCtrl.text.trim();
|
||||
if (newTopic != (widget.room.topic ?? '')) {
|
||||
if (newTopic != widget.room.topic) {
|
||||
await widget.room.setDescription(newTopic);
|
||||
}
|
||||
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(desc, style: TextStyle(color: pt.fgDim, fontSize: 11)),
|
||||
])),
|
||||
Switch(value: value, onChanged: onChanged, activeColor: pt.accent),
|
||||
Switch(value: value, onChanged: onChanged, activeThumbColor: pt.accent),
|
||||
]),
|
||||
);
|
||||
}
|
||||
@@ -981,7 +981,7 @@ class _ErrorCard extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _PublicRoomItem extends ConsumerWidget {
|
||||
final PublicRoomsChunk room;
|
||||
final PublishedRoomsChunk room;
|
||||
final PyramidTheme pt;
|
||||
final bool isJoining;
|
||||
final VoidCallback onJoin;
|
||||
|
||||
@@ -90,7 +90,7 @@ class _MxcAvatarState extends State<MxcAvatar> {
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => widget.placeholder(context),
|
||||
errorBuilder: (_, _, _) => widget.placeholder(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -194,7 +194,7 @@ class _MxcImageState extends State<MxcImage> {
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
errorBuilder: (_, _, _) =>
|
||||
widget.placeholder ?? const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ class _PyramidLoaderState extends State<PyramidLoader>
|
||||
|
||||
final painter = AnimatedBuilder(
|
||||
animation: _ctrl,
|
||||
builder: (_, __) => CustomPaint(
|
||||
builder: (_, _) => CustomPaint(
|
||||
painter: _PyramidPainter(
|
||||
yaw: _yawAt(_ctrl.value),
|
||||
accent: accent,
|
||||
@@ -82,7 +82,7 @@ class _PyramidLoaderState extends State<PyramidLoader>
|
||||
fit: BoxFit.contain,
|
||||
repeat: 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(cx + v[0] * sc, cy + (v[1] * cos(tr) + v[2] * sin(tr)) * sc);
|
||||
|
||||
double _frontness(List<List<double>> verts) {
|
||||
final a = verts[0], b = verts[1], c = verts[2];
|
||||
final ux = b[0]-a[0], uy = b[1]-a[1], uz = b[2]-a[2];
|
||||
final vx = c[0]-a[0], vy = c[1]-a[1], vz = c[2]-a[2];
|
||||
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()),
|
||||
Color _darken(Color c, double amount) => c.withValues(
|
||||
red: max(0.0, c.r * (1 - amount)),
|
||||
green: max(0.0, c.g * (1 - amount)),
|
||||
blue: max(0.0, c.b * (1 - amount)),
|
||||
);
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
import 'package:pyramid/core/theme.dart';
|
||||
@@ -43,7 +42,7 @@ class _ScreenSharePickerState extends State<ScreenSharePicker> with SingleTicker
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
print('[Picker] Error loading sources: $e');
|
||||
debugPrint('[Picker] Error loading sources: $e');
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -723,7 +723,7 @@ class _SearchThumbState extends State<_SearchThumb> {
|
||||
height: size,
|
||||
child: _bytes != null
|
||||
? Image.memory(_bytes!, fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => _placeholder(pt))
|
||||
errorBuilder: (_, _, _) => _placeholder(pt))
|
||||
: _placeholder(pt),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,659 @@
|
||||
part of '../../settings_modal.dart';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
enum _EncState { idle, loading, needsKey, unlocking, done, error }
|
||||
|
||||
enum _RecoveryStep { loading, showKey, confirmKey, applying, done, error }
|
||||
|
||||
class _EncryptionSection extends ConsumerStatefulWidget {
|
||||
final PyramidTheme pt;
|
||||
final WidgetRef ref;
|
||||
const _EncryptionSection({required this.pt, required this.ref});
|
||||
|
||||
@override
|
||||
ConsumerState<_EncryptionSection> createState() => _EncryptionSectionState();
|
||||
}
|
||||
|
||||
class _EncryptionSectionState extends ConsumerState<_EncryptionSection> {
|
||||
_EncState _state = _EncState.idle;
|
||||
Bootstrap? _bootstrap;
|
||||
final _keyCtrl = TextEditingController();
|
||||
String? _error;
|
||||
|
||||
// Status loaded from encryption object
|
||||
bool? _crossSigningEnabled;
|
||||
bool? _crossSigningCached;
|
||||
bool? _keyBackupEnabled;
|
||||
bool _statusLoaded = false;
|
||||
|
||||
// Key file export / import
|
||||
bool _exportLoading = false;
|
||||
bool _importLoading = false;
|
||||
String? _keyFileMsg;
|
||||
bool _keyFileMsgIsError = false;
|
||||
bool _diagUploading = false;
|
||||
String? _diagMsg;
|
||||
bool _diagMsgIsError = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadStatus();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_keyCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadStatus() async {
|
||||
try {
|
||||
final client = await ref.read(matrixClientProvider.future);
|
||||
final enc = client.encryption;
|
||||
if (enc == null) return;
|
||||
final cached = await enc.crossSigning.isCached().catchError((_) => false);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_crossSigningEnabled = enc.crossSigning.enabled;
|
||||
_crossSigningCached = cached;
|
||||
_keyBackupEnabled = enc.keyManager.enabled;
|
||||
_statusLoaded = true;
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _start() async {
|
||||
setState(() { _state = _EncState.loading; _error = null; });
|
||||
try {
|
||||
final client = await ref.read(matrixClientProvider.future);
|
||||
if (client.encryption == null) {
|
||||
setState(() { _state = _EncState.error; _error = 'Verschlüsselung nicht verfügbar.'; });
|
||||
return;
|
||||
}
|
||||
final enc = client.encryption!;
|
||||
final cached = await enc.keyManager.isCached().catchError((_) => false);
|
||||
if (cached) {
|
||||
await enc.keyManager.loadAllKeys().catchError((_) {});
|
||||
if (mounted) setState(() { _state = _EncState.done; _error = null; });
|
||||
await _loadStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
await client.roomsLoading;
|
||||
await client.accountDataLoading;
|
||||
await client.userDeviceKeysLoading;
|
||||
|
||||
final completer = Completer<bool>();
|
||||
_bootstrap = enc.bootstrap(onUpdate: (bs) {
|
||||
if (!mounted) return;
|
||||
switch (bs.state) {
|
||||
case BootstrapState.askWipeSsss:
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => bs.wipeSsss(false));
|
||||
case BootstrapState.askBadSsss:
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => bs.ignoreBadSecrets(true));
|
||||
case BootstrapState.askUseExistingSsss:
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => bs.useExistingSsss(true));
|
||||
case BootstrapState.openExistingSsss:
|
||||
if (!completer.isCompleted) completer.complete(true);
|
||||
case BootstrapState.askNewSsss:
|
||||
if (!completer.isCompleted) completer.complete(false);
|
||||
case BootstrapState.error:
|
||||
if (!completer.isCompleted) completer.complete(false);
|
||||
if (mounted) setState(() { _state = _EncState.error; _error = 'Bootstrap-Fehler.'; });
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
final needsKey = await completer.future
|
||||
.timeout(const Duration(seconds: 30), onTimeout: () => false);
|
||||
if (!mounted) return;
|
||||
setState(() => _state = needsKey ? _EncState.needsKey : _EncState.done);
|
||||
await _loadStatus();
|
||||
} catch (e) {
|
||||
if (mounted) setState(() { _state = _EncState.error; _error = e.toString().split('\n').first; });
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _unlock() async {
|
||||
final bs = _bootstrap;
|
||||
if (bs == null || bs.newSsssKey == null) return;
|
||||
final key = _keyCtrl.text.trim();
|
||||
if (key.isEmpty) { setState(() => _error = 'Bitte Wiederherstellungsschlüssel eingeben.'); return; }
|
||||
setState(() { _state = _EncState.unlocking; _error = null; });
|
||||
try {
|
||||
await bs.newSsssKey!.unlock(keyOrPassphrase: key);
|
||||
await bs.openExistingSsss();
|
||||
final client = await ref.read(matrixClientProvider.future);
|
||||
if (bs.encryption.crossSigning.enabled) {
|
||||
await client.encryption!.crossSigning.selfSign(recoveryKey: key);
|
||||
}
|
||||
await client.encryption!.keyManager.loadAllKeys();
|
||||
if (mounted) setState(() => _state = _EncState.done);
|
||||
await _loadStatus();
|
||||
} on InvalidPassphraseException catch (_) {
|
||||
if (mounted) setState(() { _state = _EncState.needsKey; _error = 'Falscher Schlüssel.'; });
|
||||
} on FormatException catch (_) {
|
||||
if (mounted) setState(() { _state = _EncState.needsKey; _error = 'Ungültiges Format.'; });
|
||||
} catch (e) {
|
||||
if (mounted) setState(() { _state = _EncState.error; _error = e.toString().split('\n').first; });
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _resetSecurity() 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('Sicherheit zurücksetzen?',
|
||||
style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)),
|
||||
content: Text(
|
||||
'Dies löscht deine aktuellen Cross-Signing-Schlüssel und den Online-Schlüssel-Backup. Nicht gesicherte Sitzungen können Nachrichten möglicherweise nicht mehr entschlüsseln.',
|
||||
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('Zurücksetzen'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
if (confirm != true || !mounted) return;
|
||||
setState(() { _state = _EncState.loading; _error = null; });
|
||||
try {
|
||||
final client = await ref.read(matrixClientProvider.future);
|
||||
final enc = client.encryption;
|
||||
if (enc == null) throw Exception('Verschlüsselung nicht verfügbar');
|
||||
await client.roomsLoading;
|
||||
await client.accountDataLoading;
|
||||
await client.userDeviceKeysLoading;
|
||||
final completer = Completer<void>();
|
||||
enc.bootstrap(onUpdate: (bs) {
|
||||
if (!mounted) return;
|
||||
switch (bs.state) {
|
||||
case BootstrapState.askWipeSsss:
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => bs.wipeSsss(true));
|
||||
case BootstrapState.askBadSsss:
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => bs.ignoreBadSecrets(true));
|
||||
case BootstrapState.askUseExistingSsss:
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => bs.useExistingSsss(false));
|
||||
case BootstrapState.askNewSsss:
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => bs.newSsss());
|
||||
case BootstrapState.askWipeCrossSigning:
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => bs.wipeCrossSigning(true));
|
||||
case BootstrapState.askSetupCrossSigning:
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => bs.askSetupCrossSigning(
|
||||
setupMasterKey: true, setupSelfSigningKey: true, setupUserSigningKey: true));
|
||||
case BootstrapState.askWipeOnlineKeyBackup:
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => bs.wipeOnlineKeyBackup(true));
|
||||
case BootstrapState.askSetupOnlineKeyBackup:
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => bs.askSetupOnlineKeyBackup(true));
|
||||
case BootstrapState.done:
|
||||
if (!completer.isCompleted) completer.complete();
|
||||
case BootstrapState.error:
|
||||
if (!completer.isCompleted) completer.completeError('Bootstrap-Fehler');
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
await completer.future.timeout(const Duration(seconds: 60));
|
||||
if (mounted) setState(() => _state = _EncState.done);
|
||||
await _loadStatus();
|
||||
} catch (e) {
|
||||
if (mounted) setState(() { _state = _EncState.error; _error = e.toString().split('\n').first; });
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _showPassphraseDialog({required bool forExport}) {
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => _PassphraseDialog(pt: widget.pt, forExport: forExport),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _exportKeys() async {
|
||||
final passphrase = await _showPassphraseDialog(forExport: true);
|
||||
if (passphrase == null || !mounted) return;
|
||||
|
||||
setState(() { _exportLoading = true; _keyFileMsg = null; });
|
||||
try {
|
||||
final client = await ref.read(matrixClientProvider.future);
|
||||
final db = client.database;
|
||||
|
||||
final stored = await db.getAllInboundGroupSessions();
|
||||
final sessions = <Map<String, dynamic>>[];
|
||||
|
||||
for (final s in stored) {
|
||||
try {
|
||||
final content = jsonDecode(s.content) as Map<String, dynamic>;
|
||||
final sessionKey = content['session_key'] as String?;
|
||||
if (sessionKey == null || sessionKey.isEmpty) continue;
|
||||
|
||||
final claimedKeys = s.senderClaimedKeys.isNotEmpty
|
||||
? (jsonDecode(s.senderClaimedKeys) as Map<String, dynamic>)
|
||||
.map((k, v) => MapEntry(k, v.toString()))
|
||||
: <String, String>{};
|
||||
|
||||
sessions.add({
|
||||
'algorithm': 'm.megolm.v1.aes-sha2',
|
||||
'forwarding_curve25519_key_chain': <dynamic>[],
|
||||
'room_id': s.roomId,
|
||||
'sender_key': s.senderKey,
|
||||
'sender_claimed_keys': claimedKeys,
|
||||
'session_id': s.sessionId,
|
||||
'session_key': sessionKey,
|
||||
});
|
||||
} catch (_) { continue; }
|
||||
}
|
||||
|
||||
if (sessions.isEmpty) throw Exception('Keine exportierbaren Sitzungsschlüssel gefunden.');
|
||||
|
||||
final encrypted = await compute(_encryptMegolmKeys, {
|
||||
'passphrase': passphrase,
|
||||
'sessions': sessions,
|
||||
});
|
||||
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final now = DateTime.now();
|
||||
final stamp = '${now.year}'
|
||||
'${now.month.toString().padLeft(2, '0')}'
|
||||
'${now.day.toString().padLeft(2, '0')}';
|
||||
final file = File('${dir.path}/pyramid_keys_$stamp.txt');
|
||||
await file.writeAsString(encrypted);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_exportLoading = false;
|
||||
_keyFileMsgIsError = false;
|
||||
_keyFileMsg = '${sessions.length} Schlüssel exportiert → ${file.path}';
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_exportLoading = false;
|
||||
_keyFileMsgIsError = true;
|
||||
_keyFileMsg = 'Export fehlgeschlagen: ${e.toString().split('\n').first}';
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _uploadDiagnostics() async {
|
||||
setState(() { _diagUploading = true; _diagMsg = null; });
|
||||
try {
|
||||
final notifier = ref.read(e2eeDiagnosticsProvider.notifier);
|
||||
final msg = await notifier.upload(
|
||||
serverUrl: 'https://dashboard.steggi-matrix.work',
|
||||
// Eng begrenzter Diagnose-Token (haengt nur an den Diagnose-Log an,
|
||||
// KEINE Admin-Rechte). Bewusst NICHT der Matrix-Server-Admin-Token –
|
||||
// der darf niemals im Client landen. Passendes Gegenstueck in
|
||||
// /home/steggi/matrix/server.py (DIAG_TOKEN) auf dem Pi.
|
||||
token: 'pyr-diag-6AyELgODRv-4rF4dcau3kSa5YbgZqUcn',
|
||||
);
|
||||
if (mounted) setState(() { _diagUploading = false; _diagMsgIsError = false; _diagMsg = msg; });
|
||||
} catch (e) {
|
||||
if (mounted) setState(() { _diagUploading = false; _diagMsgIsError = true; _diagMsg = e.toString(); });
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _importKeys() async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['txt', 'key', 'keys'],
|
||||
allowMultiple: false,
|
||||
);
|
||||
if (result == null || result.files.isEmpty || result.files.first.path == null) return;
|
||||
if (!mounted) return;
|
||||
|
||||
final passphrase = await _showPassphraseDialog(forExport: false);
|
||||
if (passphrase == null || !mounted) return;
|
||||
|
||||
setState(() { _importLoading = true; _keyFileMsg = null; });
|
||||
try {
|
||||
final fileContent = await File(result.files.first.path!).readAsString();
|
||||
final trimmed = fileContent.trim();
|
||||
|
||||
if (!trimmed.startsWith(_kMegolmHeader) || !trimmed.endsWith(_kMegolmFooter)) {
|
||||
throw Exception('Ungültiges Dateiformat – kein Megolm-Schlüsseldatei-Header.');
|
||||
}
|
||||
|
||||
final lines = trimmed.split('\n');
|
||||
// Strip \r so Windows line endings (\r\n) don't corrupt the base64 string.
|
||||
final b64 = lines.skip(1).take(lines.length - 2).join('').replaceAll(RegExp(r'\s'), '');
|
||||
final padded = b64 + '=' * ((4 - b64.length % 4) % 4);
|
||||
final dataBytes = base64.decode(padded);
|
||||
|
||||
final sessions = await compute(_decryptMegolmKeys, {
|
||||
'passphrase': passphrase,
|
||||
'data': dataBytes.toList(),
|
||||
});
|
||||
|
||||
final client = await ref.read(matrixClientProvider.future);
|
||||
final km = client.encryption?.keyManager;
|
||||
if (km == null) throw Exception('Verschlüsselung nicht initialisiert.');
|
||||
|
||||
var imported = 0;
|
||||
for (final raw in sessions) {
|
||||
try {
|
||||
final session = raw as Map<String, dynamic>;
|
||||
final roomId = session['room_id'] as String?;
|
||||
final sessionId = session['session_id'] as String?;
|
||||
final senderKey = session['sender_key'] as String?;
|
||||
if (roomId == null || sessionId == null || senderKey == null) continue;
|
||||
|
||||
final claimedKeys = (session['sender_claimed_keys'] as Map?)
|
||||
?.map((k, v) => MapEntry(k.toString(), v.toString())) ??
|
||||
<String, String>{};
|
||||
|
||||
await km.setInboundGroupSession(
|
||||
roomId, sessionId, senderKey,
|
||||
Map<String, dynamic>.from(session),
|
||||
forwarded: true,
|
||||
senderClaimedKeys: claimedKeys,
|
||||
);
|
||||
imported++;
|
||||
} catch (_) { continue; }
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_importLoading = false;
|
||||
_keyFileMsgIsError = false;
|
||||
_keyFileMsg = '$imported von ${sessions.length} Schlüssel importiert.';
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_importLoading = false;
|
||||
_keyFileMsgIsError = true;
|
||||
_keyFileMsg = 'Import fehlgeschlagen: ${e.toString().split('\n').first}';
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _changeRecoveryKey() async {
|
||||
final changed = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => _ChangeRecoveryKeyDialog(pt: widget.pt),
|
||||
);
|
||||
if (changed == true && mounted) {
|
||||
await _loadStatus();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_SectionTitle(
|
||||
title: 'Verschlüsselung',
|
||||
subtitle: 'Ende-zu-Ende-Verschlüsselung & Sicherheit verwalten.',
|
||||
pt: pt),
|
||||
|
||||
// ── Status overview ───────────────────────────────────────────────
|
||||
if (_statusLoaded) ...[
|
||||
_SettingsGroup(title: 'Sicherheitsstatus', pt: pt, children: [
|
||||
_SettingsRow(
|
||||
title: 'Cross-Signing',
|
||||
desc: _crossSigningEnabled == true
|
||||
? (_crossSigningCached == true ? 'Eingerichtet & verifiziert' : 'Eingerichtet, Schlüssel nicht im Cache')
|
||||
: 'Nicht eingerichtet',
|
||||
pt: pt,
|
||||
control: Icon(
|
||||
_crossSigningEnabled == true && _crossSigningCached == true
|
||||
? Icons.verified_rounded
|
||||
: (_crossSigningEnabled == true ? Icons.warning_amber_rounded : Icons.cancel_outlined),
|
||||
size: 16,
|
||||
color: _crossSigningEnabled == true && _crossSigningCached == true
|
||||
? PyramidColors.success
|
||||
: (_crossSigningEnabled == true ? pt.away : pt.danger),
|
||||
),
|
||||
),
|
||||
_Divider(pt: pt),
|
||||
_SettingsRow(
|
||||
title: 'Online-Schlüsselbackup',
|
||||
desc: _keyBackupEnabled == true ? 'Aktiv – Sitzungsschlüssel gesichert' : 'Nicht aktiviert',
|
||||
pt: pt,
|
||||
control: Icon(
|
||||
_keyBackupEnabled == true ? Icons.cloud_done_rounded : Icons.cloud_off_rounded,
|
||||
size: 16,
|
||||
color: _keyBackupEnabled == true ? PyramidColors.success : pt.fgDim,
|
||||
),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
_FingerprintGroup(pt: pt),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
// ── Key recovery / unlock ─────────────────────────────────────────
|
||||
_SettingsGroup(title: 'Wiederherstellung', pt: pt, children: [
|
||||
_SettingsRow(
|
||||
title: 'Schlüssel wiederherstellen',
|
||||
desc: 'Nachrichten mit dem Wiederherstellungsschlüssel entschlüsseln',
|
||||
pt: pt,
|
||||
showArrow: true,
|
||||
onTap: _state == _EncState.loading ? null : _start,
|
||||
),
|
||||
_Divider(pt: pt),
|
||||
_SettingsRow(
|
||||
title: 'Wiederherstellungsschlüssel ändern',
|
||||
desc: 'Neuen Schlüssel generieren, notieren und aktivieren',
|
||||
pt: pt,
|
||||
showArrow: true,
|
||||
onTap: _state == _EncState.loading ? null : _changeRecoveryKey,
|
||||
),
|
||||
_Divider(pt: pt),
|
||||
_SettingsRow(
|
||||
title: 'Sicherheit neu einrichten',
|
||||
desc: 'Cross-Signing & Backup zurücksetzen und neu aufsetzen',
|
||||
pt: pt,
|
||||
showArrow: true,
|
||||
destructive: true,
|
||||
onTap: _state == _EncState.loading ? null : _resetSecurity,
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── Key file export / import ───────────────────────────────────────
|
||||
_SettingsGroup(title: 'Schlüsseldatei', pt: pt, children: [
|
||||
_SettingsRow(
|
||||
title: 'Schlüssel exportieren',
|
||||
desc: 'Sitzungsschlüssel als verschlüsselte Datei sichern (kompatibel mit Element)',
|
||||
pt: pt,
|
||||
showArrow: !_exportLoading,
|
||||
control: _exportLoading
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
|
||||
)
|
||||
: null,
|
||||
onTap: (_exportLoading || _importLoading) ? null : _exportKeys,
|
||||
),
|
||||
_Divider(pt: pt),
|
||||
_SettingsRow(
|
||||
title: 'Schlüssel importieren',
|
||||
desc: 'Sitzungsschlüssel aus exportierter Datei laden',
|
||||
pt: pt,
|
||||
showArrow: !_importLoading,
|
||||
control: _importLoading
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
|
||||
)
|
||||
: null,
|
||||
onTap: (_exportLoading || _importLoading) ? null : _importKeys,
|
||||
),
|
||||
]),
|
||||
|
||||
if (_keyFileMsg != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
_StatusCard(
|
||||
pt: pt,
|
||||
icon: _keyFileMsgIsError
|
||||
? Icons.error_outline_rounded
|
||||
: Icons.check_circle_outline_rounded,
|
||||
color: _keyFileMsgIsError ? pt.danger : PyramidColors.success,
|
||||
text: _keyFileMsg!,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── E2EE Diagnostics ──────────────────────────────────────────────
|
||||
Consumer(builder: (context, ref, _) {
|
||||
final count = ref.watch(e2eeDiagnosticsProvider).length;
|
||||
return _SettingsGroup(title: 'Diagnose', pt: pt, children: [
|
||||
_SettingsRow(
|
||||
title: 'Entschlüsselungsfehler hochladen',
|
||||
desc: count == 0
|
||||
? 'Keine Fehler gesammelt'
|
||||
: '$count Fehler gesammelt – zum Server hochladen',
|
||||
pt: pt,
|
||||
showArrow: !_diagUploading && count > 0,
|
||||
control: _diagUploading
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
|
||||
)
|
||||
: count > 0
|
||||
? Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: pt.danger.withAlpha(30),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text('$count', style: TextStyle(color: pt.danger, fontSize: 12)),
|
||||
)
|
||||
: null,
|
||||
onTap: (_diagUploading || count == 0) ? null : _uploadDiagnostics,
|
||||
),
|
||||
]);
|
||||
}),
|
||||
|
||||
if (_diagMsg != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
_StatusCard(
|
||||
pt: pt,
|
||||
icon: _diagMsgIsError
|
||||
? Icons.error_outline_rounded
|
||||
: Icons.cloud_done_rounded,
|
||||
color: _diagMsgIsError ? pt.danger : PyramidColors.success,
|
||||
text: _diagMsg!,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── Inline state UI ───────────────────────────────────────────────
|
||||
if (_state == _EncState.loading)
|
||||
const Center(child: Padding(padding: EdgeInsets.all(24), child: CircularProgressIndicator.adaptive())),
|
||||
|
||||
if (_state == _EncState.done) ...[
|
||||
_StatusCard(
|
||||
pt: pt,
|
||||
icon: Icons.check_circle_outline_rounded,
|
||||
color: PyramidColors.success,
|
||||
text: 'Alle Schlüssel importiert. Nachrichten werden entschlüsselt.'),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
|
||||
if (_state == _EncState.needsKey || _state == _EncState.unlocking) ...[
|
||||
_StatusCard(
|
||||
pt: pt,
|
||||
icon: Icons.lock_outline_rounded,
|
||||
color: pt.accent,
|
||||
text: 'Wiederherstellungsschlüssel erforderlich.'),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _keyCtrl,
|
||||
readOnly: _state == _EncState.unlocking,
|
||||
autofocus: true,
|
||||
autocorrect: false,
|
||||
style: TextStyle(color: pt.fg, fontSize: 13, fontFamily: 'monospace'),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'EsXX XXXX XXXX XXXX …',
|
||||
hintStyle: TextStyle(color: pt.fgDim, fontSize: 12),
|
||||
labelText: 'Wiederherstellungsschlüssel',
|
||||
labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13),
|
||||
errorText: _error,
|
||||
errorMaxLines: 2,
|
||||
filled: true,
|
||||
fillColor: pt.bg2,
|
||||
prefixIcon: Icon(Icons.vpn_key_outlined, color: pt.fgDim, size: 18),
|
||||
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)),
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
),
|
||||
cursorColor: pt.accent,
|
||||
onSubmitted: (_) => _unlock(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: _PrimaryBtn(
|
||||
label: _state == _EncState.unlocking ? 'Entschlüssele…' : 'Nachrichten entschlüsseln',
|
||||
icon: Icons.lock_open_outlined,
|
||||
pt: pt,
|
||||
loading: _state == _EncState.unlocking,
|
||||
onPressed: _unlock,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
|
||||
if (_state == _EncState.error) ...[
|
||||
_StatusCard(
|
||||
pt: pt, icon: Icons.error_outline_rounded, color: PyramidColors.danger,
|
||||
text: _error ?? 'Unbekannter Fehler.'),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: pt.fg,
|
||||
side: BorderSide(color: pt.border),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
onPressed: _start,
|
||||
icon: const Icon(Icons.refresh_rounded, size: 16),
|
||||
label: const Text('Erneut versuchen'),
|
||||
),
|
||||
],
|
||||
|
||||
if (_state == _EncState.idle && !_statusLoaded)
|
||||
_StatusCard(
|
||||
pt: pt,
|
||||
icon: Icons.shield_outlined,
|
||||
color: pt.fgMuted,
|
||||
text: 'Lade Sicherheitsstatus…'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
part of '../../settings_modal.dart';
|
||||
|
||||
class _FingerprintGroup extends ConsumerWidget {
|
||||
final PyramidTheme pt;
|
||||
const _FingerprintGroup({required this.pt});
|
||||
|
||||
static String _fmt(String key) {
|
||||
final clean = key.replaceAll(RegExp(r'\s'), '');
|
||||
final groups = <String>[];
|
||||
for (var i = 0; i < clean.length; i += 4) {
|
||||
groups.add(clean.substring(i, (i + 4).clamp(0, clean.length)));
|
||||
}
|
||||
return groups.join(' ');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final clientAsync = ref.watch(matrixClientProvider);
|
||||
final client = clientAsync.valueOrNull;
|
||||
if (client == null) return const SizedBox.shrink();
|
||||
|
||||
final fingerprint = _fmt(client.fingerprintKey);
|
||||
final deviceId = client.deviceID ?? '';
|
||||
|
||||
return _SettingsGroup(title: 'Dieses Gerät', pt: pt, children: [
|
||||
_SettingsRow(
|
||||
title: 'Geräte-ID',
|
||||
desc: deviceId,
|
||||
pt: pt,
|
||||
control: IconButton(
|
||||
icon: Icon(Icons.copy_rounded, size: 14, color: pt.fgDim),
|
||||
tooltip: 'Kopieren',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28),
|
||||
onPressed: () => Clipboard.setData(ClipboardData(text: deviceId)),
|
||||
),
|
||||
),
|
||||
_Divider(pt: pt),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text('Ed25519-Fingerabdruck',
|
||||
style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500)),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: Icon(Icons.copy_rounded, size: 14, color: pt.fgDim),
|
||||
tooltip: 'Fingerabdruck kopieren',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28),
|
||||
onPressed: () => Clipboard.setData(ClipboardData(text: client.fingerprintKey)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
SelectableText(
|
||||
fingerprint,
|
||||
style: TextStyle(
|
||||
color: pt.fgMuted, fontSize: 11,
|
||||
fontFamily: 'monospace', letterSpacing: 0.5, height: 1.6,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Passphrase dialog (for key export/import)
|
||||
class _PassphraseDialog extends StatefulWidget {
|
||||
final PyramidTheme pt;
|
||||
final bool forExport;
|
||||
const _PassphraseDialog({required this.pt, required this.forExport});
|
||||
|
||||
@override
|
||||
State<_PassphraseDialog> createState() => _PassphraseDialogState();
|
||||
}
|
||||
|
||||
class _PassphraseDialogState extends State<_PassphraseDialog> {
|
||||
final _ctrl1 = TextEditingController();
|
||||
final _ctrl2 = TextEditingController();
|
||||
bool _obscure = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl1.dispose();
|
||||
_ctrl2.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
final pass = _ctrl1.text.trim();
|
||||
if (pass.isEmpty) {
|
||||
setState(() => _error = 'Bitte ein Passwort eingeben.');
|
||||
return;
|
||||
}
|
||||
if (widget.forExport && pass != _ctrl2.text.trim()) {
|
||||
setState(() => _error = 'Passwörter stimmen nicht überein.');
|
||||
return;
|
||||
}
|
||||
Navigator.pop(context, pass);
|
||||
}
|
||||
|
||||
InputDecoration _inputDeco(String label, PyramidTheme pt) => InputDecoration(
|
||||
labelText: label,
|
||||
labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13),
|
||||
errorText: _error,
|
||||
filled: true,
|
||||
fillColor: pt.bg3,
|
||||
prefixIcon: Icon(Icons.lock_outline_rounded, color: pt.fgDim, size: 18),
|
||||
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)),
|
||||
isDense: true,
|
||||
errorMaxLines: 2,
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscure ? Icons.visibility_off_outlined : Icons.visibility_outlined,
|
||||
size: 18,
|
||||
color: pt.fgDim,
|
||||
),
|
||||
onPressed: () => setState(() => _obscure = !_obscure),
|
||||
),
|
||||
);
|
||||
|
||||
@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),
|
||||
),
|
||||
title: Text(
|
||||
widget.forExport ? 'Schlüssel exportieren' : 'Schlüssel importieren',
|
||||
style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700),
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 340,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.forExport
|
||||
? 'Wähle ein Passwort zum Verschlüsseln der Schlüsseldatei.'
|
||||
: 'Gib das Passwort ein, mit dem die Datei verschlüsselt wurde.',
|
||||
style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _ctrl1,
|
||||
obscureText: _obscure,
|
||||
autofocus: true,
|
||||
style: TextStyle(color: pt.fg, fontSize: 13),
|
||||
decoration: _inputDeco('Passwort', pt).copyWith(
|
||||
// Only show error on confirm field if export, else here
|
||||
errorText: widget.forExport ? null : _error,
|
||||
),
|
||||
cursorColor: pt.accent,
|
||||
onSubmitted: widget.forExport ? null : (_) => _submit(),
|
||||
),
|
||||
if (widget.forExport) ...[
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _ctrl2,
|
||||
obscureText: _obscure,
|
||||
style: TextStyle(color: pt.fg, fontSize: 13),
|
||||
decoration: _inputDeco('Passwort bestätigen', pt),
|
||||
cursorColor: pt.accent,
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
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: _submit,
|
||||
child: Text(widget.forExport ? 'Exportieren' : 'Importieren'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
part of '../../settings_modal.dart';
|
||||
|
||||
// ── Megolm key export / import – top-level so compute() can use them ─────────
|
||||
|
||||
const _kMegolmHeader = '-----BEGIN MEGOLM SESSION DATA-----';
|
||||
const _kMegolmFooter = '-----END MEGOLM SESSION DATA-----';
|
||||
|
||||
// PBKDF2-HMAC-SHA512. args: {pass: Uint8List, salt: Uint8List, iter: int}
|
||||
Uint8List _runPbkdf2(Map<String, dynamic> args) {
|
||||
final pass = args['pass'] as Uint8List;
|
||||
final salt = args['salt'] as Uint8List;
|
||||
final iterations = args['iter'] as int;
|
||||
const blockLen = 64; // SHA-512 digest size
|
||||
const dkLen = 64;
|
||||
|
||||
final hmac = mcrypto.Hmac(mcrypto.sha512, pass);
|
||||
final blockCount = (dkLen / blockLen).ceil();
|
||||
final dk = Uint8List(blockCount * blockLen);
|
||||
|
||||
for (var i = 1; i <= blockCount; i++) {
|
||||
final saltI = Uint8List(salt.length + 4);
|
||||
saltI.setRange(0, salt.length, salt);
|
||||
ByteData.sublistView(saltI, salt.length).setUint32(0, i);
|
||||
|
||||
var u = Uint8List.fromList(hmac.convert(saltI).bytes);
|
||||
final block = Uint8List.fromList(u);
|
||||
|
||||
for (var j = 1; j < iterations; j++) {
|
||||
u = Uint8List.fromList(hmac.convert(u).bytes);
|
||||
for (var k = 0; k < blockLen; k++) { block[k] ^= u[k]; }
|
||||
}
|
||||
dk.setRange((i - 1) * blockLen, i * blockLen, block);
|
||||
}
|
||||
return dk.sublist(0, dkLen);
|
||||
}
|
||||
|
||||
Uint8List _aesCtr(Uint8List key, Uint8List iv, Uint8List data) {
|
||||
final cipher = pc.SICStreamCipher(pc.AESEngine())
|
||||
..init(true, pc.ParametersWithIV(pc.KeyParameter(key), iv));
|
||||
final out = Uint8List(data.length);
|
||||
cipher.processBytes(data, 0, data.length, out, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
// args: {passphrase: String, sessions: List<dynamic>} → encrypted String
|
||||
String _encryptMegolmKeys(Map<String, dynamic> args) {
|
||||
final pass = args['passphrase'] as String;
|
||||
final sessions = args['sessions'] as List<dynamic>;
|
||||
|
||||
final rng = Random.secure();
|
||||
final salt = Uint8List.fromList(List.generate(16, (_) => rng.nextInt(256)));
|
||||
// IV: 8 random bytes as nonce, last 8 bytes 0x00 (counter starts at 0)
|
||||
final iv = Uint8List(16);
|
||||
for (var i = 0; i < 8; i++) { iv[i] = rng.nextInt(256); }
|
||||
|
||||
const iterations = 100000;
|
||||
final dk = _runPbkdf2({
|
||||
'pass': Uint8List.fromList(utf8.encode(pass)),
|
||||
'salt': salt,
|
||||
'iter': iterations,
|
||||
});
|
||||
final aesKey = dk.sublist(0, 32);
|
||||
final hmacKey = dk.sublist(32, 64);
|
||||
|
||||
final plaintext = Uint8List.fromList(utf8.encode(jsonEncode(sessions)));
|
||||
final encrypted = _aesCtr(aesKey, iv, plaintext);
|
||||
|
||||
final iterBe = Uint8List(4)..buffer.asByteData().setUint32(0, iterations);
|
||||
|
||||
// header bytes: 0x01 | salt(16) | iv(16) | iterBe(4) | encrypted(n)
|
||||
final header = Uint8List(1 + 16 + 16 + 4 + encrypted.length);
|
||||
var off = 0;
|
||||
header[off++] = 0x01;
|
||||
header.setRange(off, off += 16, salt);
|
||||
header.setRange(off, off += 16, iv);
|
||||
header.setRange(off, off += 4, iterBe);
|
||||
header.setRange(off, off + encrypted.length, encrypted);
|
||||
|
||||
final mac = Uint8List.fromList(
|
||||
mcrypto.Hmac(mcrypto.sha256, hmacKey).convert(header).bytes,
|
||||
);
|
||||
|
||||
final full = Uint8List(header.length + 32);
|
||||
full.setRange(0, header.length, header);
|
||||
full.setRange(header.length, full.length, mac);
|
||||
|
||||
return '$_kMegolmHeader\n${base64.encode(full)}\n$_kMegolmFooter';
|
||||
}
|
||||
|
||||
// args: {passphrase: String, data: List<int>} → List<dynamic> sessions
|
||||
List<dynamic> _decryptMegolmKeys(Map<String, dynamic> args) {
|
||||
final pass = args['passphrase'] as String;
|
||||
final raw = Uint8List.fromList(args['data'] as List<int>);
|
||||
|
||||
if (raw.length < 1 + 16 + 16 + 4 + 32 || raw[0] != 0x01) {
|
||||
throw Exception('Ungültiges Dateiformat (Version ${raw.isEmpty ? "?" : raw[0]}).');
|
||||
}
|
||||
|
||||
final salt = raw.sublist(1, 17);
|
||||
final iv = raw.sublist(17, 33);
|
||||
final iterations = raw.buffer.asByteData().getUint32(33);
|
||||
final encrypted = raw.sublist(37, raw.length - 32);
|
||||
final storedMac = raw.sublist(raw.length - 32);
|
||||
|
||||
if (iterations < 1 || iterations > 10000000) {
|
||||
throw Exception('Iterationsanzahl außerhalb des erlaubten Bereichs.');
|
||||
}
|
||||
|
||||
final dk = _runPbkdf2({
|
||||
'pass': Uint8List.fromList(utf8.encode(pass)),
|
||||
'salt': Uint8List.fromList(salt),
|
||||
'iter': iterations,
|
||||
});
|
||||
final aesKey = dk.sublist(0, 32);
|
||||
final hmacKey = dk.sublist(32, 64);
|
||||
|
||||
// Verify MAC over all bytes except the appended MAC itself
|
||||
final toMac = raw.sublist(0, raw.length - 32);
|
||||
final expectedMac = mcrypto.Hmac(mcrypto.sha256, hmacKey).convert(toMac).bytes;
|
||||
var macOk = true;
|
||||
for (var i = 0; i < 32; i++) {
|
||||
if (expectedMac[i] != storedMac[i]) { macOk = false; break; }
|
||||
}
|
||||
if (!macOk) throw Exception('Falsches Passwort oder beschädigte Datei.');
|
||||
|
||||
final decrypted = _aesCtr(aesKey, Uint8List.fromList(iv), Uint8List.fromList(encrypted));
|
||||
return jsonDecode(utf8.decode(decrypted)) as List<dynamic>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
part of '../../settings_modal.dart';
|
||||
|
||||
class _ChangeRecoveryKeyDialog extends ConsumerStatefulWidget {
|
||||
final PyramidTheme pt;
|
||||
const _ChangeRecoveryKeyDialog({required this.pt});
|
||||
|
||||
@override
|
||||
ConsumerState<_ChangeRecoveryKeyDialog> createState() =>
|
||||
_ChangeRecoveryKeyDialogState();
|
||||
}
|
||||
|
||||
class _ChangeRecoveryKeyDialogState
|
||||
extends ConsumerState<_ChangeRecoveryKeyDialog> {
|
||||
_RecoveryStep _step = _RecoveryStep.loading;
|
||||
String? _generatedKey;
|
||||
String? _error;
|
||||
bool _copied = false;
|
||||
// Completer signals when bootstrap reaches `done`
|
||||
final _doneCompleter = Completer<void>();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_startBootstrap();
|
||||
}
|
||||
|
||||
Future<void> _startBootstrap() async {
|
||||
try {
|
||||
final client = await ref.read(matrixClientProvider.future);
|
||||
final enc = client.encryption;
|
||||
if (enc == null) throw Exception('Verschlüsselung nicht verfügbar.');
|
||||
await client.roomsLoading;
|
||||
await client.accountDataLoading;
|
||||
await client.userDeviceKeysLoading;
|
||||
enc.bootstrap(onUpdate: (bs) {
|
||||
if (!mounted) return;
|
||||
_onBootstrapUpdate(bs);
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_step = _RecoveryStep.error;
|
||||
_error = e.toString().split('\n').first;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// After newSsss() calls checkCrossSigning(), the bootstrap immediately
|
||||
// transitions to askWipeCrossSigning (or askSetupCrossSigning). All post-
|
||||
// newSsss states must therefore auto-advance unconditionally — guarding on
|
||||
// _step would leave them stuck.
|
||||
void _onBootstrapUpdate(Bootstrap bs) {
|
||||
switch (bs.state) {
|
||||
case BootstrapState.askWipeSsss:
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (bs.state == BootstrapState.askWipeSsss) bs.wipeSsss(true);
|
||||
});
|
||||
case BootstrapState.askBadSsss:
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (bs.state == BootstrapState.askBadSsss) bs.ignoreBadSecrets(true);
|
||||
});
|
||||
case BootstrapState.askUseExistingSsss:
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (bs.state == BootstrapState.askUseExistingSsss) bs.useExistingSsss(false);
|
||||
});
|
||||
case BootstrapState.askUnlockSsss:
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (bs.state == BootstrapState.askUnlockSsss) bs.unlockedSsss();
|
||||
});
|
||||
case BootstrapState.askNewSsss:
|
||||
// Only generate once
|
||||
if (_step == _RecoveryStep.loading) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
if (bs.state != BootstrapState.askNewSsss) return;
|
||||
try {
|
||||
await bs.newSsss();
|
||||
// newSsss() internally calls checkCrossSigning() which already
|
||||
// transitions to askWipeCrossSigning; capture key after it returns.
|
||||
final key = bs.newSsssKey?.recoveryKey;
|
||||
if (mounted) setState(() { _generatedKey = key; _step = _RecoveryStep.showKey; });
|
||||
} catch (e) {
|
||||
if (mounted) setState(() { _step = _RecoveryStep.error; _error = e.toString().split('\n').first; });
|
||||
}
|
||||
});
|
||||
}
|
||||
case BootstrapState.askWipeCrossSigning:
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (bs.state == BootstrapState.askWipeCrossSigning) bs.wipeCrossSigning(false);
|
||||
});
|
||||
case BootstrapState.askSetupCrossSigning:
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (bs.state == BootstrapState.askSetupCrossSigning) {
|
||||
bs.askSetupCrossSigning(
|
||||
setupMasterKey: true,
|
||||
setupSelfSigningKey: true,
|
||||
setupUserSigningKey: true,
|
||||
);
|
||||
}
|
||||
});
|
||||
case BootstrapState.askWipeOnlineKeyBackup:
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (bs.state == BootstrapState.askWipeOnlineKeyBackup) bs.wipeOnlineKeyBackup(true);
|
||||
});
|
||||
case BootstrapState.askSetupOnlineKeyBackup:
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (bs.state == BootstrapState.askSetupOnlineKeyBackup) bs.askSetupOnlineKeyBackup(true);
|
||||
});
|
||||
case BootstrapState.done:
|
||||
if (!_doneCompleter.isCompleted) _doneCompleter.complete();
|
||||
// Only auto-advance to done if user already clicked through (applying).
|
||||
// If still on showKey/confirmKey, the user must finish reading first.
|
||||
if (mounted && _step == _RecoveryStep.applying) {
|
||||
setState(() => _step = _RecoveryStep.done);
|
||||
}
|
||||
case BootstrapState.error:
|
||||
if (!_doneCompleter.isCompleted) _doneCompleter.completeError('Bootstrap-Fehler');
|
||||
if (mounted) setState(() { _step = _RecoveryStep.error; _error = 'Fehler bei der Schlüsselgenerierung.'; });
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Called when user taps "Schlüssel gespeichert – weiter" on the showKey screen.
|
||||
void _onKeySaved() {
|
||||
setState(() => _step = _RecoveryStep.confirmKey);
|
||||
}
|
||||
|
||||
// Called when user submits the correct key confirmation.
|
||||
// Bootstrap may still be running (cross-signing + key backup); wait for done.
|
||||
Future<void> _onKeyConfirmed() async {
|
||||
setState(() => _step = _RecoveryStep.applying);
|
||||
try {
|
||||
await _doneCompleter.future.timeout(const Duration(seconds: 90));
|
||||
if (mounted) setState(() => _step = _RecoveryStep.done);
|
||||
} catch (e) {
|
||||
if (mounted) setState(() { _step = _RecoveryStep.error; _error = 'Timeout: Bootstrap abgebrochen.'; });
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
final canClose = _step != _RecoveryStep.loading && _step != _RecoveryStep.applying && _step != _RecoveryStep.confirmKey;
|
||||
|
||||
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: 320),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 12, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.key_rounded, size: 18, color: pt.accent),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Wiederherstellungsschlüssel',
|
||||
style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
if (canClose)
|
||||
IconButton(
|
||||
icon: Icon(Icons.close_rounded, color: pt.fgDim, size: 16),
|
||||
onPressed: () => Navigator.of(context).pop(_step == _RecoveryStep.done),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Divider(color: pt.border, height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: _buildBody(pt),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(PyramidTheme pt) {
|
||||
return switch (_step) {
|
||||
_RecoveryStep.loading => const Center(
|
||||
child: Padding(padding: EdgeInsets.all(24), child: CircularProgressIndicator.adaptive()),
|
||||
),
|
||||
_RecoveryStep.showKey => _ShowKeyBody(
|
||||
pt: pt,
|
||||
generatedKey: _generatedKey ?? '',
|
||||
copied: _copied,
|
||||
onCopy: () {
|
||||
Clipboard.setData(ClipboardData(text: _generatedKey ?? ''));
|
||||
setState(() => _copied = true);
|
||||
},
|
||||
onKeySaved: _onKeySaved,
|
||||
),
|
||||
_RecoveryStep.confirmKey => _ConfirmKeyBody(
|
||||
pt: pt,
|
||||
expectedKey: _generatedKey ?? '',
|
||||
onConfirmed: _onKeyConfirmed,
|
||||
),
|
||||
_RecoveryStep.applying => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator.adaptive(),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Schlüssel wird aktiviert…\nCross-Signing und Backup werden eingerichtet.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
_RecoveryStep.done => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.check_circle_outline_rounded, color: PyramidColors.success, size: 52),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Wiederherstellungsschlüssel wurde erfolgreich geändert.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Bewahre deinen Schlüssel sicher auf – du brauchst ihn bei Geräteverlust.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.4),
|
||||
),
|
||||
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(true),
|
||||
child: const Text('Fertig'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
_RecoveryStep.error => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline_rounded, color: PyramidColors.danger, size: 48),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_error ?? 'Ein unbekannter 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(false),
|
||||
child: const Text('Schließen'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class _ShowKeyBody extends StatelessWidget {
|
||||
final PyramidTheme pt;
|
||||
final String generatedKey;
|
||||
final bool copied;
|
||||
final VoidCallback onCopy;
|
||||
final VoidCallback onKeySaved;
|
||||
|
||||
const _ShowKeyBody({
|
||||
required this.pt,
|
||||
required this.generatedKey,
|
||||
required this.copied,
|
||||
required this.onCopy,
|
||||
required this.onKeySaved,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Dein neuer Wiederherstellungsschlüssel',
|
||||
style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Speichere diesen Schlüssel sicher – du brauchst ihn, wenn du den Zugang zu allen Geräten verlierst.',
|
||||
style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg3,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: pt.border),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
child: SelectableText(
|
||||
generatedKey,
|
||||
style: TextStyle(
|
||||
color: pt.accent, fontSize: 13,
|
||||
fontFamily: 'monospace', letterSpacing: 0.5, height: 1.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: onCopy,
|
||||
icon: Icon(
|
||||
copied ? Icons.check_rounded : Icons.copy_rounded,
|
||||
size: 18,
|
||||
color: copied ? PyramidColors.success : pt.fgDim,
|
||||
),
|
||||
tooltip: copied ? 'Kopiert!' : 'In Zwischenablage kopieren',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withAlpha(28),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.orange.withAlpha(80)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, size: 16, color: Colors.orange.shade700),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Schreibe diesen Schlüssel auf oder speichere ihn in einem Passwort-Manager. Er kann nicht wiederhergestellt werden.',
|
||||
style: TextStyle(color: Colors.orange.shade800, fontSize: 12, height: 1.4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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: onKeySaved,
|
||||
child: const Text('Schlüssel gespeichert – weiter'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ConfirmKeyBody extends StatefulWidget {
|
||||
final PyramidTheme pt;
|
||||
final String expectedKey;
|
||||
final VoidCallback onConfirmed;
|
||||
|
||||
const _ConfirmKeyBody({
|
||||
required this.pt,
|
||||
required this.expectedKey,
|
||||
required this.onConfirmed,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_ConfirmKeyBody> createState() => _ConfirmKeyBodyState();
|
||||
}
|
||||
|
||||
class _ConfirmKeyBodyState extends State<_ConfirmKeyBody> {
|
||||
final _controller = TextEditingController();
|
||||
bool _mismatch = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
final entered = _controller.text.trim();
|
||||
if (entered == widget.expectedKey) {
|
||||
widget.onConfirmed();
|
||||
} else {
|
||||
setState(() => _mismatch = true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Schlüssel bestätigen',
|
||||
style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Gib deinen neuen Wiederherstellungsschlüssel zur Bestätigung ein.',
|
||||
style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _controller,
|
||||
autofocus: true,
|
||||
style: TextStyle(
|
||||
color: pt.fg, fontSize: 13,
|
||||
fontFamily: 'monospace', letterSpacing: 0.4,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'EsTX XXXX XXXX …',
|
||||
hintStyle: TextStyle(color: pt.fgDim, 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: _mismatch ? PyramidColors.danger : pt.border),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: _mismatch ? PyramidColors.danger : pt.accent, width: 1.5),
|
||||
),
|
||||
errorText: _mismatch ? 'Schlüssel stimmt nicht überein.' : null,
|
||||
),
|
||||
onChanged: (_) { if (_mismatch) setState(() => _mismatch = false); },
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
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: _submit,
|
||||
child: const Text('Aktivieren'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
)),
|
||||
],
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user