Compare commits
109
Commits
@@ -45,3 +45,7 @@ app.*.map.json
|
|||||||
/android/app/release
|
/android/app/release
|
||||||
autopilot.log
|
autopilot.log
|
||||||
.claude/worktrees/
|
.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.
|
||||||
+305
@@ -1187,3 +1187,308 @@
|
|||||||
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/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 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
|
(`C:\Users\nordm\pyramid` und `C:\Users\nordm\MatrixPi\pyramid` sind veraltete
|
||||||
April-Schnappschüsse – dort NIE arbeiten.)
|
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
|
## Infrastruktur
|
||||||
|
|
||||||
- Homeserver: Continuwuity auf dem Pi5 (`steggi-matrix.work`)
|
- Homeserver: Continuwuity auf dem Pi5 (`steggi-matrix.work`)
|
||||||
|
|||||||
+2283
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,7 @@ für die unterstützte Dart-Version).
|
|||||||
flutter pub get
|
flutter pub get
|
||||||
flutter run -d windows # schnellster Praxistest während der Entwicklung
|
flutter run -d windows # schnellster Praxistest während der Entwicklung
|
||||||
flutter analyze # muss sauber bleiben
|
flutter analyze # muss sauber bleiben
|
||||||
|
pwsh scripts/test.ps1 # Analyse + Tests (-Live, -Bench, -All: siehe docs/TESTS_UND_BENCHMARKS.md)
|
||||||
```
|
```
|
||||||
|
|
||||||
Unterstützte Zielplattformen: Android, Windows, Linux, iOS, macOS (Ordner
|
Unterstützte Zielplattformen: Android, Windows, Linux, iOS, macOS (Ordner
|
||||||
@@ -52,5 +53,6 @@ lib/
|
|||||||
|
|
||||||
- `CLAUDE.md` – Arbeitsregeln und Infrastruktur für die Zusammenarbeit mit Claude
|
- `CLAUDE.md` – Arbeitsregeln und Infrastruktur für die Zusammenarbeit mit Claude
|
||||||
- `ROADMAP.md` / `PROGRESS.md` – aktueller Stand und nächste Schritte
|
- `ROADMAP.md` / `PROGRESS.md` – aktueller Stand und nächste Schritte
|
||||||
|
- `docs/TESTS_UND_BENCHMARKS.md` – Tests, Live-Tests, Benchmarks (ein Befehl)
|
||||||
- `docs/NOTIFICATIONS.md` – Push-Architektur (native Anzeige vs. Hintergrund-Entschlüsselung)
|
- `docs/NOTIFICATIONS.md` – Push-Architektur (native Anzeige vs. Hintergrund-Entschlüsselung)
|
||||||
- `docs/matrix-sdk/` – Hinweise zum verwendeten Matrix-SDK
|
- `docs/matrix-sdk/` – Hinweise zum verwendeten Matrix-SDK
|
||||||
|
|||||||
+256
-15
@@ -15,16 +15,16 @@ Punkte abhaken (`[x]`), wenn erledigt UND in `PROGRESS.md` protokolliert.
|
|||||||
- [x] `lib/features/call/` vs. `lib/features/calls/` (leer?) klären – Leiche entfernen
|
- [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)
|
(geprüft: `calls/` existiert nicht mehr, nur `call/` mit den echten Dateien)
|
||||||
- [x] README.md durch echte Projektbeschreibung ersetzt (74d38fd)
|
- [x] README.md durch echte Projektbeschreibung ersetzt (74d38fd)
|
||||||
- [ ] **Fable-5-Qualitäts-Review aller bisherigen Autopilot-Commits (Bernds Wunsch, VOR allem anderen):**
|
- [x] **Fable-5-Qualitäts-Review aller bisherigen Autopilot-Commits (Bernds Wunsch, VOR allem anderen):**
|
||||||
Sämtliche Commits vom 2026-07-03 (25ed765..HEAD, gemacht von Sonnet 5) kritisch
|
ERLEDIGT – siehe Abschnitt „Fable-5-Review" oben in PROGRESS.md.
|
||||||
und in der Tiefe nachprüfen – nicht nur formal: (a) die 6 Gott-Datei-Aufteilungen
|
(a) alle 6 Gott-Datei-Aufteilungen unabhängig verifiziert (Zeilen-Multiset,
|
||||||
stichprobenartig fachlich lesen (stimmen `part of`-Bezüge, Sichtbarkeiten,
|
part-Bezüge): korrekt, verlorene Erklärkommentare waren mit f9979e4 schon
|
||||||
keine verlorenen Blöcke – über den automatisierten Blockvergleich hinaus),
|
wiederhergestellt; (b) Uta-Logout-/Push-Fix gegen matrix-SDK 6.2.0
|
||||||
(b) den Uta-Logout-/Push-Fix (63e4919, 9dc83f7) fachlich gegen das matrix-SDK
|
verifiziert: Analyse richtig, Lücke (SDK eskaliert Refresh-Fehler zu
|
||||||
verifizieren (ist die Ursachen-Analyse schlüssig? Nebenwirkungen?),
|
logout()+clear()) war mit 891f348 (onSoftLogout-Guard) geschlossen, Guard
|
||||||
(c) die analyze-/Lint-Fixes auf versehentliche Verhaltensänderungen prüfen,
|
heute gegenverifiziert; (c) Lint-Fixes verhaltensäquivalent (onReorderItem
|
||||||
(d) abgehakte ROADMAP-Punkte gegen PROGRESS.md und den Code gegenprüfen.
|
gegen Flutter-SDK geprüft, kein Off-by-One); (d) abgehakte Punkte stimmen
|
||||||
Befunde direkt fixen, alles in PROGRESS.md protokollieren.
|
mit dem Code überein. Alles weiterhin UNGETESTET (Pi) → PC-Praxistest bleibt.
|
||||||
|
|
||||||
- [x] **Uta-Random-Logout untersuchen:** UNGETESTET (Pi) – Ursache im matrix-SDK
|
- [x] **Uta-Random-Logout untersuchen:** UNGETESTET (Pi) – Ursache im matrix-SDK
|
||||||
gefunden + Fix committet (63e4919, 9dc83f7), siehe PROGRESS.md 2026-07-03.
|
gefunden + Fix committet (63e4919, 9dc83f7), siehe PROGRESS.md 2026-07-03.
|
||||||
@@ -42,6 +42,30 @@ Punkte abhaken (`[x]`), wenn erledigt UND in `PROGRESS.md` protokolliert.
|
|||||||
+ Memory „Pyramid Push" erneut konsultieren und die dort gelisteten 3
|
+ Memory „Pyramid Push" erneut konsultieren und die dort gelisteten 3
|
||||||
Fallstricke der Hintergrund-Entschlüsselung gegenprüfen.
|
Fallstricke der Hintergrund-Entschlüsselung gegenprüfen.
|
||||||
- [ ] **Sichere Speicherung von Access-Token & Krypto-DB: LÜCKE GEFUNDEN.**
|
- [ ] **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
|
`sqlcipher_flutter_libs` steht zwar in `pubspec.yaml`, wird aber nirgends
|
||||||
benutzt – `matrix_client.dart` und `background_push.dart` öffnen die
|
benutzt – `matrix_client.dart` und `background_push.dart` öffnen die
|
||||||
`pyramid.sqlite` (Access-Token, gepickelter Olm-Account, Nachrichten) über
|
`pyramid.sqlite` (Access-Token, gepickelter Olm-Account, Nachrichten) über
|
||||||
@@ -49,7 +73,9 @@ Punkte abhaken (`[x]`), wenn erledigt UND in `PROGRESS.md` protokolliert.
|
|||||||
auf der Platte. Bewusst NICHT blind auf dem Pi gefixt: eine Umstellung auf
|
auf der Platte. Bewusst NICHT blind auf dem Pi gefixt: eine Umstellung auf
|
||||||
SQLCipher braucht eine Migration für bereits bestehende Klartext-DBs
|
SQLCipher braucht eine Migration für bereits bestehende Klartext-DBs
|
||||||
(Utas Gerät!) – ohne Testgerät hier zu riskant für „Kein Datenverlust".
|
(Utas Gerät!) – ohne Testgerät hier zu riskant für „Kein Datenverlust".
|
||||||
Siehe PROGRESS.md 2026-07-03 + Frage an Bernd.
|
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`,
|
- [x] Key-Backup einrichten-Flow: bereits vorhanden (`bootstrap_dialog.dart`,
|
||||||
wird automatisch getriggert wenn Cross-Signing fehlt, `app_shell.dart`)
|
wird automatisch getriggert wenn Cross-Signing fehlt, `app_shell.dart`)
|
||||||
- [x] Key-Backup wiederherstellen-Flow: bereits vorhanden (`bootstrap_dialog.dart`,
|
- [x] Key-Backup wiederherstellen-Flow: bereits vorhanden (`bootstrap_dialog.dart`,
|
||||||
@@ -58,8 +84,123 @@ Punkte abhaken (`[x]`), wenn erledigt UND in `PROGRESS.md` protokolliert.
|
|||||||
- [x] Geräte-/Sessionverwaltung in den Einstellungen: bereits vorhanden
|
- [x] Geräte-/Sessionverwaltung in den Einstellungen: bereits vorhanden
|
||||||
(`_SessionsSection` in `settings_modal.dart` – Liste, Umbenennen, Verifizieren
|
(`_SessionsSection` in `settings_modal.dart` – Liste, Umbenennen, Verifizieren
|
||||||
per SAS/QR, Abmelden, Massen-Abmeldung mit Passwort-Reauth)
|
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). **Client umgestellt 2026-10-07 (756486d):** kein Secret
|
||||||
|
mehr in der App, echter LiveKit-Beitritt mit Testkonto grün
|
||||||
|
(`integration_test/livekit_token_connect_test.dart`). **Offen:** Anruftest
|
||||||
|
auf dem Handy, danach Secret-Rotation durch Bernd (erst wenn alle Geräte die
|
||||||
|
neue Version haben), siehe `docs/LIVEKIT_TOKEN_MIGRATION.md`.
|
||||||
|
- [ ] **SICHERHEIT: geleakte Secrets rotieren + aus dem Client holen** (Fable-5-Review (k),
|
||||||
|
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).
|
||||||
|
**Client-Teil ERLEDIGT 2026-10-07** (Secret aus dem Code; Rotation steht noch aus).
|
||||||
|
**Umsetzungsplan liegt fertig in `docs/LIVEKIT_TOKEN_MIGRATION.md`** (2026-07-04,
|
||||||
|
gegen Code + Pi-Konfig geschrieben) – braucht echten Call-Test auf einem Gerät.
|
||||||
|
- **E2EE-Diagnose-Upload: ERLEDIGT (2026-07-04).** Der Dashboard-Server auf dem Pi
|
||||||
|
(`/home/steggi/matrix/server.py`) benutzte für `/api/e2ee-diagnostics` denselben
|
||||||
|
String wie den Matrix-Admin-Token – deshalb steckte er im Client. Jetzt hat das
|
||||||
|
Diagnose-Endpoint einen eigenen, eng begrenzten Token (`DIAG_TOKEN`, hängt nur an
|
||||||
|
den Log an, keine Admin-Rechte); `settings_encryption.dart` benutzt diesen. Der
|
||||||
|
**Admin-Token ist damit nicht mehr im App-Code** (per `grep` bestätigt). Server +
|
||||||
|
Client umgestellt und verifiziert (neuer Token 200, alter Admin-Token 403). Der
|
||||||
|
Admin-Token selbst muss trotzdem noch rotiert werden (steht oben, Bernds Sache).
|
||||||
|
- Optional: Git-History-Rewrite (Bernds Entscheidung, Repo ist auch Backup).
|
||||||
|
- **Stand 2026-07-05 (`ANTWORTEN_BERND.md` Nr. 4): Rotation und
|
||||||
|
History-Rewrite ruhen BEWUSST – beides bleibt Bernds manuelle Sache,
|
||||||
|
NICHT vom Autopilot anstoßen oder nachfragen. Der Punkt bleibt nur als
|
||||||
|
Merkposten offen; Autopilot-bearbeitbar ist hier nichts mehr.**
|
||||||
|
- **ACHTUNG bei „Repo einfach privat schalten":** Der In-App-Updater
|
||||||
|
(`lib/core/update_checker.dart`) holt Releases **anonym** über die öffentliche
|
||||||
|
Gitea-API (`/api/v1/repos/steggi/pyramid/releases`). Repo privat = Utas App
|
||||||
|
findet keine Updates mehr (und ein Fix ließe sich nicht mehr per Update
|
||||||
|
verteilen – Henne-Ei). Erst Updater-Strategie klären, dann Sichtbarkeit ändern.
|
||||||
|
- [x] **Android-Backup/Geräteumzug abgeschaltet** (2026-10-07): ohne
|
||||||
|
`allowBackup=false` + `dataExtractionRules` wäre die SQLCipher-DB beim
|
||||||
|
Handywechsel ohne ihren Keystore-Schlüssel mitgezogen → App startet
|
||||||
|
nicht (Aussperr-Fall), und der Push-Zugangsschlüssel aus den
|
||||||
|
SharedPreferences läge im Cloud-Backup. Neues Gerät = neu anmelden +
|
||||||
|
Schlüssel per Wiederherstellungsschlüssel. Im APK geprüft (aapt2,
|
||||||
|
`dumpsys package`: kein ALLOW_BACKUP mehr).
|
||||||
- [ ] Härtetest dokumentieren: Login → Nachrichten → Logout → Login neu → alles noch lesbar
|
- [ ] Härtetest dokumentieren: Login → Nachrichten → Logout → Login neu → alles noch lesbar
|
||||||
(gehört zum ausstehenden PC-Praxistest der beiden Bugfixes oben)
|
(gehört zum ausstehenden PC-Praxistest der beiden Bugfixes oben;
|
||||||
|
kompletter Ablauf inkl. aller aufgelaufenen UNGETESTET-Punkte steht
|
||||||
|
jetzt in `docs/PC_TESTPLAN.md` – dort abarbeiten und abhaken)
|
||||||
|
**Teilstand 2026-07-06 (Autopilot, siehe PC_TESTPLAN):** flutter test
|
||||||
|
grün (29), Session über 3 Starts + DB-Migration stabil eingeloggt,
|
||||||
|
E2EE-Empfang live belegt, auth_log unauffällig; App läuft seit 12:43
|
||||||
|
für die Langzeit-Beobachtung weiter. **Offen (Klick/Bernd):**
|
||||||
|
Senden, Logout-Warnung, Re-Login mit Lesbarkeits-Check.
|
||||||
|
- [x] **SDK-Wettlauf: verspätete 401 einer alten Sync-Anfrage löscht ggf. die
|
||||||
|
NEUE Session** – ERLEDIGT 2026-10-07: alle App-Abmeldungen laufen über
|
||||||
|
`logoutWithoutStraySync` (lib/core/session_logout.dart: Sync anhalten,
|
||||||
|
Long-Poll per Account-Daten wecken, auslaufen lassen, dann abmelden).
|
||||||
|
Live gemessen (test/live_logout_race_test.dart): die verspätete 401 tritt
|
||||||
|
real auf, kam aber in allen Läufen an, BEVOR die Neuanmeldung fertig
|
||||||
|
war – reales Risiko in der App klein (Neuanmeldung binnen ~1 s nötig),
|
||||||
|
jetzt ausgeschlossen; Regressionstest in `scripts/test.ps1 -Live`.
|
||||||
|
Ursprüngliche Analyse: (gefunden 2026-10-07 über einen sporadisch roten
|
||||||
|
Live-Test, siehe PROGRESS.md). matrix 6.2 `Client._innerSync` wirft den
|
||||||
|
Fehler einer Sync-Antwort, BEVOR geprüft wird, ob die Anfrage noch
|
||||||
|
aktuell ist (`_currentSyncId`). Läuft beim Logout noch ein Long-Poll
|
||||||
|
(bis 30 s) mit dem alten Token, kommt dessen M_UNKNOWN_TOKEN danach an
|
||||||
|
und das SDK ruft `clear()` – nach einem schnellen Re-Login auf
|
||||||
|
demselben Client wäre das ein „Random-Logout" direkt nach dem Login.
|
||||||
|
Upstream (famedly/matrix-dart-sdk `main`, 2026-10-07) unverändert;
|
||||||
|
neuere SDKs versuchen bei vorhandenem Refresh-Token aber zuerst einen
|
||||||
|
Refresh statt `clear()` (entschärft). Im Live-Test per
|
||||||
|
`_logoutWithoutStraySync` umgangen. **Offen:** App-Logout-Pfad prüfen
|
||||||
|
(läuft der Re-Login auf demselben Client-Objekt? dann vor
|
||||||
|
`client.logout()` Sync anhalten + laufenden Long-Poll abwarten) –
|
||||||
|
heiliger Pfad, nur mit Gerätetest; ggf. Upstream-Issue melden.
|
||||||
|
|
||||||
## M2 – Das große Refactoring (erst nach M0/M1!)
|
## M2 – Das große Refactoring (erst nach M0/M1!)
|
||||||
|
|
||||||
@@ -82,7 +223,33 @@ lassen, ohne dass andere Module angefasst werden müssen.
|
|||||||
Bernds Go für den Call-Pilot (Frage in PROGRESS.md).
|
Bernds Go für den Call-Pilot (Frage in PROGRESS.md).
|
||||||
- [ ] Call-Schicht entwirren: EINE klare Zuständigkeit pro Klasse (Signalisierung /
|
- [ ] Call-Schicht entwirren: EINE klare Zuständigkeit pro Klasse (Signalisierung /
|
||||||
LiveKit-Verbindung / UI-State sauber getrennt)
|
LiveKit-Verbindung / UI-State sauber getrennt)
|
||||||
|
**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)
|
- [ ] Einheitliches State-Management (Riverpod konsequent, keine Misch-Patterns)
|
||||||
|
**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
|
- [x] Toten Code & Duplikate entfernen, Ordnerstruktur vereinheitlichen
|
||||||
(PROGRESS.md 2026-07-03: `message_bubble.dart` tot entfernt, doppelte
|
(PROGRESS.md 2026-07-03: `message_bubble.dart` tot entfernt, doppelte
|
||||||
`_formatTime` in `message_group.dart` zusammengeführt; alle 6 in der
|
`_formatTime` in `message_group.dart` zusammengeführt; alle 6 in der
|
||||||
@@ -99,9 +266,18 @@ lassen, ohne dass andere Module angefasst werden müssen.
|
|||||||
`features/chat/document/`) – jeweils verifiziert per automatisiertem
|
`features/chat/document/`) – jeweils verifiziert per automatisiertem
|
||||||
Blockvergleich gegen das Original und `flutter analyze`, siehe
|
Blockvergleich gegen das Original und `flutter analyze`, siehe
|
||||||
PROGRESS.md-Einträge. `features/call/voice_channel.dart` (1123 Zeilen)
|
PROGRESS.md-Einträge. `features/call/voice_channel.dart` (1123 Zeilen)
|
||||||
und `layout/app_shell.dart` (1073 Zeilen) bewusst NICHT mit angefasst –
|
und `layout/app_shell.dart` (1073 Zeilen) waren zunächst bewusst NICHT
|
||||||
beide hängen eng mit der noch unentschiedenen Call-Fassade bzw. dem
|
mit angefasst – beide hängen eng mit der damals unentschiedenen
|
||||||
Bootstrap/Login-Pfad zusammen, siehe „Call-Schicht entwirren" unten.
|
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
|
UNGETESTET (Pi) im Sinne von „nicht in echter UI gesehen" – nächster
|
||||||
Windows-Lauf sollte alle 6 betroffenen Bereiche einmal durchklicken
|
Windows-Lauf sollte alle 6 betroffenen Bereiche einmal durchklicken
|
||||||
(zusätzlich zu den 5 unten: eine PDF/Text/Archiv/Bild-Datei im Chat
|
(zusätzlich zu den 5 unten: eine PDF/Text/Archiv/Bild-Datei im Chat
|
||||||
@@ -109,11 +285,58 @@ lassen, ohne dass andere Module angefasst werden müssen.
|
|||||||
prüfen), siehe „Fragen an Bernd"/Nächster-Schritt in den jeweiligen
|
prüfen), siehe „Fragen an Bernd"/Nächster-Schritt in den jeweiligen
|
||||||
PROGRESS.md-Einträgen)
|
PROGRESS.md-Einträgen)
|
||||||
- [ ] Nach JEDEM Schritt: App läuft, Kernflows getestet, Commit
|
- [ ] Nach JEDEM Schritt: App läuft, Kernflows getestet, Commit
|
||||||
|
- [x] Test-/Benchmark-Gerüst: `scripts/test.ps1` (Analyse, Unit-, Live-Tests,
|
||||||
|
Benchmarks mit einem Befehl), `benchmark/` mit Baseline, lokale Suche
|
||||||
|
als testbares Modul (`lib/features/search/`). Siehe
|
||||||
|
`docs/TESTS_UND_BENCHMARKS.md`, PROGRESS.md 2026-10-07.
|
||||||
|
- [ ] **Abhängigkeiten aktualisieren (3 Monate Stand, 119 Pakete veraltet)** –
|
||||||
|
einzeln, jeweils mit `scripts/test.ps1 -All` abgesichert:
|
||||||
|
- **`sqlcipher_flutter_libs` ist EOL:** 0.7.0 ist ein leerer Platzhalter
|
||||||
|
(pub.dev). Unsere Bindung `^0.6.8` schließt ihn aus – NIEMALS per
|
||||||
|
`pub upgrade --major-versions` mitziehen, sonst fehlt SQLCipher und
|
||||||
|
die App kann die verschlüsselte DB nicht mehr öffnen
|
||||||
|
(`AppDatabase` wirft dann bewusst, kein Datenverlust, aber Aussperren).
|
||||||
|
Nachfolger: `sqlite3` 3.x mit `hooks: user_defines: sqlite3: source:
|
||||||
|
sqlcipher` – Kompatibilität mit `sqflite_common_ffi` und Android
|
||||||
|
vorher klären; Migrations-/DB-Tests + Gerätetest Pflicht.
|
||||||
|
- `matrix` 6.2 → 13.x (großer Sprung, Breaking Changes; heiliger
|
||||||
|
Login-/Krypto-Pfad), `flutter_vodozemac` 0.5 → 0.8 gehört dazu.
|
||||||
|
- `livekit_client` 2.7 → 2.13 (innerhalb der Bindung möglich),
|
||||||
|
`flutter_webrtc`-Override 1.4.1 → 1.6.2: der Windows-Audiogeräte-Fix
|
||||||
|
(libwebrtc #142) ist auch in 1.6.2+hotfix.4 NICHT enthalten
|
||||||
|
(Changelog geprüft 2026-10-07) → Dummy-PeerConnection-Workaround
|
||||||
|
bleibt nötig.
|
||||||
|
- Android-Build: Gradle 8.13 → 8.14.3 ERLEDIGT (2026-10-07). „Built-in Kotlin“
|
||||||
|
**bewusst noch nicht** (Entscheidung 2026-10-07): AGP bleibt 8.11; die vom
|
||||||
|
Flutter-Migrator gesetzten Flags `android.builtInKotlin=false` /
|
||||||
|
`android.newDsl=false` halten den Build stabil. Umstieg erst zusammen mit
|
||||||
|
AGP 9 und wenn flutter_webrtc, livekit_client, media_kit & Co. ihn
|
||||||
|
unterstützen – vorher Release-APK + Anruftest.
|
||||||
|
- ERLEDIGT 2026-10-07 (0ad06e3): `flutter_markdown` → `flutter_markdown_plus`
|
||||||
|
(Rendertest `test/document_markdown_test.dart`).
|
||||||
|
|
||||||
## M3 – Calls: Logik, UI & Audio (Discord als Referenz)
|
## M3 – Calls: Logik, UI & Audio (Discord als Referenz)
|
||||||
|
|
||||||
|
- [ ] **Bildschirm teilen mit HDR (Windows):** WebRTC-DXGI-Aufnahme liefert bei
|
||||||
|
eingeschaltetem HDR ein blasses Bild (bekannte Grenze, Zoom/Discord gleich).
|
||||||
|
Lösung: eigenes Aufnahmemodul über Windows Graphics Capture mit
|
||||||
|
HDR→SDR-Tonemapping (wie Chrome ab Win11 24H2) oder neuere libwebrtc mit
|
||||||
|
WGC-Option. Bis dahin Hinweis im Auswähler (Fenster teilen / Win+Alt+B).
|
||||||
|
|
||||||
- [ ] Voice-Channel-Logik robust machen: Beitreten/Verlassen/Wechseln ohne Hänger,
|
- [ ] Voice-Channel-Logik robust machen: Beitreten/Verlassen/Wechseln ohne Hänger,
|
||||||
klarer Zustand wer drin ist, sauberes Verhalten bei Verbindungsabbruch
|
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)
|
- [ ] Call-UI aufräumen: aufgeräumter, intuitiver, konsistent (Mini-Call-Widget, Vollansicht)
|
||||||
- [ ] **Windows: Audiogeräte-Erkennung reparieren** (Mikrofon/Ausgabe erkennen und in den
|
- [ ] **Windows: Audiogeräte-Erkennung reparieren** (Mikrofon/Ausgabe erkennen und in den
|
||||||
Einstellungen auswählbar machen – geht aktuell gar nicht)
|
Einstellungen auswählbar machen – geht aktuell gar nicht)
|
||||||
@@ -127,7 +350,19 @@ lassen, ohne dass andere Module angefasst werden müssen.
|
|||||||
|
|
||||||
- [ ] **Framerate verbessern:** Ursache messen (Encoder? Auflösung? Simulcast-Settings?
|
- [ ] **Framerate verbessern:** Ursache messen (Encoder? Auflösung? Simulcast-Settings?
|
||||||
Software- statt Hardware-Encoding?), dann gezielt fixen
|
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)
|
- [ ] 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)
|
- [ ] Qualität wählbar für den **Empfänger** (LiveKit-Simulcast-Layer auswählen)
|
||||||
- [ ] Sauberes Degradieren bei schwacher Bandbreite statt Ruckel-Chaos
|
- [ ] Sauberes Degradieren bei schwacher Bandbreite statt Ruckel-Chaos
|
||||||
|
|
||||||
@@ -148,6 +383,12 @@ lassen, ohne dass andere Module angefasst werden müssen.
|
|||||||
|
|
||||||
## M7 – Design & Feinschliff
|
## M7 – Design & Feinschliff
|
||||||
|
|
||||||
|
- [x] **Beta-Feedback 2026-10-07 umgesetzt (UNGETESTET auf echtem Handy):**
|
||||||
|
Push als Chat-Verlauf mit Bildvorschau + echtem „Gelesen“, Statusleiste
|
||||||
|
überdeckt nichts mehr, Bild-Vollansicht mit Gesten, weiches Dunkel,
|
||||||
|
Schriftgrößen-Regler, Sprechblasen umschaltbar, Messenger-Chatliste,
|
||||||
|
deutsche Beschriftungen. Test-Version 0.1.37 (siehe PROGRESS.md).
|
||||||
|
|
||||||
- [ ] Rooms-Icon überarbeiten – passt stilistisch nicht zu den anderen Buttons
|
- [ ] Rooms-Icon überarbeiten – passt stilistisch nicht zu den anderen Buttons
|
||||||
- [ ] Design-Konsistenz-Pass über alle Icons/Buttons
|
- [ ] Design-Konsistenz-Pass über alle Icons/Buttons
|
||||||
- [ ] Onboarding: erster Start erklärt Server-Wahl und Verifikation verständlich
|
- [ ] Onboarding: erster Start erklärt Server-Wahl und Verifikation verständlich
|
||||||
|
|||||||
@@ -4,4 +4,10 @@
|
|||||||
to allow setting breakpoints, to provide hot reload, etc.
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
-->
|
-->
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<application>
|
||||||
|
<!-- NUR Debug: Benachrichtigungen per adb auslösen (DebugNotifyReceiver.kt). -->
|
||||||
|
<receiver
|
||||||
|
android:name=".DebugNotifyReceiver"
|
||||||
|
android:exported="true" />
|
||||||
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package chat.pyramid.pyramid
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NUR IM DEBUG-BUILD (src/debug): löst Nachrichten-Benachrichtigungen per adb
|
||||||
|
* aus, um NotificationHelper ohne Login/Push auf dem Emulator zu prüfen.
|
||||||
|
* Ist nicht im Release-APK enthalten.
|
||||||
|
*
|
||||||
|
* adb shell am broadcast -n chat.pyramid.pyramid/.DebugNotifyReceiver \
|
||||||
|
* --es sender Uta --es body "Hallo" [--es roomName Familie] \
|
||||||
|
* [--es event e1] [--ez image true] [--ez avatar true] [--es room !r:x]
|
||||||
|
*/
|
||||||
|
class DebugNotifyReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
val roomId = intent.getStringExtra("room") ?: "!debug:pyramid.local"
|
||||||
|
val sender = intent.getStringExtra("sender") ?: "Testnutzer"
|
||||||
|
val roomName = intent.getStringExtra("roomName")
|
||||||
|
val eventId = intent.getStringExtra("event") ?: "\$debug${System.nanoTime()}"
|
||||||
|
val args = mapOf(
|
||||||
|
"roomId" to roomId,
|
||||||
|
"notifId" to NotificationHelper.stableId(roomId),
|
||||||
|
"eventId" to eventId,
|
||||||
|
"senderId" to "@${sender.lowercase()}:pyramid.local",
|
||||||
|
"senderName" to sender,
|
||||||
|
"roomName" to roomName,
|
||||||
|
"isGroup" to (roomName != null),
|
||||||
|
"body" to (intent.getStringExtra("body") ?: NotificationHelper.PLACEHOLDER_BODY),
|
||||||
|
"timestamp" to System.currentTimeMillis(),
|
||||||
|
"imagePath" to if (intent.getBooleanExtra("image", false)) testImage(context, "img_debug", 960, 640) else null,
|
||||||
|
"avatarPath" to if (intent.getBooleanExtra("avatar", false)) testImage(context, "avatar_$sender", 256, 256) else null,
|
||||||
|
)
|
||||||
|
NotificationHelper.showFromArgs(context, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erzeugt ein buntes Testbild unter cache/downloads/notif/ (FileProvider-Pfad). */
|
||||||
|
private fun testImage(context: Context, name: String, w: Int, h: Int): String {
|
||||||
|
val dir = File(context.cacheDir, "downloads/notif").apply { mkdirs() }
|
||||||
|
val file = File(dir, name)
|
||||||
|
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||||
|
val c = Canvas(bmp)
|
||||||
|
c.drawColor(Color.rgb(0x3B, 0x6D, 0x11))
|
||||||
|
val p = Paint().apply { color = Color.rgb(0xF5, 0xA6, 0x14); isAntiAlias = true }
|
||||||
|
c.drawCircle(w * 0.5f, h * 0.5f, minOf(w, h) * 0.3f, p)
|
||||||
|
file.outputStream().use { bmp.compress(Bitmap.CompressFormat.JPEG, 90, it) }
|
||||||
|
return file.absolutePath
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||||
@@ -17,15 +18,24 @@
|
|||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
|
||||||
|
|
||||||
|
<!-- Kein Backup/Umzug der App-Daten: siehe res/xml/data_extraction_rules.xml
|
||||||
|
(verschlüsselte DB ohne Keystore-Schlüssel = App startet nicht). -->
|
||||||
<application
|
<application
|
||||||
android:label="pyramid"
|
android:label="Pyramid"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:fullBackupContent="false"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules">
|
||||||
|
<!-- Laufender Anruf: hält das Mikrofon im Hintergrund am Leben und
|
||||||
|
ist Pflicht für Bildschirmübertragung ab Android 14 (CallService.kt).
|
||||||
|
Der frühere Eintrag com.cloudwebrtc...FlutterForegroundService
|
||||||
|
verwies auf eine Klasse, die flutter_webrtc 1.x nicht mehr hat. -->
|
||||||
<service
|
<service
|
||||||
android:name="com.cloudwebrtc.webrtc.FlutterForegroundService"
|
android:name=".CallService"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="mediaProjection" />
|
android:foregroundServiceType="microphone|mediaProjection" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
@@ -35,14 +35,17 @@ object BgEngine {
|
|||||||
private val fallbacks = mutableMapOf<Int, Runnable>()
|
private val fallbacks = mutableMapOf<Int, Runnable>()
|
||||||
|
|
||||||
/** Schedule a placeholder shown after [delayMs] unless decryption beats it. */
|
/** Schedule a placeholder shown after [delayMs] unless decryption beats it. */
|
||||||
fun scheduleFallback(context: Context, title: String, roomId: String, notifId: Int, delayMs: Long) {
|
fun scheduleFallback(
|
||||||
|
context: Context, meta: PushService.PushMeta, roomId: String, notifId: Int,
|
||||||
|
eventId: String?, delayMs: Long,
|
||||||
|
) {
|
||||||
val ctx = context.applicationContext
|
val ctx = context.applicationContext
|
||||||
appContext = ctx
|
appContext = ctx
|
||||||
fallbackHandler.post {
|
fallbackHandler.post {
|
||||||
cancelFallback(notifId)
|
cancelFallback(notifId)
|
||||||
val r = Runnable {
|
val r = Runnable {
|
||||||
fallbacks.remove(notifId)
|
fallbacks.remove(notifId)
|
||||||
NotificationHelper.show(ctx, title, "Neue Nachricht", roomId, notifId)
|
meta.showPlaceholder(ctx, roomId, notifId, eventId)
|
||||||
Log.d(TAG, "fallback placeholder shown for $roomId (decrypt too slow)")
|
Log.d(TAG, "fallback placeholder shown for $roomId (decrypt too slow)")
|
||||||
}
|
}
|
||||||
fallbacks[notifId] = r
|
fallbacks[notifId] = r
|
||||||
@@ -114,16 +117,19 @@ object BgEngine {
|
|||||||
}
|
}
|
||||||
// Dart finished decrypting — show the real content using the SAME
|
// Dart finished decrypting — show the real content using the SAME
|
||||||
// native notification (keeps the reliable MainActivity reply action).
|
// native notification (keeps the reliable MainActivity reply action).
|
||||||
|
// May arrive twice per event: first the text, then silently
|
||||||
|
// the same event with avatar/image (NotificationHelper merges).
|
||||||
"showDecrypted" -> {
|
"showDecrypted" -> {
|
||||||
val title = call.argument<String>("title") ?: ""
|
@Suppress("UNCHECKED_CAST")
|
||||||
val body = call.argument<String>("body") ?: "Neue Nachricht"
|
val args = (call.arguments as? Map<String, Any?>) ?: emptyMap()
|
||||||
val roomId = call.argument<String>("room_id") ?: ""
|
val roomId = (args["roomId"] ?: args["room_id"]) as? String ?: ""
|
||||||
val notifId = call.argument<Int>("notif_id") ?: NotificationHelper.stableId(roomId)
|
val notifId = ((args["notifId"] ?: args["notif_id"]) as? Number)?.toInt()
|
||||||
|
?: NotificationHelper.stableId(roomId)
|
||||||
val ctx = appContext
|
val ctx = appContext
|
||||||
if (ctx != null && roomId.isNotEmpty()) {
|
if (ctx != null && roomId.isNotEmpty()) {
|
||||||
// Beat the fallback placeholder to it — show decrypted directly.
|
// Beat the fallback placeholder to it — show decrypted directly.
|
||||||
cancelFallback(notifId)
|
cancelFallback(notifId)
|
||||||
NotificationHelper.show(ctx, title, body, roomId, notifId)
|
NotificationHelper.showFromArgs(ctx, args)
|
||||||
Log.d(TAG, "showDecrypted → shown decrypted notification for $roomId")
|
Log.d(TAG, "showDecrypted → shown decrypted notification for $roomId")
|
||||||
}
|
}
|
||||||
result.success(null)
|
result.success(null)
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package chat.pyramid.pyramid
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vordergrunddienst für laufende Anrufe (1:1 und Sprachkanäle).
|
||||||
|
*
|
||||||
|
* Warum: Android schaltet das Mikrofon einer App im Hintergrund stumm, und
|
||||||
|
* seit Android 14 stürzt jede Bildschirmübertragung ab, wenn vorher kein
|
||||||
|
* Dienst vom Typ „mediaProjection“ läuft (flutter_webrtc startet keinen).
|
||||||
|
* Genau das war Bernds Fehlerbild 2026-10-07: Teilen gestartet → App weg →
|
||||||
|
* beim Öffnen kein Anruf mehr.
|
||||||
|
*
|
||||||
|
* Typen: immer „microphone“ (ab Android 11), bei laufender Übertragung
|
||||||
|
* zusätzlich „mediaProjection“. Der Dienst darf mit mediaProjection erst
|
||||||
|
* NACH der Zustimmung des Nutzers gestartet werden – das stellt die
|
||||||
|
* Dart-Seite sicher (lib/core/call_foreground.dart).
|
||||||
|
*/
|
||||||
|
class CallService : Service() {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "PYRAMID-CALL"
|
||||||
|
private const val CHANNEL_ID = "pyramid_call"
|
||||||
|
private const val NOTIF_ID = 7301
|
||||||
|
private const val EXTRA_TITLE = "title"
|
||||||
|
private const val EXTRA_SCREEN = "screen"
|
||||||
|
|
||||||
|
// Wer auf den tatsächlichen startForeground wartet (Hauptthread).
|
||||||
|
// Die Bildschirmübertragung darf erst danach beginnen.
|
||||||
|
private val waiters = mutableListOf<(Boolean) -> Unit>()
|
||||||
|
|
||||||
|
fun whenStarted(callback: (Boolean) -> Unit) {
|
||||||
|
waiters.add(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notifyWaiters(ok: Boolean) {
|
||||||
|
val list = waiters.toList()
|
||||||
|
waiters.clear()
|
||||||
|
list.forEach { it(ok) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun start(ctx: Context, title: String, screen: Boolean) {
|
||||||
|
val intent = Intent(ctx, CallService::class.java)
|
||||||
|
.putExtra(EXTRA_TITLE, title)
|
||||||
|
.putExtra(EXTRA_SCREEN, screen)
|
||||||
|
ContextCompat.startForegroundService(ctx, intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(ctx: Context) {
|
||||||
|
ctx.stopService(Intent(ctx, CallService::class.java))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
val title = intent?.getStringExtra(EXTRA_TITLE) ?: "Pyramid"
|
||||||
|
val screen = intent?.getBooleanExtra(EXTRA_SCREEN, false) ?: false
|
||||||
|
ensureChannel()
|
||||||
|
|
||||||
|
val launch = packageManager.getLaunchIntentForPackage(packageName)?.apply {
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
|
||||||
|
}
|
||||||
|
val open = launch?.let {
|
||||||
|
PendingIntent.getActivity(
|
||||||
|
this, 0, it,
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setContentTitle(if (screen) "Bildschirm wird übertragen" else "Anruf läuft")
|
||||||
|
.setContentText("$title – tippen, um zurückzukehren")
|
||||||
|
.setOngoing(true)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setSilent(true)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_CALL)
|
||||||
|
.setContentIntent(open)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// Typ „microphone“ nur mit Mikrofon-Berechtigung (sonst verweigert
|
||||||
|
// Android den GANZEN Dienst – auch den für die Übertragung).
|
||||||
|
val micAllowed = ContextCompat.checkSelfPermission(
|
||||||
|
this, Manifest.permission.RECORD_AUDIO
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
val candidates = mutableListOf<Int>()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
val mic = ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
|
||||||
|
val proj = ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
|
||||||
|
if (screen) {
|
||||||
|
if (micAllowed) candidates.add(mic or proj)
|
||||||
|
candidates.add(proj)
|
||||||
|
} else if (micAllowed) {
|
||||||
|
candidates.add(mic)
|
||||||
|
}
|
||||||
|
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
candidates.add(if (screen) ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION else 0)
|
||||||
|
} else {
|
||||||
|
candidates.add(-1) // ohne Typ
|
||||||
|
}
|
||||||
|
|
||||||
|
var ok = false
|
||||||
|
for (type in candidates) {
|
||||||
|
try {
|
||||||
|
if (type == -1) startForeground(NOTIF_ID, notification)
|
||||||
|
else startForeground(NOTIF_ID, notification, type)
|
||||||
|
ok = true
|
||||||
|
break
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Nächste, schmalere Variante versuchen – lieber ohne Dienst
|
||||||
|
// weiter als die App abstürzen zu lassen.
|
||||||
|
Log.e(TAG, "startForeground fehlgeschlagen (screen=$screen, type=$type): ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!ok) stopSelf()
|
||||||
|
notifyWaiters(ok)
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
val nm = getSystemService(NotificationManager::class.java)
|
||||||
|
if (nm.getNotificationChannel(CHANNEL_ID) != null) return
|
||||||
|
nm.createNotificationChannel(
|
||||||
|
NotificationChannel(CHANNEL_ID, "Laufende Anrufe", NotificationManager.IMPORTANCE_LOW).apply {
|
||||||
|
description = "Zeigt an, dass ein Anruf oder eine Bildschirmübertragung läuft."
|
||||||
|
setShowBadge(false)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,6 +97,39 @@ class MainActivity : FlutterActivity() {
|
|||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
// Cache so ReplyReceiver can call back into Dart without starting a new engine.
|
// Cache so ReplyReceiver can call back into Dart without starting a new engine.
|
||||||
FlutterEngineCache.getInstance().put("main", flutterEngine)
|
FlutterEngineCache.getInstance().put("main", flutterEngine)
|
||||||
|
// Anruf-Vordergrunddienst (lib/core/call_foreground.dart ↔ CallService.kt).
|
||||||
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "chat.pyramid.pyramid/call")
|
||||||
|
.setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
|
"start" -> {
|
||||||
|
// Erst antworten, wenn der Dienst wirklich im Vordergrund
|
||||||
|
// läuft (oder nach 3 s aufgeben) – eine Übertragung vor
|
||||||
|
// dem startForeground würde die App abstürzen lassen.
|
||||||
|
val done = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||||
|
val reply = { ok: Boolean ->
|
||||||
|
if (done.compareAndSet(false, true)) result.success(ok)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
CallService.whenStarted(reply)
|
||||||
|
CallService.start(
|
||||||
|
this,
|
||||||
|
call.argument<String>("title") ?: "Pyramid",
|
||||||
|
call.argument<Boolean>("screen") ?: false,
|
||||||
|
)
|
||||||
|
android.os.Handler(mainLooper).postDelayed({ reply(false) }, 3000)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// z. B. Start aus dem Hintergrund verboten (Android 12+).
|
||||||
|
Log.e("PYRAMID-CALL", "CallService-Start abgelehnt: ${e.message}")
|
||||||
|
reply(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"stop" -> {
|
||||||
|
CallService.stop(this)
|
||||||
|
result.success(true)
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channel)
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channel)
|
||||||
.setMethodCallHandler { call, result ->
|
.setMethodCallHandler { call, result ->
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
@@ -206,12 +239,11 @@ class MainActivity : FlutterActivity() {
|
|||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
"showNativeNotification" -> {
|
"showNativeNotification" -> {
|
||||||
val title = call.argument<String>("title") ?: ""
|
@Suppress("UNCHECKED_CAST")
|
||||||
val body = call.argument<String>("body") ?: ""
|
val args = (call.arguments as? Map<String, Any?>) ?: emptyMap()
|
||||||
val roomId = call.argument<String>("roomId") ?: ""
|
val roomId = args["roomId"] as? String ?: ""
|
||||||
val notifId = call.argument<Int>("notifId") ?: NotificationHelper.stableId(roomId)
|
|
||||||
if (roomId.isNotEmpty()) {
|
if (roomId.isNotEmpty()) {
|
||||||
NotificationHelper.show(this, title, body, roomId, notifId)
|
NotificationHelper.showFromArgs(this, args)
|
||||||
result.success(null)
|
result.success(null)
|
||||||
} else {
|
} else {
|
||||||
result.error("INVALID_ARG", "roomId missing", null)
|
result.error("INVALID_ARG", "roomId missing", null)
|
||||||
|
|||||||
@@ -5,13 +5,79 @@ import android.app.NotificationManager
|
|||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.Person
|
||||||
import androidx.core.app.RemoteInput
|
import androidx.core.app.RemoteInput
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.core.graphics.drawable.IconCompat
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native Nachrichten-Benachrichtigungen (alle Pfade: Platzhalter aus
|
||||||
|
* PushService, Klartext aus der Haupt-Engine oder dem BgEngine-Isolate).
|
||||||
|
*
|
||||||
|
* Pro Raum gibt es EINE Benachrichtigung (notifId = stableId(roomId)), die als
|
||||||
|
* Chat-Verlauf (MessagingStyle) die letzten [MAX_MESSAGES] Nachrichten zeigt –
|
||||||
|
* wie WhatsApp/Element. Der Verlauf liegt in SharedPreferences, weil bei
|
||||||
|
* beendeter App jeder Push in einem neuen Prozess ankommt. Er wird nur
|
||||||
|
* fortgeführt, solange die Benachrichtigung noch sichtbar ist: hat der Nutzer
|
||||||
|
* sie weggewischt oder den Raum in der App geöffnet (Dart cancelt), beginnt
|
||||||
|
* der nächste Push mit einem leeren Verlauf.
|
||||||
|
*
|
||||||
|
* Gleiche eventId = Aktualisierung (Platzhalter → Klartext, Text → Text+Bild):
|
||||||
|
* die Nachricht wird ersetzt statt doppelt angehängt, und es klingelt nicht
|
||||||
|
* erneut. Nur eine wirklich neue Nachricht alarmiert.
|
||||||
|
*/
|
||||||
object NotificationHelper {
|
object NotificationHelper {
|
||||||
const val CHANNEL_ID = "pyramid_messages"
|
const val CHANNEL_ID = "pyramid_messages"
|
||||||
|
const val PLACEHOLDER_BODY = "Neue Nachricht"
|
||||||
|
private const val TAG = "PYRAMID-NOTIF"
|
||||||
|
private const val STORE = "pyramid_notif_history"
|
||||||
|
private const val MAX_MESSAGES = 8
|
||||||
|
private const val SYSTEM_UI = "com.android.systemui"
|
||||||
|
|
||||||
|
/** Eine Nachricht im Verlauf einer Raum-Benachrichtigung. */
|
||||||
|
data class Msg(
|
||||||
|
val eventId: String?,
|
||||||
|
val senderKey: String,
|
||||||
|
val senderName: String,
|
||||||
|
val body: String,
|
||||||
|
val timestamp: Long,
|
||||||
|
val imagePath: String? = null,
|
||||||
|
val avatarPath: String? = null,
|
||||||
|
) {
|
||||||
|
val isPlaceholder get() = body == PLACEHOLDER_BODY
|
||||||
|
|
||||||
|
fun toJson(): JSONObject = JSONObject().apply {
|
||||||
|
put("eventId", eventId ?: JSONObject.NULL)
|
||||||
|
put("senderKey", senderKey)
|
||||||
|
put("senderName", senderName)
|
||||||
|
put("body", body)
|
||||||
|
put("timestamp", timestamp)
|
||||||
|
put("imagePath", imagePath ?: JSONObject.NULL)
|
||||||
|
put("avatarPath", avatarPath ?: JSONObject.NULL)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromJson(o: JSONObject) = Msg(
|
||||||
|
eventId = o.optStringOrNull("eventId"),
|
||||||
|
senderKey = o.optString("senderKey"),
|
||||||
|
senderName = o.optString("senderName"),
|
||||||
|
body = o.optString("body"),
|
||||||
|
timestamp = o.optLong("timestamp"),
|
||||||
|
imagePath = o.optStringOrNull("imagePath"),
|
||||||
|
avatarPath = o.optStringOrNull("avatarPath"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Deterministic notification ID — same algorithm as Dart's _stableRoomId. */
|
/** Deterministic notification ID — same algorithm as Dart's _stableRoomId. */
|
||||||
fun stableId(roomId: String): Int {
|
fun stableId(roomId: String): Int {
|
||||||
@@ -32,15 +98,226 @@ object NotificationHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun show(context: Context, title: String, body: String, roomId: String, notifId: Int) {
|
/**
|
||||||
|
* Platzhalter/Alt-Aufrufer mit fertigem Titel „Absender · Raum“ (bzw. nur
|
||||||
|
* „Absender“ bei Direktnachrichten).
|
||||||
|
*/
|
||||||
|
fun show(
|
||||||
|
context: Context,
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
roomId: String,
|
||||||
|
notifId: Int,
|
||||||
|
eventId: String? = null,
|
||||||
|
) {
|
||||||
|
val parts = title.split(" · ", limit = 2)
|
||||||
|
val sender = parts[0].ifBlank { "Pyramid" }
|
||||||
|
val roomName = parts.getOrNull(1)?.takeIf { it.isNotBlank() }
|
||||||
|
showMessage(
|
||||||
|
context, roomId, notifId, roomName, isGroup = roomName != null,
|
||||||
|
msg = Msg(eventId, sender, sender, body, System.currentTimeMillis()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strukturierter Aufruf aus Dart (`toNativeArgs` in notification_content.dart). */
|
||||||
|
fun showFromArgs(context: Context, args: Map<String, Any?>) {
|
||||||
|
val roomId = (args["roomId"] ?: args["room_id"]) as? String ?: return
|
||||||
|
if (roomId.isEmpty()) return
|
||||||
|
val notifId = ((args["notifId"] ?: args["notif_id"]) as? Number)?.toInt()
|
||||||
|
?: stableId(roomId)
|
||||||
|
val senderName = args["senderName"] as? String
|
||||||
|
if (senderName == null) {
|
||||||
|
// Alter Aufrufer ohne strukturierte Felder.
|
||||||
|
show(
|
||||||
|
context, args["title"] as? String ?: "",
|
||||||
|
args["body"] as? String ?: PLACEHOLDER_BODY,
|
||||||
|
roomId, notifId, args["eventId"] as? String,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val roomName = (args["roomName"] as? String)?.takeIf { it.isNotBlank() }
|
||||||
|
showMessage(
|
||||||
|
context, roomId, notifId, roomName,
|
||||||
|
isGroup = (args["isGroup"] as? Boolean) ?: (roomName != null),
|
||||||
|
msg = Msg(
|
||||||
|
eventId = args["eventId"] as? String,
|
||||||
|
senderKey = args["senderId"] as? String ?: senderName,
|
||||||
|
senderName = senderName,
|
||||||
|
body = args["body"] as? String ?: PLACEHOLDER_BODY,
|
||||||
|
timestamp = (args["timestamp"] as? Number)?.toLong() ?: System.currentTimeMillis(),
|
||||||
|
imagePath = args["imagePath"] as? String,
|
||||||
|
avatarPath = args["avatarPath"] as? String,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fügt [msg] dem Verlauf des Raums hinzu (oder aktualisiert sie) und zeigt ihn. */
|
||||||
|
fun showMessage(
|
||||||
|
context: Context,
|
||||||
|
roomId: String,
|
||||||
|
notifId: Int,
|
||||||
|
roomName: String?,
|
||||||
|
isGroup: Boolean,
|
||||||
|
msg: Msg,
|
||||||
|
) {
|
||||||
ensureChannel(context)
|
ensureChannel(context)
|
||||||
|
val (history, isNew) = mergeIntoHistory(context, roomId, notifId, msg)
|
||||||
|
val latest = history.last()
|
||||||
|
|
||||||
val mutFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
val style = NotificationCompat.MessagingStyle(
|
||||||
PendingIntent.FLAG_MUTABLE else 0
|
Person.Builder().setName("Du").setKey("me").build()
|
||||||
|
)
|
||||||
|
if (isGroup && roomName != null) {
|
||||||
|
style.conversationTitle = roomName
|
||||||
|
style.isGroupConversation = true
|
||||||
|
}
|
||||||
|
for (m in history) {
|
||||||
|
val person = Person.Builder()
|
||||||
|
.setName(m.senderName)
|
||||||
|
.setKey(m.senderKey)
|
||||||
|
.apply {
|
||||||
|
contentUri(context, m.avatarPath)?.let { setIcon(IconCompat.createWithContentUri(it)) }
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
val message = NotificationCompat.MessagingStyle.Message(m.body, m.timestamp, person)
|
||||||
|
contentUri(context, m.imagePath)?.let { message.setData("image/jpeg", it) }
|
||||||
|
style.addMessage(message)
|
||||||
|
}
|
||||||
|
|
||||||
// Reply action — BroadcastReceiver so replying NEVER opens the app.
|
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
// ReplyReceiver decrypts/sends via the background engine when killed.
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
// (ReplyReceiver has multiple RemoteInput key fallbacks for OEM quirks.)
|
.setColor(Color.rgb(0x7B, 0x61, 0xFF))
|
||||||
|
.setStyle(style)
|
||||||
|
// Fallback-Texte für Geräte/Ansichten ohne MessagingStyle.
|
||||||
|
.setContentTitle(if (isGroup && roomName != null) roomName else latest.senderName)
|
||||||
|
.setContentText(latest.body)
|
||||||
|
.setWhen(latest.timestamp)
|
||||||
|
.setShowWhen(true)
|
||||||
|
.setNumber(history.size)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
// Nur eine wirklich neue Nachricht klingelt; Aktualisierungen
|
||||||
|
// (Platzhalter → Klartext, Bild nachgeladen) ersetzen still.
|
||||||
|
.setOnlyAlertOnce(!isNew)
|
||||||
|
.setContentIntent(tapIntent(context, roomId, notifId))
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.addAction(replyAction(context, roomId, notifId))
|
||||||
|
.addAction(markReadAction(context, roomId, notifId, latest.eventId))
|
||||||
|
.setGroup(CHANNEL_ID)
|
||||||
|
if (!isGroup) {
|
||||||
|
avatarBitmap(latest.avatarPath)?.let { builder.setLargeIcon(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
nm(context).notify(notifId, builder.build())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Sicherheitsnetz: lieber ohne Bilder/Avatare als gar nicht.
|
||||||
|
Log.w(TAG, "notify failed (${e.message}) — retrying as plain text")
|
||||||
|
builder.setLargeIcon(null as Bitmap?)
|
||||||
|
.setStyle(NotificationCompat.BigTextStyle().bigText(latest.body))
|
||||||
|
nm(context).notify(notifId, builder.build())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vergisst den Verlauf eines Raums (nach Antworten/„Gelesen“). */
|
||||||
|
@Synchronized
|
||||||
|
fun clearHistory(context: Context, roomId: String) {
|
||||||
|
prefs(context).edit().remove(roomId).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun mergeIntoHistory(
|
||||||
|
context: Context, roomId: String, notifId: Int, msg: Msg,
|
||||||
|
): Pair<List<Msg>, Boolean> {
|
||||||
|
val prefs = prefs(context)
|
||||||
|
val list = if (isActive(context, notifId)) load(prefs.getString(roomId, null)) else mutableListOf()
|
||||||
|
val index = msg.eventId?.let { id -> list.indexOfFirst { it.eventId == id } } ?: -1
|
||||||
|
val isNew = index < 0
|
||||||
|
if (isNew) {
|
||||||
|
list.add(msg)
|
||||||
|
} else {
|
||||||
|
val old = list[index]
|
||||||
|
// Ein später eintreffender Platzhalter überschreibt nie echten Text.
|
||||||
|
val keepOld = msg.isPlaceholder && !old.isPlaceholder
|
||||||
|
list[index] = Msg(
|
||||||
|
eventId = old.eventId,
|
||||||
|
senderKey = if (keepOld) old.senderKey else msg.senderKey,
|
||||||
|
senderName = if (keepOld) old.senderName else msg.senderName,
|
||||||
|
body = if (keepOld) old.body else msg.body,
|
||||||
|
timestamp = if (old.isPlaceholder) msg.timestamp else old.timestamp,
|
||||||
|
imagePath = msg.imagePath ?: old.imagePath,
|
||||||
|
avatarPath = msg.avatarPath ?: old.avatarPath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
list.sortBy { it.timestamp }
|
||||||
|
while (list.size > MAX_MESSAGES) list.removeAt(0)
|
||||||
|
prefs.edit().putString(roomId, JSONArray(list.map { it.toJson() }).toString()).apply()
|
||||||
|
return list to isNew
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun load(json: String?): MutableList<Msg> {
|
||||||
|
if (json == null) return mutableListOf()
|
||||||
|
return try {
|
||||||
|
val arr = JSONArray(json)
|
||||||
|
MutableList(arr.length()) { Msg.fromJson(arr.getJSONObject(it)) }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
mutableListOf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ist die Benachrichtigung noch sichtbar? (Sonst beginnt ein neuer Verlauf.) */
|
||||||
|
private fun isActive(context: Context, notifId: Int): Boolean {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
|
||||||
|
return try {
|
||||||
|
nm(context).activeNotifications.any { it.id == notifId }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* content://-URI für eine Datei unter cache/downloads/ (FileProvider). Die
|
||||||
|
* System-UI zeichnet die Benachrichtigung und braucht dafür Leserecht.
|
||||||
|
*/
|
||||||
|
private fun contentUri(context: Context, path: String?): Uri? {
|
||||||
|
if (path.isNullOrEmpty()) return null
|
||||||
|
val file = File(path)
|
||||||
|
if (!file.exists()) return null
|
||||||
|
return try {
|
||||||
|
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||||
|
context.grantUriPermission(SYSTEM_UI, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
uri
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "contentUri failed for $path: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kleines Avatar-Bitmap (≈192 px) für das große Icon bei Direktnachrichten. */
|
||||||
|
private fun avatarBitmap(path: String?): Bitmap? {
|
||||||
|
if (path.isNullOrEmpty() || !File(path).exists()) return null
|
||||||
|
return try {
|
||||||
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
BitmapFactory.decodeFile(path, bounds)
|
||||||
|
var sample = 1
|
||||||
|
while (bounds.outWidth / (sample * 2) >= 192 && bounds.outHeight / (sample * 2) >= 192) {
|
||||||
|
sample *= 2
|
||||||
|
}
|
||||||
|
BitmapFactory.decodeFile(path, BitmapFactory.Options().apply { inSampleSize = sample })
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Aktionen ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun mutableFlag() =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
|
||||||
|
|
||||||
|
// Reply action — BroadcastReceiver so replying NEVER opens the app.
|
||||||
|
// ReplyReceiver decrypts/sends via the background engine when killed.
|
||||||
|
// (ReplyReceiver has multiple RemoteInput key fallbacks for OEM quirks.)
|
||||||
|
private fun replyAction(context: Context, roomId: String, notifId: Int): NotificationCompat.Action {
|
||||||
val replyInput = RemoteInput.Builder(ReplyReceiver.REMOTE_INPUT_KEY)
|
val replyInput = RemoteInput.Builder(ReplyReceiver.REMOTE_INPUT_KEY)
|
||||||
.setLabel("Antworten…").build()
|
.setLabel("Antworten…").build()
|
||||||
val replyIntent = Intent(context, ReplyReceiver::class.java).apply {
|
val replyIntent = Intent(context, ReplyReceiver::class.java).apply {
|
||||||
@@ -49,59 +326,57 @@ object NotificationHelper {
|
|||||||
putExtra(ReplyReceiver.EXTRA_NOTIF_ID, notifId)
|
putExtra(ReplyReceiver.EXTRA_NOTIF_ID, notifId)
|
||||||
}
|
}
|
||||||
val replyPi = PendingIntent.getBroadcast(
|
val replyPi = PendingIntent.getBroadcast(
|
||||||
context, notifId,
|
context, notifId, replyIntent,
|
||||||
replyIntent,
|
PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag(),
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or mutFlag,
|
|
||||||
)
|
)
|
||||||
val replyAction = NotificationCompat.Action.Builder(0, "Antworten", replyPi)
|
return NotificationCompat.Action.Builder(0, "Antworten", replyPi)
|
||||||
.addRemoteInput(replyInput)
|
.addRemoteInput(replyInput)
|
||||||
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
|
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
|
||||||
|
.setShowsUserInterface(false)
|
||||||
.build()
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
// Dismiss action — also points to ReplyReceiver so notification is cancelled.
|
// „Gelesen“ — schließt die Benachrichtigung UND setzt die Lesebestätigung
|
||||||
val dismissIntent = Intent(context, ReplyReceiver::class.java).apply {
|
// bis zur neuesten Nachricht (ReplyReceiver → Haupt-Engine bzw. BgEngine).
|
||||||
|
private fun markReadAction(
|
||||||
|
context: Context, roomId: String, notifId: Int, eventId: String?,
|
||||||
|
): NotificationCompat.Action {
|
||||||
|
val intent = Intent(context, ReplyReceiver::class.java).apply {
|
||||||
action = ReplyReceiver.ACTION_DISMISS
|
action = ReplyReceiver.ACTION_DISMISS
|
||||||
putExtra(ReplyReceiver.EXTRA_ROOM_ID, roomId)
|
putExtra(ReplyReceiver.EXTRA_ROOM_ID, roomId)
|
||||||
putExtra(ReplyReceiver.EXTRA_NOTIF_ID, notifId)
|
putExtra(ReplyReceiver.EXTRA_NOTIF_ID, notifId)
|
||||||
|
putExtra(ReplyReceiver.EXTRA_EVENT_ID, eventId)
|
||||||
}
|
}
|
||||||
val dismissPi = PendingIntent.getBroadcast(
|
val pi = PendingIntent.getBroadcast(
|
||||||
context, notifId + 1_000_000,
|
context, notifId + 1_000_000, intent,
|
||||||
dismissIntent,
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
)
|
)
|
||||||
|
return NotificationCompat.Action.Builder(0, "Gelesen", pi)
|
||||||
|
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ)
|
||||||
|
.setShowsUserInterface(false)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
// Tap action — opens MainActivity to the right room.
|
// Tap action — opens MainActivity to the right room.
|
||||||
|
private fun tapIntent(context: Context, roomId: String, notifId: Int): PendingIntent {
|
||||||
val tapIntent = Intent(context, MainActivity::class.java).apply {
|
val tapIntent = Intent(context, MainActivity::class.java).apply {
|
||||||
action = Intent.ACTION_MAIN
|
action = Intent.ACTION_MAIN
|
||||||
addCategory(Intent.CATEGORY_LAUNCHER)
|
addCategory(Intent.CATEGORY_LAUNCHER)
|
||||||
putExtra("room_id", roomId)
|
putExtra("room_id", roomId)
|
||||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
}
|
}
|
||||||
val tapPi = PendingIntent.getActivity(
|
return PendingIntent.getActivity(
|
||||||
context, notifId + 2_000_000,
|
context, notifId + 2_000_000, tapIntent,
|
||||||
tapIntent,
|
PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag(),
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or mutFlag,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
val notif = NotificationCompat.Builder(context, CHANNEL_ID)
|
|
||||||
.setSmallIcon(R.drawable.ic_notification)
|
|
||||||
.setColor(Color.rgb(0x7B, 0x61, 0xFF))
|
|
||||||
.setContentTitle(title)
|
|
||||||
.setContentText(body)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
|
||||||
// Only buzz/heads-up on the first show — the decrypted update reuses
|
|
||||||
// the same notifId and replaces the text silently (no second alert).
|
|
||||||
.setOnlyAlertOnce(true)
|
|
||||||
.setContentIntent(tapPi)
|
|
||||||
.setAutoCancel(true)
|
|
||||||
.addAction(replyAction)
|
|
||||||
.addAction(NotificationCompat.Action(0, "Gelesen", dismissPi))
|
|
||||||
.setGroup(CHANNEL_ID)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
nm(context).notify(notifId, notif)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun prefs(context: Context) =
|
||||||
|
context.getSharedPreferences(STORE, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
private fun nm(context: Context) =
|
private fun nm(context: Context) =
|
||||||
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun JSONObject.optStringOrNull(key: String): String? =
|
||||||
|
if (!has(key) || isNull(key)) null else optString(key).takeIf { it.isNotEmpty() }
|
||||||
|
|||||||
@@ -41,23 +41,23 @@ class PushService : FirebaseMessagingService() {
|
|||||||
val token = (prefs.getString("flutter.notif_access_token", "") ?: "")
|
val token = (prefs.getString("flutter.notif_access_token", "") ?: "")
|
||||||
|
|
||||||
// Fetch sender + room name on a background thread (main thread forbids I/O).
|
// Fetch sender + room name on a background thread (main thread forbids I/O).
|
||||||
var title = "Pyramid"
|
var meta = PushMeta()
|
||||||
if (hs.isNotEmpty() && token.isNotEmpty() && eventId != null) {
|
if (hs.isNotEmpty() && token.isNotEmpty() && eventId != null) {
|
||||||
val latch = CountDownLatch(1)
|
val latch = CountDownLatch(1)
|
||||||
Thread {
|
Thread {
|
||||||
try { title = fetchTitle(hs, token, roomId, eventId) ?: "Pyramid" }
|
try { meta = fetchMeta(hs, token, roomId, eventId) ?: PushMeta() }
|
||||||
finally { latch.countDown() }
|
finally { latch.countDown() }
|
||||||
}.start()
|
}.start()
|
||||||
latch.await(12L, TimeUnit.SECONDS)
|
latch.await(12L, TimeUnit.SECONDS)
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d(TAG, "handling push: title=$title roomId=$roomId")
|
Log.d(TAG, "handling push: sender=${meta.senderName} room=${meta.roomName} roomId=$roomId")
|
||||||
|
|
||||||
val engine = FlutterEngineCache.getInstance().get("main")
|
val engine = FlutterEngineCache.getInstance().get("main")
|
||||||
if (engine != null) {
|
if (engine != null) {
|
||||||
// App backgrounded but alive — show placeholder immediately, then the
|
// App backgrounded but alive — show placeholder immediately, then the
|
||||||
// warm main client decrypts and updates it (fast, barely visible).
|
// warm main client decrypts and updates it (fast, barely visible).
|
||||||
NotificationHelper.show(this, title, "Neue Nachricht", roomId, notifId)
|
meta.showPlaceholder(this, roomId, notifId, eventId)
|
||||||
if (eventId != null) {
|
if (eventId != null) {
|
||||||
val capturedRoomId = roomId
|
val capturedRoomId = roomId
|
||||||
val capturedEventId = eventId
|
val capturedEventId = eventId
|
||||||
@@ -82,7 +82,7 @@ class PushService : FirebaseMessagingService() {
|
|||||||
// real content directly. The "Neue Nachricht" placeholder is only a
|
// real content directly. The "Neue Nachricht" placeholder is only a
|
||||||
// fallback if decryption takes too long or fails.
|
// fallback if decryption takes too long or fails.
|
||||||
Log.d(TAG, "main engine gone — decrypt-first via background engine for $roomId")
|
Log.d(TAG, "main engine gone — decrypt-first via background engine for $roomId")
|
||||||
BgEngine.scheduleFallback(this, title, roomId, notifId, 6_000L)
|
BgEngine.scheduleFallback(this, meta, roomId, notifId, eventId, 6_000L)
|
||||||
BgEngine.run(this, "decryptAndShow", mapOf(
|
BgEngine.run(this, "decryptAndShow", mapOf(
|
||||||
"room_id" to roomId,
|
"room_id" to roomId,
|
||||||
"event_id" to eventId,
|
"event_id" to eventId,
|
||||||
@@ -90,7 +90,7 @@ class PushService : FirebaseMessagingService() {
|
|||||||
))
|
))
|
||||||
} else {
|
} else {
|
||||||
// No event id to decrypt — just show the placeholder.
|
// No event id to decrypt — just show the placeholder.
|
||||||
NotificationHelper.show(this, title, "Neue Nachricht", roomId, notifId)
|
meta.showPlaceholder(this, roomId, notifId, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,11 +102,31 @@ class PushService : FirebaseMessagingService() {
|
|||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Absender + Raumname aus unverschlüsselten Room-States (für den Platzhalter). */
|
||||||
|
data class PushMeta(
|
||||||
|
val senderId: String = "",
|
||||||
|
val senderName: String = "Pyramid",
|
||||||
|
val roomName: String? = null,
|
||||||
|
) {
|
||||||
|
fun showPlaceholder(context: android.content.Context, roomId: String, notifId: Int, eventId: String?) {
|
||||||
|
NotificationHelper.showMessage(
|
||||||
|
context, roomId, notifId, roomName, isGroup = roomName != null,
|
||||||
|
msg = NotificationHelper.Msg(
|
||||||
|
eventId = eventId,
|
||||||
|
senderKey = senderId.ifEmpty { senderName },
|
||||||
|
senderName = senderName,
|
||||||
|
body = NotificationHelper.PLACEHOLDER_BODY,
|
||||||
|
timestamp = System.currentTimeMillis(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── HTTP helpers ─────────────────────────────────────────────────────────
|
// ─── HTTP helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private fun fetchTitle(
|
private fun fetchMeta(
|
||||||
hs: String, token: String, roomId: String, eventId: String
|
hs: String, token: String, roomId: String, eventId: String
|
||||||
): String? {
|
): PushMeta? {
|
||||||
return try {
|
return try {
|
||||||
val encRoom = java.net.URLEncoder.encode(roomId, "UTF-8")
|
val encRoom = java.net.URLEncoder.encode(roomId, "UTF-8")
|
||||||
val encEvent = java.net.URLEncoder.encode(eventId, "UTF-8")
|
val encEvent = java.net.URLEncoder.encode(eventId, "UTF-8")
|
||||||
@@ -128,8 +148,11 @@ class PushService : FirebaseMessagingService() {
|
|||||||
val nameState = httpGet("$hs/_matrix/client/v3/rooms/$encRoom/state/m.room.name", token)
|
val nameState = httpGet("$hs/_matrix/client/v3/rooms/$encRoom/state/m.room.name", token)
|
||||||
val roomName = nameState?.optString("name")
|
val roomName = nameState?.optString("name")
|
||||||
|
|
||||||
return if (!roomName.isNullOrEmpty()) "$senderName · $roomName"
|
return PushMeta(
|
||||||
else senderName.ifEmpty { null }
|
senderId = senderId,
|
||||||
|
senderName = senderName.ifEmpty { "Pyramid" },
|
||||||
|
roomName = roomName?.takeIf { it.isNotEmpty() },
|
||||||
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "fetchTitle failed: ${e.message}")
|
Log.w(TAG, "fetchTitle failed: ${e.message}")
|
||||||
null
|
null
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class ReplyReceiver : BroadcastReceiver() {
|
|||||||
const val ACTION_DISMISS = "chat.pyramid.pyramid.DISMISS"
|
const val ACTION_DISMISS = "chat.pyramid.pyramid.DISMISS"
|
||||||
const val EXTRA_ROOM_ID = "room_id"
|
const val EXTRA_ROOM_ID = "room_id"
|
||||||
const val EXTRA_NOTIF_ID = "notif_id"
|
const val EXTRA_NOTIF_ID = "notif_id"
|
||||||
|
const val EXTRA_EVENT_ID = "event_id"
|
||||||
const val REMOTE_INPUT_KEY = "reply_text"
|
const val REMOTE_INPUT_KEY = "reply_text"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,12 +24,13 @@ class ReplyReceiver : BroadcastReceiver() {
|
|||||||
val roomId = intent.getStringExtra(EXTRA_ROOM_ID) ?: return
|
val roomId = intent.getStringExtra(EXTRA_ROOM_ID) ?: return
|
||||||
val notifId = intent.getIntExtra(EXTRA_NOTIF_ID, 0)
|
val notifId = intent.getIntExtra(EXTRA_NOTIF_ID, 0)
|
||||||
|
|
||||||
// Dismiss the notification immediately.
|
// Dismiss the notification immediately; its message history goes too.
|
||||||
(context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
|
(context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
|
||||||
.cancel(notifId)
|
.cancel(notifId)
|
||||||
|
NotificationHelper.clearHistory(context, roomId)
|
||||||
|
|
||||||
if (intent.action == ACTION_DISMISS) {
|
if (intent.action == ACTION_DISMISS) {
|
||||||
Log.d("PYRAMID-REPLY", "dismiss action for room $roomId")
|
markRead(context, roomId, intent.getStringExtra(EXTRA_EVENT_ID))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,4 +83,30 @@ class ReplyReceiver : BroadcastReceiver() {
|
|||||||
try { pending.finish() } catch (_: Exception) {}
|
try { pending.finish() } catch (_: Exception) {}
|
||||||
}, 15_000L)
|
}, 15_000L)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* „Gelesen“: Lesebestätigung bis [eventId] setzen – über die laufende
|
||||||
|
* Haupt-Engine oder (App beendet) still über die BgEngine. Ohne eventId
|
||||||
|
* (alter Platzhalter) bleibt es beim Schließen.
|
||||||
|
*/
|
||||||
|
private fun markRead(context: Context, roomId: String, eventId: String?) {
|
||||||
|
Log.d("PYRAMID-REPLY", "mark-read action for room $roomId event=$eventId")
|
||||||
|
if (eventId.isNullOrEmpty()) return
|
||||||
|
val args = mapOf("room_id" to roomId, "event_id" to eventId)
|
||||||
|
val engine = FlutterEngineCache.getInstance().get("main")
|
||||||
|
if (engine != null) {
|
||||||
|
try {
|
||||||
|
MethodChannel(engine.dartExecutor.binaryMessenger, "chat.pyramid.pyramid/install")
|
||||||
|
.invokeMethod("markReadFromNotification", args)
|
||||||
|
return
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w("PYRAMID-REPLY", "markRead via main engine failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val pending = goAsync()
|
||||||
|
BgEngine.run(context, "markRead", args)
|
||||||
|
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
|
||||||
|
try { pending.finish() } catch (_: Exception) {}
|
||||||
|
}, 10_000L)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Kein Cloud-Backup und keine Gerät-zu-Gerät-Übertragung der App-Daten
|
||||||
|
(Android 12+). Grund: Die lokale Datenbank ist mit SQLCipher verschlüsselt,
|
||||||
|
ihr Schlüssel liegt im Android-Keystore und zieht NICHT mit um – eine
|
||||||
|
übertragene Datenbank ließe sich auf dem neuen Gerät nicht öffnen
|
||||||
|
(AppDatabase bricht dann bewusst ab, statt Daten zu überschreiben).
|
||||||
|
Außerdem enthalten die SharedPreferences den Zugangsschlüssel für den
|
||||||
|
Push-Dienst. Matrix-Weg auf neuem Gerät: neu anmelden + Schlüssel mit dem
|
||||||
|
Wiederherstellungsschlüssel aus dem Server-Backup holen.
|
||||||
|
-->
|
||||||
|
<data-extraction-rules>
|
||||||
|
<cloud-backup>
|
||||||
|
<exclude domain="root" path="." />
|
||||||
|
<exclude domain="file" path="." />
|
||||||
|
<exclude domain="database" path="." />
|
||||||
|
<exclude domain="sharedpref" path="." />
|
||||||
|
<exclude domain="external" path="." />
|
||||||
|
</cloud-backup>
|
||||||
|
<device-transfer>
|
||||||
|
<exclude domain="root" path="." />
|
||||||
|
<exclude domain="file" path="." />
|
||||||
|
<exclude domain="database" path="." />
|
||||||
|
<exclude domain="sharedpref" path="." />
|
||||||
|
<exclude domain="external" path="." />
|
||||||
|
</device-transfer>
|
||||||
|
</data-extraction-rules>
|
||||||
@@ -1,2 +1,6 @@
|
|||||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
|
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||||
|
android.builtInKotlin=false
|
||||||
|
# This newDsl flag was added automatically by Flutter migrator
|
||||||
|
android.newDsl=false
|
||||||
|
|||||||
+1
-1
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
|||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
|
||||||
|
|||||||
+26
-7
@@ -1,14 +1,24 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Pyramid-Autopilot (Pi/Linux): startet Claude-Sessions in Endlosschleife.
|
# Pyramid-Autopilot (Pi/Linux): startet Claude-Sessions in Endlosschleife.
|
||||||
# Läuft eine Session ins 5h-Limit, wird alle 20 Minuten neu versucht.
|
# Laeuft eine Session ins Limit, wird alle 20 Minuten neu versucht.
|
||||||
# Start (am besten in tmux): tmux new -d -s pyramid ./autopilot.sh
|
# Start (am besten in tmux): tmux new -d -s pyramid ./autopilot.sh
|
||||||
# Wieder reinschauen: tmux attach -t pyramid (verlassen: Strg+B, dann D)
|
# Wieder reinschauen: tmux attach -t pyramid (verlassen: Strg+B, dann D)
|
||||||
# PAUSIEREN: touch ~/pyramid/PAUSE (greift vor der nächsten Session)
|
# PAUSIEREN: touch ~/pyramid/PAUSE (greift vor der naechsten Session)
|
||||||
# FORTSETZEN: rm ~/pyramid/PAUSE
|
# FORTSETZEN: rm ~/pyramid/PAUSE
|
||||||
# GANZ STOPPEN: tmux kill-session -t pyramid
|
# GANZ STOPPEN: tmux kill-session -t pyramid
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
export PATH="$HOME/.local/bin:$HOME/flutter/bin:$PATH"
|
export PATH="$HOME/.local/bin:$HOME/flutter/bin:$PATH"
|
||||||
|
|
||||||
|
# --- 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-Statusberichte (Raum "🔺 Pyramid Autopilot", Token vom Gatus-Bot) ---
|
||||||
MATRIX_URL="http://127.0.0.1:6167"
|
MATRIX_URL="http://127.0.0.1:6167"
|
||||||
MATRIX_ROOM="!sYfOwD4Dw4jBDTlNmL:steggi-matrix.work"
|
MATRIX_ROOM="!sYfOwD4Dw4jBDTlNmL:steggi-matrix.work"
|
||||||
@@ -24,9 +34,7 @@ notify_matrix() {
|
|||||||
-d "$body" > /dev/null || true
|
-d "$body" > /dev/null || true
|
||||||
}
|
}
|
||||||
|
|
||||||
PROMPT="Lies CLAUDE.md und arbeite ROADMAP.md Punkt für Punkt weiter ab. \
|
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."
|
||||||
Halte dich strikt an den Arbeitszyklus (PROGRESS.md pflegen, kleine Commits + Push). \
|
|
||||||
Arbeite so viele Punkte ab wie möglich."
|
|
||||||
|
|
||||||
while true; do
|
while true; do
|
||||||
if [ -f PAUSE ]; then
|
if [ -f PAUSE ]; then
|
||||||
@@ -34,9 +42,11 @@ while true; do
|
|||||||
sleep 600
|
sleep 600
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
echo "===== $(date '+%F %T') Neue Claude-Session =====" | tee -a autopilot.log
|
apply_account
|
||||||
|
echo "===== $(date '+%F %T') Neue Claude-Session (Konto $ACCOUNT_NAME) =====" | tee -a autopilot.log
|
||||||
BEFORE=$(git rev-parse HEAD 2>/dev/null)
|
BEFORE=$(git rev-parse HEAD 2>/dev/null)
|
||||||
claude -p "$PROMPT" --model claude-fable-5 --dangerously-skip-permissions 2>&1 | tee -a autopilot.log
|
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)
|
AFTER=$(git rev-parse HEAD 2>/dev/null)
|
||||||
if [ -n "$BEFORE" ] && [ "$BEFORE" != "$AFTER" ]; then
|
if [ -n "$BEFORE" ] && [ "$BEFORE" != "$AFTER" ]; then
|
||||||
COMMITS=$(git log --oneline --no-decorate "$BEFORE..$AFTER" | head -15)
|
COMMITS=$(git log --oneline --no-decorate "$BEFORE..$AFTER" | head -15)
|
||||||
@@ -48,6 +58,15 @@ $COMMITS
|
|||||||
|
|
||||||
$NEXT"
|
$NEXT"
|
||||||
fi
|
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
|
echo "===== $(date '+%F %T') Session beendet - Neustart in 20 Minuten (Strg+C zum Stoppen) =====" | tee -a autopilot.log
|
||||||
sleep 1200
|
sleep 1200
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
{
|
||||||
|
"suite": "db",
|
||||||
|
"timestamp": "2026-10-07T11:35:13.322832",
|
||||||
|
"git": "b2413ef",
|
||||||
|
"host": {
|
||||||
|
"os": "\"Windows 11 Home\" 10.0 (Build 26200)",
|
||||||
|
"cpus": 12
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"rooms": 20,
|
||||||
|
"eventsPerRoom": 500
|
||||||
|
},
|
||||||
|
"metrics": {
|
||||||
|
"klartext.write_total_ms": {
|
||||||
|
"label": "[klartext] Sync-Schreiben, 10000 Nachrichten",
|
||||||
|
"unit": "ms",
|
||||||
|
"value": 2513.443,
|
||||||
|
"min": 2469.054,
|
||||||
|
"runs": 2,
|
||||||
|
"compare": true
|
||||||
|
},
|
||||||
|
"klartext.write_per_event_us": {
|
||||||
|
"label": "[klartext] Sync-Schreiben je Nachricht",
|
||||||
|
"unit": "µs",
|
||||||
|
"value": 251.344,
|
||||||
|
"runs": 1,
|
||||||
|
"compare": true
|
||||||
|
},
|
||||||
|
"klartext.startup_ms": {
|
||||||
|
"label": "[klartext] App-Start: DB + Raumliste",
|
||||||
|
"unit": "ms",
|
||||||
|
"value": 4.121,
|
||||||
|
"min": 3.254,
|
||||||
|
"runs": 6,
|
||||||
|
"compare": true
|
||||||
|
},
|
||||||
|
"klartext.open_chat_ms": {
|
||||||
|
"label": "[klartext] Chat öffnen (50 Nachr.)",
|
||||||
|
"unit": "ms",
|
||||||
|
"value": 0.328,
|
||||||
|
"min": 0.286,
|
||||||
|
"runs": 6,
|
||||||
|
"compare": true
|
||||||
|
},
|
||||||
|
"klartext.search_miss_ms": {
|
||||||
|
"label": "[klartext] Suche ohne Treffer (Worst Case)",
|
||||||
|
"unit": "ms",
|
||||||
|
"value": 63.897,
|
||||||
|
"min": 63.716,
|
||||||
|
"runs": 4,
|
||||||
|
"compare": true
|
||||||
|
},
|
||||||
|
"klartext.search_hit_ms": {
|
||||||
|
"label": "[klartext] Suche mit Treffern",
|
||||||
|
"unit": "ms",
|
||||||
|
"value": 31.777,
|
||||||
|
"min": 31.202,
|
||||||
|
"runs": 4,
|
||||||
|
"compare": true
|
||||||
|
},
|
||||||
|
"klartext.size_mb": {
|
||||||
|
"label": "[klartext] DB-Größe inkl. WAL",
|
||||||
|
"unit": "MB",
|
||||||
|
"value": 4.008,
|
||||||
|
"runs": 1,
|
||||||
|
"compare": false
|
||||||
|
},
|
||||||
|
"sqlcipher.write_total_ms": {
|
||||||
|
"label": "[sqlcipher] Sync-Schreiben, 10000 Nachrichten",
|
||||||
|
"unit": "ms",
|
||||||
|
"value": 2615.633,
|
||||||
|
"min": 2456.356,
|
||||||
|
"runs": 2,
|
||||||
|
"compare": true
|
||||||
|
},
|
||||||
|
"sqlcipher.write_per_event_us": {
|
||||||
|
"label": "[sqlcipher] Sync-Schreiben je Nachricht",
|
||||||
|
"unit": "µs",
|
||||||
|
"value": 261.563,
|
||||||
|
"runs": 1,
|
||||||
|
"compare": true
|
||||||
|
},
|
||||||
|
"sqlcipher.startup_ms": {
|
||||||
|
"label": "[sqlcipher] App-Start: DB + Raumliste",
|
||||||
|
"unit": "ms",
|
||||||
|
"value": 4.313,
|
||||||
|
"min": 3.718,
|
||||||
|
"runs": 6,
|
||||||
|
"compare": true
|
||||||
|
},
|
||||||
|
"sqlcipher.open_chat_ms": {
|
||||||
|
"label": "[sqlcipher] Chat öffnen (50 Nachr.)",
|
||||||
|
"unit": "ms",
|
||||||
|
"value": 0.323,
|
||||||
|
"min": 0.309,
|
||||||
|
"runs": 6,
|
||||||
|
"compare": true
|
||||||
|
},
|
||||||
|
"sqlcipher.search_miss_ms": {
|
||||||
|
"label": "[sqlcipher] Suche ohne Treffer (Worst Case)",
|
||||||
|
"unit": "ms",
|
||||||
|
"value": 63.748,
|
||||||
|
"min": 62.8,
|
||||||
|
"runs": 4,
|
||||||
|
"compare": true
|
||||||
|
},
|
||||||
|
"sqlcipher.search_hit_ms": {
|
||||||
|
"label": "[sqlcipher] Suche mit Treffern",
|
||||||
|
"unit": "ms",
|
||||||
|
"value": 31.676,
|
||||||
|
"min": 31.296,
|
||||||
|
"runs": 4,
|
||||||
|
"compare": true
|
||||||
|
},
|
||||||
|
"sqlcipher.size_mb": {
|
||||||
|
"label": "[sqlcipher] DB-Größe inkl. WAL",
|
||||||
|
"unit": "MB",
|
||||||
|
"value": 4.098,
|
||||||
|
"runs": 1,
|
||||||
|
"compare": false
|
||||||
|
},
|
||||||
|
"cipher_overhead.write_total_ms": {
|
||||||
|
"label": "Aufschlag Verschlüsselung: Sync-Schreiben",
|
||||||
|
"unit": "%",
|
||||||
|
"value": 4.066,
|
||||||
|
"runs": 1,
|
||||||
|
"compare": false
|
||||||
|
},
|
||||||
|
"cipher_overhead.startup_ms": {
|
||||||
|
"label": "Aufschlag Verschlüsselung: App-Start",
|
||||||
|
"unit": "%",
|
||||||
|
"value": 4.634,
|
||||||
|
"runs": 1,
|
||||||
|
"compare": false
|
||||||
|
},
|
||||||
|
"cipher_overhead.open_chat_ms": {
|
||||||
|
"label": "Aufschlag Verschlüsselung: Chat öffnen",
|
||||||
|
"unit": "%",
|
||||||
|
"value": -1.388,
|
||||||
|
"runs": 1,
|
||||||
|
"compare": false
|
||||||
|
},
|
||||||
|
"cipher_overhead.search_miss_ms": {
|
||||||
|
"label": "Aufschlag Verschlüsselung: Suche ohne Treffer",
|
||||||
|
"unit": "%",
|
||||||
|
"value": -0.233,
|
||||||
|
"runs": 1,
|
||||||
|
"compare": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"suite": "migration",
|
||||||
|
"timestamp": "2026-10-07T11:35:21.106818",
|
||||||
|
"git": "b2413ef",
|
||||||
|
"host": {
|
||||||
|
"os": "\"Windows 11 Home\" 10.0 (Build 26200)",
|
||||||
|
"cpus": 12
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"rooms": 20,
|
||||||
|
"eventsPerRoom": 1000
|
||||||
|
},
|
||||||
|
"metrics": {
|
||||||
|
"db_size_mb": {
|
||||||
|
"label": "DB-Größe vor Migration",
|
||||||
|
"unit": "MB",
|
||||||
|
"value": 7.793,
|
||||||
|
"runs": 1,
|
||||||
|
"compare": false
|
||||||
|
},
|
||||||
|
"migrate_ms": {
|
||||||
|
"label": "Migration gesamt",
|
||||||
|
"unit": "ms",
|
||||||
|
"value": 325.058,
|
||||||
|
"min": 322.058,
|
||||||
|
"runs": 3,
|
||||||
|
"compare": true
|
||||||
|
},
|
||||||
|
"migrate_ms_per_mb": {
|
||||||
|
"label": "Migration je MB",
|
||||||
|
"unit": "ms/MB",
|
||||||
|
"value": 41.712,
|
||||||
|
"runs": 1,
|
||||||
|
"compare": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
// Benchmarks sind Test-Code, liegen aber bewusst außerhalb von test/ (sie
|
||||||
|
// sollen in einem normalen `flutter test` NICHT mitlaufen) – deshalb kennt
|
||||||
|
// der Analyzer sie nicht als Tests.
|
||||||
|
// ignore_for_file: invalid_use_of_visible_for_testing_member
|
||||||
|
@TestOn('windows')
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:pyramid/core/app_database.dart';
|
||||||
|
import 'package:pyramid/features/search/local_message_search.dart';
|
||||||
|
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||||
|
|
||||||
|
import '../test/support/synthetic_matrix_data.dart';
|
||||||
|
import 'support/bench.dart';
|
||||||
|
|
||||||
|
/// Datenbank-Benchmark: Was kostet die SQLCipher-Verschlüsselung im Alltag?
|
||||||
|
///
|
||||||
|
/// Misst – jeweils für eine Klartext-DB und eine SQLCipher-DB mit DERSELBEN
|
||||||
|
/// Bibliothek aus dem App-Build – die vier Wege, auf denen Nutzer die DB
|
||||||
|
/// spüren:
|
||||||
|
/// 1. Sync-Schreiben (Nachrichten landen in Transaktionen wie beim Sync),
|
||||||
|
/// 2. App-Start (DB öffnen + SDK-Schema + Raumliste laden),
|
||||||
|
/// 3. Chat öffnen (50 neueste Nachrichten eines Raums),
|
||||||
|
/// 4. lokale Suche (Strg+K) – ohne Treffer = Worst Case, scannt alles.
|
||||||
|
///
|
||||||
|
/// Daten sind synthetisch (`test/support/synthetic_matrix_data.dart`), laufen
|
||||||
|
/// aber über die echte SDK-Speicherschicht. Lauf:
|
||||||
|
/// flutter test benchmark/db_benchmark_test.dart
|
||||||
|
/// oder alles zusammen über `scripts/test.ps1 -Bench`.
|
||||||
|
|
||||||
|
const _modes = ['klartext', 'sqlcipher'];
|
||||||
|
|
||||||
|
const _benchKey =
|
||||||
|
'0b1e2c3d4f5a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final skip = findCipherDll() == null
|
||||||
|
? 'SQLCipher-DLL fehlt – erst "flutter build windows" ausführen'
|
||||||
|
: null;
|
||||||
|
|
||||||
|
test(
|
||||||
|
'DB: Klartext vs. SQLCipher',
|
||||||
|
() async {
|
||||||
|
final rooms = scaled(20);
|
||||||
|
final eventsPerRoom = scaled(500);
|
||||||
|
final totalEvents = rooms * eventsPerRoom;
|
||||||
|
final report = BenchReport(
|
||||||
|
'db',
|
||||||
|
params: {'rooms': rooms, 'eventsPerRoom': eventsPerRoom},
|
||||||
|
);
|
||||||
|
final factory = cipherFactory();
|
||||||
|
final tempDir = await Directory.systemTemp.createTemp('pyramid_bench_db');
|
||||||
|
|
||||||
|
Future<Database> openBenchDb(String mode, String path) async {
|
||||||
|
final db = mode == 'sqlcipher'
|
||||||
|
? await AppDatabase.openEncrypted(factory, path, _benchKey)
|
||||||
|
: await factory.openDatabase(path);
|
||||||
|
await applyAppPragmas(db);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Ungemessener Aufwärm-Durchlauf (JIT, DB-Isolate, SQLCipher-Init).
|
||||||
|
// Bewusst mit voller Raumgröße: Der JIT optimiert die SDK-Pfade erst
|
||||||
|
// nach vielen Aufrufen mit wachsender Fragment-Liste – mit einem
|
||||||
|
// Mini-Durchlauf war die erste Messrunde noch ~45 % langsamer.
|
||||||
|
for (final mode in _modes) {
|
||||||
|
final path = p.join(tempDir.path, 'warmup_$mode.sqlite');
|
||||||
|
final client = await SyntheticMatrixData.openClient(
|
||||||
|
await openBenchDb(mode, path),
|
||||||
|
);
|
||||||
|
await SyntheticMatrixData.populate(
|
||||||
|
client,
|
||||||
|
rooms: (rooms ~/ 4).clamp(1, rooms),
|
||||||
|
eventsPerRoom: eventsPerRoom,
|
||||||
|
);
|
||||||
|
await SyntheticMatrixData.loadRooms(client);
|
||||||
|
await localMessageSearch(client, 'aufwärmen');
|
||||||
|
await client.dispose(closeDatabase: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gemessen wird abwechselnd A-B-B-A: Die Schreibkosten sind von
|
||||||
|
// Dart-Code (SDK-JSON) dominiert, und der JIT wird während des Laufs
|
||||||
|
// weiter schneller. Ohne das Muster gewinnt einfach der später
|
||||||
|
// gemessene Modus (2026-10-07 nachgemessen: ±20 % je nach Reihenfolge).
|
||||||
|
const order = ['klartext', 'sqlcipher', 'sqlcipher', 'klartext'];
|
||||||
|
final timings = {
|
||||||
|
for (final mode in _modes) mode: <String, List<double>>{},
|
||||||
|
};
|
||||||
|
void record(String mode, String metric, Iterable<double> values) =>
|
||||||
|
timings[mode]!.putIfAbsent(metric, () => []).addAll(values);
|
||||||
|
|
||||||
|
// 1. Sync-Schreiben – je Runde eine frische DB.
|
||||||
|
final paths = <String, String>{};
|
||||||
|
for (var round = 0; round < order.length; round++) {
|
||||||
|
final mode = order[round];
|
||||||
|
final path = p.join(tempDir.path, '${mode}_$round.sqlite');
|
||||||
|
final client = await SyntheticMatrixData.openClient(
|
||||||
|
await openBenchDb(mode, path),
|
||||||
|
);
|
||||||
|
final watch = Stopwatch()..start();
|
||||||
|
await SyntheticMatrixData.populate(
|
||||||
|
client,
|
||||||
|
rooms: rooms,
|
||||||
|
eventsPerRoom: eventsPerRoom,
|
||||||
|
needleEvery: 97,
|
||||||
|
);
|
||||||
|
watch.stop();
|
||||||
|
await client.dispose(closeDatabase: true);
|
||||||
|
record(mode, 'write', [watch.elapsedMicroseconds / 1000]);
|
||||||
|
paths[mode] = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. App-Start: DB öffnen + SDK-Schema + Raumliste, danach wieder
|
||||||
|
// schließen – wie ein Neustart. Eigene Runde VOR den Lese-Messungen:
|
||||||
|
// sqflite liefert für denselben Pfad dieselbe Verbindung zurück,
|
||||||
|
// ein Schließen hier würde sonst deren offene Verbindung kappen.
|
||||||
|
for (final mode in order) {
|
||||||
|
record(
|
||||||
|
mode,
|
||||||
|
'startup',
|
||||||
|
await sample(() async {
|
||||||
|
final c = await SyntheticMatrixData.openClient(
|
||||||
|
await openBenchDb(mode, paths[mode]!),
|
||||||
|
);
|
||||||
|
await SyntheticMatrixData.loadRooms(c);
|
||||||
|
await c.dispose(closeDatabase: true);
|
||||||
|
}, runs: 3),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3.–4. Lesen auf der jeweils zuletzt geschriebenen DB.
|
||||||
|
final clients = <String, Client>{};
|
||||||
|
final roomLists = <String, List<Room>>{};
|
||||||
|
for (final mode in _modes) {
|
||||||
|
final client = await SyntheticMatrixData.openClient(
|
||||||
|
await openBenchDb(mode, paths[mode]!),
|
||||||
|
);
|
||||||
|
clients[mode] = client;
|
||||||
|
roomLists[mode] = await SyntheticMatrixData.loadRooms(client);
|
||||||
|
expect(
|
||||||
|
roomLists[mode],
|
||||||
|
hasLength(rooms),
|
||||||
|
reason: 'Raumliste vollständig ($mode)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (final mode in order) {
|
||||||
|
final client = clients[mode]!;
|
||||||
|
record(
|
||||||
|
mode,
|
||||||
|
'open_chat',
|
||||||
|
(await sample(() async {
|
||||||
|
for (final room in roomLists[mode]!) {
|
||||||
|
final page = await client.database.getEventList(
|
||||||
|
room,
|
||||||
|
limit: 50,
|
||||||
|
);
|
||||||
|
if (page.length != 50) throw StateError('Seite unvollständig');
|
||||||
|
}
|
||||||
|
}, runs: 3)).map((t) => t / rooms),
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
mode,
|
||||||
|
'search_miss',
|
||||||
|
await sample(
|
||||||
|
() => localMessageSearch(client, 'gibtsnichtwort'),
|
||||||
|
runs: 2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
mode,
|
||||||
|
'search_hit',
|
||||||
|
await sample(
|
||||||
|
() => localMessageSearch(client, 'nadelwort'),
|
||||||
|
runs: 2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (final client in clients.values) {
|
||||||
|
await client.dispose(closeDatabase: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final mode in _modes) {
|
||||||
|
final t = timings[mode]!;
|
||||||
|
final writeMs = median(t['write']!);
|
||||||
|
report.addTimings(
|
||||||
|
'$mode.write_total_ms',
|
||||||
|
'[$mode] Sync-Schreiben, $totalEvents Nachrichten',
|
||||||
|
t['write']!,
|
||||||
|
);
|
||||||
|
report.addValue(
|
||||||
|
'$mode.write_per_event_us',
|
||||||
|
'[$mode] Sync-Schreiben je Nachricht',
|
||||||
|
writeMs * 1000 / totalEvents,
|
||||||
|
'µs',
|
||||||
|
compare: true,
|
||||||
|
);
|
||||||
|
report.addTimings(
|
||||||
|
'$mode.startup_ms',
|
||||||
|
'[$mode] App-Start: DB + Raumliste',
|
||||||
|
t['startup']!,
|
||||||
|
);
|
||||||
|
report.addTimings(
|
||||||
|
'$mode.open_chat_ms',
|
||||||
|
'[$mode] Chat öffnen (50 Nachr.)',
|
||||||
|
t['open_chat']!,
|
||||||
|
);
|
||||||
|
report.addTimings(
|
||||||
|
'$mode.search_miss_ms',
|
||||||
|
'[$mode] Suche ohne Treffer (Worst Case)',
|
||||||
|
t['search_miss']!,
|
||||||
|
);
|
||||||
|
report.addTimings(
|
||||||
|
'$mode.search_hit_ms',
|
||||||
|
'[$mode] Suche mit Treffern',
|
||||||
|
t['search_hit']!,
|
||||||
|
);
|
||||||
|
report.addValue(
|
||||||
|
'$mode.size_mb',
|
||||||
|
'[$mode] DB-Größe inkl. WAL',
|
||||||
|
dbSizeMb(paths[mode]!),
|
||||||
|
'MB',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final (id, label) in [
|
||||||
|
('write_total_ms', 'Sync-Schreiben'),
|
||||||
|
('startup_ms', 'App-Start'),
|
||||||
|
('open_chat_ms', 'Chat öffnen'),
|
||||||
|
('search_miss_ms', 'Suche ohne Treffer'),
|
||||||
|
]) {
|
||||||
|
final plain = report.valueOf('klartext.$id');
|
||||||
|
final enc = report.valueOf('sqlcipher.$id');
|
||||||
|
report.addValue(
|
||||||
|
'cipher_overhead.$id',
|
||||||
|
'Aufschlag Verschlüsselung: $label',
|
||||||
|
(enc / plain - 1) * 100,
|
||||||
|
'%',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final regressions = await report.finish();
|
||||||
|
if (strictBench) expect(regressions, isEmpty);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await tempDir.delete(recursive: true);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
skip: skip,
|
||||||
|
timeout: const Timeout(Duration(minutes: 20)),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// Benchmarks sind Test-Code, liegen aber bewusst außerhalb von test/ (sie
|
||||||
|
// sollen in einem normalen `flutter test` NICHT mitlaufen) – deshalb kennt
|
||||||
|
// der Analyzer sie nicht als Tests.
|
||||||
|
// ignore_for_file: invalid_use_of_visible_for_testing_member
|
||||||
|
@TestOn('windows')
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:pyramid/core/app_database.dart';
|
||||||
|
|
||||||
|
import '../test/support/synthetic_matrix_data.dart';
|
||||||
|
import 'support/bench.dart';
|
||||||
|
|
||||||
|
/// Migrations-Benchmark: Wie lange dauert die einmalige Umstellung
|
||||||
|
/// Klartext → SQLCipher beim ersten Start nach dem Update (Utas Fall)?
|
||||||
|
///
|
||||||
|
/// Baut eine Klartext-DB über die echte SDK-Speicherschicht, kopiert sie vor
|
||||||
|
/// jedem Durchlauf frisch und misst `AppDatabase.migratePlaintextToEncrypted`
|
||||||
|
/// (exakt der App-Codepfad: Checkpoint, Backup, Export, Verifikation, Tausch).
|
||||||
|
/// Danach wird geprüft, dass die Raumliste aus der verschlüsselten DB wieder
|
||||||
|
/// vollständig lädt. Ergebnis zusätzlich als ms pro MB – damit lässt sich die
|
||||||
|
/// Dauer für eine echte DB-Größe hochrechnen.
|
||||||
|
|
||||||
|
const _benchKey =
|
||||||
|
'5e4d3c2b1a0f9e8d7c6b5a4f3e2d1c0b5e4d3c2b1a0f9e8d7c6b5a4f3e2d1c0b';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final skip = findCipherDll() == null
|
||||||
|
? 'SQLCipher-DLL fehlt – erst "flutter build windows" ausführen'
|
||||||
|
: null;
|
||||||
|
|
||||||
|
test(
|
||||||
|
'Migration Klartext → SQLCipher',
|
||||||
|
() async {
|
||||||
|
final rooms = scaled(20);
|
||||||
|
final eventsPerRoom = scaled(1000);
|
||||||
|
final report = BenchReport(
|
||||||
|
'migration',
|
||||||
|
params: {'rooms': rooms, 'eventsPerRoom': eventsPerRoom},
|
||||||
|
);
|
||||||
|
final factory = cipherFactory();
|
||||||
|
final tempDir = await Directory.systemTemp.createTemp(
|
||||||
|
'pyramid_bench_migration',
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Vorlage: Klartext-DB wie vor der SQLCipher-Einführung.
|
||||||
|
final templatePath = p.join(tempDir.path, 'template.sqlite');
|
||||||
|
var db = await factory.openDatabase(templatePath);
|
||||||
|
await applyAppPragmas(db);
|
||||||
|
var client = await SyntheticMatrixData.openClient(db);
|
||||||
|
await SyntheticMatrixData.populate(
|
||||||
|
client,
|
||||||
|
rooms: rooms,
|
||||||
|
eventsPerRoom: eventsPerRoom,
|
||||||
|
);
|
||||||
|
await client.dispose(closeDatabase: true);
|
||||||
|
// WAL in die Hauptdatei schreiben, damit die Kopie vollständig ist.
|
||||||
|
db = await factory.openDatabase(templatePath);
|
||||||
|
await db.rawQuery('PRAGMA wal_checkpoint(TRUNCATE)');
|
||||||
|
await db.close();
|
||||||
|
final sizeMb = dbSizeMb(templatePath);
|
||||||
|
|
||||||
|
final workPath = p.join(tempDir.path, 'pyramid.sqlite');
|
||||||
|
Future<void> freshCopy() async {
|
||||||
|
for (final suffix in ['', '-wal', '-shm', '.enc', '.premigration']) {
|
||||||
|
final f = File('$workPath$suffix');
|
||||||
|
if (f.existsSync()) f.deleteSync();
|
||||||
|
}
|
||||||
|
await File(templatePath).copy(workPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
final times = await sample(
|
||||||
|
() async {
|
||||||
|
final ok = await AppDatabase.migratePlaintextToEncrypted(
|
||||||
|
factory: factory,
|
||||||
|
dbPath: workPath,
|
||||||
|
hexKey: _benchKey,
|
||||||
|
);
|
||||||
|
if (!ok) throw StateError('Migration fehlgeschlagen');
|
||||||
|
},
|
||||||
|
setup: freshCopy,
|
||||||
|
warmup: 0,
|
||||||
|
runs: 3,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ergebnis der letzten Migration prüfen: verschlüsselt + vollständig.
|
||||||
|
expect(await AppDatabase.isPlaintextSqlite(File(workPath)), isFalse);
|
||||||
|
db = await AppDatabase.openEncrypted(factory, workPath, _benchKey);
|
||||||
|
client = await SyntheticMatrixData.openClient(db);
|
||||||
|
final loaded = await SyntheticMatrixData.loadRooms(client);
|
||||||
|
final firstPage = await client.database.getEventList(
|
||||||
|
loaded.first,
|
||||||
|
limit: 50,
|
||||||
|
);
|
||||||
|
await client.dispose(closeDatabase: true);
|
||||||
|
expect(loaded, hasLength(rooms));
|
||||||
|
expect(firstPage, hasLength(50));
|
||||||
|
|
||||||
|
report.addValue('db_size_mb', 'DB-Größe vor Migration', sizeMb, 'MB');
|
||||||
|
report.addTimings('migrate_ms', 'Migration gesamt', times);
|
||||||
|
report.addValue(
|
||||||
|
'migrate_ms_per_mb',
|
||||||
|
'Migration je MB',
|
||||||
|
median(times) / sizeMb,
|
||||||
|
'ms/MB',
|
||||||
|
compare: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
final regressions = await report.finish();
|
||||||
|
if (strictBench) expect(regressions, isEmpty);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await tempDir.delete(recursive: true);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
skip: skip,
|
||||||
|
timeout: const Timeout(Duration(minutes: 20)),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:ffi';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||||
|
import 'package:sqlite3/open.dart';
|
||||||
|
|
||||||
|
/// Kleines Benchmark-Gerüst für `benchmark/` (bewusst ohne Zusatzpaket).
|
||||||
|
///
|
||||||
|
/// Jede Suite sammelt Messwerte in einem [BenchReport]. Am Ende:
|
||||||
|
/// * Tabelle in der Konsole (inkl. Vergleich mit der Baseline),
|
||||||
|
/// * JSON nach `build/benchmarks/<suite>.json` (für spätere Vergleiche),
|
||||||
|
/// * Vergleich mit `benchmark/baseline/<suite>.json`, falls vorhanden.
|
||||||
|
///
|
||||||
|
/// Umgebungsvariablen:
|
||||||
|
/// * `PYRAMID_BENCH_UPDATE_BASELINE=1` – aktuelle Werte als neue Baseline
|
||||||
|
/// speichern (nach einer bewussten Änderung / auf neuer Hardware).
|
||||||
|
/// * `PYRAMID_BENCH_STRICT=1` – Regressionen lassen den Lauf scheitern
|
||||||
|
/// (sonst nur Warnung, weil Messrauschen normal ist).
|
||||||
|
/// * `PYRAMID_BENCH_SCALE` – Datenmenge skalieren (Standard 1.0;
|
||||||
|
/// z. B. 0.2 für einen schnellen Probelauf, 3 für „Power-User").
|
||||||
|
|
||||||
|
/// Ab so viel Prozent langsamer als die Baseline gilt ein Wert als Regression.
|
||||||
|
const regressionThresholdPercent = 25.0;
|
||||||
|
|
||||||
|
/// Unterhalb dieser absoluten Differenz (ms) wird nie gewarnt – bei sehr
|
||||||
|
/// kleinen Werten ist das reines Rauschen.
|
||||||
|
const regressionFloorMs = 2.0;
|
||||||
|
|
||||||
|
double get benchScale =>
|
||||||
|
double.tryParse(Platform.environment['PYRAMID_BENCH_SCALE'] ?? '') ?? 1.0;
|
||||||
|
|
||||||
|
int scaled(int value) => (value * benchScale).round().clamp(1, 1 << 30);
|
||||||
|
|
||||||
|
/// Führt [body] erst [warmup]-mal ungemessen, dann [runs]-mal gemessen aus
|
||||||
|
/// und liefert die Laufzeiten in Millisekunden. [setup] läuft vor JEDEM
|
||||||
|
/// Durchlauf und wird nicht mitgemessen.
|
||||||
|
Future<List<double>> sample(
|
||||||
|
Future<void> Function() body, {
|
||||||
|
int warmup = 1,
|
||||||
|
int runs = 5,
|
||||||
|
Future<void> Function()? setup,
|
||||||
|
}) async {
|
||||||
|
for (var i = 0; i < warmup; i++) {
|
||||||
|
if (setup != null) await setup();
|
||||||
|
await body();
|
||||||
|
}
|
||||||
|
final out = <double>[];
|
||||||
|
for (var i = 0; i < runs; i++) {
|
||||||
|
if (setup != null) await setup();
|
||||||
|
final sw = Stopwatch()..start();
|
||||||
|
await body();
|
||||||
|
sw.stop();
|
||||||
|
out.add(sw.elapsedMicroseconds / 1000.0);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
double median(List<double> values) {
|
||||||
|
final sorted = [...values]..sort();
|
||||||
|
final mid = sorted.length ~/ 2;
|
||||||
|
return sorted.length.isOdd
|
||||||
|
? sorted[mid]
|
||||||
|
: (sorted[mid - 1] + sorted[mid]) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
class BenchMetric {
|
||||||
|
BenchMetric({
|
||||||
|
required this.id,
|
||||||
|
required this.label,
|
||||||
|
required this.unit,
|
||||||
|
required this.value,
|
||||||
|
this.min,
|
||||||
|
this.runs = 1,
|
||||||
|
this.compare = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String label;
|
||||||
|
final String unit;
|
||||||
|
|
||||||
|
/// Median (bzw. Einzelwert) – der Wert, der verglichen wird.
|
||||||
|
final double value;
|
||||||
|
final double? min;
|
||||||
|
final int runs;
|
||||||
|
|
||||||
|
/// `false` für reine Info-Werte (z. B. Dateigröße), die nicht als
|
||||||
|
/// Regression gewertet werden.
|
||||||
|
final bool compare;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'label': label,
|
||||||
|
'unit': unit,
|
||||||
|
'value': double.parse(value.toStringAsFixed(3)),
|
||||||
|
if (min != null) 'min': double.parse(min!.toStringAsFixed(3)),
|
||||||
|
'runs': runs,
|
||||||
|
'compare': compare,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class BenchReport {
|
||||||
|
BenchReport(this.suite, {this.params = const {}});
|
||||||
|
|
||||||
|
final String suite;
|
||||||
|
final Map<String, Object?> params;
|
||||||
|
final _metrics = <BenchMetric>[];
|
||||||
|
|
||||||
|
/// Laufzeit-Messreihe (ms) als Metrik ablegen.
|
||||||
|
void addTimings(String id, String label, List<double> timingsMs) {
|
||||||
|
_metrics.add(
|
||||||
|
BenchMetric(
|
||||||
|
id: id,
|
||||||
|
label: label,
|
||||||
|
unit: 'ms',
|
||||||
|
value: median(timingsMs),
|
||||||
|
min: timingsMs.reduce((a, b) => a < b ? a : b),
|
||||||
|
runs: timingsMs.length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void addValue(
|
||||||
|
String id,
|
||||||
|
String label,
|
||||||
|
double value,
|
||||||
|
String unit, {
|
||||||
|
bool compare = false,
|
||||||
|
}) {
|
||||||
|
_metrics.add(
|
||||||
|
BenchMetric(
|
||||||
|
id: id,
|
||||||
|
label: label,
|
||||||
|
unit: unit,
|
||||||
|
value: value,
|
||||||
|
compare: compare,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
double valueOf(String id) => _metrics.firstWhere((m) => m.id == id).value;
|
||||||
|
|
||||||
|
/// Schreibt Bericht + ggf. Baseline und liefert die Liste der Regressionen
|
||||||
|
/// (leer = alles im Rahmen).
|
||||||
|
Future<List<String>> finish() async {
|
||||||
|
final baselineFile = File('benchmark/baseline/$suite.json');
|
||||||
|
Map<String, Object?>? baseline;
|
||||||
|
if (baselineFile.existsSync()) {
|
||||||
|
final json =
|
||||||
|
jsonDecode(baselineFile.readAsStringSync()) as Map<String, Object?>;
|
||||||
|
baseline = (json['metrics'] as Map).cast<String, Object?>();
|
||||||
|
}
|
||||||
|
|
||||||
|
final regressions = <String>[];
|
||||||
|
final buf = StringBuffer()
|
||||||
|
..writeln()
|
||||||
|
..writeln(
|
||||||
|
'══ Benchmark "$suite" '
|
||||||
|
'${params.isEmpty ? '' : params.entries.map((e) => '${e.key}=${e.value}').join(', ')}',
|
||||||
|
)
|
||||||
|
..writeln(
|
||||||
|
'${'Messung'.padRight(52)}${'Wert'.padLeft(12)} ${'Baseline'.padLeft(10)} Δ',
|
||||||
|
);
|
||||||
|
for (final m in _metrics) {
|
||||||
|
final base = (baseline?[m.id] as Map?)?['value'] as num?;
|
||||||
|
var delta = '';
|
||||||
|
// Für Prozent-Werte (z. B. Verschlüsselungs-Aufschlag) wäre ein
|
||||||
|
// relatives Δ irreführend (4 % → 11 % = „+150 %“) – dort nur die Werte.
|
||||||
|
if (base != null && base > 0 && m.unit != '%') {
|
||||||
|
final pct = (m.value - base) / base * 100;
|
||||||
|
delta = '${pct >= 0 ? '+' : ''}${pct.toStringAsFixed(0)} %';
|
||||||
|
if (m.compare &&
|
||||||
|
pct > regressionThresholdPercent &&
|
||||||
|
(m.unit != 'ms' || m.value - base > regressionFloorMs)) {
|
||||||
|
delta += ' ⚠ LANGSAMER';
|
||||||
|
regressions.add(
|
||||||
|
'${m.label}: ${_fmt(m.value)} ${m.unit} '
|
||||||
|
'(Baseline ${_fmt(base.toDouble())} ${m.unit}, $delta)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buf.writeln(
|
||||||
|
'${m.label.padRight(52)}'
|
||||||
|
'${'${_fmt(m.value)} ${m.unit}'.padLeft(12)} '
|
||||||
|
'${base == null ? '–'.padLeft(10) : _fmt(base.toDouble()).padLeft(10)} $delta',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// ignore: avoid_print
|
||||||
|
print(buf);
|
||||||
|
|
||||||
|
final report = {
|
||||||
|
'suite': suite,
|
||||||
|
'timestamp': DateTime.now().toIso8601String(),
|
||||||
|
'git': await _gitRevision(),
|
||||||
|
'host': {
|
||||||
|
'os': Platform.operatingSystemVersion,
|
||||||
|
'cpus': Platform.numberOfProcessors,
|
||||||
|
},
|
||||||
|
'params': params,
|
||||||
|
'metrics': {for (final m in _metrics) m.id: m.toJson()},
|
||||||
|
};
|
||||||
|
const encoder = JsonEncoder.withIndent(' ');
|
||||||
|
final outFile = File('build/benchmarks/$suite.json');
|
||||||
|
outFile.parent.createSync(recursive: true);
|
||||||
|
outFile.writeAsStringSync(encoder.convert(report));
|
||||||
|
|
||||||
|
if (Platform.environment['PYRAMID_BENCH_UPDATE_BASELINE'] == '1') {
|
||||||
|
baselineFile.parent.createSync(recursive: true);
|
||||||
|
baselineFile.writeAsStringSync('${encoder.convert(report)}\n');
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('Baseline aktualisiert: ${baselineFile.path}');
|
||||||
|
}
|
||||||
|
return regressions;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _fmt(double v) =>
|
||||||
|
v >= 100 ? v.toStringAsFixed(0) : v.toStringAsFixed(v >= 10 ? 1 : 2);
|
||||||
|
|
||||||
|
static Future<String?> _gitRevision() async {
|
||||||
|
try {
|
||||||
|
final r = await Process.run('git', ['rev-parse', '--short', 'HEAD']);
|
||||||
|
return r.exitCode == 0 ? (r.stdout as String).trim() : null;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get strictBench => Platform.environment['PYRAMID_BENCH_STRICT'] == '1';
|
||||||
|
|
||||||
|
// ── SQLCipher aus dem App-Build ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Die vom sqlcipher_flutter_libs-Plugin gebaute `sqlite3.dll` – dieselbe
|
||||||
|
/// Bibliothek, die die App benutzt. `null`, wenn noch nie gebaut wurde.
|
||||||
|
String? findCipherDll() {
|
||||||
|
for (final config in ['Release', 'Profile', 'Debug']) {
|
||||||
|
final file = File('build/windows/x64/runner/$config/sqlite3.dll');
|
||||||
|
if (file.existsSync()) return file.absolute.path;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Läuft (wie in der App) im Hintergrund-Isolate von sqflite_common_ffi –
|
||||||
|
/// deshalb top-level und ohne eingefangene Variablen.
|
||||||
|
void cipherFfiInit() {
|
||||||
|
final dll = findCipherDll();
|
||||||
|
if (dll == null) throw StateError('SQLCipher-DLL fehlt');
|
||||||
|
open.overrideFor(OperatingSystem.windows, () => DynamicLibrary.open(dll));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Factory wie in der App: eigenes DB-Isolate, SQLCipher-Bibliothek.
|
||||||
|
/// Ohne `PRAGMA key` verhält sich SQLCipher wie normales SQLite – so laufen
|
||||||
|
/// Klartext- und verschlüsselte Messung mit DERSELBEN Bibliothek und sind
|
||||||
|
/// fair vergleichbar.
|
||||||
|
DatabaseFactory cipherFactory() =>
|
||||||
|
createDatabaseFactoryFfi(ffiInit: cipherFfiInit);
|
||||||
|
|
||||||
|
/// Dieselben PRAGMAs wie `AppDatabase._applyPragmas` (dort privat).
|
||||||
|
Future<void> applyAppPragmas(Database db) async {
|
||||||
|
await db.execute('PRAGMA journal_mode=WAL');
|
||||||
|
await db.execute('PRAGMA busy_timeout = 5000');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gesamtgröße der DB inkl. WAL-Sidecar in MB.
|
||||||
|
double dbSizeMb(String path) {
|
||||||
|
var bytes = 0;
|
||||||
|
for (final suffix in ['', '-wal']) {
|
||||||
|
final f = File('$path$suffix');
|
||||||
|
if (f.existsSync()) bytes += f.lengthSync();
|
||||||
|
}
|
||||||
|
return bytes / (1024 * 1024);
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
# Dashboard-Server absichern: Admin-Endpoints haben KEINE Authentifizierung
|
||||||
|
|
||||||
|
**Status (ABGESCHLOSSEN 2026-07-05): Das Loch ist mit dem Heimnetz-Gate
|
||||||
|
(„Weg C", siehe unten) geschlossen, und Bernd hat entschieden
|
||||||
|
(`ANTWORTEN_BERND.md` Nr. 3): KEIN Fernzugriff – das Gate ist der ENDZUSTAND.**
|
||||||
|
Die fünf Admin-Endpoints akzeptieren nur noch Anfragen aus dem
|
||||||
|
Heimnetz/localhost; alles, was über den Cloudflare-Tunnel kommt, bekommt 403 mit
|
||||||
|
einer Erklärung (die das Dashboard per `alert()` anzeigt). Bernds Dashboard
|
||||||
|
funktioniert im Heimnetz unverändert unter
|
||||||
|
`http://192.168.178.71:8080/stats.html`; unterwegs kommt Bernd per WireGuard ins
|
||||||
|
Heimnetz. Die A/B-Frage (Cloudflare Access vs. `DASH_TOKEN`) ist damit
|
||||||
|
GESCHLOSSEN – die Optionen unten bleiben nur als Archiv stehen, falls sich der
|
||||||
|
Bedarf je ändert. Die unten dokumentierten Restrisiken des Gates sind bewusst
|
||||||
|
akzeptiert. **Nicht weiter an diesem Thema arbeiten.**
|
||||||
|
|
||||||
|
## Heimnetz-Gate („Weg C", aktiv seit 2026-07-04, seit 2026-07-05 Endzustand)
|
||||||
|
|
||||||
|
In `server.py` (Konstante `ADMIN_PATHS` + Helfer `lan_denied_reason`): eine
|
||||||
|
Admin-POST-Anfrage wird nur angenommen, wenn (1) **kein** `Cf-Connecting-Ip`/
|
||||||
|
`Cf-Ray`-Header vorhanden ist (= sie kam nicht durch den Cloudflare-Tunnel) und
|
||||||
|
(2) die **Socket-Absender-IP** (nicht spoofbar) `127.0.0.1`/`::1`/`192.168.*`
|
||||||
|
ist. Abgelehnte Versuche landen größenbegrenzt (5 MB-Deckel) in
|
||||||
|
`security_alerts.log` und damit in der Dashboard-Anzeige. Headless verifiziert:
|
||||||
|
öffentliche URL → 403 (ban/toggle), Heimnetz-IP und localhost → App-Validierung
|
||||||
|
erreicht (400 bei leerem Nutzer), gespoofter Cf-Header aus dem LAN → 403,
|
||||||
|
`GET /` öffentlich → 200, `/api/livekit-token` (401 bei ungültigem Token) und
|
||||||
|
`/api/e2ee-diagnostics` (403 bei falschem Token) unverändert öffentlich
|
||||||
|
erreichbar (die App braucht sie, eigene Auth vorhanden).
|
||||||
|
|
||||||
|
**Bekannte Restrisiken des Gates (Bernd bekannt und bewusst akzeptiert,
|
||||||
|
Entscheidung 2026-07-05):**
|
||||||
|
- Die **öffentliche Ansicht bleibt offen** und zeigt Nutzerliste + Präsenz
|
||||||
|
(Informations-Preisgabe). Weg A würde auch das schließen.
|
||||||
|
- **CSRF aus dem Heimnetz:** Eine bösartige Webseite in Bernds Browser könnte
|
||||||
|
theoretisch POSTs an `http://192.168.178.71:8080` schicken (moderne
|
||||||
|
Chrome-Versionen blocken Public→Private-Requests, Firefox nicht vollständig).
|
||||||
|
Erst ein Token/Access-Login (Weg A/B) schließt das sauber.
|
||||||
|
- Wer **im Heimnetz** ist, kann weiterhin ohne Anmeldung administrieren.
|
||||||
|
|
||||||
|
## Problem (belegt, nicht vermutet)
|
||||||
|
|
||||||
|
Der Dashboard-Server `/home/steggi/matrix/server.py` (läuft als
|
||||||
|
`matrix-stats.service` auf `0.0.0.0:8080`) ist über Cloudflare unter
|
||||||
|
`https://dashboard.steggi-matrix.work` **öffentlich aus dem Internet erreichbar**.
|
||||||
|
Getestet: `GET /` liefert 200 (stats.html), und die **zustandsändernden POST-Endpoints
|
||||||
|
antworten mit der App-eigenen Validierung – ohne jede Authentifizierung**:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ curl -X POST https://dashboard.steggi-matrix.work/api/ban -d '{"user":""}'
|
||||||
|
{"error": "Kein Nutzer angegeben"} # HTTP 400 – App-Antwort, KEIN Login davor
|
||||||
|
```
|
||||||
|
|
||||||
|
Damit kann **jeder Fremde im Internet**, der den Hostnamen kennt, ohne Anmeldung:
|
||||||
|
|
||||||
|
- `/api/ban` / `/api/unban` – **jeden Matrix-Nutzer sperren/entsperren** (auch Uta!)
|
||||||
|
→ Aussperrung / Denial-of-Service gegen echte Nutzer.
|
||||||
|
- `/api/toggle-registration` und `/api/create-invite` – **offene Registrierung am
|
||||||
|
Homeserver aktivieren** (setzt `allow_registration = true` +
|
||||||
|
`yes_i_am_very_very_sure…`) → der Homeserver wird zur Spam-/Missbrauchsschleuder.
|
||||||
|
- `/api/run-stats` – Stats-Skript auslösen (harmlos, aber ebenfalls ungeschützt).
|
||||||
|
|
||||||
|
**Der Hostname ist nicht wirklich geheim:** Cloudflare stellt TLS-Zertifikate aus,
|
||||||
|
die in den öffentlichen Certificate-Transparency-Logs landen – jede Subdomain von
|
||||||
|
`steggi-matrix.work` ist per CT-Log-Scan auffindbar. Die bisherige „Sicherheit"
|
||||||
|
beruht also allein auf Verborgenheit eines Namens, der praktisch entdeckbar ist.
|
||||||
|
|
||||||
|
Bereits **korrekt abgesichert** (zum Vergleich, nicht betroffen):
|
||||||
|
- `/api/e2ee-diagnostics` – eigener `DIAG_TOKEN` (Bearer).
|
||||||
|
- `/api/livekit-token` – Matrix-Access-Token per `whoami` gegen Continuwuity.
|
||||||
|
|
||||||
|
Nur die vier Admin-/Stats-Endpoints oben sind offen.
|
||||||
|
|
||||||
|
## Wie das Dashboard heute aufruft
|
||||||
|
|
||||||
|
`stats.html` ruft alle Aktionen als **Same-Origin-`fetch` ohne Auth-Header** auf
|
||||||
|
(`toggleRegistration`, `createInvite`, `refreshStats`, `doUserAction` →
|
||||||
|
`/api/ban` /`/api/unban`). Es gibt keine Session, kein Cookie, keinen Token –
|
||||||
|
deshalb bricht **jede** hinzugefügte Authentifizierung das Dashboard so lange,
|
||||||
|
bis `stats.html` den Token mitschickt. Genau darum ist das eine Bernd-Entscheidung
|
||||||
|
und kein stiller Fix.
|
||||||
|
|
||||||
|
## Option A (empfohlen, robust): Cloudflare Access vor die Subdomain
|
||||||
|
|
||||||
|
Am saubersten wird das Dashboard **komplett** durch Cloudflare Access geschützt
|
||||||
|
(Zero-Trust-Login vor der Subdomain), statt in `server.py` Auth nachzubauen:
|
||||||
|
|
||||||
|
1. Im Cloudflare-Zero-Trust-Dashboard eine **Access-Application** für
|
||||||
|
`dashboard.steggi-matrix.work` anlegen.
|
||||||
|
2. Policy: nur Bernds E-Mail (`[email protected]`) per One-Time-PIN
|
||||||
|
oder Google-Login zulassen.
|
||||||
|
3. Fertig – `server.py` und `stats.html` bleiben **unverändert**, das Dashboard
|
||||||
|
funktioniert für Bernd wie bisher (nach dem Access-Login), aber Fremde kommen
|
||||||
|
gar nicht erst an die Endpoints.
|
||||||
|
|
||||||
|
Vorteil: kein Secret im Code, kein Umbau der HTML-Fetches, kein Aussperr-Risiko
|
||||||
|
durch vergessene Tokens. Nachteil: Bernd muss die Access-App einmal im
|
||||||
|
Cloudflare-Panel klicken (kein Code, aber sein Konto nötig). **Das ist der
|
||||||
|
Königsweg – Option B nur, falls Access nicht gewünscht ist.**
|
||||||
|
|
||||||
|
## Option B (Fallback, rein in server.py): eigener Dashboard-Token
|
||||||
|
|
||||||
|
Wenn Access nicht gewünscht ist, ein **neuer, zufälliger `DASH_TOKEN`** (getrennt
|
||||||
|
vom geleakten Admin-Token `J5lax…` und vom `DIAG_TOKEN`!), den die vier
|
||||||
|
Admin-/Stats-Endpoints als `Authorization: Bearer <DASH_TOKEN>` verlangen:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# server.py, zu den anderen Token-Konstanten:
|
||||||
|
DASH_TOKEN = "<neu erzeugen: python3 -c 'import secrets;print(\"dash-\"+secrets.token_urlsafe(24))'>"
|
||||||
|
|
||||||
|
def _dash_authed(self):
|
||||||
|
return self.headers.get("Authorization", "") == "Bearer " + DASH_TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
In `do_POST` **vor** der Behandlung von `/api/ban`, `/api/unban`,
|
||||||
|
`/api/toggle-registration`, `/api/create-invite`, `/api/run-stats` einfügen:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if self.path in ("/api/ban", "/api/unban", "/api/toggle-registration",
|
||||||
|
"/api/create-invite", "/api/run-stats") and not self._dash_authed():
|
||||||
|
self.send_error(403)
|
||||||
|
return
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /` und `/api/registration-status` bleiben öffentlich (nur Lesen), damit die
|
||||||
|
Stats-Seite ohne Token sichtbar bleibt.
|
||||||
|
|
||||||
|
**`stats.html`** darf den Token **NIEMALS fest eingebettet** ausliefern (die Seite
|
||||||
|
ist öffentlich – der Token läge sonst für jeden offen). Stattdessen einmal im
|
||||||
|
Browser abfragen und in `localStorage` halten:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function dashToken() {
|
||||||
|
var t = localStorage.getItem('dashToken');
|
||||||
|
if (!t) { t = prompt('Dashboard-Token:'); if (t) localStorage.setItem('dashToken', t); }
|
||||||
|
return t || '';
|
||||||
|
}
|
||||||
|
// und bei JEDEM Aktions-fetch:
|
||||||
|
fetch('/api/ban', { method:'POST',
|
||||||
|
headers: {'Content-Type':'application/json', 'Authorization':'Bearer ' + dashToken()},
|
||||||
|
body: JSON.stringify({user:u}) })
|
||||||
|
```
|
||||||
|
|
||||||
|
(betrifft `toggleRegistration`, `createInvite`, `refreshStats`, `doUserAction`).
|
||||||
|
Bei 403 den gecachten Token verwerfen (`localStorage.removeItem('dashToken')`) und
|
||||||
|
neu fragen. Bernd gibt den Token so genau **einmal pro Browser** ein.
|
||||||
|
|
||||||
|
## Rollout / Testplan
|
||||||
|
|
||||||
|
**Option A:** Access-App klicken → von einem fremden Netz/Inkognito prüfen, dass
|
||||||
|
`https://dashboard.steggi-matrix.work/` zum Access-Login umleitet statt die Seite
|
||||||
|
zu zeigen; dann als Bernd einloggen und alle Buttons testen. Kein Code-Deploy.
|
||||||
|
|
||||||
|
**Option B (headless auf dem Pi verifizierbar):**
|
||||||
|
1. `server.py` + `stats.html` ändern, Dienst-Neustart (Prozess killen,
|
||||||
|
`Restart=always` lädt neu – siehe PROGRESS 2026-07-04 zur polkit-Falle).
|
||||||
|
2. `curl -X POST .../api/ban -d '{"user":"x"}'` **ohne** Bearer → **403** erwartet.
|
||||||
|
3. Mit `Authorization: Bearer <DASH_TOKEN>` + leerem Nutzer → 400 (App-Validierung
|
||||||
|
erreicht) → beweist: Token akzeptiert, Auth greift.
|
||||||
|
4. Im Browser: Dashboard öffnen, Token eingeben, je ein Button testen
|
||||||
|
(Registrierung toggeln + sofort zurück, Stats aktualisieren; Ban/Unban an
|
||||||
|
einem **Test-Nutzer**, nicht an Uta).
|
||||||
|
|
||||||
|
## Warum erst nur der Plan kam – und dann doch das Interims-Gate
|
||||||
|
|
||||||
|
Eine Token-/Access-Absicherung (A/B) macht die Dashboard-Buttons überall
|
||||||
|
funktionslos, bis Bernd aktiv etwas tut (Token eingeben bzw. Access-App
|
||||||
|
einrichten) – das mitten in einer autonomen Session zu deployen verletzt die
|
||||||
|
CLAUDE.md-Leitplanke „kein Aussperren". Das *Wie* der Anmeldung bleibt deshalb
|
||||||
|
Bernds Richtungsentscheidung. Das später ergänzte Interims-Gate umgeht dieses
|
||||||
|
Dilemma: Es braucht keinerlei Aktion von Bernd (Heimnetz-Nutzung läuft
|
||||||
|
unverändert weiter, inkl. spontanem Spam-Bann von zu Hause), sperrt aber
|
||||||
|
Fremde aus dem Internet sofort aus. Einzige Einschränkung: Admin-Buttons über
|
||||||
|
die öffentliche URL gehen nicht – die 403-Meldung im Dashboard erklärt das und
|
||||||
|
nennt die Heimnetz-URL. Mit Bernds Entscheidung vom 2026-07-05 (Heimnetz-only,
|
||||||
|
unterwegs WireGuard) ist genau dieser Zustand der gewollte Endzustand. Rückbau
|
||||||
|
(falls je nötig) = die Gate-Zeilen in `server.py` entfernen
|
||||||
|
(Backup: `server.py.bak-20260704-dashgate`).
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# LiveKit-Token server-seitig minten (apiSecret aus dem Client entfernen)
|
||||||
|
|
||||||
|
**Status (2026-10-07):** Server-Route UND Client umgestellt.
|
||||||
|
|
||||||
|
- Server: `POST /api/livekit-token` im Dashboard-Server auf dem Pi
|
||||||
|
(`/home/steggi/matrix/server.py`) – whoami-Prüfung, Identität = geprüfte
|
||||||
|
Matrix-ID, Secret aus `livekit.yaml`. Geprüft: ungültiger Token → 401
|
||||||
|
(öffentlich über `dashboard.steggi-matrix.work`), gültiger Token → JWT mit
|
||||||
|
`sub` = Matrix-ID und denselben Grants wie früher.
|
||||||
|
- Client: `lib/core/livekit_token.dart` holt das Token per
|
||||||
|
`LiveKitTokenService.fetch` (kein Secret, kein JWT-Signieren mehr in der App);
|
||||||
|
`livekit_call_manager.dart` nutzt es für alle LiveKit-Beitritte. Fehler
|
||||||
|
(offline, 401, Serverfehler) erscheinen als lesbare Meldung im Anruf.
|
||||||
|
- Tests: `test/livekit_token_test.dart` (Anfrage, Fehlerpfade, kein Secret im
|
||||||
|
Code) und `integration_test/livekit_token_connect_test.dart` (ECHTER
|
||||||
|
LiveKit-Beitritt mit Server-Token, Testkonto pyramidtest1, Wegwerf-Raum) –
|
||||||
|
grün am 2026-10-07.
|
||||||
|
|
||||||
|
**Offen:**
|
||||||
|
1. Anruf/Sprachkanal auf dem echten Handy prüfen (Ton, Video, Bildschirm
|
||||||
|
teilen, zweiter Teilnehmer, Verlassen/Wiederbeitreten).
|
||||||
|
2. Erst wenn ALLE genutzten Geräte (Bernd + Uta) die neue Version haben:
|
||||||
|
Secret rotieren (siehe „Rotation“ unten) – das macht Bernd, nicht Claude.
|
||||||
|
Bis dahin funktionieren alte und neue App-Versionen parallel.
|
||||||
|
3. Neue Abhängigkeit beachten: Anrufe brauchen jetzt zusätzlich den
|
||||||
|
Dashboard-Server (`matrix-stats.service`). Ist er aus, meldet die App
|
||||||
|
„Anrufserver nicht erreichbar“.
|
||||||
|
|
||||||
|
Die ursprüngliche Planung (2026-07-04) folgt unverändert.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`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.
|
||||||
@@ -1,5 +1,32 @@
|
|||||||
# M2 – Modul-Schnitt (Entwurf)
|
# 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:
|
Stand: 2026-07-03. Reine Schnittstellen-Planung, noch kein Umbau. Grundlage:
|
||||||
Ist-Analyse in `PROGRESS.md` (Eintrag „M2: Ist-Analyse der Architektur
|
Ist-Analyse in `PROGRESS.md` (Eintrag „M2: Ist-Analyse der Architektur
|
||||||
geschrieben"). Leitprinzip (Bernd, `CLAUDE.md`): jedes Modul muss sich in
|
geschrieben"). Leitprinzip (Bernd, `CLAUDE.md`): jedes Modul muss sich in
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# M2 – Einheitliches State-Management: Ist-Analyse + Umbauplan
|
||||||
|
|
||||||
|
> **STATUS 2026-07-06: reine Analyse, noch KEIN Umbau.** Grundlage für den
|
||||||
|
> ROADMAP-M2-Punkt „Einheitliches State-Management (Riverpod konsequent,
|
||||||
|
> keine Misch-Patterns)". Die Schritte 1–2 unten betreffen den heiligen
|
||||||
|
> Call-Pfad und sind GESPERRT, bis Bernds Klick-Test des Call-Piloten
|
||||||
|
> (`docs/PC_TESTPLAN.md` Abschnitt 8) grün ist – vorher nur Schritt 3.
|
||||||
|
|
||||||
|
## Ist-Stand (repo-weit erhoben, 2026-07-06)
|
||||||
|
|
||||||
|
Riverpod ist bereits das dominante Muster: rund 50 Provider –
|
||||||
|
`StateProvider` für UI-Zustand, `StreamProvider` auf `client.onSync`,
|
||||||
|
`FutureProvider` (Client, Update-Info), `StateNotifierProvider` für die
|
||||||
|
Prefs-Notifier (`BoolPref`/`StringSetPref` in `core/settings_prefs.dart`).
|
||||||
|
`ValueNotifier`/eigene `StreamController` gibt es NICHT. Lokales
|
||||||
|
`setState` in Widgets für rein ephemeren Zustand (Hover, Dialog-Schritte,
|
||||||
|
Textfelder) ist normales Flutter und zählt hier bewusst NICHT als
|
||||||
|
Misch-Pattern.
|
||||||
|
|
||||||
|
Gefundene Misch-Patterns, nach Gewicht:
|
||||||
|
|
||||||
|
1. **Call-Schicht: Singletons + ChangeNotifier hinter den Fassaden.**
|
||||||
|
`LiveKitCallManager.instance` (static final Singleton,
|
||||||
|
`features/voice_channel/livekit_call_manager.dart:17`) und
|
||||||
|
`PyramidVoipManager._instance` (nullable static,
|
||||||
|
`features/call_signaling/voip_manager.dart:17`; konstruiert im
|
||||||
|
Kompositions-Punkt `core/matrix_client.dart:66–71`). Die Modul-Provider
|
||||||
|
(`voiceChannelProvider`/`callSignalingProvider`) geben nur die
|
||||||
|
Singletons zurück. Das ist die im Pilot BEWUSST gewählte Zwischenform
|
||||||
|
(Verhaltensgleichheit vor Schönheit). Eigentliches Problem: UI-Zustand
|
||||||
|
(Mute-Flags, Teilnehmer, Renderer, Qualitätswahl) lebt mutierbar IN den
|
||||||
|
Managern und wird per `notifyListeners()` grob-granular publiziert.
|
||||||
|
2. **Voice-UI-Zustand außerhalb des Moduls:** `activeVoiceRoomIdProvider`
|
||||||
|
und `voiceParticipantsProvider` liegen in `core/app_state.dart`
|
||||||
|
(Kopplungspunkt laut Ist-Analyse), werden aber von Rooms-Panel und
|
||||||
|
Mini-Call-Widget gesetzt/gelesen und gehören fachlich ins
|
||||||
|
`voice_channel`-Modul (Join-Zustand + Presence sind Modulsache).
|
||||||
|
3. **`MediaCache.instance`** (`core/media_cache.dart:19`): reines
|
||||||
|
Singleton, kein UI-Zustand, nur Cache. Nutzer sind `widgets/mxc_image.dart`
|
||||||
|
(7 Stellen, Widgets – leicht umstellbar) UND
|
||||||
|
`features/chat/message/message_media.dart` (7 Stellen, darunter der
|
||||||
|
statische Wrapper `_DocBytesCache` für Dokument-Vorschauen, dessen
|
||||||
|
Aufrufer keinen `ref`-Zugang haben). Eine Umstellung müsste `ref`/die
|
||||||
|
Cache-Instanz durch die statischen Helfer der Chat-Timeline fädeln –
|
||||||
|
und genau dieser Code (Download-/Entschlüsselungs-Fehlerpfade) ist
|
||||||
|
laut PC_TESTPLAN Abschnitt 3 der am wenigsten geprüfte Teil der App.
|
||||||
|
4. **Push/Notifications:** modul-globale Mutable-Zustände in
|
||||||
|
`core/notification_service.dart` (`_windowsNotifs`-Map, Plugin-Globals)
|
||||||
|
und direkte `SharedPreferences`-Zugriffe in `background_push.dart`/
|
||||||
|
`fcm_push_service.dart`. Das ist dem Background-Isolate-Zwang
|
||||||
|
geschuldet (siehe Schnitt-Doc Punkt 8 + Memory „Pyramid Push") –
|
||||||
|
**bewusst KEIN Umbau-Kandidat**, nur hier dokumentiert.
|
||||||
|
|
||||||
|
Bereits erledigt im Zuge dieser Analyse: der seit dem Call-Piloten tote
|
||||||
|
`incomingCallProvider` ist aus `app_state.dart` entfernt (41bcaf2).
|
||||||
|
|
||||||
|
## Zielbild
|
||||||
|
|
||||||
|
- `core/app_state.dart` enthält nur noch ECHTEN App-weiten UI-Zustand
|
||||||
|
(Theme, Layout/Panels, Navigation, Toast, Space-Auswahl) – kein
|
||||||
|
Feature-Fachzustand.
|
||||||
|
- Feature-Zustand lebt im jeweiligen Modul hinter dessen Provider;
|
||||||
|
andere Module lesen ihn NUR über die öffentliche Schnittstelle.
|
||||||
|
- Kein `.instance`-Zugriff über Modulgrenzen; Konstruktion ausschließlich
|
||||||
|
im Kompositions-Punkt bzw. im Provider des Moduls.
|
||||||
|
- ChangeNotifier ist als Übergangsform ok; Endzustand der Call-Module ist
|
||||||
|
ein immutables Zustandsobjekt (z. B. `VoiceChannelState`) pro Modul,
|
||||||
|
das der Provider publiziert, plus Kommando-Methoden auf dem Service
|
||||||
|
(entspricht dem Schnitt-Doc-Entwurf, dort als Stream skizziert).
|
||||||
|
|
||||||
|
## Schrittfolge (Reihenfolge = Priorität)
|
||||||
|
|
||||||
|
1. **[GESPERRT bis Klick-Test] UI-State aus den Managern herauslösen:**
|
||||||
|
je Call-Modul ein immutables Zustandsobjekt + `Notifier`/
|
||||||
|
`StateNotifier`, Manager wird interne Implementierung ohne
|
||||||
|
UI-Zustand. Das ist der bereits in PROGRESS.md zurückgestellte
|
||||||
|
Logik-Umbau – NICHT vorher anfangen (heiliger Call-Pfad, ein
|
||||||
|
ungetesteter Umbau liegt schon auf dem Stapel).
|
||||||
|
2. **[GESPERRT bis Klick-Test] `activeVoiceRoomIdProvider` +
|
||||||
|
`voiceParticipantsProvider` ins `voice_channel`-Modul umziehen**
|
||||||
|
(zusammen mit `kVoicePresenceType`). Mechanisch klein, aber
|
||||||
|
Call-Pfad-nah – sinnvoll im selben Zug wie Schritt 1.
|
||||||
|
3. **[ZURÜCKGESTELLT] `MediaCache` hinter `Provider<MediaCache>`:**
|
||||||
|
Erst als „risikoarm, jederzeit" eingestuft – das stimmte NICHT
|
||||||
|
(Korrektur 2026-07-06, siehe Ist-Stand Punkt 3: auch die Chat-Timeline
|
||||||
|
hängt dran, inkl. statischer Helfer ohne `ref`). Gehört in den Zug des
|
||||||
|
`chat_timeline`-Moduls (Schnitt-Doc Punkt 9) bzw. frühestens nach dem
|
||||||
|
Klick-Test von PC_TESTPLAN Abschnitt 3 – nicht als Lückenfüller.
|
||||||
|
4. **[kein Umbau] Push/Notifications:** bleibt wie es ist
|
||||||
|
(Isolate-Zwang); nur bei ohnehin anstehenden Push-Arbeiten die
|
||||||
|
Prefs-Zugriffe auf die `Prefs`-Fassade ziehen (Schnitt-Doc Punkt 6).
|
||||||
|
|
||||||
|
Abhak-Kriterium für den ROADMAP-Punkt: Schritte 1–3 umgesetzt und die
|
||||||
|
Kernflows (Login, Raum, Nachricht, Voice-Join) nach jedem Schritt
|
||||||
|
praktisch geprüft; Punkt 4 braucht nur den Doku-Vermerk (steht hiermit).
|
||||||
+35
-1
@@ -70,6 +70,39 @@ Ergebnis: Die Benachrichtigung erscheint ~4 s nach der Nachricht **direkt mit
|
|||||||
echtem Text** (kein „Neue Nachricht"-Zwischenschritt). Die ~4 s sind der
|
echtem Text** (kein „Neue Nachricht"-Zwischenschritt). Die ~4 s sind der
|
||||||
Engine-Bootstrap (~1,7 s) + Client-Init/Entschlüsselung (~2,3 s).
|
Engine-Bootstrap (~1,7 s) + Client-Init/Entschlüsselung (~2,3 s).
|
||||||
|
|
||||||
|
## Darstellung: Chat-Verlauf statt Einzelnachricht (seit 2026-10-07)
|
||||||
|
|
||||||
|
Auf Bernds Beta-Feedback („nur eine Nachricht, wenig Text, kein Bild“):
|
||||||
|
|
||||||
|
- **Pro Raum ein Chat-Verlauf** (Android `MessagingStyle`) mit den letzten 8
|
||||||
|
Nachrichten, Absendername + Avatar je Nachricht, Gruppenname als Titel.
|
||||||
|
Der Verlauf liegt in SharedPreferences (`pyramid_notif_history`), weil bei
|
||||||
|
gekillter App jeder Push in einem neuen Prozess ankommt. Er wird nur
|
||||||
|
fortgeführt, solange die Benachrichtigung noch sichtbar ist (weggewischt
|
||||||
|
oder Raum geöffnet → nächster Push beginnt neu).
|
||||||
|
- **Gleiche eventId = Aktualisierung**, nicht neue Nachricht: der
|
||||||
|
„Neue Nachricht“-Platzhalter wird durch den Klartext ersetzt, später kommen
|
||||||
|
lautlos Avatar/Bild dazu. Nur wirklich neue Nachrichten klingeln
|
||||||
|
(`setOnlyAlertOnce(!isNew)`).
|
||||||
|
- **Text:** nicht mehr auf 100 Zeichen gekürzt (Deckel 1000), Antwort-Zitat
|
||||||
|
entfernt, Medien als „📷 Foto“/„🎤 Sprachnachricht“/„📎 datei.pdf“ usw.
|
||||||
|
Logik in `lib/core/notification_content.dart` (Tests:
|
||||||
|
`test/notification_content_test.dart`). Einstellung „Nachrichtenvorschau“
|
||||||
|
gilt jetzt auch bei gekillter App.
|
||||||
|
- **Bilder/Avatare:** Dart legt sie unter `<cache>/downloads/notif/` ab
|
||||||
|
(Vorschaubild, bei E2EE entschlüsselt; Avatar aus dem MediaCache), native
|
||||||
|
reicht sie per FileProvider-URI an die System-UI weiter
|
||||||
|
(`grantUriPermission("com.android.systemui")`). Dateien älter als 2 Tage
|
||||||
|
werden aufgeräumt. Scheitert das Anzeigen mit Medien, fällt
|
||||||
|
`NotificationHelper` auf reinen Text zurück.
|
||||||
|
- **„Gelesen“** setzt jetzt eine echte Lesebestätigung bis zur neuesten
|
||||||
|
Nachricht (Haupt-Engine `markReadFromNotification` bzw. BgEngine
|
||||||
|
`markRead`), statt nur zu schließen.
|
||||||
|
- **Testen ohne Login (nur Debug-Build):** `DebugNotifyReceiver`
|
||||||
|
(`android/app/src/debug/`), z. B.
|
||||||
|
`adb shell "am broadcast -n chat.pyramid.pyramid/.DebugNotifyReceiver --es roomName Familie --es sender Uta --es event e1 --ez avatar true --es body 'Hallo'"`
|
||||||
|
(`--ez image true` für ein Testbild). Nicht im Release-APK.
|
||||||
|
|
||||||
## Verschlüsselt antworten (gekillt, vollständig still)
|
## Verschlüsselt antworten (gekillt, vollständig still)
|
||||||
|
|
||||||
- Die Reply-Action ist ein **BroadcastReceiver** (`getBroadcast` → `ReplyReceiver`),
|
- Die Reply-Action ist ein **BroadcastReceiver** (`getBroadcast` → `ReplyReceiver`),
|
||||||
@@ -103,7 +136,8 @@ Engine-Bootstrap (~1,7 s) + Client-Init/Entschlüsselung (~2,3 s).
|
|||||||
| `android/.../PushService.kt` | FCM-Empfang, Heartbeat-Check, decrypt-first-Routing |
|
| `android/.../PushService.kt` | FCM-Empfang, Heartbeat-Check, decrypt-first-Routing |
|
||||||
| `android/.../BgEngine.kt` | Headless-Engine-Bootstrap, Fallback-Platzhalter |
|
| `android/.../BgEngine.kt` | Headless-Engine-Bootstrap, Fallback-Platzhalter |
|
||||||
| `android/.../ReplyReceiver.kt` | Stilles verschlüsseltes Antworten (Broadcast) |
|
| `android/.../ReplyReceiver.kt` | Stilles verschlüsseltes Antworten (Broadcast) |
|
||||||
| `android/.../NotificationHelper.kt` | Native Benachrichtigung (Reply/Tap/Dismiss, setOnlyAlertOnce) |
|
| `android/.../NotificationHelper.kt` | Native Benachrichtigung: Chat-Verlauf (MessagingStyle), Bilder, Antworten/Gelesen/Tippen |
|
||||||
|
| `lib/core/notification_content.dart` | Inhalt einer Benachrichtigung (Text, Medien-Labels, Bild/Avatar-Dateien) |
|
||||||
| `android/.../MainActivity.kt` | MethodChannel `install`, Cold-Start-Reply-Fallback |
|
| `android/.../MainActivity.kt` | MethodChannel `install`, Cold-Start-Reply-Fallback |
|
||||||
| `lib/core/background_push.dart` | Dart-Hintergrund-Isolate: decrypt + reply |
|
| `lib/core/background_push.dart` | Dart-Hintergrund-Isolate: decrypt + reply |
|
||||||
| `lib/core/fcm_push_service.dart` | Pusher-Registrierung bei Sygnal |
|
| `lib/core/fcm_push_service.dart` | Pusher-Registrierung bei Sygnal |
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
# PC-Testplan – alle aufgelaufenen UNGETESTET(Pi)-Punkte
|
||||||
|
|
||||||
|
Stand: 2026-07-03; Autopilot-Ergebnisse vom **2026-07-06** eingetragen.
|
||||||
|
Alle Pi-Sessions seit dem 2026-07-03 konnten nur
|
||||||
|
`flutter analyze` + automatisierte Textvergleiche fahren (kein GUI auf dem Pi).
|
||||||
|
Dieser Plan konsolidiert JEDEN ausstehenden Praxistest aus PROGRESS.md an einer
|
||||||
|
Stelle. Beim nächsten Windows-Lauf von oben nach unten abarbeiten, Ergebnis je
|
||||||
|
Punkt hier eintragen (✅/❌ + Datum), Befunde als eigene PROGRESS-Einträge.
|
||||||
|
|
||||||
|
Schnellster Weg: `flutter run -d windows`.
|
||||||
|
|
||||||
|
> **Grenze des Autopilot-Laufs 2026-07-06:** Der Autopilot kann bauen,
|
||||||
|
> testen und die App starten/beenden, aber NICHT in der UI klicken. Alles
|
||||||
|
> Klick-Interaktive (Abschnitte 2–8, Logout/Re-Login in Abschnitt 1) braucht
|
||||||
|
> Bernd am PC – die App-Starts heute liefen mit Bernds bestehender Session.
|
||||||
|
|
||||||
|
## 0. Vorab (1 Minute)
|
||||||
|
|
||||||
|
- [x] `flutter test` – ✅ 2026-07-06: **32 Tests grün** (24 Bestand + 5 neue
|
||||||
|
SQLCipher-Tests aus `test/app_database_test.dart` + 3 neue
|
||||||
|
VoicePrefs-Vertragstests in `test/settings_prefs_test.dart`),
|
||||||
|
mehrfach gelaufen (vor/nach jedem Umbau des Tages).
|
||||||
|
|
||||||
|
- [x] **Seit 2026-10-07: alles Automatisierbare mit EINEM Befehl** –
|
||||||
|
`pwsh scripts/test.ps1 -All` (Analyse, 41 Unit-Tests, Live-Test mit
|
||||||
|
Test-Account, Benchmarks). Details: `docs/TESTS_UND_BENCHMARKS.md`.
|
||||||
|
|
||||||
|
## 1. Härtetest Login/Krypto (M1 – PFLICHT, zuerst!)
|
||||||
|
|
||||||
|
Deckt die Fixes 63e4919, 9dc83f7, 891f348 ab (Uta-Random-Logout + Guard).
|
||||||
|
Seit 2026-07-06 zusätzlich relevant: SQLCipher-Migration ist auf diesem PC
|
||||||
|
gelaufen – die lokale DB ist jetzt verschlüsselt (PROGRESS.md 2026-07-06).
|
||||||
|
|
||||||
|
- [ ] Login mit Test-Account → Nachrichten in verschlüsseltem Raum senden/lesen
|
||||||
|
(Teil-Evidenz ✅ 2026-07-06: Bestands-Session blieb über 3 App-Starts
|
||||||
|
und die DB-Migration hinweg eingeloggt; App hat live to_device-Events
|
||||||
|
von @uta entschlüsselt und Room-Keys forwardet → Lesen/E2EE belegt.
|
||||||
|
Aktives SENDEN + frischer Login noch offen → Klick-Test Bernd)
|
||||||
|
- [x] `auth_log.txt` (App-Support-Verzeichnis) prüfen: ✅ 2026-07-06 –
|
||||||
|
LoginState-Wechsel werden mitgeschrieben (je Start `loggedIn`), dazu
|
||||||
|
der DB-Migrations-Eintrag; keine unerwarteten Warnungen, kein
|
||||||
|
Soft-Logout-Eintrag.
|
||||||
|
- [ ] Logout (Warnung bei fehlendem Key-Backup erscheint? b5762ea) – Klick-Test
|
||||||
|
- [ ] Erneuter Login → **alte verschlüsselte Nachrichten noch lesbar?** – Klick-Test
|
||||||
|
- [x] Mehrere Stunden laufen lassen: kein Random-Logout, kein
|
||||||
|
Login-Screen-Flackern (isLoggedInProvider-Änderung), `auth_log.txt`
|
||||||
|
zeigt ggf. „Soft-Logout durch Token-Refresh abgewendet"
|
||||||
|
(ERFÜLLT 2026-07-07 03:06: Fenster 22:49–03:06 = >4 h am Stück ohne
|
||||||
|
Logout, sechs echte Netzausfälle kumulativ überstanden; ein
|
||||||
|
Login-Screen-Flackern wäre als `LoginState`-Wechsel im auth_log
|
||||||
|
sichtbar – keiner vorhanden. Historie: Zwischenstand 2026-07-06 16:57: Der 12:43-Lauf endete
|
||||||
|
schon ~13:03 (letzter DB-Write; keine Absturzspur in Event-Log/WER,
|
||||||
|
kein Logout-Eintrag – vermutlich Fenster geschlossen). Im 20-min-
|
||||||
|
Fenster KEIN Random-Logout. Session überlebte danach den 4. Start
|
||||||
|
mit verschlüsselter DB (auth_log 16:57:46 `loggedIn`, keine erneute
|
||||||
|
Migration). Danach zwei geplante Neustarts für die Refactoring-
|
||||||
|
Praxistests (17:08 VoicePrefs/call_ui, 17:14 voice_channel-Split),
|
||||||
|
jeweils sauberer `loggedIn`, die Fenster dazwischen ohne Auffälligkeit
|
||||||
|
(16:57–17:08 ≈ 50 min sauber). **Check 17:37 (dritte PC-Session):**
|
||||||
|
Fenster 17:14–17:42 sauber – kein Logout-/Fehler-Eintrag, DB-Writes
|
||||||
|
liefen durchgehend; Lauf um 17:42 vom Autopilot beendet (Rebuild für
|
||||||
|
den settings_encryption-Split). Beobachtung läuft seit 17:43 mit dem
|
||||||
|
aktuellen Code (auth_log 17:43:14 `loggedIn`, keine Migration).
|
||||||
|
**Check 18:19 (vierte PC-Session), Fenster 17:43–18:19:** kein Logout,
|
||||||
|
und obendrauf der bisher wertvollste Positiv-Befund – ein ECHTER
|
||||||
|
Netzausfall 18:09:57–18:16:39 (~7 min, „Client has not connection to
|
||||||
|
the server" alle 3 s im auth_log) wurde sauber überstanden: kein
|
||||||
|
Logout, keine Eskalation, Sync erholte sich von selbst (WAL-Write
|
||||||
|
18:18:13 nach der letzten Fehlerzeile). Genau der Fehlerpfad, den die
|
||||||
|
Fixes 63e4919/9dc83f7/891f348 absichern sollen. **Check 18:29:**
|
||||||
|
zweiter, kurzer Netz-Blip 18:25:56 (~40 s) ebenfalls selbst erholt
|
||||||
|
(WAL-Write 18:28:38 danach), wieder kein Logout. Beobachtung läuft
|
||||||
|
weiter (Lauf seit 17:43, nächstes Fenster ab 18:29); erst nach einem
|
||||||
|
echten Mehr-Stunden-Fenster abhaken.
|
||||||
|
**Check 23:12 (Nacht-Session), Fenster 18:29–22:49:** dritter
|
||||||
|
Netzausfall 18:41:32–18:46:03 (~4,5 min, wieder „Client has not
|
||||||
|
connection") ebenfalls selbst erholt, KEIN Logout, keine Eskalation;
|
||||||
|
danach bis zum geplanten Stopp (Rebuild für die Nacht-Session-Fixes)
|
||||||
|
keine weiteren Einträge. Neustart 22:49:22 `loggedIn`, KEINE
|
||||||
|
Re-Migration → 8. Start in Folge mit verschlüsselter DB sauber.
|
||||||
|
Fenster 22:49–23:12 leer, WAL-Writes laufen (23:12:33). Beobachtung
|
||||||
|
läuft weiter, nächstes Fenster ab 23:12.
|
||||||
|
**Check 03:06 (2026-07-07, Nacht-Session 2), Fenster 23:12–03:06:**
|
||||||
|
DREI weitere Netz-Blips, alle selbst erholt, KEIN Logout: 01:32:17–
|
||||||
|
01:32:20 (~3 s), 02:01:53–02:06:36 (~4,7 min), 02:33:21–02:36:37
|
||||||
|
(~3,3 min; „Client has not connection" alle 3 s). WAL-Write 03:05:43
|
||||||
|
NACH der letzten Fehlerzeile, Prozess (PID 47412, seit 22:49) lebt.
|
||||||
|
Gegenprobe: die parallel laufende pyramidtest1-Instanz (profile1)
|
||||||
|
loggt DIESELBEN Fenster → echter Netz-/Server-Ausfall, nicht
|
||||||
|
app-spezifisch; auch sie ohne Logout erholt. Server beim Check
|
||||||
|
wieder erreichbar (HTTP 200). Beobachtung läuft weiter, nächstes
|
||||||
|
Fenster ab 03:06.
|
||||||
|
Kumulativ: 2026-07-06 12:26 – 2026-07-07 03:06 über 8 Starts, sechs
|
||||||
|
echte Netzausfälle (~7 min / ~40 s / ~4,5 min / ~3 s / ~4,7 min /
|
||||||
|
~3,3 min) – ohne einen einzigen Random-Logout. Das aktuelle Fenster
|
||||||
|
22:49–03:06 ist erstmals ein echtes Mehr-Stunden-Fenster (>4 h) am
|
||||||
|
Stück.)
|
||||||
|
- [ ] Ergebnis als „Härtetest"-Eintrag in PROGRESS.md dokumentieren
|
||||||
|
(= ROADMAP-M1-Punkt „Härtetest dokumentieren" abhaken)
|
||||||
|
- [ ] Android: nach Neu-Login beobachten, ob Push-Inhalte wieder echt
|
||||||
|
entschlüsselt ankommen (statt „Neue Nachricht"-Platzhalter). Falls nicht:
|
||||||
|
zweite Ursache, siehe `docs/NOTIFICATIONS.md` + Memory „Pyramid Push"
|
||||||
|
- [ ] Danach Uta: einmal aus- und wieder einloggen (erst dann hat ihre
|
||||||
|
Session einen Refresh-Token!)
|
||||||
|
|
||||||
|
## 2. Settings-Split (5d77238, 12 Teildateien)
|
||||||
|
|
||||||
|
Alle Reiter öffnen und benutzen: Profil, Benachrichtigungen, Erscheinungsbild,
|
||||||
|
Voice, Tastaturkürzel, Privatsphäre, Sessions, Verschlüsselung, Account, Über.
|
||||||
|
|
||||||
|
- [ ] Verschlüsselung (HEILIG): Recovery-Key anzeigen, Megolm-Export UND
|
||||||
|
-Import (Passphrase), SAS/QR-Verifizierung starten
|
||||||
|
- [ ] Sessions: Gerät umbenennen, Einzel-Logout, Massen-Logout-Dialog
|
||||||
|
(Passwort-Reauth) nur bis zur Abfrage
|
||||||
|
- [ ] Account: Passwort-ändern-Dialog öffnen, Account-löschen-Dialog öffnen
|
||||||
|
(NICHT bestätigen)
|
||||||
|
- [ ] Logout-Warnungs-Text bei fehlendem Backup sichtbar (settings_about/shared)
|
||||||
|
|
||||||
|
## 3. Chat-Timeline-Split (5bf45a9) + tote-Code-Bereinigung (6400fb6)
|
||||||
|
|
||||||
|
Einen Chat mit allen Nachrichtentypen durchgehen:
|
||||||
|
|
||||||
|
- [ ] Text mit Link-Vorschau, Bild, Video, Audio, Datei-Anhang
|
||||||
|
- [ ] Download-/Entschlüsselungs-Fehlerpfade in `message_media.dart` gezielt
|
||||||
|
ansehen (am wenigsten geprüfter Teil)
|
||||||
|
- [ ] Reaktionen setzen/entfernen, Hover-Aktionsleiste, Antworten/Zitat
|
||||||
|
- [ ] Datums-Trenner, „Neue Nachrichten"-Divider
|
||||||
|
- [ ] Gescheiterte Nachricht: bleibt mit Retry/Löschen stehen, Retry geht
|
||||||
|
- [ ] Zeitanzeige bei aufeinanderfolgenden Nachrichten + Hover-Uhrzeit bei
|
||||||
|
Fortsetzungsnachrichten (`_formatMessageTime`-Zusammenführung)
|
||||||
|
|
||||||
|
## 4. Raumlisten-Split (a863fa3) + Reorder-Fix (401bd6a)
|
||||||
|
|
||||||
|
- [ ] Space wechseln, Raum beitreten/verlassen
|
||||||
|
- [ ] **Drag&Drop-Umsortieren: landet der Raum EXAKT an der Zielposition?**
|
||||||
|
(onReorder→onReorderItem, Index-Korrektur jetzt im Framework – Off-by-One
|
||||||
|
wäre hier sichtbar; nach oben UND nach unten ziehen testen)
|
||||||
|
- [ ] Voice-Channel-Teilnehmerliste in der Raumliste
|
||||||
|
- [ ] Einladung annehmen/ablehnen, Ungelesen-Badge, DM-Avatare
|
||||||
|
|
||||||
|
## 5. Space-Admin-Split (0c7d359)
|
||||||
|
|
||||||
|
An einem TEST-Space, nicht an Utas echtem:
|
||||||
|
|
||||||
|
- [ ] Übersicht: Sichtbarkeit umschalten, Banner ändern (Danger-Zone nur ansehen)
|
||||||
|
- [ ] Mitglieder: einladen, Rolle ändern (Selbst-Aussperr-Schutz
|
||||||
|
`updatePowerLevelsSafely` greift?), kicken
|
||||||
|
- [ ] Berechtigungen ändern, Kanäle: erstellen + bestehenden Raum hinzufügen
|
||||||
|
|
||||||
|
## 6. Chat-View-Split (31e4001)
|
||||||
|
|
||||||
|
- [ ] Chat-Header (normal + DM mit ausklappendem Header/Presence)
|
||||||
|
- [ ] Tippanzeige, Offline-/Reconnect-Banner (Netz kurz trennen)
|
||||||
|
- [ ] Datei per Drag&Drop in den Chat ziehen (Overlay + Senden)
|
||||||
|
|
||||||
|
## 7. Dokument-Viewer-Split (daf4cf3) + Matrix4-Fix (401bd6a)
|
||||||
|
|
||||||
|
- [ ] PDF inline im Chat, Vollbild-Viewer: **Zoom per Buttons/Strg+Mausrad**
|
||||||
|
(translateByDouble/scaleByDouble-Umbau – zoomt es zentriert wie vorher?),
|
||||||
|
Seiten-Navigation, Thumbnail-Leiste, Speichern-Button
|
||||||
|
- [ ] Text/Markdown/SVG-Vorschau, Office-Text-Extraktion
|
||||||
|
- [ ] ZIP/TAR-Archiv-Browser, PSD-Thumbnail
|
||||||
|
- [ ] Bild im Chat öffnen (Fallback-Pfade)
|
||||||
|
|
||||||
|
## 8. Kleinkram aus den Lint-Commits
|
||||||
|
|
||||||
|
- [ ] Mini-Call-Widget: Buttons zeigen jetzt Hover-Farbe (9dc1175, gewollt) –
|
||||||
|
sieht das ok aus?
|
||||||
|
- [ ] Anhang-Dialog + Raum-erstellen-Dialog: Switches (activeThumbColor) sehen
|
||||||
|
aus wie vorher
|
||||||
|
- [ ] Voice-Channel beitreten/verlassen (Kernflow laut CLAUDE.md, von den
|
||||||
|
Splits nicht berührt, aber Pflicht nach jedem Refactoring)
|
||||||
|
|
||||||
|
## Danach
|
||||||
|
|
||||||
|
- [ ] Alle ✅ hier eintragen, PROGRESS.md-Eintrag „PC-Praxistest" schreiben,
|
||||||
|
erst DANN neue riskante Punkte anfangen (Call-Fassade, SQLCipher –
|
||||||
|
siehe `docs/SQLCIPHER_MIGRATION.md`)
|
||||||
+4
-2
@@ -1,6 +1,8 @@
|
|||||||
# Pyramid – Fortschritt & offene Punkte
|
# Pyramid – Fortschritt & offene Punkte (ARCHIV, Stand 2026-06-04)
|
||||||
|
|
||||||
Stand: 2026-06-04
|
> **ACHTUNG:** Dies ist ein eingefrorener Alt-Stand von vor der neuen
|
||||||
|
> Arbeitsstruktur. Das aktuelle Arbeitsprotokoll liegt im Projekt-Root:
|
||||||
|
> `PROGRESS.md`. Diese Datei nur als historische Referenz lesen, nie pflegen.
|
||||||
|
|
||||||
## Erledigt
|
## Erledigt
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,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.
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# Tests & Benchmarks
|
||||||
|
|
||||||
|
Stand: 2026-10-07. Alles läuft mit **einem Befehl** aus dem Projektordner:
|
||||||
|
|
||||||
|
```
|
||||||
|
pwsh scripts/test.ps1 # Analyse + Unit-Tests (~1 min) – vor jedem Commit
|
||||||
|
pwsh scripts/test.ps1 -Live # + Live-Test gegen steggi-matrix.work (Test-Account)
|
||||||
|
pwsh scripts/test.ps1 -Bench # + Benchmarks (Datenbank/Verschlüsselung, Migration)
|
||||||
|
pwsh scripts/test.ps1 -Ui # + UI-Durchlauf im Handy-Format mit Screenshots
|
||||||
|
pwsh scripts/test.ps1 -All # alles (~4 min)
|
||||||
|
```
|
||||||
|
|
||||||
|
Am Ende steht eine Zusammenfassung mit OK/FEHLER je Schritt. Fehlt die
|
||||||
|
SQLCipher-Bibliothek (frischer Checkout), baut das Skript einmalig
|
||||||
|
`flutter build windows --debug`.
|
||||||
|
|
||||||
|
**Für Bernd in einem Satz:** Die Tests prüfen automatisch die heiklen Stellen
|
||||||
|
(Login-/Logout-Schutz, Verschlüsselung der Datenbank, Suche, Einstellungen),
|
||||||
|
die Benchmarks messen, ob die App dabei langsamer wird. Was man nur mit Klicken
|
||||||
|
oder zwei echten Geräten prüfen kann, steht weiterhin in `docs/PC_TESTPLAN.md`.
|
||||||
|
|
||||||
|
## Tests (`test/`)
|
||||||
|
|
||||||
|
| Datei | Prüft | Läuft wann |
|
||||||
|
|---|---|---|
|
||||||
|
| `app_database_test.dart` | SQLCipher: Bibliothek echt, Header-Erkennung, Klartext→verschlüsselt-Migration, falscher Schlüssel, Fehlschlag lässt Daten unangetastet | immer (braucht die DLL aus dem Windows-Build) |
|
||||||
|
| `soft_logout_guard_test.dart` | Uta-Random-Logout-Fix: Token-Refresh-Fehler enden nie im stillen Logout | immer |
|
||||||
|
| `auth_log_test.dart` | Login-Protokoll (Diagnose für Logout-Bugs) | immer |
|
||||||
|
| `power_levels_safety_test.dart` | Space-Admin kann sich nicht selbst aussperren | immer |
|
||||||
|
| `settings_prefs_test.dart` | VoicePrefs-Fassade (gespeicherte Spracheinstellungen) | immer |
|
||||||
|
| `streaming_presets_test.dart` | 60-FPS-Streaming-Presets (Auflösung/FPS/Bitrate) | immer |
|
||||||
|
| `local_message_search_test.dart` | Lokale Nachrichtensuche (Strg+K): Treffer, Sortierung, Groß-/Kleinschreibung inkl. Umlaute, Medien-Filter, Trefferdeckel | immer (neu 2026-10-07) |
|
||||||
|
| `live_encryption_e2e_test.dart` | Echter Login (pyramidtest1) → Platte verschlüsselt → Neustart → Logout → Re-Login → alte Nachricht lesbar; Utas Update-Fall mit echter Session | nur mit `-Live` |
|
||||||
|
| `live_logout_race_test.dart` | Abmelden + sofortige Neuanmeldung auf demselben Client: neue Session muss bleiben (SDK-Wettlauf, `logoutWithoutStraySync`); mit `PYRAMID_LIVE_RACE_PROOF=1` zusätzlich Nachweis mit altem Weg | nur mit `-Live` |
|
||||||
|
| `live_ui_fixture_test.dart` | Kein Test im engeren Sinn: legt Testinhalte (DM + Gruppe mit Bildern) für den UI-Durchlauf an | nur manuell mit `PYRAMID_LIVE_TEST=1` |
|
||||||
|
| `live_call_setup_test.dart` | Kein Test im engeren Sinn: legt den Voice-Testraum für GUI-Call-Tests mit zwei Test-Accounts an | nur manuell mit `PYRAMID_LIVE_TEST=1` |
|
||||||
|
|
||||||
|
`test/support/synthetic_matrix_data.dart` erzeugt synthetische Räume und
|
||||||
|
Nachrichten über die **echte SDK-Speicherschicht** – komplett offline, ohne
|
||||||
|
Konten. Tests und Benchmarks nutzen sie gemeinsam.
|
||||||
|
|
||||||
|
Live-Tests: Zugangsdaten liegen AUSSERHALB des Repos
|
||||||
|
(`%USERPROFILE%\.pyramid-autopilot\test-accounts.txt` bzw. `PYRAMID_TEST_ACCOUNTS`).
|
||||||
|
Es sind die Test-Accounts `pyramidtest1/2`, nie Bernds oder Utas Konten.
|
||||||
|
|
||||||
|
## UI-Durchlauf (`integration_test/`, seit 2026-10-07)
|
||||||
|
|
||||||
|
`integration_test/mobile_ui_screens_test.dart` startet die ECHTE App
|
||||||
|
(Windows-Build) mit dem Testprofil `pyramidtest1`
|
||||||
|
(`PYRAMID_PROFILE_DIR=%USERPROFILE%\.pyramid-autopilot\profile1`) in
|
||||||
|
simulierter Handy-Größe (1080×2340, Statusleiste 24 dp, Gestenleiste 16 dp),
|
||||||
|
tippt sich durch Chatliste → Chat → Bild und prüft dabei: Vollansicht öffnet,
|
||||||
|
Tippen daneben schließt, Tippen aufs Bild schließt nicht, Doppeltipp zoomt,
|
||||||
|
Wischen nach unten schließt. Screenshots landen in `build/ui_screens/` – damit
|
||||||
|
lassen sich UI-Änderungen ohne Handy ansehen.
|
||||||
|
|
||||||
|
- Testinhalte (DM von pyramidtest2 mit langem Text, Antwort, Hoch- und
|
||||||
|
Querformat-Bild) legt `test/live_ui_fixture_test.dart` an
|
||||||
|
(`PYRAMID_LIVE_TEST=1`).
|
||||||
|
- Läuft nur, wenn keine Pyramid-Instanz offen ist (nie zwei Clients auf
|
||||||
|
einer Datenbank) – das Skript prüft das.
|
||||||
|
- Simulierte Fensterklicks (PostMessage) erreichen Flutter unter Windows
|
||||||
|
NICHT; deshalb dieser Weg über `integration_test`.
|
||||||
|
|
||||||
|
## Benchmarks (`benchmark/`)
|
||||||
|
|
||||||
|
Laufen bewusst NICHT im normalen `flutter test` mit (nur `-Bench` bzw.
|
||||||
|
`flutter test benchmark --concurrency=1`).
|
||||||
|
|
||||||
|
| Suite | Misst |
|
||||||
|
|---|---|
|
||||||
|
| `db_benchmark_test.dart` | Klartext- vs. SQLCipher-DB mit derselben Bibliothek: Sync-Schreiben (Transaktionen wie beim echten Sync), App-Start (DB öffnen + Raumliste), Chat öffnen (50 Nachrichten), lokale Suche (ohne Treffer = Worst Case, mit Treffern) |
|
||||||
|
| `migration_benchmark_test.dart` | Dauer der einmaligen Klartext→SQLCipher-Migration beim ersten Start nach dem Update (exakt der App-Codepfad), danach Vollständigkeitsprüfung |
|
||||||
|
|
||||||
|
**Wie gemessen wird:** DB im eigenen Isolate wie in der App; ungemessener
|
||||||
|
Aufwärm-Durchlauf; Modi abwechselnd A-B-B-A (ohne das gewann einfach der
|
||||||
|
später gemessene Modus um ±20 %, weil der Dart-JIT während des Laufs schneller
|
||||||
|
wird); Median mehrerer Durchläufe.
|
||||||
|
|
||||||
|
**Baseline & Regressionen:** Jeder Lauf schreibt `build/benchmarks/<suite>.json`
|
||||||
|
und vergleicht mit `benchmark/baseline/<suite>.json`. Mehr als 25 % langsamer
|
||||||
|
(und bei Zeiten mehr als 2 ms absolut) wird als „⚠ LANGSAMER" markiert.
|
||||||
|
Umgebungsvariablen:
|
||||||
|
|
||||||
|
- `-UpdateBaseline` bzw. `PYRAMID_BENCH_UPDATE_BASELINE=1` – aktuelle Werte als
|
||||||
|
neue Baseline speichern (nach bewusster Änderung oder auf neuer Hardware).
|
||||||
|
Die Baseline gilt nur für den Rechner, auf dem sie entstand.
|
||||||
|
- `PYRAMID_BENCH_STRICT=1` – Regressionen lassen den Lauf scheitern.
|
||||||
|
- `PYRAMID_BENCH_SCALE=0.2` – kleinere Datenmenge für einen schnellen Probelauf
|
||||||
|
(andere Mengen sind mit der Baseline nicht vergleichbar).
|
||||||
|
|
||||||
|
### Erste Ergebnisse (2026-10-07, Bernds PC, 12 Kerne, Commit b2413ef)
|
||||||
|
|
||||||
|
20 Räume × 500 Nachrichten (DB-Suite), 20 × 1000 (Migration):
|
||||||
|
|
||||||
|
| Messung | Klartext | SQLCipher |
|
||||||
|
|---|---|---|
|
||||||
|
| Sync-Schreiben je Nachricht | 0,25 ms | 0,26 ms |
|
||||||
|
| App-Start (DB + Raumliste) | 4,1 ms | 4,3 ms |
|
||||||
|
| Chat öffnen (50 Nachrichten) | 0,33 ms | 0,32 ms |
|
||||||
|
| Suche ohne Treffer (10 000 Nachrichten) | 64 ms | 64 ms |
|
||||||
|
| Suche mit Treffern | 32 ms | 32 ms |
|
||||||
|
| Migration Klartext → SQLCipher | – | 325 ms für 7,8 MB (≈ 42 ms/MB) |
|
||||||
|
|
||||||
|
Was das heißt:
|
||||||
|
|
||||||
|
- **Die Verschlüsselung kostet praktisch nichts.** Alle Unterschiede liegen im
|
||||||
|
Messrauschen (±5–10 %, zwei Läufe hintereinander). Die Kosten stecken im
|
||||||
|
Matrix-SDK selbst (JSON-Verarbeitung), nicht in SQLCipher.
|
||||||
|
- **Sync-Schreiben** liegt bei ~0,26 ms pro Nachricht und hängt kaum von der
|
||||||
|
Raumgröße ab (nachgemessen: 250 vs. 4 000 Nachrichten pro Raum ≈ +15 %).
|
||||||
|
- **Suche** wächst linear mit der Zahl der gespeicherten Nachrichten
|
||||||
|
(gedeckelt auf 1 500 pro Raum). Hochrechnung, nicht gemessen: 100 aktive
|
||||||
|
Räume am Deckel ≈ 150 000 Nachrichten ≈ 1 s pro Suche auf diesem PC –
|
||||||
|
auf Handys entsprechend länger. Kandidat für einen Suchindex (FTS), falls
|
||||||
|
das in der Praxis stört.
|
||||||
|
- **Migration**: Hochrechnung für eine 100-MB-DB ≈ 4 s einmalig auf diesem
|
||||||
|
PC; auf Utas Handy vermutlich ein Mehrfaches davon (nicht gemessen).
|
||||||
|
|
||||||
|
## Bekannte Grenzen (ehrlich)
|
||||||
|
|
||||||
|
- Synthetische Daten: unverschlüsselte Räume, keine Olm/Megolm-Sessions
|
||||||
|
(vodozemac läuft im Host-Test nicht). Die DB-Struktur ist aber die echte.
|
||||||
|
- Gemessen auf Windows im JIT-Modus von `flutter test`; die Release-App läuft
|
||||||
|
AOT-kompiliert und ist eher schneller. Für Vergleiche über die Zeit taugt
|
||||||
|
das, für absolute Aussagen über Handys nicht.
|
||||||
|
- Nicht automatisiert: alles mit Klicks, echte Calls/Audio, 60-FPS-Streaming
|
||||||
|
zwischen zwei Geräten, Android-Push → `docs/PC_TESTPLAN.md`.
|
||||||
|
|
||||||
|
## Nächste sinnvolle Messungen
|
||||||
|
|
||||||
|
- **Streaming (M4 „Ursache messen"):** WebRTC-`getStats()` des Senders
|
||||||
|
auswerten (`framesPerSecond`, `qualityLimitationReason`, Encoder-Name) –
|
||||||
|
zeigt direkt, ob CPU, Bandbreite oder Encoder bremst. Braucht eine kleine
|
||||||
|
Diagnose-Anzeige im Call und zwei echte Geräte.
|
||||||
|
- **UI-Flüssigkeit:** `integration_test` mit Frame-Timing beim Scrollen der
|
||||||
|
Raumliste/Timeline (Windows, Test-Account-Profil über `PYRAMID_PROFILE_DIR`).
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# UI/UX-Audit Pyramid (2026-10-08)
|
||||||
|
|
||||||
|
Grundlage: Code-Analyse der Navigation + automatische Screenshot-Tour
|
||||||
|
`integration_test/ui_audit_tour_test.dart` (35 Zustände, Handy 1080×2340 und
|
||||||
|
PC 1600×1000, zwei Testkonten, Bilder in `build/ui_audit/`, `p_*` = Handy,
|
||||||
|
`d_*` = PC). Neu erzeugen:
|
||||||
|
|
||||||
|
```
|
||||||
|
$env:PYRAMID_PROFILE_DIR="$env:USERPROFILE\.pyramid-autopilot\profile1"
|
||||||
|
flutter test integration_test/ui_audit_tour_test.dart -d windows
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1. Wie die Navigation heute funktioniert
|
||||||
|
|
||||||
|
Vier Zustände steuern, was man sieht (`lib/core/app_state.dart`):
|
||||||
|
|
||||||
|
| Zustand | Werte | Wirkung |
|
||||||
|
|---|---|---|
|
||||||
|
| `activeSpaceIdProvider` | `dms`, `rooms`, Space-ID | welche Liste links/unten steht |
|
||||||
|
| `activeRoomIdProvider` | Raum-ID oder leer | welcher Chat offen ist |
|
||||||
|
| `viewModeProvider` | `chat`, `voice` | Hauptbereich zeigt Chat **oder** Anrufansicht |
|
||||||
|
| `activeModalProvider` | `none`, `settings`, `search` | Overlay |
|
||||||
|
|
||||||
|
**Handy:** Hamburger öffnet ein schmales Seitenmenü (unbeschriftet: Logo =
|
||||||
|
Chats, orangenes „R“ = Räume, Space-Kürzel, „+“, Zahnrad). Darunter die
|
||||||
|
Liste; ein Chat gleitet darüber; „Anruf“ ist ein weiterer Vordergrund.
|
||||||
|
**PC:** Leiste (Spaces) + Liste + Hauptbereich (+ Mitglieder rechts).
|
||||||
|
|
||||||
|
Was wohin führt:
|
||||||
|
- Liste → Chat (alle Listen), Sprachkanal antippen → beitreten (Mini-Leiste
|
||||||
|
unten), nochmal antippen → verlassen.
|
||||||
|
- Chat-Kopf (nur DM) → Anruf/Video → Anrufansicht. **Gruppen: kein Anruf.**
|
||||||
|
- ⋮ (Handy) → Suche, Mitglieder. PC-Kopf: Threads (ohne Funktion), Anheften,
|
||||||
|
Suche, Mitglieder.
|
||||||
|
- Mini-Leiste → Anrufansicht; Anrufansicht „Minimieren“ → zurück zum Chat.
|
||||||
|
- Zahnrad (Profilbalken unten **und** Seitenmenü) → Einstellungen.
|
||||||
|
|
||||||
|
## 2. Sackgassen und tote Elemente (alle behoben, siehe 5.)
|
||||||
|
|
||||||
|
| # | Wo | Was passiert | Schwere |
|
||||||
|
|---|---|---|---|
|
||||||
|
| S1 | Gegenseite legt auf / Anruf scheitert / Sprachkanal bricht ab | Ansicht bleibt auf „Anruf“: englische Leerseite „No active call – Join a voice room“, Handy ohne Zurück (p_18) | hoch |
|
||||||
|
| S2 | dieselbe Leerseite | LiveKit-Fehlermeldung wird dort nicht angezeigt (stiller Fehler) | hoch |
|
||||||
|
| S3 | Gruppenchat (Handy + PC) | kein Anruf-Knopf – Gruppenanruf-Code nicht erreichbar (p_06, d_06) | hoch |
|
||||||
|
| S4 | PC-Chatkopf „Threads“ | Knopf ohne Funktion | mittel |
|
||||||
|
| S5 | Space betreten (PC) | rechts nur „Wähle einen Chat aus“, letzter/erster Kanal öffnet nicht (d_04) | mittel |
|
||||||
|
| S6 | Anruf läuft (PC) | Steuerung doppelt: Anrufansicht + Mini-Leiste links (d_15) | niedrig |
|
||||||
|
|
||||||
|
## 3. Befunde nach Bereich
|
||||||
|
|
||||||
|
**Listen**
|
||||||
|
- Haupt-Navigation am Handy versteckt im Seitenmenü, Symbole unbeschriftet
|
||||||
|
und teils kryptisch (orangenes „R“-Dreieck für „Räume“) (p_02).
|
||||||
|
- „Räume“ listet auch alle Kanäle aus Spaces → doppelt; Sprachkanäle hängen
|
||||||
|
ohne Überschrift unten, sehen anders aus als Chats (p_03, p_17).
|
||||||
|
- Suchfeld ohne Lupe, eingerückter Text.
|
||||||
|
- Profilbalken unten + Anruf-Leiste (zweizeilig) belegen am Handy ~25 % der
|
||||||
|
Höhe (p_12, p_17).
|
||||||
|
|
||||||
|
**Chats**
|
||||||
|
- Gruppen sehen aus wie „#Kanal“ (Kopf, Liste) statt wie Gruppen.
|
||||||
|
- Leerer Chat englisch („No messages yet“), PC-Leerzustand halb englisch.
|
||||||
|
- Mitgliederliste: eckige Avatare (sonst rund), man selbst nicht als „Du“.
|
||||||
|
|
||||||
|
**Anrufe**
|
||||||
|
- Anrufansicht für reine Sprachanrufe: riesige schwarze Video-Kachel mit
|
||||||
|
Initial (p_15). Eigene Kachel zeigt „D“ (von „Du“) statt eigenem Avatar.
|
||||||
|
Keine Gesprächsdauer.
|
||||||
|
- Kopf-Symbole „Hero/Grid“ und „Minimieren“ unklar (englische Tooltips).
|
||||||
|
- Mini-Leiste „VOICE CONNECTED“ (englisch) auch bei 1:1-Anrufen; Mikro/Kopfhörer
|
||||||
|
lila gefüllt = „an“ – liest sich wie Warnung; in der Anrufansicht ist die
|
||||||
|
Logik umgekehrt (rot = stumm).
|
||||||
|
- Annehmen-Fenster: Platzhalter-Symbol statt Avatar, kein „Sprach-/Videoanruf“.
|
||||||
|
|
||||||
|
**Allgemein**
|
||||||
|
- Englische Reste (Bildschirm-Auswähler erledigt; Anrufansicht, Leerzustände).
|
||||||
|
- Statusleisten-Streifen in anderer Farbe über Overlays (p_07, p_09).
|
||||||
|
- „Tastaturkürzel“ in den Handy-Einstellungen.
|
||||||
|
|
||||||
|
## 4. Konzept: modern, aufgeräumt, übersichtlich
|
||||||
|
|
||||||
|
Leitlinie: **WhatsApp für Chats, Discord für Server/Sprachkanäle** – beides
|
||||||
|
in einer klaren Struktur, nichts doppelt, kein Zustand ohne Weg zurück.
|
||||||
|
|
||||||
|
1. **Navigation Handy:** untere Leiste mit beschrifteten Tabs statt
|
||||||
|
Seitenmenü – *Chats* (DMs + Gruppen zusammen, nach Aktivität), *Server*
|
||||||
|
(Spaces als Karten → Kanäle, Sprachkanäle mit Live-Teilnehmern), *Du*
|
||||||
|
(Profil, Status, Einstellungen). Profilbalken unten entfällt.
|
||||||
|
2. **Listen:** einheitliche Kacheln; Space-Kanäle gruppiert in „Textkanäle“ /
|
||||||
|
„Sprachkanäle“; Sprachkanal-Kachel zeigt Teilnehmer-Avatare; Kanäle eines
|
||||||
|
Spaces erscheinen nicht zusätzlich unter „Räume“.
|
||||||
|
3. **Anruf-Logik (ein Modell):** 1:1-Anruf, Gruppenanruf (Gruppenchat) und
|
||||||
|
Sprachkanal (Server) teilen eine Anrufansicht und eine kompakte
|
||||||
|
„Anruf-Pille“. Anruf endet → automatisch zurück zum Chat, mit kurzem
|
||||||
|
Hinweis („Anruf beendet · 3:12“).
|
||||||
|
4. **Anrufansicht:** Sprachanruf = große runde Avatare mit Sprech-Ring,
|
||||||
|
Name, Dauer (wie WhatsApp/Discord); Video/Bildschirm = Kacheln bzw.
|
||||||
|
großes Bild + Filmstreifen. Steuerung einheitlich: neutral = an,
|
||||||
|
rot durchgestrichen = aus.
|
||||||
|
5. **Visuell:** runde Avatare überall (echte Bilder), Kopfzeilen mit Avatar +
|
||||||
|
Untertitel (Mitgliederzahl/online), Leerzustände mit Handlung („Schreib die
|
||||||
|
erste Nachricht“), ruhigere Flächen (Karten statt Linien), einheitliche
|
||||||
|
Abstände/Radien, vollständig deutsch.
|
||||||
|
|
||||||
|
## 5. Sofort behoben (diese Runde)
|
||||||
|
|
||||||
|
S1–S6, englische Reste in Anruf/Leerzuständen, Annehmen-Fenster mit Avatar
|
||||||
|
und Anrufart, eigene Kachel mit eigenem Avatar, „Du“ in der Mitgliederliste,
|
||||||
|
„Tastaturkürzel“ nur am PC – Details in PROGRESS.md.
|
||||||
|
|
||||||
|
## 6. Bernds Entscheidungen (2026-10-09) und Umsetzung (0.1.42)
|
||||||
|
|
||||||
|
| Frage | Entscheidung | Umsetzung |
|
||||||
|
|---|---|---|
|
||||||
|
| Navigation Handy | Seitenmenü; untere Leiste als Mockup zum Vergleich | Einstellung „Navigation“ (Standard Seitenmenü), `layout/mobile_tabs.dart`, Vergleichsbild `build/ui_vergleich/1_navigation_vergleich.png` |
|
||||||
|
| Chatliste | Alles in „Chats“ | DMs + Gruppen ohne Server zusammen, Filter-Chips; Server-Kanäle nur im Server |
|
||||||
|
| Gruppenanruf | Anruf + Beitreten-Karte | Knopf im Gruppenkopf, Start-Nachricht (Push), Karte „Gruppenanruf läuft · Beitreten“ |
|
||||||
|
| Anrufansicht | animierte, aneinandergedrückte Bubbles | `call_ui/bubble_physics.dart`, Kacheln umschaltbar |
|
||||||
|
|
||||||
|
Noch offen aus §4: einheitliche Hinweis-Meldungen (Snackbar-Stil), kompaktere
|
||||||
|
Anruf-Pille, Abschnitts-Überschriften Text/Sprache in Server-Listen.
|
||||||
@@ -70,7 +70,7 @@ Continuwuity hat einen speziellen Admin-Raum. Als Server-Admin kannst du dort Be
|
|||||||
# Als curl (Admin-Token erforderlich):
|
# Als curl (Admin-Token erforderlich):
|
||||||
# Admin-Room-ID herausfinden:
|
# Admin-Room-ID herausfinden:
|
||||||
curl -s "https://steggi-matrix.work/_matrix/client/v3/directory/room/%23admins%3Asteggi-matrix.work" \
|
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", ...}
|
# → {"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
|
# User zu Server-Admin machen
|
||||||
curl -X POST "https://steggi-matrix.work/_conduwuit/admin/v1/make_user_admin" \
|
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" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"user_id": "@todesneutron:steggi-matrix.work"}'
|
-d '{"user_id": "@todesneutron:steggi-matrix.work"}'
|
||||||
|
|
||||||
# Server-Status abfragen
|
# Server-Status abfragen
|
||||||
curl "https://steggi-matrix.work/_conduwuit/server/version" \
|
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):
|
# Alternativ — Standard Matrix-Whoami (prüft ob Token gültig):
|
||||||
curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \
|
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}
|
# → {"user_id": "@todesneutron:steggi-matrix.work", "is_guest": false}
|
||||||
|
|
||||||
# Prüfen ob User Server-Admin ist (Conduwuit-spezifisch):
|
# Prüfen ob User Server-Admin ist (Conduwuit-spezifisch):
|
||||||
curl "https://steggi-matrix.work/_conduwuit/admin/v1/users/@todesneutron:steggi-matrix.work" \
|
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
|
```bash
|
||||||
# Schritt 1: Aktuelles Power-Level-Event lesen
|
# Schritt 1: Aktuelles Power-Level-Event lesen
|
||||||
ROOM_ID="!raumId:steggi-matrix.work"
|
ROOM_ID="!raumId:steggi-matrix.work"
|
||||||
TOKEN="J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
TOKEN="<ADMIN_TOKEN>"
|
||||||
SERVER="https://steggi-matrix.work"
|
SERVER="https://steggi-matrix.work"
|
||||||
|
|
||||||
curl "${SERVER}/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.power_levels/" \
|
curl "${SERVER}/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.power_levels/" \
|
||||||
@@ -172,7 +172,7 @@ await client.setRoomStateWithKey(
|
|||||||
```bash
|
```bash
|
||||||
# Testen ob restricted join rules funktionieren:
|
# Testen ob restricted join rules funktionieren:
|
||||||
ROOM_ID="!testRaum:steggi-matrix.work"
|
ROOM_ID="!testRaum:steggi-matrix.work"
|
||||||
TOKEN="J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
TOKEN="<ADMIN_TOKEN>"
|
||||||
SPACE_ID="!spaceId:steggi-matrix.work"
|
SPACE_ID="!spaceId:steggi-matrix.work"
|
||||||
|
|
||||||
curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.join_rules/" \
|
curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.join_rules/" \
|
||||||
@@ -230,7 +230,7 @@ try {
|
|||||||
```bash
|
```bash
|
||||||
# 1. Prüfen: Welchen Power Level hat mein User in diesem Raum?
|
# 1. Prüfen: Welchen Power Level hat mein User in diesem Raum?
|
||||||
ROOM_ID="!raumId:steggi-matrix.work"
|
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/" \
|
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.power_levels/" \
|
||||||
-H "Authorization: Bearer ${TOKEN}"
|
-H "Authorization: Bearer ${TOKEN}"
|
||||||
@@ -269,7 +269,7 @@ curl "https://steggi-matrix.work/_matrix/client/v3/capabilities" \
|
|||||||
```bash
|
```bash
|
||||||
# Prüfen ob User Server-Admin ist:
|
# Prüfen ob User Server-Admin ist:
|
||||||
curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \
|
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)
|
# Auf Pi (SSH): User-Datenbank direkt abfragen (Conduwuit/Continuwuity nutzt RocksDB)
|
||||||
# Alternativ: Über Admin-Room-Befehl
|
# Alternativ: Über Admin-Room-Befehl
|
||||||
@@ -278,7 +278,7 @@ curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \
|
|||||||
|
|
||||||
# Über HTTP (Conduwuit Admin-Endpoint):
|
# Über HTTP (Conduwuit Admin-Endpoint):
|
||||||
curl -X POST "https://steggi-matrix.work/_conduwuit/admin/v1/make_user_admin" \
|
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" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"user_id": "@todesneutron:steggi-matrix.work"}'
|
-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
|
```bash
|
||||||
# Raum beitreten (als Admin, auch wenn invite-only):
|
# Raum beitreten (als Admin, auch wenn invite-only):
|
||||||
curl -X POST "https://steggi-matrix.work/_matrix/client/v3/join/!roomId%3Asteggi-matrix.work" \
|
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" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{}'
|
-d '{}'
|
||||||
|
|
||||||
# User in Raum einladen:
|
# User in Raum einladen:
|
||||||
curl -X POST "https://steggi-matrix.work/_matrix/client/v3/rooms/!roomId%3Asteggi-matrix.work/invite" \
|
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" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"user_id": "@user:steggi-matrix.work"}'
|
-d '{"user_id": "@user:steggi-matrix.work"}'
|
||||||
|
|
||||||
# Space-Child-Relation setzen (Channel einem Space hinzufügen):
|
# 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/" \
|
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" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"via": ["steggi-matrix.work"],
|
"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):
|
# 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/" \
|
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" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"via": ["steggi-matrix.work"],
|
"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:
|
# Alle Mitglieder eines Raums auflisten:
|
||||||
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/!roomId%3Asteggi-matrix.work/members" \
|
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):
|
# Raum verlassen (als User, nicht löschen):
|
||||||
curl -X POST "https://steggi-matrix.work/_matrix/client/v3/rooms/!roomId%3Asteggi-matrix.work/leave" \
|
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" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{}'
|
-d '{}'
|
||||||
```
|
```
|
||||||
@@ -419,15 +419,15 @@ Wenn du M_FORBIDDEN bekommst:
|
|||||||
```bash
|
```bash
|
||||||
# 1. Token gültig?
|
# 1. Token gültig?
|
||||||
curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \
|
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:
|
# 2. PL im betroffenen Raum prüfen:
|
||||||
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/!ROOM_ID/state/m.room.power_levels/" \
|
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?
|
# 3. Mitglied des Raums?
|
||||||
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/!ROOM_ID/state/m.room.member/%40todesneutron%3Asteggi-matrix.work/" \
|
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!)
|
# 4. Was genau schlägt fehl? (Antwort-Body lesen!)
|
||||||
# M_FORBIDDEN + "Power level too low" → PL erhöhen
|
# M_FORBIDDEN + "Power level too low" → PL erhöhen
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||||
|
import 'package:integration_test/integration_test.dart';
|
||||||
|
import 'package:pyramid/core/call_foreground.dart';
|
||||||
|
|
||||||
|
/// Android 14+: Bildschirmübertragung darf die App nicht abstürzen lassen
|
||||||
|
/// (Bernds Test 2026-10-07: „Teilen“ → App weg → Anruf verloren).
|
||||||
|
///
|
||||||
|
/// Ablauf wie im Anruf: Zustimmung → Anruf-Dienst mit mediaProjection →
|
||||||
|
/// Mitschnitt. Der System-Dialog muss bestätigt werden (auf dem Emulator per
|
||||||
|
/// adb, siehe docs/TESTS_UND_BENCHMARKS.md).
|
||||||
|
///
|
||||||
|
/// flutter test integration_test/android_screen_capture_test.dart -d emulator-5554
|
||||||
|
///
|
||||||
|
/// PYRAMID_SCREEN_NEGATIVE=1 (per --dart-define) überspringt den Dienst –
|
||||||
|
/// Gegenprobe: dann stürzt die App wie früher ab.
|
||||||
|
const _negative = bool.fromEnvironment('PYRAMID_SCREEN_NEGATIVE');
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'Bildschirmübertragung startet ohne Absturz',
|
||||||
|
(tester) async {
|
||||||
|
await tester.pumpWidget(const MaterialApp(home: Scaffold()));
|
||||||
|
if (_negative) {
|
||||||
|
expect(await Helper.requestCapturePermission(), isTrue);
|
||||||
|
} else {
|
||||||
|
expect(
|
||||||
|
await CallForeground.prepareScreenCapture('test'),
|
||||||
|
isTrue,
|
||||||
|
reason: 'Zustimmung oder Dienststart fehlgeschlagen',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final stream = await navigator.mediaDevices
|
||||||
|
.getDisplayMedia({'video': true, 'audio': false});
|
||||||
|
expect(stream.getVideoTracks(), isNotEmpty);
|
||||||
|
await Future.delayed(const Duration(seconds: 3));
|
||||||
|
for (final t in stream.getTracks()) {
|
||||||
|
await t.stop();
|
||||||
|
}
|
||||||
|
await stream.dispose();
|
||||||
|
await CallForeground.screenCaptureStopped('test');
|
||||||
|
await CallForeground.callEnded('test');
|
||||||
|
},
|
||||||
|
skip: !Platform.isAndroid,
|
||||||
|
timeout: const Timeout(Duration(minutes: 3)),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:integration_test/integration_test.dart';
|
||||||
|
import 'package:pyramid/core/theme.dart';
|
||||||
|
import 'package:pyramid/features/call_ui/voice_channel.dart' show CallBubblesDemo;
|
||||||
|
|
||||||
|
/// Vorschaubilder der Anrufansicht mit erfundenen Teilnehmern (ohne Login):
|
||||||
|
/// Standbilder für 1–7 Personen (Handy + PC) und eine Bildfolge der
|
||||||
|
/// Animation (Sprecherwechsel, jemand kommt dazu, jemand geht) für ein GIF.
|
||||||
|
/// Bilder: build/call_demo/.
|
||||||
|
///
|
||||||
|
/// flutter test integration_test/call_bubbles_demo_test.dart -d windows
|
||||||
|
void main() {
|
||||||
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
testWidgets('Anruf-Formen: Vorschaubilder', (tester) async {
|
||||||
|
final count = ValueNotifier(5);
|
||||||
|
final speaking = ValueNotifier(2);
|
||||||
|
void phone() => tester.view
|
||||||
|
..physicalSize = const Size(1080, 2340)
|
||||||
|
..devicePixelRatio = 2.625;
|
||||||
|
void desktop() => tester.view
|
||||||
|
..physicalSize = const Size(1600, 1000)
|
||||||
|
..devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
|
||||||
|
Future<void> smooth(Duration d) async {
|
||||||
|
final end = DateTime.now().add(d);
|
||||||
|
while (DateTime.now().isBefore(end)) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
phone();
|
||||||
|
await tester.pumpWidget(PyramidTheme(
|
||||||
|
child: MaterialApp(
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
theme: buildPyramidThemeData(ThemeVariant.soft),
|
||||||
|
home: ValueListenableBuilder<int>(
|
||||||
|
valueListenable: count,
|
||||||
|
builder: (_, n, _) => ValueListenableBuilder<int>(
|
||||||
|
valueListenable: speaking,
|
||||||
|
builder: (_, s, _) => CallBubblesDemo(count: n, speaking: s),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
|
||||||
|
// ── Standbilder ──
|
||||||
|
for (final n in [1, 2, 3, 5, 7]) {
|
||||||
|
count.value = n;
|
||||||
|
speaking.value = n >= 3 ? 2 : -1;
|
||||||
|
phone();
|
||||||
|
await smooth(const Duration(milliseconds: 2500));
|
||||||
|
await _shot(tester, 'p_${n}_personen');
|
||||||
|
desktop();
|
||||||
|
await smooth(const Duration(milliseconds: 2500));
|
||||||
|
await _shot(tester, 'd_${n}_personen');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Animation (Handy) ──
|
||||||
|
phone();
|
||||||
|
count.value = 4;
|
||||||
|
speaking.value = -1;
|
||||||
|
await smooth(const Duration(seconds: 3));
|
||||||
|
var frame = 0;
|
||||||
|
Future<void> record(Duration d) async {
|
||||||
|
final end = DateTime.now().add(d);
|
||||||
|
while (DateTime.now().isBefore(end)) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
await _shot(tester, 'anim/f${(frame++).toString().padLeft(4, '0')}',
|
||||||
|
scale: 0.4, log: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await record(const Duration(milliseconds: 600));
|
||||||
|
speaking.value = 1; // Anna spricht
|
||||||
|
await record(const Duration(milliseconds: 1600));
|
||||||
|
speaking.value = 3; // Clara spricht
|
||||||
|
await record(const Duration(milliseconds: 1600));
|
||||||
|
speaking.value = -1;
|
||||||
|
count.value = 5; // David kommt dazu
|
||||||
|
await record(const Duration(milliseconds: 1800));
|
||||||
|
count.value = 4; // David geht wieder
|
||||||
|
await record(const Duration(milliseconds: 1800));
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('Animation: $frame Bilder');
|
||||||
|
}, timeout: const Timeout(Duration(minutes: 6)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _shot(WidgetTester tester, String name,
|
||||||
|
{double scale = 1, bool log = true}) async {
|
||||||
|
final renderView = tester.binding.renderViews.first;
|
||||||
|
final layer = renderView.debugLayer! as OffsetLayer;
|
||||||
|
final physical = tester.view.physicalSize;
|
||||||
|
final image = await tester.binding.runAsync(
|
||||||
|
() => layer.toImage(Offset.zero & physical, pixelRatio: scale),
|
||||||
|
);
|
||||||
|
final bytes = await tester.binding.runAsync(
|
||||||
|
() => image!.toByteData(format: ui.ImageByteFormat.png),
|
||||||
|
);
|
||||||
|
final file = File('build/call_demo/$name.png');
|
||||||
|
file.parent.createSync(recursive: true);
|
||||||
|
file.writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||||
|
// ignore: avoid_print
|
||||||
|
if (log) print('Bild $name');
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
||||||
|
import 'package:integration_test/integration_test.dart';
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
import 'package:pyramid/core/matrix_client.dart';
|
||||||
|
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
||||||
|
import 'package:pyramid/main.dart' as app;
|
||||||
|
import 'package:webrtc_interface/webrtc_interface.dart';
|
||||||
|
|
||||||
|
import 'support/test_partner.dart';
|
||||||
|
|
||||||
|
/// Echter 1:1-Anruf zwischen der App (pyramidtest1, Testprofil) und einem
|
||||||
|
/// zweiten Matrix-Client im selben Prozess (pyramidtest2, kurzlebiges Gerät,
|
||||||
|
/// wird am Ende abgemeldet). Bernds Fehlerbericht 2026-10-08: Ton kam nicht
|
||||||
|
/// an, Bildschirm teilen ging weder am PC noch am Handy.
|
||||||
|
///
|
||||||
|
/// Geprüft wird: Verbindung steht, Ton fließt in BEIDE Richtungen (Bytes),
|
||||||
|
/// Bildschirm der Gegenseite wird in der App angezeigt, und die App kann
|
||||||
|
/// über den echten Quellen-Auswähler ihren Bildschirm teilen, der bei der
|
||||||
|
/// Gegenseite ankommt. (Ob das Mikrofon hörbaren Ton liefert, kann ein
|
||||||
|
/// Automat nicht prüfen – dafür gibt es den Mikrofontest und den Sprech-Ring.)
|
||||||
|
///
|
||||||
|
/// $env:PYRAMID_PROFILE_DIR="$env:USERPROFILE\.pyramid-autopilot\profile1"
|
||||||
|
/// flutter test integration_test/live_call_test.dart -d windows
|
||||||
|
void main() {
|
||||||
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
final profile = Platform.environment['PYRAMID_PROFILE_DIR'];
|
||||||
|
final accounts = loadTestAccounts();
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'1:1-Anruf: Ton beide Richtungen, Bildschirm teilen beide Richtungen',
|
||||||
|
(tester) async {
|
||||||
|
Logs().level = Level.verbose;
|
||||||
|
// app.main() ersetzt FlutterError.onError – danach den Empfänger des
|
||||||
|
// Testrahmens zurücksetzen, sonst verschwinden Fehlermeldungen.
|
||||||
|
final testOnError = FlutterError.onError;
|
||||||
|
app.main();
|
||||||
|
await pumpUntil(tester, () => find.byType(MaterialApp).evaluate().isNotEmpty);
|
||||||
|
FlutterError.onError = testOnError;
|
||||||
|
final container = ProviderScope.containerOf(
|
||||||
|
tester.element(find.byType(MaterialApp).first),
|
||||||
|
);
|
||||||
|
final me = (await tester.runAsync(
|
||||||
|
() => container.read(matrixClientProvider.future),
|
||||||
|
))!;
|
||||||
|
expect(me.userID, '@pyramidtest1:steggi-matrix.work');
|
||||||
|
|
||||||
|
final partner = (await tester.runAsync(
|
||||||
|
() => TestPartner.start(accounts!['user2']!, accounts['pass2']!),
|
||||||
|
))!;
|
||||||
|
String? roomId;
|
||||||
|
addTearDown(() => tester.runAsync(() async {
|
||||||
|
await partner.dispose(roomId);
|
||||||
|
final r = roomId == null ? null : me.getRoomById(roomId);
|
||||||
|
await r?.leave().catchError((_) {});
|
||||||
|
await r?.forget().catchError((_) {});
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Eigener, unverschlüsselter Testraum (nur für diesen Lauf).
|
||||||
|
roomId = await tester.runAsync(() => partner.client.createRoom(
|
||||||
|
invite: [me.userID!],
|
||||||
|
preset: CreateRoomPreset.privateChat,
|
||||||
|
name: 'Anruftest (automatisch)',
|
||||||
|
));
|
||||||
|
logStep('Raum angelegt: $roomId');
|
||||||
|
await pumpUntil(
|
||||||
|
tester,
|
||||||
|
() => me.getRoomById(roomId!)?.membership == Membership.invite,
|
||||||
|
what: 'Einladung kommt bei der App an',
|
||||||
|
);
|
||||||
|
await tester.runAsync(() => me.getRoomById(roomId!)!.join());
|
||||||
|
logStep('App ist beigetreten');
|
||||||
|
// Die Gegenseite ist Ersteller und damit im Raum; ihr Mitglieder-Cache
|
||||||
|
// wird vom SDK nur bei Bedarf geladen – kurz warten genügt.
|
||||||
|
await pumpFor(tester, const Duration(seconds: 3));
|
||||||
|
|
||||||
|
final voip = container.read(callSignalingProvider);
|
||||||
|
|
||||||
|
// ═══ Teil 1: Gegenseite ruft an, App nimmt über den echten Knopf an ═══
|
||||||
|
// (prüft die Kandidaten-Pufferung der App mit einem ungepatchten
|
||||||
|
// Anrufer – genau Bernds Fall „lange/kein Verbindungsaufbau“)
|
||||||
|
logStep('Teil 1: Gegenseite ruft an');
|
||||||
|
await tester.runAsync(() => partner.run(() => partner.voip.inviteToCall(
|
||||||
|
partner.client.getRoomById(roomId!)!,
|
||||||
|
CallType.kVoice,
|
||||||
|
)));
|
||||||
|
final answerBtn = find.byWidgetPredicate(
|
||||||
|
(w) => w is FloatingActionButton && w.heroTag == 'answer_call',
|
||||||
|
);
|
||||||
|
await pumpUntil(tester, () => answerBtn.evaluate().isNotEmpty,
|
||||||
|
what: 'Annehmen-Knopf erscheint');
|
||||||
|
await pumpFor(tester, const Duration(seconds: 2)); // klingeln lassen
|
||||||
|
await tester.tap(answerBtn.first);
|
||||||
|
await _waitConnected(tester, voip, partner);
|
||||||
|
|
||||||
|
await _checkAudio(tester, voip, partner);
|
||||||
|
|
||||||
|
// Stumm über den echten Knopf → Gegenseite sieht „stumm“, Spur ist aus.
|
||||||
|
bool partnerSeesMuted() =>
|
||||||
|
partner.delegate.call?.remoteUserMediaStream?.audioMuted == true;
|
||||||
|
await tester.tap(_ctrlBtn(Icons.mic_rounded));
|
||||||
|
await pumpUntil(tester, partnerSeesMuted,
|
||||||
|
timeout: const Duration(seconds: 15),
|
||||||
|
what: 'Gegenseite sieht: App ist stumm');
|
||||||
|
expect(voip.isMicMuted, isTrue);
|
||||||
|
expect(
|
||||||
|
voip.currentCall!.localUserMediaStream!.stream!.getAudioTracks().first.enabled,
|
||||||
|
isFalse,
|
||||||
|
reason: 'Mikrofonspur ist trotz Stumm noch an',
|
||||||
|
);
|
||||||
|
await tester.tap(_ctrlBtn(Icons.mic_off_rounded));
|
||||||
|
await pumpUntil(tester, () => !partnerSeesMuted() && !voip.isMicMuted,
|
||||||
|
timeout: const Duration(seconds: 15),
|
||||||
|
what: 'Stumm wieder aufgehoben');
|
||||||
|
logStep('Stumm/Entstummen über den Knopf: ok');
|
||||||
|
|
||||||
|
// Taub → Gegenseite nicht mehr hören UND selbst stumm; danach zurück.
|
||||||
|
await tester.tap(_ctrlBtn(Icons.headphones_rounded));
|
||||||
|
await pumpUntil(
|
||||||
|
tester,
|
||||||
|
() => voip.isDeafened && partnerSeesMuted(),
|
||||||
|
timeout: const Duration(seconds: 15),
|
||||||
|
what: 'Taub: selbst stumm',
|
||||||
|
);
|
||||||
|
final remoteAudio = voip.currentCall!.getRemoteStreams
|
||||||
|
.expand((w) => w.stream?.getAudioTracks() ?? const <MediaStreamTrack>[]);
|
||||||
|
expect(remoteAudio, isNotEmpty);
|
||||||
|
expect(remoteAudio.every((t) => !t.enabled), isTrue,
|
||||||
|
reason: 'Ton der Gegenseite ist trotz Taub noch an');
|
||||||
|
await tester.tap(_ctrlBtn(Icons.headset_off_rounded));
|
||||||
|
await pumpUntil(
|
||||||
|
tester,
|
||||||
|
() => !voip.isDeafened && !partnerSeesMuted() && !voip.isMicMuted,
|
||||||
|
timeout: const Duration(seconds: 15),
|
||||||
|
what: 'Taub aufgehoben, Mikro wieder an',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
voip.currentCall!.getRemoteStreams
|
||||||
|
.expand((w) => w.stream?.getAudioTracks() ?? const <MediaStreamTrack>[])
|
||||||
|
.every((t) => t.enabled),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
logStep('Taub/Hören über den Knopf: ok');
|
||||||
|
|
||||||
|
// Gegenseite teilt Bildschirm → App zeigt ihn
|
||||||
|
await tester.runAsync(
|
||||||
|
() => partner.run(() => partner.delegate.call!.setScreensharingEnabled(true)),
|
||||||
|
);
|
||||||
|
await pumpUntil(
|
||||||
|
tester,
|
||||||
|
() =>
|
||||||
|
voip.isRemoteScreensharing &&
|
||||||
|
(voip.remoteRenderer.srcObject?.getVideoTracks().isNotEmpty ?? false),
|
||||||
|
timeout: const Duration(seconds: 30),
|
||||||
|
what: 'Bildschirm der Gegenseite erscheint in der App',
|
||||||
|
);
|
||||||
|
await pumpFor(tester, const Duration(seconds: 3));
|
||||||
|
final appVideoIn = (await tester.runAsync(
|
||||||
|
() => _rtp(voip.currentCall!.pc!, 'video'),
|
||||||
|
))!;
|
||||||
|
logStep('BILD bei der App (von der Gegenseite): $appVideoIn');
|
||||||
|
expect(appVideoIn.received, greaterThan(0),
|
||||||
|
reason: 'Bildschirm der Gegenseite kommt nicht an');
|
||||||
|
await tester.runAsync(
|
||||||
|
() => partner.run(() => partner.delegate.call!.setScreensharingEnabled(false)),
|
||||||
|
);
|
||||||
|
await pumpUntil(tester, () => !voip.isRemoteScreensharing,
|
||||||
|
what: 'Bildschirm der Gegenseite verschwindet wieder');
|
||||||
|
|
||||||
|
// App teilt ihren Bildschirm über den echten Auswähler
|
||||||
|
await pumpUntil(
|
||||||
|
tester,
|
||||||
|
() => find.byIcon(Icons.screen_share_rounded).evaluate().isNotEmpty,
|
||||||
|
what: 'Teilen-Knopf in der Anrufleiste',
|
||||||
|
);
|
||||||
|
await tester.tap(find.byIcon(Icons.screen_share_rounded).first);
|
||||||
|
final tile = find.byWidgetPredicate(
|
||||||
|
(w) => w.runtimeType.toString() == '_SourceTile',
|
||||||
|
);
|
||||||
|
await pumpUntil(tester, () => tile.evaluate().isNotEmpty,
|
||||||
|
timeout: const Duration(seconds: 20), what: 'Quellen-Auswähler');
|
||||||
|
await tester.tap(tile.first);
|
||||||
|
await pumpUntil(
|
||||||
|
tester,
|
||||||
|
() {
|
||||||
|
final s = partner.delegate.call?.remoteScreenSharingStream?.stream;
|
||||||
|
return voip.isScreensharing &&
|
||||||
|
s != null &&
|
||||||
|
s.getVideoTracks().isNotEmpty;
|
||||||
|
},
|
||||||
|
timeout: const Duration(seconds: 30),
|
||||||
|
what: 'Bildschirm der App kommt bei der Gegenseite an',
|
||||||
|
);
|
||||||
|
await pumpFor(tester, const Duration(seconds: 3));
|
||||||
|
final partnerVideoIn = (await tester.runAsync(
|
||||||
|
() => _rtp(partner.delegate.call!.pc!, 'video'),
|
||||||
|
))!;
|
||||||
|
logStep('BILD bei der Gegenseite (von der App): $partnerVideoIn');
|
||||||
|
expect(partnerVideoIn.received, greaterThan(0),
|
||||||
|
reason: 'Bildschirm der App kommt nicht an');
|
||||||
|
|
||||||
|
await tester.runAsync(() => voip.hangup());
|
||||||
|
await pumpUntil(tester, () => voip.currentCall == null,
|
||||||
|
what: 'aufgelegt (Teil 1)');
|
||||||
|
await pumpFor(tester, const Duration(seconds: 3));
|
||||||
|
|
||||||
|
// ═══ Teil 2: App ruft an, Gegenseite nimmt an ═══
|
||||||
|
logStep('Teil 2: App ruft an');
|
||||||
|
partner.fixCandidates = true; // Gegenseite wie eine gepatchte Pyramid
|
||||||
|
await tester.runAsync(() => voip.startCall(roomId!, video: false));
|
||||||
|
await _waitConnected(tester, voip, partner);
|
||||||
|
await _checkAudio(tester, voip, partner);
|
||||||
|
await tester.runAsync(() => voip.hangup());
|
||||||
|
await pumpUntil(tester, () => voip.currentCall == null,
|
||||||
|
what: 'aufgelegt (Teil 2)');
|
||||||
|
},
|
||||||
|
skip: profile == null || accounts == null,
|
||||||
|
timeout: const Timeout(Duration(minutes: 5)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _waitConnected(
|
||||||
|
WidgetTester tester,
|
||||||
|
CallSignalingService voip,
|
||||||
|
TestPartner partner,
|
||||||
|
) async {
|
||||||
|
final t0 = DateTime.now();
|
||||||
|
await pumpUntil(
|
||||||
|
tester,
|
||||||
|
() =>
|
||||||
|
voip.currentCall?.state == CallState.kConnected &&
|
||||||
|
partner.delegate.call?.state == CallState.kConnected,
|
||||||
|
timeout: const Duration(seconds: 60),
|
||||||
|
what: 'Anruf verbunden',
|
||||||
|
every5s: () {
|
||||||
|
final a = voip.currentCall;
|
||||||
|
final b = partner.delegate.call;
|
||||||
|
logStep('App: ${a?.state} ice=${a?.pc?.iceConnectionState} | '
|
||||||
|
'Gegenseite: ${b?.state} ice=${b?.pc?.iceConnectionState}');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
logStep('verbunden nach ${DateTime.now().difference(t0).inMilliseconds} ms');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkAudio(
|
||||||
|
WidgetTester tester,
|
||||||
|
CallSignalingService voip,
|
||||||
|
TestPartner partner,
|
||||||
|
) async {
|
||||||
|
await pumpFor(tester, const Duration(seconds: 4));
|
||||||
|
final appAudio = (await tester.runAsync(
|
||||||
|
() => _rtp(voip.currentCall!.pc!, 'audio'),
|
||||||
|
))!;
|
||||||
|
final partnerAudio = (await tester.runAsync(
|
||||||
|
() => _rtp(partner.delegate.call!.pc!, 'audio'),
|
||||||
|
))!;
|
||||||
|
logStep('TON App: $appAudio | Gegenseite: $partnerAudio');
|
||||||
|
expect(appAudio.sent, greaterThan(0), reason: 'App sendet keinen Ton');
|
||||||
|
expect(appAudio.received, greaterThan(0), reason: 'App empfängt keinen Ton');
|
||||||
|
expect(partnerAudio.received, greaterThan(0),
|
||||||
|
reason: 'Gegenseite empfängt keinen Ton der App');
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Rtp {
|
||||||
|
_Rtp(this.sent, this.received);
|
||||||
|
final num sent;
|
||||||
|
final num received;
|
||||||
|
@override
|
||||||
|
String toString() => 'gesendet=$sent B, empfangen=$received B';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<_Rtp> _rtp(rtc.RTCPeerConnection pc, String kind) async {
|
||||||
|
num sent = 0;
|
||||||
|
num received = 0;
|
||||||
|
for (final r in await pc.getStats()) {
|
||||||
|
if (r.values['kind'] != kind) continue;
|
||||||
|
if (r.type == 'outbound-rtp') sent += (r.values['bytesSent'] as num?) ?? 0;
|
||||||
|
if (r.type == 'inbound-rtp') received += (r.values['bytesReceived'] as num?) ?? 0;
|
||||||
|
}
|
||||||
|
return _Rtp(sent, received);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Knopf der Anrufleiste mit diesem Symbol (nicht die Stumm-ANZEIGE in der
|
||||||
|
/// Teilnehmer-Kachel, die dasselbe Symbol trägt).
|
||||||
|
Finder _ctrlBtn(IconData icon) => find.descendant(
|
||||||
|
of: find.byWidgetPredicate((w) =>
|
||||||
|
w.runtimeType.toString() == '_VoiceCtrlBtn' &&
|
||||||
|
(w as dynamic).icon == icon),
|
||||||
|
matching: find.byIcon(icon),
|
||||||
|
).first;
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:integration_test/integration_test.dart';
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
import 'package:pyramid/core/app_state.dart';
|
||||||
|
import 'package:pyramid/core/matrix_client.dart';
|
||||||
|
import 'package:pyramid/main.dart' as app;
|
||||||
|
|
||||||
|
/// Live mit beiden Testkonten (Bernds Fehlerbericht 2026-10-07):
|
||||||
|
/// 1. ✓✓ erscheint LIVE, sobald der Partner liest – ohne den Chat neu zu
|
||||||
|
/// öffnen (vorher zeichnete eine reine Lesebestätigung nichts neu).
|
||||||
|
/// 2. Einladung lässt sich direkt im Chat annehmen, danach ist der Verlauf
|
||||||
|
/// sofort da (vorher leerer Chat bis zum App-Neustart).
|
||||||
|
///
|
||||||
|
/// Die App läuft mit dem Testprofil (PYRAMID_PROFILE_DIR, pyramidtest1).
|
||||||
|
/// pyramidtest2 wird per Client-Server-API mit einem kurzlebigen Gerät
|
||||||
|
/// gesteuert, das am Ende abgemeldet wird – andere Sitzungen (z. B. Bernds
|
||||||
|
/// Testinstanz mit profile2) bleiben unberührt.
|
||||||
|
///
|
||||||
|
/// $env:PYRAMID_PROFILE_DIR="$env:USERPROFILE\.pyramid-autopilot\profile1"
|
||||||
|
/// flutter test integration_test/live_receipts_invite_test.dart -d windows
|
||||||
|
const _hs = 'https://steggi-matrix.work';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
final profile = Platform.environment['PYRAMID_PROFILE_DIR'];
|
||||||
|
final accounts = _loadAccounts();
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'Lesebestätigung live + Einladung im Chat annehmen',
|
||||||
|
(tester) async {
|
||||||
|
tester.view
|
||||||
|
..physicalSize = const Size(1080, 2340)
|
||||||
|
..devicePixelRatio = 2.625;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
|
||||||
|
final partner = await tester.runAsync(
|
||||||
|
() => _RestUser.login(accounts!['user2']!, accounts['pass2']!),
|
||||||
|
);
|
||||||
|
addTearDown(() => tester.runAsync(() => partner!.logout()));
|
||||||
|
|
||||||
|
// app.main() ersetzt FlutterError.onError – danach den Empfänger des
|
||||||
|
|
||||||
|
// Testrahmens zurücksetzen, sonst verschwinden Fehlermeldungen.
|
||||||
|
|
||||||
|
final testOnError = FlutterError.onError;
|
||||||
|
|
||||||
|
app.main();
|
||||||
|
await _pumpUntil(tester, find.byType(MaterialApp));
|
||||||
|
|
||||||
|
FlutterError.onError = testOnError;
|
||||||
|
final container = ProviderScope.containerOf(
|
||||||
|
tester.element(find.byType(MaterialApp).first),
|
||||||
|
);
|
||||||
|
final client = (await tester.runAsync(
|
||||||
|
() => container.read(matrixClientProvider.future),
|
||||||
|
))!;
|
||||||
|
expect(client.userID, '@pyramidtest1:steggi-matrix.work');
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 4));
|
||||||
|
|
||||||
|
// ── 2. Einladung im Chat annehmen ──
|
||||||
|
final welcome = 'Willkommen im Einladungstest ${DateTime.now().millisecondsSinceEpoch}';
|
||||||
|
final inviteRoomId = await tester.runAsync(() async {
|
||||||
|
final id = await partner!.createRoom('Einladungstest');
|
||||||
|
await partner.sendText(id, welcome);
|
||||||
|
await partner.invite(id, client.userID!);
|
||||||
|
return id;
|
||||||
|
});
|
||||||
|
addTearDown(() => tester.runAsync(() => partner!.leave(inviteRoomId!)));
|
||||||
|
|
||||||
|
// Einladung kommt per Sync an → Chat öffnen (wie aus der Benachrichtigung).
|
||||||
|
final end = DateTime.now().add(const Duration(seconds: 30));
|
||||||
|
while (client.getRoomById(inviteRoomId!)?.membership != Membership.invite &&
|
||||||
|
DateTime.now().isBefore(end)) {
|
||||||
|
await _pumpFor(tester, const Duration(milliseconds: 500));
|
||||||
|
}
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = inviteRoomId;
|
||||||
|
await _pumpUntil(tester, find.text('Annehmen'));
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 1)); // Hereingleiten abwarten
|
||||||
|
await tester.tap(find.text('Annehmen'));
|
||||||
|
// Ohne App-Neustart: Verlauf mit der Begrüßung erscheint.
|
||||||
|
await _pumpUntil(
|
||||||
|
tester,
|
||||||
|
find.textContaining(welcome),
|
||||||
|
timeout: const Duration(seconds: 40),
|
||||||
|
);
|
||||||
|
expect(find.text('Annehmen'), findsNothing);
|
||||||
|
|
||||||
|
// ── 1. Lesebestätigung live (nach dem Beitritt) ──
|
||||||
|
// (im Wegwerf-Raum aus Teil 2 – die DM soll nicht mit jedem Lauf
|
||||||
|
// wachsen)
|
||||||
|
final dm = client.getRoomById(inviteRoomId)!;
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = dm.id;
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
|
||||||
|
final text = 'Lesetest ${DateTime.now().millisecondsSinceEpoch}';
|
||||||
|
final eventId = await tester.runAsync(() => dm.sendTextEvent(text));
|
||||||
|
await _pumpUntil(tester, find.textContaining(text));
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 3));
|
||||||
|
expect(
|
||||||
|
find.byIcon(Icons.done_rounded),
|
||||||
|
findsOneWidget,
|
||||||
|
reason: 'eigene neueste Nachricht sollte noch ✓ (ungelesen) zeigen',
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.runAsync(() => partner!.readReceipt(dm.id, eventId!));
|
||||||
|
// Ohne den Chat neu zu öffnen: ✓ wird zu ✓✓ direkt unter der Nachricht.
|
||||||
|
await _pumpUntil(
|
||||||
|
tester,
|
||||||
|
find.byIcon(Icons.done_rounded),
|
||||||
|
gone: true,
|
||||||
|
timeout: const Duration(seconds: 30),
|
||||||
|
);
|
||||||
|
final msgY = tester.getCenter(find.textContaining(text).first).dy;
|
||||||
|
final readIcons = find.byIcon(Icons.done_all_rounded).evaluate();
|
||||||
|
expect(
|
||||||
|
readIcons.any((e) {
|
||||||
|
final box = e.renderObject as RenderBox;
|
||||||
|
final y = box.localToGlobal(box.size.center(Offset.zero)).dy;
|
||||||
|
return y > msgY && y - msgY < 120;
|
||||||
|
}),
|
||||||
|
isTrue,
|
||||||
|
reason: '✓✓ erscheint nicht live unter der gelesenen Nachricht',
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── 3. „Neue Nachrichten“-Trenner MITTEN in einer Gruppe verschwindet ──
|
||||||
|
// (Bernd 2026-10-08: blieb manchmal stehen – bei mehreren Nachrichten
|
||||||
|
// desselben Absenders hintereinander, gelesen bis zur ersten.)
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = null;
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
final ids = (await tester.runAsync(() async => [
|
||||||
|
await partner!.sendText(inviteRoomId, 'Gruppe 1'),
|
||||||
|
await partner.sendText(inviteRoomId, 'Gruppe 2'),
|
||||||
|
await partner.sendText(inviteRoomId, 'Gruppe 3'),
|
||||||
|
]))!;
|
||||||
|
final room = client.getRoomById(inviteRoomId)!;
|
||||||
|
// Erst warten, bis alle drei da sind, dann bis „Gruppe 1“ gelesen melden.
|
||||||
|
final tEnd = DateTime.now().add(const Duration(seconds: 30));
|
||||||
|
while (room.lastEvent?.eventId != ids[2] && DateTime.now().isBefore(tEnd)) {
|
||||||
|
await _pumpFor(tester, const Duration(milliseconds: 300));
|
||||||
|
}
|
||||||
|
await tester.runAsync(() => room.setReadMarker(ids[0], mRead: ids[0]));
|
||||||
|
final fEnd = DateTime.now().add(const Duration(seconds: 30));
|
||||||
|
while (room.fullyRead != ids[0] && DateTime.now().isBefore(fEnd)) {
|
||||||
|
await _pumpFor(tester, const Duration(milliseconds: 300));
|
||||||
|
}
|
||||||
|
expect(room.fullyRead, ids[0], reason: 'Lesemarker kam nicht an');
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = inviteRoomId;
|
||||||
|
await _pumpUntil(tester, find.text('Neue Nachrichten'),
|
||||||
|
timeout: const Duration(seconds: 20));
|
||||||
|
// Nach der Verweildauer (6 s) + Ausblenden muss er weg sein.
|
||||||
|
await _pumpUntil(
|
||||||
|
tester,
|
||||||
|
find.text('Neue Nachrichten'),
|
||||||
|
gone: true,
|
||||||
|
timeout: const Duration(seconds: 15),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Aufräumen: Testraum verlassen + vergessen.
|
||||||
|
await tester.runAsync(() async {
|
||||||
|
final r = client.getRoomById(inviteRoomId);
|
||||||
|
await r?.leave();
|
||||||
|
await r?.forget().catchError((_) {});
|
||||||
|
});
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = null;
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
},
|
||||||
|
skip: profile == null || accounts == null,
|
||||||
|
timeout: const Timeout(Duration(minutes: 4)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String>? _loadAccounts() {
|
||||||
|
final home = Platform.environment['USERPROFILE'] ?? '';
|
||||||
|
final file = File('$home\\.pyramid-autopilot\\test-accounts.txt');
|
||||||
|
if (!file.existsSync()) return null;
|
||||||
|
final map = <String, String>{};
|
||||||
|
for (final line in file.readAsLinesSync()) {
|
||||||
|
final t = line.trim();
|
||||||
|
if (t.isEmpty || t.startsWith('#')) continue;
|
||||||
|
final i = t.indexOf('=');
|
||||||
|
if (i > 0) map[t.substring(0, i)] = t.substring(i + 1);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimaler Matrix-Zugang per Client-Server-API (kurzlebiges Gerät).
|
||||||
|
class _RestUser {
|
||||||
|
_RestUser(this._token);
|
||||||
|
final String _token;
|
||||||
|
int _txn = 0;
|
||||||
|
|
||||||
|
static Future<_RestUser> login(String user, String pass) async {
|
||||||
|
final res = await http.post(
|
||||||
|
Uri.parse('$_hs/_matrix/client/v3/login'),
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({
|
||||||
|
'type': 'm.login.password',
|
||||||
|
'identifier': {'type': 'm.id.user', 'user': user},
|
||||||
|
'password': pass,
|
||||||
|
'initial_device_display_name': 'Pyramid-Livetest (kurzlebig)',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (res.statusCode != 200) throw StateError('Login ${res.statusCode}');
|
||||||
|
return _RestUser(jsonDecode(res.body)['access_token'] as String);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> _call(String method, String path,
|
||||||
|
[Object? body]) async {
|
||||||
|
final req = http.Request(method, Uri.parse('$_hs/_matrix/client/v3$path'))
|
||||||
|
..headers['Authorization'] = 'Bearer $_token'
|
||||||
|
..headers['Content-Type'] = 'application/json'
|
||||||
|
..body = jsonEncode(body ?? {});
|
||||||
|
final res = await http.Response.fromStream(await req.send());
|
||||||
|
if (res.statusCode != 200) {
|
||||||
|
throw StateError('$method $path → ${res.statusCode}');
|
||||||
|
}
|
||||||
|
return jsonDecode(res.body) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _enc(String s) => Uri.encodeComponent(s);
|
||||||
|
|
||||||
|
Future<void> readReceipt(String roomId, String eventId) => _call(
|
||||||
|
'POST',
|
||||||
|
'/rooms/${_enc(roomId)}/read_markers',
|
||||||
|
{'m.fully_read': eventId, 'm.read': eventId},
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<String> createRoom(String name) async =>
|
||||||
|
(await _call('POST', '/createRoom', {
|
||||||
|
'name': name,
|
||||||
|
'preset': 'private_chat',
|
||||||
|
}))['room_id'] as String;
|
||||||
|
|
||||||
|
Future<String> sendText(String roomId, String text) async =>
|
||||||
|
(await _call(
|
||||||
|
'PUT',
|
||||||
|
'/rooms/${_enc(roomId)}/send/m.room.message/t${DateTime.now().microsecondsSinceEpoch}${_txn++}',
|
||||||
|
{'msgtype': 'm.text', 'body': text},
|
||||||
|
))['event_id'] as String;
|
||||||
|
|
||||||
|
Future<void> invite(String roomId, String userId) =>
|
||||||
|
_call('POST', '/rooms/${_enc(roomId)}/invite', {'user_id': userId});
|
||||||
|
|
||||||
|
Future<void> leave(String roomId) async {
|
||||||
|
try {
|
||||||
|
await _call('POST', '/rooms/${_enc(roomId)}/leave');
|
||||||
|
await _call('POST', '/rooms/${_enc(roomId)}/forget');
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> logout() async {
|
||||||
|
try {
|
||||||
|
await _call('POST', '/logout');
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pumpFor(WidgetTester tester, Duration d) async {
|
||||||
|
final end = DateTime.now().add(d);
|
||||||
|
while (DateTime.now().isBefore(end)) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pumpUntil(
|
||||||
|
WidgetTester tester,
|
||||||
|
Finder finder, {
|
||||||
|
bool gone = false,
|
||||||
|
Duration timeout = const Duration(seconds: 60),
|
||||||
|
}) async {
|
||||||
|
final end = DateTime.now().add(timeout);
|
||||||
|
while (DateTime.now().isBefore(end)) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
final found = finder.evaluate().isNotEmpty;
|
||||||
|
if (found != gone) return;
|
||||||
|
}
|
||||||
|
fail('Zeitüberschreitung: $finder ${gone ? "verschwindet nicht" : "nicht gefunden"}');
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:integration_test/integration_test.dart';
|
||||||
|
import 'package:pyramid/core/matrix_client.dart';
|
||||||
|
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
|
||||||
|
import 'package:pyramid/main.dart' as app;
|
||||||
|
|
||||||
|
/// Echter Beitritt zu LiveKit mit einem vom SERVER ausgestellten Token
|
||||||
|
/// (docs/LIVEKIT_TOKEN_MIGRATION.md) – über denselben Weg wie die App
|
||||||
|
/// (voiceChannelProvider.startCall), nur mit dem TEST-Konto.
|
||||||
|
///
|
||||||
|
/// Läuft nur mit gesetztem PYRAMID_PROFILE_DIR (eingeloggtes Testprofil,
|
||||||
|
/// z. B. pyramidtest1) – nie gegen Bernds echtes Profil:
|
||||||
|
///
|
||||||
|
/// $env:PYRAMID_PROFILE_DIR="$env:USERPROFILE\.pyramid-autopilot\profile1"
|
||||||
|
/// flutter test integration_test/livekit_token_connect_test.dart -d windows
|
||||||
|
///
|
||||||
|
/// Betritt einen eigenen Wegwerf-Raum („pyramid-tokentest“), nur Ton, und
|
||||||
|
/// legt sofort wieder auf. Ersetzt NICHT den Anruftest auf dem Handy.
|
||||||
|
void main() {
|
||||||
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
final profile = Platform.environment['PYRAMID_PROFILE_DIR'];
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'LiveKit-Beitritt mit Server-Token: Identität = eigene Matrix-ID',
|
||||||
|
(tester) async {
|
||||||
|
// app.main() ersetzt FlutterError.onError – danach den Empfänger des
|
||||||
|
// Testrahmens zurücksetzen, sonst verschwinden Fehlermeldungen.
|
||||||
|
final testOnError = FlutterError.onError;
|
||||||
|
app.main();
|
||||||
|
final end = DateTime.now().add(const Duration(seconds: 90));
|
||||||
|
while (find.byType(MaterialApp).evaluate().isEmpty &&
|
||||||
|
DateTime.now().isBefore(end)) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
}
|
||||||
|
FlutterError.onError = testOnError;
|
||||||
|
final container = ProviderScope.containerOf(
|
||||||
|
tester.element(find.byType(MaterialApp).first),
|
||||||
|
);
|
||||||
|
final client = await tester.runAsync(
|
||||||
|
() => container.read(matrixClientProvider.future),
|
||||||
|
);
|
||||||
|
expect(client!.isLogged(), isTrue, reason: 'Testprofil nicht angemeldet');
|
||||||
|
|
||||||
|
final call = container.read(voiceChannelProvider);
|
||||||
|
await tester.runAsync(() => call.startCall(
|
||||||
|
roomName: 'pyramid-tokentest',
|
||||||
|
roomDisplayName: 'Token-Test',
|
||||||
|
identity: 'wird-vom-server-ignoriert',
|
||||||
|
audioOnly: true,
|
||||||
|
matrixClient: client,
|
||||||
|
));
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
|
expect(call.error, isNull, reason: 'Beitritt fehlgeschlagen');
|
||||||
|
expect(call.room, isNotNull, reason: 'keine LiveKit-Verbindung');
|
||||||
|
// Der Server bestimmt die Identität aus dem geprüften Matrix-Login –
|
||||||
|
// der vom Client übergebene Wert spielt keine Rolle mehr.
|
||||||
|
expect(call.room!.localParticipant?.identity, client.userID);
|
||||||
|
|
||||||
|
await tester.runAsync(() => call.hangUp());
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
expect(call.room, isNull);
|
||||||
|
},
|
||||||
|
skip: profile == null,
|
||||||
|
timeout: const Timeout(Duration(minutes: 3)),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:integration_test/integration_test.dart';
|
||||||
|
import 'package:pyramid/core/app_state.dart';
|
||||||
|
import 'package:pyramid/core/appearance.dart';
|
||||||
|
import 'package:pyramid/core/theme.dart';
|
||||||
|
import 'package:pyramid/main.dart' as app;
|
||||||
|
import 'package:pyramid/widgets/spring_press.dart';
|
||||||
|
|
||||||
|
/// UI-Durchlauf im HANDY-Format mit der echten App (Windows-Build) und einem
|
||||||
|
/// TEST-Profil – erzeugt Screenshots unter build/ui_screens/.
|
||||||
|
///
|
||||||
|
/// Läuft nur mit gesetztem PYRAMID_PROFILE_DIR (eingeloggtes Testprofil, z. B.
|
||||||
|
/// pyramidtest1 unter %USERPROFILE%\.pyramid-autopilot\profile1) – nie gegen
|
||||||
|
/// Bernds echtes Profil. Testinhalte vorher mit
|
||||||
|
/// `test/live_ui_fixture_test.dart` anlegen (DM + Gruppe mit pyramidtest2).
|
||||||
|
///
|
||||||
|
/// $env:PYRAMID_PROFILE_DIR="$env:USERPROFILE\.pyramid-autopilot\profile1"
|
||||||
|
/// flutter test integration_test/mobile_ui_screens_test.dart -d windows
|
||||||
|
///
|
||||||
|
/// Achtung: Keine zweite Pyramid-Instanz auf demselben Profil laufen lassen
|
||||||
|
/// (zwei Clients auf einer olm-DB). Ansichten (Darstellung, Farbmodus, Raum)
|
||||||
|
/// werden nur über den App-Zustand umgeschaltet – nichts wird gespeichert.
|
||||||
|
///
|
||||||
|
/// Simuliert wird ein typisches Handy: 1080×2340 px, Dichte 2,625
|
||||||
|
/// (≈ 411×891 dp), Statusleiste oben 24 dp, Gestenleiste unten 16 dp.
|
||||||
|
const _otherUser = 'pyramidtest2';
|
||||||
|
const _groupName = 'UI-Test Gruppe';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
final profile = Platform.environment['PYRAMID_PROFILE_DIR'];
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'Handy-UI: Chatliste, Chat, Bild-Vollansicht, Gruppe, Einstellungen',
|
||||||
|
(tester) async {
|
||||||
|
tester.view
|
||||||
|
..physicalSize = const Size(1080, 2340)
|
||||||
|
..devicePixelRatio = 2.625
|
||||||
|
..padding = const FakeViewPadding(top: 63, bottom: 42)
|
||||||
|
..viewPadding = const FakeViewPadding(top: 63, bottom: 42);
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
final size = tester.view.physicalSize / tester.view.devicePixelRatio;
|
||||||
|
|
||||||
|
// app.main() ersetzt FlutterError.onError – danach den Empfänger des
|
||||||
|
|
||||||
|
// Testrahmens zurücksetzen, sonst verschwinden Fehlermeldungen.
|
||||||
|
|
||||||
|
final testOnError = FlutterError.onError;
|
||||||
|
|
||||||
|
app.main();
|
||||||
|
await _pumpUntil(
|
||||||
|
tester,
|
||||||
|
find.text(_otherUser),
|
||||||
|
timeout: const Duration(seconds: 90),
|
||||||
|
);
|
||||||
|
final container = ProviderScope.containerOf(
|
||||||
|
tester.element(find.byType(MaterialApp).first),
|
||||||
|
);
|
||||||
|
|
||||||
|
FlutterError.onError = testOnError;
|
||||||
|
await _shot(tester, '01_chatliste');
|
||||||
|
|
||||||
|
// ── Direktnachricht: Sprechblasen (Handy-Standard) und Liste ──
|
||||||
|
await tester.tap(find.text(_otherUser).first);
|
||||||
|
// Chat gleitet federnd über die Liste.
|
||||||
|
await tester.pump(const Duration(milliseconds: 90));
|
||||||
|
await _shot(tester, '01b_chat_gleitet_herein');
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 6));
|
||||||
|
await _scrollToNewest(tester);
|
||||||
|
await _shot(tester, '02_chat');
|
||||||
|
|
||||||
|
// Kontakt-Banner aufklappen (Tipp auf den Namen im Kopf) und wieder zu.
|
||||||
|
await tester.tap(find.text(_otherUser).first);
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
await _shot(tester, '02c_dm_kopf_aufgeklappt');
|
||||||
|
await tester.tap(find.text(_otherUser).first);
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
|
||||||
|
// Antwort-Zitat antippen → springt zur zitierten Nachricht.
|
||||||
|
final quote = find.byWidgetPredicate(
|
||||||
|
(w) => w is SpringPress && w.pressedScale == 0.97,
|
||||||
|
);
|
||||||
|
// Nach oben blättern, bis ein Zitat gebaut ist (die Liste baut nur
|
||||||
|
// sichtbare Einträge; .first auf eine leere Trefferliste wirft).
|
||||||
|
for (var i = 0; i < 60 && quote.evaluate().isEmpty; i++) {
|
||||||
|
await tester.drag(find.byType(Scrollable).first, const Offset(0, 300),
|
||||||
|
warnIfMissed: false);
|
||||||
|
await tester.pump(const Duration(milliseconds: 120));
|
||||||
|
}
|
||||||
|
expect(quote, findsWidgets, reason: 'kein Antwort-Zitat im Test-Chat');
|
||||||
|
await tester.ensureVisible(quote.first);
|
||||||
|
await _pumpFor(tester, const Duration(milliseconds: 600));
|
||||||
|
await _shot(tester, '02e_zitat_vor_dem_tippen');
|
||||||
|
await tester.tap(quote.first, warnIfMissed: false);
|
||||||
|
await _pumpFor(tester, const Duration(milliseconds: 900));
|
||||||
|
await _shot(tester, '02f_sprung_zum_zitat');
|
||||||
|
await _scrollToNewest(tester);
|
||||||
|
|
||||||
|
// ⋮-Menü mit Suchen/Mitglieder.
|
||||||
|
await tester.tap(find.byIcon(Icons.more_vert_rounded).first);
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
expect(find.text('Mitglieder'), findsWidgets);
|
||||||
|
await _shot(tester, '02d_menue');
|
||||||
|
await tester.tapAt(Offset(size.width / 2, size.height / 2));
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
|
||||||
|
// Antworten → Antwort-Leiste federt auf. (Wischen zum Antworten gibt
|
||||||
|
// es nur auf echten Handys; hier über Lange-Drücken → Antworten.)
|
||||||
|
// Neben die eigene Sprechblase drücken (nicht in den Text – dort
|
||||||
|
// greift am PC die Textauswahl).
|
||||||
|
final ownText = find.text('Danke, sieht gut aus!');
|
||||||
|
for (var i = 0; i < 60 && ownText.evaluate().isEmpty; i++) {
|
||||||
|
await tester.drag(find.byType(Scrollable).first, const Offset(0, 300),
|
||||||
|
warnIfMissed: false);
|
||||||
|
await tester.pump(const Duration(milliseconds: 120));
|
||||||
|
}
|
||||||
|
// Mittig holen – unten läge sie unter der schwebenden Eingabeleiste.
|
||||||
|
await Scrollable.ensureVisible(tester.element(ownText.first), alignment: 0.5);
|
||||||
|
await _pumpFor(tester, const Duration(milliseconds: 600));
|
||||||
|
final ownMsg = tester.getCenter(ownText.first);
|
||||||
|
await tester.longPressAt(Offset(size.width * 0.15, ownMsg.dy));
|
||||||
|
await _pumpFor(tester, const Duration(milliseconds: 500));
|
||||||
|
await tester.tap(find.byIcon(Icons.reply_rounded).first);
|
||||||
|
await tester.pump(const Duration(milliseconds: 60));
|
||||||
|
await _shot(tester, '02g_antwort_leiste_federt');
|
||||||
|
await _pumpFor(tester, const Duration(milliseconds: 800));
|
||||||
|
await _shot(tester, '02h_antwort_leiste');
|
||||||
|
final closeReply = find.byIcon(Icons.close_rounded);
|
||||||
|
if (closeReply.evaluate().isNotEmpty) {
|
||||||
|
await tester.tap(closeReply.first);
|
||||||
|
await _pumpFor(tester, const Duration(milliseconds: 600));
|
||||||
|
}
|
||||||
|
|
||||||
|
container.read(messageStyleProvider.notifier).state = MessageStyle.list;
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
await _shot(tester, '02b_chat_liste');
|
||||||
|
container.read(messageStyleProvider.notifier).state = MessageStyle.auto;
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
|
||||||
|
// ── Bild-Vollansicht ──
|
||||||
|
// Größtes Bild im Chat = eines der Testbilder; erst sichtbar scrollen.
|
||||||
|
Future<void> openViewer() async {
|
||||||
|
final image = _largestImage(tester);
|
||||||
|
expect(image, isNotNull, reason: 'kein Bild im Test-Chat gefunden');
|
||||||
|
await tester.ensureVisible(image!);
|
||||||
|
await _pumpFor(tester, const Duration(milliseconds: 600));
|
||||||
|
await tester.tap(image);
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
expect(
|
||||||
|
find.byType(InteractiveViewer),
|
||||||
|
findsOneWidget,
|
||||||
|
reason: 'Antippen des Bildes hat die Vollansicht nicht geöffnet',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await openViewer();
|
||||||
|
await _shot(tester, '03_vollansicht');
|
||||||
|
|
||||||
|
// Tippen NEBEN das Bild (ganz unten, unter dem Bild) schließt.
|
||||||
|
await tester.tapAt(Offset(size.width / 2, size.height - 30));
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
await _shot(tester, '04_nach_tippen_daneben');
|
||||||
|
expect(
|
||||||
|
find.byType(InteractiveViewer),
|
||||||
|
findsNothing,
|
||||||
|
reason: 'Tippen neben das Bild hat die Vollansicht nicht geschlossen',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tippen AUFS Bild schließt NICHT (blendet nur die Leiste aus).
|
||||||
|
await openViewer();
|
||||||
|
await tester.tapAt(Offset(size.width / 2, size.height / 2));
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
expect(
|
||||||
|
find.byType(InteractiveViewer),
|
||||||
|
findsOneWidget,
|
||||||
|
reason: 'Tippen aufs Bild darf die Vollansicht nicht schließen',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Doppeltipp zoomt hinein, nochmal doppelt wieder heraus.
|
||||||
|
Future<void> doubleTapCenter() async {
|
||||||
|
await tester.tapAt(Offset(size.width / 2, size.height / 2));
|
||||||
|
await tester.pump(const Duration(milliseconds: 80));
|
||||||
|
await tester.tapAt(Offset(size.width / 2, size.height / 2));
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
await doubleTapCenter();
|
||||||
|
final viewer = tester.widget<InteractiveViewer>(
|
||||||
|
find.byType(InteractiveViewer),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
viewer.transformationController!.value.getMaxScaleOnAxis(),
|
||||||
|
greaterThan(1.5),
|
||||||
|
reason: 'Doppeltipp hat nicht gezoomt',
|
||||||
|
);
|
||||||
|
await _shot(tester, '05_doppeltipp_zoom');
|
||||||
|
await doubleTapCenter();
|
||||||
|
|
||||||
|
// Wischen nach unten schließt.
|
||||||
|
await tester.dragFrom(
|
||||||
|
Offset(size.width / 2, size.height / 2),
|
||||||
|
const Offset(0, 320),
|
||||||
|
);
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
expect(
|
||||||
|
find.byType(InteractiveViewer),
|
||||||
|
findsNothing,
|
||||||
|
reason: 'Wischen nach unten hat die Vollansicht nicht geschlossen',
|
||||||
|
);
|
||||||
|
await _shot(tester, '06_nach_wischen');
|
||||||
|
|
||||||
|
// ── Farbmodus Hell (nur Zustand) ──
|
||||||
|
final savedVariant = container.read(themeVariantProvider);
|
||||||
|
container.read(themeVariantProvider.notifier).state = ThemeVariant.light;
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
await _shot(tester, '07_chat_hell');
|
||||||
|
container.read(themeVariantProvider.notifier).state = savedVariant;
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
|
||||||
|
// ── Gruppenraum (Namen + Avatare in den Sprechblasen) ──
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = null;
|
||||||
|
container.read(activeSpaceIdProvider.notifier).state = 'dms';
|
||||||
|
await _pumpUntil(
|
||||||
|
tester,
|
||||||
|
find.text(_groupName),
|
||||||
|
timeout: const Duration(seconds: 30),
|
||||||
|
);
|
||||||
|
await tester.tap(find.text(_groupName).first);
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 5));
|
||||||
|
await _scrollToNewest(tester);
|
||||||
|
await _shot(tester, '08_gruppe');
|
||||||
|
|
||||||
|
// ── Einstellungen → Erscheinungsbild ──
|
||||||
|
container.read(activeModalProvider.notifier).state = ModalKind.settings;
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
await tester.tap(find.text('Erscheinungsbild').first);
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
await _shot(tester, '09_einstellungen_erscheinungsbild');
|
||||||
|
await tester.drag(
|
||||||
|
find.text('Schriftgröße').first,
|
||||||
|
const Offset(0, -500),
|
||||||
|
warnIfMissed: false,
|
||||||
|
);
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
await _shot(tester, '10_einstellungen_text_chat');
|
||||||
|
container.read(activeModalProvider.notifier).state = ModalKind.none;
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
|
||||||
|
// ── Zurück zur Chatliste: Chat gleitet hinaus, Liste bleibt erhalten ──
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = null;
|
||||||
|
await tester.pump(const Duration(milliseconds: 90));
|
||||||
|
await _shot(tester, '11_zurueck_gleitet_hinaus');
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
expect(find.text(_groupName), findsWidgets);
|
||||||
|
await _shot(tester, '12_liste_nach_zurueck');
|
||||||
|
},
|
||||||
|
skip: profile == null,
|
||||||
|
timeout: const Timeout(Duration(minutes: 6)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Finder? _largestImage(WidgetTester tester) {
|
||||||
|
Element? best;
|
||||||
|
var bestArea = 150.0 * 150.0;
|
||||||
|
for (final element in find.byType(Image).evaluate()) {
|
||||||
|
final box = element.renderObject;
|
||||||
|
if (box is! RenderBox || !box.hasSize) continue;
|
||||||
|
final area = box.size.width * box.size.height;
|
||||||
|
if (area > bestArea) {
|
||||||
|
bestArea = area;
|
||||||
|
best = element;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best == null ? null : find.byElementPredicate((e) => e == best);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ganz nach unten zu den neuesten Nachrichten scrollen.
|
||||||
|
Future<void> _scrollToNewest(WidgetTester tester) async {
|
||||||
|
for (var i = 0; i < 6; i++) {
|
||||||
|
final lists = find.byType(Scrollable);
|
||||||
|
if (lists.evaluate().isEmpty) return;
|
||||||
|
await tester.fling(lists.first, const Offset(0, -1500), 3000,
|
||||||
|
warnIfMissed: false);
|
||||||
|
await _pumpFor(tester, const Duration(milliseconds: 500));
|
||||||
|
}
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pumpFor(WidgetTester tester, Duration duration) async {
|
||||||
|
final end = DateTime.now().add(duration);
|
||||||
|
while (DateTime.now().isBefore(end)) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pumpUntil(
|
||||||
|
WidgetTester tester,
|
||||||
|
Finder finder, {
|
||||||
|
required Duration timeout,
|
||||||
|
}) async {
|
||||||
|
final end = DateTime.now().add(timeout);
|
||||||
|
while (DateTime.now().isBefore(end)) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
if (finder.evaluate().isNotEmpty) return;
|
||||||
|
}
|
||||||
|
fail('Zeitüberschreitung: $finder nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Speichert, was gerade auf dem (simulierten) Bildschirm ist.
|
||||||
|
Future<void> _shot(WidgetTester tester, String name) async {
|
||||||
|
await tester.pump();
|
||||||
|
final renderView = tester.binding.renderViews.first;
|
||||||
|
final layer = renderView.debugLayer! as OffsetLayer;
|
||||||
|
final physical = tester.view.physicalSize;
|
||||||
|
final image = await tester.binding.runAsync(
|
||||||
|
() => layer.toImage(Offset.zero & physical),
|
||||||
|
);
|
||||||
|
final bytes = await tester.binding.runAsync(
|
||||||
|
() => image!.toByteData(format: ui.ImageByteFormat.png),
|
||||||
|
);
|
||||||
|
final file = File('build/ui_screens/$name.png');
|
||||||
|
file.parent.createSync(recursive: true);
|
||||||
|
file.writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('Screenshot: ${file.path}');
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||||
|
import 'package:webrtc_interface/webrtc_interface.dart';
|
||||||
|
|
||||||
|
// Gemeinsame Hilfen für Live-Tests mit zwei Testkonten (Anruftest,
|
||||||
|
// UI-Tour): zweiter Matrix-Client im selben Prozess, Zugangsdaten,
|
||||||
|
// Warte-Helfer. Kein eigenständiger Test.
|
||||||
|
|
||||||
|
/// Zweiter Teilnehmer: eigener Matrix-Client + VoIP im selben Prozess.
|
||||||
|
class TestPartner {
|
||||||
|
TestPartner(this.client, this.delegate, this.voip, this.zone) {
|
||||||
|
_sub = client.onCallEvents.stream.listen((events) {
|
||||||
|
for (final e in events) {
|
||||||
|
if (e.type != EventTypes.CallCandidates || e.senderId == client.userID) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final list = e.content.tryGetList<Map<String, dynamic>>('candidates');
|
||||||
|
if (list != null) _buffer.addAll(list);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_timer = Timer.periodic(const Duration(milliseconds: 500), (_) async {
|
||||||
|
final pc = delegate.call?.pc;
|
||||||
|
if (!fixCandidates || pc == null) return;
|
||||||
|
try {
|
||||||
|
if (await pc.getRemoteDescription() == null) return;
|
||||||
|
for (final c in List.of(_buffer)) {
|
||||||
|
final cand = c['candidate'] as String?;
|
||||||
|
if (cand == null || !_applied.add(cand)) continue;
|
||||||
|
await pc.addCandidate(RTCIceCandidate(
|
||||||
|
cand,
|
||||||
|
c['sdpMid']?.toString() ?? '',
|
||||||
|
(c['sdpMLineIndex'] as num?)?.round() ?? 0,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
final Client client;
|
||||||
|
final TestPartnerDelegate delegate;
|
||||||
|
final VoIP voip;
|
||||||
|
final Zone zone;
|
||||||
|
|
||||||
|
/// Aktion der Gegenseite in ihrer Zone ausführen (Fehler über Completer).
|
||||||
|
Future<T> run<T>(Future<T> Function() f) {
|
||||||
|
final done = Completer<T>();
|
||||||
|
zone.run(() async {
|
||||||
|
try {
|
||||||
|
done.complete(await f());
|
||||||
|
} catch (e, s) {
|
||||||
|
done.completeError(e, s);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return done.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gegenseite reicht Kandidaten nach wie die gepatchte App.
|
||||||
|
bool fixCandidates = false;
|
||||||
|
final _buffer = <Map<String, dynamic>>[];
|
||||||
|
final _applied = <String>{};
|
||||||
|
StreamSubscription? _sub;
|
||||||
|
Timer? _timer;
|
||||||
|
|
||||||
|
static Future<TestPartner> start(String user, String pass) async {
|
||||||
|
final db = await databaseFactoryFfi.openDatabase(inMemoryDatabasePath);
|
||||||
|
final client = Client(
|
||||||
|
'pyramid-call-partner',
|
||||||
|
database: await MatrixSdkDatabase.init('pyramid-call-partner', database: db),
|
||||||
|
);
|
||||||
|
await client.init(waitForFirstSync: false, waitUntilLoadCompletedLoaded: false);
|
||||||
|
await client.checkHomeserver(Uri.parse('https://steggi-matrix.work'));
|
||||||
|
await client.login(
|
||||||
|
LoginType.mLoginPassword,
|
||||||
|
identifier: AuthenticationUserIdentifier(user: user),
|
||||||
|
password: pass,
|
||||||
|
initialDeviceDisplayName: 'Pyramid-Anruftest (kurzlebig)',
|
||||||
|
);
|
||||||
|
final delegate = TestPartnerDelegate();
|
||||||
|
// Eigene Zone: Fehler der Gegenseite sichtbar protokollieren statt sie
|
||||||
|
// im Testrahmen zu verlieren.
|
||||||
|
final zone = Zone.current.fork(
|
||||||
|
specification: ZoneSpecification(
|
||||||
|
handleUncaughtError: (self, parent, z, e, s) =>
|
||||||
|
logStep('Gegenseite (abgefangen): ${e.toString().split('\n').first}'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final voip = zone.run(() => VoIP(client, delegate));
|
||||||
|
return TestPartner(client, delegate, voip, zone);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> dispose(String? roomId) async {
|
||||||
|
_timer?.cancel();
|
||||||
|
await _sub?.cancel();
|
||||||
|
try {
|
||||||
|
await delegate.call?.hangup(reason: CallErrorCode.userHangup);
|
||||||
|
} catch (_) {}
|
||||||
|
if (roomId != null) {
|
||||||
|
try {
|
||||||
|
await client.leaveRoom(roomId);
|
||||||
|
await client.forgetRoom(roomId);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
client.backgroundSync = false;
|
||||||
|
await client.logout();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TestPartnerDelegate implements WebRTCDelegate {
|
||||||
|
CallSession? call;
|
||||||
|
|
||||||
|
@override
|
||||||
|
MediaDevices get mediaDevices => TestPartnerMedia(rtc.navigator.mediaDevices);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<RTCPeerConnection> createPeerConnection(
|
||||||
|
Map<String, dynamic> configuration, [
|
||||||
|
Map<String, dynamic> constraints = const {},
|
||||||
|
]) =>
|
||||||
|
rtc.createPeerConnection(configuration, constraints);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> handleNewCall(CallSession session) async {
|
||||||
|
call = session;
|
||||||
|
if (session.direction == CallDirection.kIncoming) {
|
||||||
|
// Wie ein Mensch nach kurzem Klingeln annehmen. Sofortiges Annehmen
|
||||||
|
// trifft eine Lücke im SDK: Kandidaten, die WÄHREND answer()
|
||||||
|
// eintreffen, landen in einer Warteschlange, die nie mehr abgearbeitet
|
||||||
|
// wird (CallSession.onCandidatesReceived vs. answer()).
|
||||||
|
unawaited(Future.delayed(const Duration(seconds: 2), () async {
|
||||||
|
try {
|
||||||
|
await session.answer();
|
||||||
|
} catch (e) {
|
||||||
|
logStep('Gegenseite: Annehmen fehlgeschlagen: $e');
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> handleCallEnded(CallSession session) async {}
|
||||||
|
@override
|
||||||
|
Future<void> handleMissedCall(CallSession session) async {}
|
||||||
|
@override
|
||||||
|
Future<void> handleNewGroupCall(GroupCallSession groupCall) async {}
|
||||||
|
@override
|
||||||
|
Future<void> handleGroupCallEnded(GroupCallSession groupCall) async {}
|
||||||
|
@override
|
||||||
|
Future<void> playRingtone() async {}
|
||||||
|
@override
|
||||||
|
Future<void> stopRingtone() async {}
|
||||||
|
@override
|
||||||
|
Future<void> registerListeners(CallSession session) async {}
|
||||||
|
@override
|
||||||
|
bool get isWeb => false;
|
||||||
|
@override
|
||||||
|
bool get canHandleNewCall => true;
|
||||||
|
@override
|
||||||
|
EncryptionKeyProvider? get keyProvider => null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie die App am PC: Bildschirm über eine Desktop-Quelle (erster Monitor).
|
||||||
|
class TestPartnerMedia extends MediaDevices {
|
||||||
|
TestPartnerMedia(this._d);
|
||||||
|
final MediaDevices _d;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<MediaStream> getUserMedia(Map<String, dynamic> c) => _d.getUserMedia(c);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<MediaStream> getDisplayMedia(Map<String, dynamic> c) async {
|
||||||
|
final sources = await rtc.desktopCapturer
|
||||||
|
.getSources(types: [rtc.SourceType.Screen]);
|
||||||
|
return _d.getDisplayMedia({
|
||||||
|
'video': {
|
||||||
|
'mandatory': {
|
||||||
|
'chromeMediaSource': 'desktop',
|
||||||
|
'chromeMediaSourceId': sources.first.id,
|
||||||
|
},
|
||||||
|
'optional': [],
|
||||||
|
},
|
||||||
|
'audio': false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<MediaDeviceInfo>> enumerateDevices() => _d.enumerateDevices();
|
||||||
|
@override
|
||||||
|
MediaTrackSupportedConstraints getSupportedConstraints() =>
|
||||||
|
_d.getSupportedConstraints();
|
||||||
|
@override
|
||||||
|
set ondevicechange(Function(dynamic event)? callback) =>
|
||||||
|
_d.ondevicechange = callback;
|
||||||
|
@override
|
||||||
|
Function(dynamic event)? get ondevicechange => _d.ondevicechange;
|
||||||
|
@override
|
||||||
|
// ignore: deprecated_member_use
|
||||||
|
Future<List<dynamic>> getSources() => _d.getSources();
|
||||||
|
@override
|
||||||
|
Future<MediaDeviceInfo> selectAudioOutput([AudioOutputOptions? options]) =>
|
||||||
|
_d.selectAudioOutput(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String>? loadTestAccounts() {
|
||||||
|
final home = Platform.environment['USERPROFILE'] ?? '';
|
||||||
|
final file = File('$home\\.pyramid-autopilot\\test-accounts.txt');
|
||||||
|
if (!file.existsSync()) return null;
|
||||||
|
final map = <String, String>{};
|
||||||
|
for (final line in file.readAsLinesSync()) {
|
||||||
|
final t = line.trim();
|
||||||
|
if (t.isEmpty || t.startsWith('#')) continue;
|
||||||
|
final i = t.indexOf('=');
|
||||||
|
if (i > 0) map[t.substring(0, i)] = t.substring(i + 1);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> pumpFor(WidgetTester tester, Duration d) async {
|
||||||
|
final end = DateTime.now().add(d);
|
||||||
|
while (DateTime.now().isBefore(end)) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void logStep(String m) {
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('SCHRITT ${DateTime.now().toIso8601String().substring(11, 19)} $m');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> pumpUntil(
|
||||||
|
WidgetTester tester,
|
||||||
|
bool Function() condition, {
|
||||||
|
Duration timeout = const Duration(seconds: 60),
|
||||||
|
String what = '',
|
||||||
|
void Function()? onTimeout,
|
||||||
|
void Function()? every5s,
|
||||||
|
}) async {
|
||||||
|
final end = DateTime.now().add(timeout);
|
||||||
|
var next = DateTime.now().add(const Duration(seconds: 5));
|
||||||
|
while (DateTime.now().isBefore(end)) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
if (condition()) return;
|
||||||
|
if (every5s != null && DateTime.now().isAfter(next)) {
|
||||||
|
every5s();
|
||||||
|
next = DateTime.now().add(const Duration(seconds: 5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logStep('ZEITÜBERSCHREITUNG: $what');
|
||||||
|
onTimeout?.call();
|
||||||
|
fail('Zeitüberschreitung: $what');
|
||||||
|
}
|
||||||
@@ -0,0 +1,413 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:integration_test/integration_test.dart';
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
import 'package:pyramid/core/app_state.dart';
|
||||||
|
import 'package:livekit_client/livekit_client.dart' as lk;
|
||||||
|
import 'package:pyramid/core/livekit_token.dart';
|
||||||
|
import 'package:pyramid/core/matrix_client.dart';
|
||||||
|
import 'package:pyramid/core/settings_prefs.dart' show bottomNavProvider;
|
||||||
|
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
||||||
|
import 'package:pyramid/features/call_ui/voice_channel.dart' show CallBubblesDemo;
|
||||||
|
import 'package:pyramid/features/group_call/group_call.dart';
|
||||||
|
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
|
||||||
|
import 'package:pyramid/layout/mobile_tabs.dart';
|
||||||
|
import 'package:pyramid/main.dart' as app;
|
||||||
|
|
||||||
|
import 'support/test_partner.dart';
|
||||||
|
|
||||||
|
/// UI-Tour für das UX-Audit (Bernd 2026-10-08): fährt alle wichtigen
|
||||||
|
/// Zustände ab und speichert jeden als Screenshot im Handy- UND im
|
||||||
|
/// PC-Format nach build/ui_audit/ (p_* = Handy, d_* = PC).
|
||||||
|
///
|
||||||
|
/// Testkonten: App = pyramidtest1 (Testprofil), Gegenseite = pyramidtest2
|
||||||
|
/// (kurzlebiges Gerät). Legt beim ersten Lauf einen Test-Space mit Text- und
|
||||||
|
/// Sprachkanal an (bleibt für spätere Touren bestehen) und für den Anruf einen
|
||||||
|
/// Wegwerf-Raum. Nutzt kurz Mikrofon (Anruf, Sprachkanal).
|
||||||
|
///
|
||||||
|
/// $env:PYRAMID_PROFILE_DIR="$env:USERPROFILE\.pyramid-autopilot\profile1"
|
||||||
|
/// flutter test integration_test/ui_audit_tour_test.dart -d windows
|
||||||
|
const _spaceName = 'UI-Test Space';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
final profile = Platform.environment['PYRAMID_PROFILE_DIR'];
|
||||||
|
final accounts = loadTestAccounts();
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'UI-Tour: alle Zustände, Handy + PC',
|
||||||
|
(tester) async {
|
||||||
|
void phone() => tester.view
|
||||||
|
..physicalSize = const Size(1080, 2340)
|
||||||
|
..devicePixelRatio = 2.625
|
||||||
|
..padding = const FakeViewPadding(top: 63, bottom: 42)
|
||||||
|
..viewPadding = const FakeViewPadding(top: 63, bottom: 42);
|
||||||
|
void desktop() => tester.view
|
||||||
|
..physicalSize = const Size(1600, 1000)
|
||||||
|
..devicePixelRatio = 1.0
|
||||||
|
..padding = FakeViewPadding.zero
|
||||||
|
..viewPadding = FakeViewPadding.zero;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
phone();
|
||||||
|
|
||||||
|
// Flüssig pumpen (~60 Bilder/s), damit Animationen wie auf dem Gerät
|
||||||
|
// zur Ruhe kommen (pumpFor zeichnet nur alle 100 ms).
|
||||||
|
Future<void> smooth(Duration d) async {
|
||||||
|
final end = DateTime.now().add(d);
|
||||||
|
while (DateTime.now().isBefore(end)) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> both(String name, {Duration settle = const Duration(milliseconds: 1500)}) async {
|
||||||
|
phone();
|
||||||
|
await smooth(settle);
|
||||||
|
await _shot(tester, 'p_$name');
|
||||||
|
desktop();
|
||||||
|
await smooth(settle);
|
||||||
|
await _shot(tester, 'd_$name');
|
||||||
|
phone();
|
||||||
|
await pumpFor(tester, const Duration(milliseconds: 400));
|
||||||
|
}
|
||||||
|
|
||||||
|
// app.main() ersetzt FlutterError.onError – danach den Empfänger des
|
||||||
|
// Testrahmens zurücksetzen, sonst verschwinden Fehlermeldungen.
|
||||||
|
final testOnError = FlutterError.onError;
|
||||||
|
app.main();
|
||||||
|
await pumpUntil(tester, () => find.byType(MaterialApp).evaluate().isNotEmpty);
|
||||||
|
FlutterError.onError = testOnError;
|
||||||
|
final container = ProviderScope.containerOf(
|
||||||
|
tester.element(find.byType(MaterialApp).first),
|
||||||
|
);
|
||||||
|
final me = (await tester.runAsync(
|
||||||
|
() => container.read(matrixClientProvider.future),
|
||||||
|
))!;
|
||||||
|
await pumpFor(tester, const Duration(seconds: 5));
|
||||||
|
|
||||||
|
final partner = (await tester.runAsync(
|
||||||
|
() => TestPartner.start(accounts!['user2']!, accounts['pass2']!),
|
||||||
|
))!;
|
||||||
|
String? callRoomId;
|
||||||
|
addTearDown(() => tester.runAsync(() async {
|
||||||
|
await partner.dispose(callRoomId);
|
||||||
|
final r = callRoomId == null ? null : me.getRoomById(callRoomId);
|
||||||
|
await r?.leave().catchError((_) {});
|
||||||
|
await r?.forget().catchError((_) {});
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Fixture: Test-Space mit Text- und Sprachkanal (einmalig) ──
|
||||||
|
var space = me.rooms.where((r) => r.isSpace && r.name == _spaceName).firstOrNull;
|
||||||
|
if (space == null) {
|
||||||
|
logStep('lege Test-Space an');
|
||||||
|
final ids = (await tester.runAsync(() async {
|
||||||
|
final c = partner.client;
|
||||||
|
final spaceId = await c.createRoom(
|
||||||
|
name: _spaceName,
|
||||||
|
creationContent: {'type': 'm.space'},
|
||||||
|
preset: CreateRoomPreset.privateChat,
|
||||||
|
invite: [me.userID!],
|
||||||
|
);
|
||||||
|
final textId = await c.createRoom(
|
||||||
|
name: 'allgemein',
|
||||||
|
preset: CreateRoomPreset.privateChat,
|
||||||
|
invite: [me.userID!],
|
||||||
|
);
|
||||||
|
final voiceId = await c.createRoom(
|
||||||
|
name: 'Sprachkanal',
|
||||||
|
preset: CreateRoomPreset.privateChat,
|
||||||
|
creationContent: {'type': 'io.pyramid.voice_channel'},
|
||||||
|
invite: [me.userID!],
|
||||||
|
);
|
||||||
|
final pl = Map<String, dynamic>.from(
|
||||||
|
await c.getRoomStateWithKey(voiceId, EventTypes.RoomPowerLevels, ''),
|
||||||
|
);
|
||||||
|
pl['events'] = {
|
||||||
|
...Map<String, dynamic>.from(pl['events'] as Map? ?? {}),
|
||||||
|
'io.pyramid.voice.presence': 0,
|
||||||
|
};
|
||||||
|
await c.setRoomStateWithKey(voiceId, EventTypes.RoomPowerLevels, '', pl);
|
||||||
|
for (final child in [textId, voiceId]) {
|
||||||
|
await c.setRoomStateWithKey(spaceId, 'm.space.child', child, {
|
||||||
|
'via': ['steggi-matrix.work'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await c.sendMessage(textId, EventTypes.Message,
|
||||||
|
'm${DateTime.now().microsecondsSinceEpoch}',
|
||||||
|
{'msgtype': 'm.text', 'body': 'Willkommen im Test-Space 👋'});
|
||||||
|
return [spaceId, textId, voiceId];
|
||||||
|
}))!;
|
||||||
|
for (final id in ids) {
|
||||||
|
await pumpUntil(tester, () => me.getRoomById(id)?.membership == Membership.invite,
|
||||||
|
what: 'Einladung $id');
|
||||||
|
await tester.runAsync(() => me.getRoomById(id)!.join());
|
||||||
|
}
|
||||||
|
await pumpUntil(tester, () =>
|
||||||
|
me.rooms.any((r) => r.isSpace && r.name == _spaceName && r.membership == Membership.join),
|
||||||
|
what: 'Space beigetreten');
|
||||||
|
space = me.rooms.firstWhere((r) => r.isSpace && r.name == _spaceName);
|
||||||
|
await pumpFor(tester, const Duration(seconds: 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1. Listen ──
|
||||||
|
container.read(activeSpaceIdProvider.notifier).state = 'dms';
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = null;
|
||||||
|
await both('01_dms');
|
||||||
|
tester.state<ScaffoldState>(find.byType(Scaffold).first).openDrawer();
|
||||||
|
await pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
await _shot(tester, 'p_02_drawer');
|
||||||
|
await tester.tapAt(const Offset(390, 400));
|
||||||
|
await pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
final groupsChip = find.text('Gruppen');
|
||||||
|
if (groupsChip.evaluate().isNotEmpty) {
|
||||||
|
await tester.tap(groupsChip.first);
|
||||||
|
await both('03_chats_gruppen');
|
||||||
|
await tester.tap(find.text('Alle').first);
|
||||||
|
await pumpFor(tester, const Duration(milliseconds: 500));
|
||||||
|
}
|
||||||
|
container.read(activeSpaceIdProvider.notifier).state = space.id;
|
||||||
|
await both('04_space');
|
||||||
|
|
||||||
|
// ── 2. Chats ──
|
||||||
|
final dm = me.rooms.firstWhere((r) =>
|
||||||
|
r.membership == Membership.join &&
|
||||||
|
r.directChatMatrixID == '@pyramidtest2:steggi-matrix.work');
|
||||||
|
container.read(activeSpaceIdProvider.notifier).state = 'dms';
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = dm.id;
|
||||||
|
await both('05_dm_chat', settle: const Duration(seconds: 3));
|
||||||
|
final group = me.rooms.firstWhere((r) => r.getLocalizedDisplayname() == 'UI-Test Gruppe');
|
||||||
|
container.read(activeSpaceIdProvider.notifier).state = 'dms';
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = group.id;
|
||||||
|
await both('06_gruppe', settle: const Duration(seconds: 3));
|
||||||
|
container.read(membersPanelOpenProvider.notifier).state = true;
|
||||||
|
await both('07_mitglieder');
|
||||||
|
container.read(membersPanelOpenProvider.notifier).state = false;
|
||||||
|
final textRoom = space.spaceChildren
|
||||||
|
.map((c) => c.roomId == null ? null : me.getRoomById(c.roomId!))
|
||||||
|
.whereType<Room>()
|
||||||
|
.firstWhere((r) => r.getLocalizedDisplayname() == 'allgemein');
|
||||||
|
container.read(activeSpaceIdProvider.notifier).state = space.id;
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = textRoom.id;
|
||||||
|
await both('08_space_textkanal', settle: const Duration(seconds: 3));
|
||||||
|
|
||||||
|
// ── 3. Modale ──
|
||||||
|
container.read(activeModalProvider.notifier).state = ModalKind.settings;
|
||||||
|
await both('09_einstellungen');
|
||||||
|
container.read(activeModalProvider.notifier).state = ModalKind.search;
|
||||||
|
await both('10_suche');
|
||||||
|
container.read(activeModalProvider.notifier).state = ModalKind.none;
|
||||||
|
|
||||||
|
// ── 4. Sackgasse: Anruf-Ansicht ohne Anruf ──
|
||||||
|
container.read(viewModeProvider.notifier).state = ViewMode.voice;
|
||||||
|
await both('11_anrufansicht_ohne_anruf');
|
||||||
|
container.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||||
|
|
||||||
|
// ── 5. Sprachkanal beitreten (echter Tipp in der Space-Liste) ──
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = null;
|
||||||
|
container.read(activeSpaceIdProvider.notifier).state = space.id;
|
||||||
|
await pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
final voiceTile = find.text('Sprachkanal');
|
||||||
|
if (voiceTile.evaluate().isNotEmpty) {
|
||||||
|
await tester.tap(voiceTile.first);
|
||||||
|
final lk = container.read(voiceChannelProvider);
|
||||||
|
await pumpUntil(tester, () => lk.isActive && !lk.isConnecting,
|
||||||
|
timeout: const Duration(seconds: 30), what: 'Sprachkanal verbunden');
|
||||||
|
await both('12_sprachkanal_verbunden_liste', settle: const Duration(seconds: 2));
|
||||||
|
container.read(viewModeProvider.notifier).state = ViewMode.voice;
|
||||||
|
await both('13_sprachkanal_ansicht', settle: const Duration(seconds: 2));
|
||||||
|
container.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||||
|
await tester.runAsync(() => lk.hangUp());
|
||||||
|
await pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
} else {
|
||||||
|
logStep('Sprachkanal nicht in der Liste gefunden');
|
||||||
|
await _shot(tester, 'p_12_sprachkanal_fehlt');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 6. 1:1-Anruf: klingelt, läuft, minimiert ──
|
||||||
|
callRoomId = await tester.runAsync(() => partner.client.createRoom(
|
||||||
|
invite: [me.userID!],
|
||||||
|
preset: CreateRoomPreset.privateChat,
|
||||||
|
name: 'pyramidtest2 (Anruftest)',
|
||||||
|
));
|
||||||
|
await pumpUntil(tester, () => me.getRoomById(callRoomId!)?.membership == Membership.invite);
|
||||||
|
await tester.runAsync(() => me.getRoomById(callRoomId!)!.join());
|
||||||
|
await pumpFor(tester, const Duration(seconds: 3));
|
||||||
|
container.read(activeSpaceIdProvider.notifier).state = 'dms';
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = callRoomId;
|
||||||
|
await pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
final voip = container.read(callSignalingProvider);
|
||||||
|
await tester.runAsync(() => partner.run(() => partner.voip.inviteToCall(
|
||||||
|
partner.client.getRoomById(callRoomId!)!,
|
||||||
|
CallType.kVoice,
|
||||||
|
)));
|
||||||
|
final answerBtn = find.byWidgetPredicate(
|
||||||
|
(w) => w is FloatingActionButton && w.heroTag == 'answer_call',
|
||||||
|
);
|
||||||
|
await pumpUntil(tester, () => answerBtn.evaluate().isNotEmpty, what: 'klingelt');
|
||||||
|
await both('14_anruf_klingelt');
|
||||||
|
await tester.tap(answerBtn.first);
|
||||||
|
await pumpUntil(tester, () => voip.currentCall?.state == CallState.kConnected,
|
||||||
|
what: 'verbunden');
|
||||||
|
await both('15_anruf_laeuft', settle: const Duration(seconds: 2));
|
||||||
|
container.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||||
|
await both('16_anruf_minimiert_chat');
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = null;
|
||||||
|
await both('17_anruf_minimiert_liste');
|
||||||
|
// Gegenseite legt auf → wo landet man?
|
||||||
|
container.read(viewModeProvider.notifier).state = ViewMode.voice;
|
||||||
|
await pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
await tester.runAsync(() => partner.run(
|
||||||
|
() => partner.delegate.call!.hangup(reason: CallErrorCode.userHangup)));
|
||||||
|
await pumpUntil(tester, () => voip.currentCall == null, what: 'aufgelegt');
|
||||||
|
// Keine Sackgasse mehr: automatisch zurück zum Chat (UI-Audit S1).
|
||||||
|
await pumpUntil(
|
||||||
|
tester,
|
||||||
|
() => container.read(viewModeProvider) == ViewMode.chat,
|
||||||
|
timeout: const Duration(seconds: 10),
|
||||||
|
what: 'nach Auflegen zurück im Chat',
|
||||||
|
);
|
||||||
|
await pumpFor(tester, const Duration(seconds: 1)); // Ausgleiten abwarten
|
||||||
|
expect(find.text('Kein Anruf'), findsNothing);
|
||||||
|
await both('18_nach_auflegen_durch_gegenseite');
|
||||||
|
container.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||||
|
|
||||||
|
// ── 7. Gruppenanruf: Beitreten-Karte, Anruf mit zwei Personen ──
|
||||||
|
// Anwesenheit für alle erlauben (wer darf, ändert die Rechte).
|
||||||
|
await tester.runAsync(() async {
|
||||||
|
for (final c in [me, partner.client]) {
|
||||||
|
final r = c.getRoomById(group.id);
|
||||||
|
if (r != null && r.canChangePowerLevel) {
|
||||||
|
await allowVoicePresenceForAll(c, group.id).catchError((_) {});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
Future<void> partnerPresence(bool active) => tester.runAsync(() =>
|
||||||
|
partner.client.setRoomStateWithKey(
|
||||||
|
group.id,
|
||||||
|
'io.pyramid.voice.presence',
|
||||||
|
partner.client.userID!,
|
||||||
|
active
|
||||||
|
? {
|
||||||
|
'active': true,
|
||||||
|
'joined_at': DateTime.now().millisecondsSinceEpoch,
|
||||||
|
'display_name': 'pyramidtest2',
|
||||||
|
}
|
||||||
|
: {'active': false},
|
||||||
|
).then((_) {}));
|
||||||
|
var presenceWritten = false;
|
||||||
|
try {
|
||||||
|
await partnerPresence(true);
|
||||||
|
presenceWritten = true;
|
||||||
|
} catch (e) {
|
||||||
|
logStep('Gegenseite darf keine Anwesenheit schreiben: $e');
|
||||||
|
}
|
||||||
|
container.read(activeSpaceIdProvider.notifier).state = 'dms';
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = group.id;
|
||||||
|
if (presenceWritten) {
|
||||||
|
await pumpUntil(tester, () => find.text('Gruppenanruf läuft').evaluate().isNotEmpty,
|
||||||
|
timeout: const Duration(seconds: 20), what: 'Beitreten-Karte');
|
||||||
|
}
|
||||||
|
await both('19_gruppenanruf_karte', settle: const Duration(seconds: 2));
|
||||||
|
|
||||||
|
// Gegenseite tritt dem LiveKit-Raum wirklich bei (zweite Bubble).
|
||||||
|
lk.Room? partnerLk;
|
||||||
|
try {
|
||||||
|
partnerLk = await tester.runAsync(() async {
|
||||||
|
final t = await LiveKitTokenService.fetch(
|
||||||
|
room: group.id,
|
||||||
|
matrixToken: partner.client.accessToken!,
|
||||||
|
);
|
||||||
|
final r = lk.Room();
|
||||||
|
await r.connect(t.url, t.token);
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logStep('Gegenseite konnte LiveKit nicht beitreten: $e');
|
||||||
|
}
|
||||||
|
final lkCall = container.read(voiceChannelProvider);
|
||||||
|
await tester.tap(find.byTooltip('Gruppenanruf beitreten').evaluate().isNotEmpty
|
||||||
|
? find.byTooltip('Gruppenanruf beitreten').first
|
||||||
|
: find.byTooltip('Gruppenanruf').first);
|
||||||
|
await pumpUntil(tester, () => lkCall.isActive && !lkCall.isConnecting,
|
||||||
|
timeout: const Duration(seconds: 30), what: 'Gruppenanruf verbunden');
|
||||||
|
await both('20_gruppenanruf_bubbles', settle: const Duration(seconds: 3));
|
||||||
|
await tester.tap(find.byTooltip('Kachelansicht').first);
|
||||||
|
await both('21_gruppenanruf_kacheln', settle: const Duration(seconds: 2));
|
||||||
|
await tester.tap(find.byTooltip('Bubble-Ansicht').first);
|
||||||
|
await pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
container.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||||
|
await both('22_gruppenanruf_minimiert_chat', settle: const Duration(seconds: 2));
|
||||||
|
await tester.runAsync(() => lkCall.hangUp());
|
||||||
|
await tester.runAsync(() async => partnerLk?.disconnect());
|
||||||
|
if (presenceWritten) await partnerPresence(false);
|
||||||
|
await pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
|
||||||
|
// ── 8. Bubbles mit mehreren Personen (Vorführ-Daten) ──
|
||||||
|
final overlay = Overlay.of(
|
||||||
|
tester.element(find.byType(Scaffold).first),
|
||||||
|
rootOverlay: true,
|
||||||
|
);
|
||||||
|
final demo = OverlayEntry(builder: (_) => const CallBubblesDemo());
|
||||||
|
overlay.insert(demo);
|
||||||
|
await both('23_bubbles_5_personen', settle: const Duration(seconds: 3));
|
||||||
|
demo.remove();
|
||||||
|
final demo7 = OverlayEntry(builder: (_) => const CallBubblesDemo(count: 7));
|
||||||
|
overlay.insert(demo7);
|
||||||
|
await both('24_bubbles_7_personen', settle: const Duration(seconds: 3));
|
||||||
|
demo7.remove();
|
||||||
|
await pumpFor(tester, const Duration(milliseconds: 500));
|
||||||
|
|
||||||
|
// ── 9. Handy: untere Leiste statt Seitenmenü (Vergleich) ──
|
||||||
|
phone();
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = null;
|
||||||
|
await tester.runAsync(() => container.read(bottomNavProvider.notifier).set(true));
|
||||||
|
container.read(mobileTabProvider.notifier).state = MobileTab.chats;
|
||||||
|
container.read(activeSpaceIdProvider.notifier).state = 'dms';
|
||||||
|
await pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
await _shot(tester, 'p_30_unten_chats');
|
||||||
|
await tester.tap(find.text('Server').last);
|
||||||
|
await pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
await _shot(tester, 'p_31_unten_server');
|
||||||
|
await tester.tap(find.text(_spaceName).first);
|
||||||
|
await pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
await _shot(tester, 'p_32_unten_server_kanaele');
|
||||||
|
await tester.tap(find.text('Du').last);
|
||||||
|
await pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
await _shot(tester, 'p_33_unten_du');
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = group.id;
|
||||||
|
await pumpFor(tester, const Duration(seconds: 2));
|
||||||
|
await _shot(tester, 'p_34_unten_chat_offen');
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = null;
|
||||||
|
await pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
await tester.tap(find.text('Chats').last);
|
||||||
|
await pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
await tester.runAsync(() => container.read(bottomNavProvider.notifier).set(false));
|
||||||
|
await pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
},
|
||||||
|
skip: profile == null || accounts == null,
|
||||||
|
timeout: const Timeout(Duration(minutes: 12)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _shot(WidgetTester tester, String name) async {
|
||||||
|
await tester.pump();
|
||||||
|
final renderView = tester.binding.renderViews.first;
|
||||||
|
final layer = renderView.debugLayer! as OffsetLayer;
|
||||||
|
final physical = tester.view.physicalSize;
|
||||||
|
final image = await tester.binding.runAsync(
|
||||||
|
() => layer.toImage(Offset.zero & physical),
|
||||||
|
);
|
||||||
|
final bytes = await tester.binding.runAsync(
|
||||||
|
() => image!.toByteData(format: ui.ImageByteFormat.png),
|
||||||
|
);
|
||||||
|
final file = File('build/ui_audit/$name.png');
|
||||||
|
file.parent.createSync(recursive: true);
|
||||||
|
file.writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||||
|
logStep('Screenshot $name');
|
||||||
|
}
|
||||||
+24
-6
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:pyramid/core/app_state.dart';
|
import 'package:pyramid/core/app_state.dart';
|
||||||
|
import 'package:pyramid/core/appearance.dart';
|
||||||
import 'package:pyramid/core/notification_service.dart' show checkPendingReply, setAppForeground;
|
import 'package:pyramid/core/notification_service.dart' show checkPendingReply, setAppForeground;
|
||||||
import 'package:pyramid/core/router.dart';
|
import 'package:pyramid/core/router.dart';
|
||||||
import 'package:pyramid/core/settings_prefs.dart';
|
import 'package:pyramid/core/settings_prefs.dart';
|
||||||
@@ -52,7 +53,9 @@ class _PyramidAppState extends ConsumerState<PyramidApp>
|
|||||||
Future<void> _loadPersistedPrefs() async {
|
Future<void> _loadPersistedPrefs() async {
|
||||||
final saved = await loadThemePrefs();
|
final saved = await loadThemePrefs();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ref.read(themeModeProvider.notifier).state = saved.isDark;
|
ref.read(themeVariantProvider.notifier).state = saved.variant;
|
||||||
|
ref.read(textScaleProvider.notifier).state = saved.textScale;
|
||||||
|
ref.read(messageStyleProvider.notifier).state = saved.messageStyle;
|
||||||
ref.read(accentProvider.notifier).state = saved.accentIdx;
|
ref.read(accentProvider.notifier).state = saved.accentIdx;
|
||||||
ref.read(radiusProvider.notifier).state = saved.radius;
|
ref.read(radiusProvider.notifier).state = saved.radius;
|
||||||
ref.read(motionProvider.notifier).state = saved.motion;
|
ref.read(motionProvider.notifier).state = saved.motion;
|
||||||
@@ -75,7 +78,8 @@ class _PyramidAppState extends ConsumerState<PyramidApp>
|
|||||||
}
|
}
|
||||||
|
|
||||||
final router = ref.watch(routerProvider);
|
final router = ref.watch(routerProvider);
|
||||||
final isDark = ref.watch(themeModeProvider);
|
final variant = ref.watch(themeVariantProvider);
|
||||||
|
final textScale = ref.watch(textScaleProvider);
|
||||||
final accentIdx = ref.watch(accentProvider);
|
final accentIdx = ref.watch(accentProvider);
|
||||||
final radius = ref.watch(radiusProvider);
|
final radius = ref.watch(radiusProvider);
|
||||||
final density = ref.watch(densityProvider);
|
final density = ref.watch(densityProvider);
|
||||||
@@ -83,17 +87,31 @@ class _PyramidAppState extends ConsumerState<PyramidApp>
|
|||||||
final accent = accentPresets[accentIdx].color;
|
final accent = accentPresets[accentIdx].color;
|
||||||
|
|
||||||
return PyramidTheme(
|
return PyramidTheme(
|
||||||
isDark: isDark,
|
variant: variant,
|
||||||
accent: accent,
|
accent: accent,
|
||||||
radius: radius,
|
radius: radius,
|
||||||
density: density,
|
density: density,
|
||||||
motion: motion,
|
motion: motion,
|
||||||
child: MaterialApp.router(
|
child: MaterialApp.router(
|
||||||
title: 'Pyramid',
|
title: 'Pyramid',
|
||||||
theme: buildPyramidThemeData(false),
|
theme: buildPyramidThemeData(ThemeVariant.light),
|
||||||
darkTheme: buildPyramidThemeData(true),
|
darkTheme: buildPyramidThemeData(
|
||||||
themeMode: isDark ? ThemeMode.dark : ThemeMode.light,
|
variant.isDark ? variant : ThemeVariant.soft),
|
||||||
|
themeMode: variant.isDark ? ThemeMode.dark : ThemeMode.light,
|
||||||
routerConfig: router,
|
routerConfig: router,
|
||||||
|
// Schriftgröße: Pyramid-Regler × Handy-/Systemeinstellung, für alle
|
||||||
|
// Seiten und Dialoge (liegt über dem Navigator).
|
||||||
|
builder: (context, child) {
|
||||||
|
final mq = MediaQuery.of(context);
|
||||||
|
final factor = textScale ??
|
||||||
|
defaultTextScale(mobile: isMobileLayout(mq.size));
|
||||||
|
return MediaQuery(
|
||||||
|
data: mq.copyWith(
|
||||||
|
textScaler: PyramidTextScaler(mq.textScaler, factor),
|
||||||
|
),
|
||||||
|
child: child!,
|
||||||
|
);
|
||||||
|
},
|
||||||
localizationsDelegates: const [
|
localizationsDelegates: const [
|
||||||
GlobalMaterialLocalizations.delegate,
|
GlobalMaterialLocalizations.delegate,
|
||||||
GlobalWidgetsLocalizations.delegate,
|
GlobalWidgetsLocalizations.delegate,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
+16
-13
@@ -1,15 +1,21 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:matrix/matrix.dart';
|
import 'package:matrix/matrix.dart';
|
||||||
|
import 'package:pyramid/core/appearance.dart';
|
||||||
import 'package:pyramid/core/settings_prefs.dart';
|
import 'package:pyramid/core/settings_prefs.dart';
|
||||||
import 'package:pyramid/core/theme.dart';
|
import 'package:pyramid/core/theme.dart';
|
||||||
import 'package:pyramid/core/matrix_client.dart';
|
import 'package:pyramid/core/matrix_client.dart';
|
||||||
import 'package:pyramid/core/livekit_call_manager.dart';
|
|
||||||
import 'package:pyramid/core/voip_manager.dart';
|
|
||||||
import 'package:pyramid/features/rooms/rooms_provider.dart';
|
import 'package:pyramid/features/rooms/rooms_provider.dart';
|
||||||
|
|
||||||
// ─── Theme state ──────────────────────────────────────────────────────────────
|
// ─── Theme state ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
final themeModeProvider = StateProvider<bool>((ref) => true); // true = dark
|
final themeVariantProvider =
|
||||||
|
StateProvider<ThemeVariant>((ref) => ThemeVariant.soft);
|
||||||
|
|
||||||
|
/// Pyramid-Schriftfaktor; `null` = automatisch (Handy größer, PC 1,0).
|
||||||
|
final textScaleProvider = StateProvider<double?>((ref) => null);
|
||||||
|
|
||||||
|
final messageStyleProvider =
|
||||||
|
StateProvider<MessageStyle>((ref) => MessageStyle.auto);
|
||||||
final accentProvider = StateProvider<int>((ref) => 0); // index into accent presets
|
final accentProvider = StateProvider<int>((ref) => 0); // index into accent presets
|
||||||
|
|
||||||
const accentPresets = [
|
const accentPresets = [
|
||||||
@@ -63,17 +69,14 @@ enum ModalKind { none, settings, search }
|
|||||||
|
|
||||||
final activeModalProvider = StateProvider<ModalKind>((ref) => ModalKind.none);
|
final activeModalProvider = StateProvider<ModalKind>((ref) => ModalKind.none);
|
||||||
|
|
||||||
|
/// Einstellungen direkt in diesem Bereich öffnen (z. B. aus dem Tab „Du“);
|
||||||
|
/// SettingsModal liest und leert es beim Öffnen.
|
||||||
|
final settingsSectionRequestProvider = StateProvider<String?>((ref) => null);
|
||||||
|
|
||||||
// ─── Call state ───────────────────────────────────────────────────────────────
|
// ─── Call state ───────────────────────────────────────────────────────────────
|
||||||
|
// Die Call-Provider leben seit dem M2-Call-Pilot in ihren Modulen:
|
||||||
final callStateProvider = ChangeNotifierProvider<LiveKitCallManager>((ref) {
|
// `callSignalingProvider` (features/call_signaling/call_signaling_service.dart)
|
||||||
return LiveKitCallManager.instance;
|
// und `voiceChannelProvider` (features/voice_channel/voice_channel_service.dart).
|
||||||
});
|
|
||||||
|
|
||||||
final voipStateProvider = ChangeNotifierProvider<PyramidVoipManager>((ref) {
|
|
||||||
return PyramidVoipManager.instance;
|
|
||||||
});
|
|
||||||
|
|
||||||
final incomingCallProvider = StateProvider<CallSession?>((ref) => null);
|
|
||||||
|
|
||||||
// ─── View mode (chat / voice) ─────────────────────────────────────────────────
|
// ─── View mode (chat / voice) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
/// Darstellungs-Einstellungen, die nicht reine Farben sind (die stehen in
|
||||||
|
/// theme.dart): Handy-Layout-Erkennung, Schriftgröße, Nachrichten-Darstellung.
|
||||||
|
|
||||||
|
/// Unterhalb dieser Breite (oder sehr flach, z. B. Handy quer) zeigt Pyramid
|
||||||
|
/// das Handy-Layout (eine Spalte, Chat als eigene Seite).
|
||||||
|
bool isMobileLayout(Size size) => size.width < 900 || size.height < 500;
|
||||||
|
|
||||||
|
// ── Schriftgröße ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Standard-Schriftfaktor, solange der Nutzer nichts eingestellt hat: auf dem
|
||||||
|
/// Handy größer (Bernd 2026-10-07: 115 % ausprobiert, 110 % als Standard
|
||||||
|
/// gewünscht), am PC wie bisher.
|
||||||
|
double defaultTextScale({required bool mobile}) => mobile ? 1.10 : 1.0;
|
||||||
|
|
||||||
|
const minTextScale = 0.85;
|
||||||
|
const maxTextScale = 1.5;
|
||||||
|
|
||||||
|
/// Kombiniert die Schriftgröße des Systems (Handy-Einstellung) mit dem
|
||||||
|
/// Pyramid-eigenen Regler: erst Pyramid-Faktor, dann die (ggf. nichtlineare)
|
||||||
|
/// Systemskalierung.
|
||||||
|
class PyramidTextScaler extends TextScaler {
|
||||||
|
const PyramidTextScaler(this.system, this.factor);
|
||||||
|
|
||||||
|
final TextScaler system;
|
||||||
|
final double factor;
|
||||||
|
|
||||||
|
@override
|
||||||
|
double scale(double fontSize) => system.scale(fontSize * factor);
|
||||||
|
|
||||||
|
@override
|
||||||
|
// ignore: deprecated_member_use
|
||||||
|
double get textScaleFactor => system.textScaleFactor * factor;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
other is PyramidTextScaler &&
|
||||||
|
other.system == system &&
|
||||||
|
other.factor == factor;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(system, factor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Nachrichten-Darstellung ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Wie Nachrichten im Chat aussehen (Einstellungen → Erscheinungsbild).
|
||||||
|
enum MessageStyle {
|
||||||
|
/// Handy: Sprechblasen, PC: Liste.
|
||||||
|
auto,
|
||||||
|
|
||||||
|
/// Sprechblasen wie WhatsApp/Signal (eigene rechts, andere links).
|
||||||
|
bubbles,
|
||||||
|
|
||||||
|
/// Liste wie Discord/Element (Name + Text untereinander).
|
||||||
|
list;
|
||||||
|
|
||||||
|
String get label => switch (this) {
|
||||||
|
MessageStyle.auto => 'Automatisch',
|
||||||
|
MessageStyle.bubbles => 'Sprechblasen',
|
||||||
|
MessageStyle.list => 'Liste',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Tatsächlich anzuwendende Darstellung.
|
||||||
|
bool useBubbles({required bool mobile}) => switch (this) {
|
||||||
|
MessageStyle.auto => mobile,
|
||||||
|
MessageStyle.bubbles => true,
|
||||||
|
MessageStyle.list => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
static MessageStyle fromName(String? name) => MessageStyle.values
|
||||||
|
.firstWhere((v) => v.name == name, orElse: () => MessageStyle.auto);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import 'dart:async';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:pyramid/core/app_dirs.dart';
|
||||||
|
|
||||||
/// Schreibt Login-/Logout-relevante Ereignisse in eine Datei, die einen
|
/// Schreibt Login-/Logout-relevante Ereignisse in eine Datei, die einen
|
||||||
/// App-Neustart übersteht. Zweck: den Uta-Random-Logout-Bug nachvollziehen
|
/// App-Neustart übersteht. Zweck: den Uta-Random-Logout-Bug nachvollziehen
|
||||||
@@ -15,7 +15,7 @@ class AuthLog {
|
|||||||
static Future<File> _ensureFile() async {
|
static Future<File> _ensureFile() async {
|
||||||
final existing = _file;
|
final existing = _file;
|
||||||
if (existing != null) return existing;
|
if (existing != null) return existing;
|
||||||
final dir = await getApplicationSupportDirectory();
|
final dir = await appSupportDir();
|
||||||
final file = File(p.join(dir.path, 'auth_log.txt'));
|
final file = File(p.join(dir.path, 'auth_log.txt'));
|
||||||
return _file = file;
|
return _file = file;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import 'package:flutter/widgets.dart';
|
|||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod;
|
import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod;
|
||||||
import 'package:matrix/matrix.dart';
|
import 'package:matrix/matrix.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:pyramid/core/app_database.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:pyramid/core/notification_content.dart';
|
||||||
|
import 'package:pyramid/core/read_markers.dart';
|
||||||
import 'package:pyramid/core/soft_logout_guard.dart';
|
import 'package:pyramid/core/soft_logout_guard.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:sqflite/sqflite.dart' as sqflite_native;
|
|
||||||
|
|
||||||
/// Port name used to locate the main isolate from background isolates.
|
/// Port name used to locate the main isolate from background isolates.
|
||||||
/// Registered by notification_service.dart when the app starts.
|
/// Registered by notification_service.dart when the app starts.
|
||||||
@@ -66,6 +66,11 @@ void notificationEngineMain() {
|
|||||||
args['room_id'] as String?,
|
args['room_id'] as String?,
|
||||||
args['text'] as String?,
|
args['text'] as String?,
|
||||||
);
|
);
|
||||||
|
case 'markRead':
|
||||||
|
return await _bgMarkRead(
|
||||||
|
args['room_id'] as String?,
|
||||||
|
args['event_id'] as String?,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[NOTIF-BGENGINE] handler error: $e');
|
debugPrint('[NOTIF-BGENGINE] handler error: $e');
|
||||||
@@ -90,26 +95,27 @@ Future<bool> _bgDecryptAndShow(String? roomId, String? eventId, int? notifId) as
|
|||||||
)
|
)
|
||||||
.timeout(const Duration(seconds: 20));
|
.timeout(const Duration(seconds: 20));
|
||||||
if (event == null) return false;
|
if (event == null) return false;
|
||||||
// Bad-encrypted body still starts with "**" — keep the native placeholder.
|
|
||||||
if (event.body.startsWith('**')) return false;
|
|
||||||
|
|
||||||
final room = client.getRoomById(roomId);
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final sender = room?.unsafeGetUserFromMemoryOrFallback(event.senderId).displayName ??
|
final content = MessageNotificationContent.fromEvent(
|
||||||
event.senderId.split(':').first.replaceFirst('@', '');
|
event,
|
||||||
final title = (room?.isDirectChat ?? true)
|
showPreview: prefs.getBool('notif_preview') ?? true,
|
||||||
? sender
|
);
|
||||||
: '$sender · ${room?.getLocalizedDisplayname() ?? ''}';
|
// Nicht entschlüsselt / nichts Anzeigbares → nativer Platzhalter bleibt.
|
||||||
|
if (content == null) return false;
|
||||||
|
|
||||||
// Hand the decrypted content back to native so it re-shows the SAME
|
// Hand the decrypted content back to native so it re-shows the SAME
|
||||||
// notification via NotificationHelper — that keeps the reliable
|
// notification via NotificationHelper — that keeps the reliable
|
||||||
// MainActivity-based reply action (the flutter_local_notifications reply
|
// ReplyReceiver-based reply action (the flutter_local_notifications reply
|
||||||
// path doesn't fire dependably when the app is killed).
|
// path doesn't fire dependably when the app is killed). Erst der Text,
|
||||||
await const MethodChannel(_kBgEngineChannel).invokeMethod('showDecrypted', {
|
// dann lautlos Avatar/Bild (deliverMessageNotification).
|
||||||
'title': title,
|
await deliverMessageNotification(
|
||||||
'body': _buildBody(event),
|
event,
|
||||||
'room_id': roomId,
|
content,
|
||||||
'notif_id': notifId ?? _stableId(roomId),
|
notifId: notifId ?? _stableId(roomId),
|
||||||
});
|
send: (a) => const MethodChannel(_kBgEngineChannel)
|
||||||
|
.invokeMethod('showDecrypted', a),
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[NOTIF-BGENGINE] decryptAndShow error: $e');
|
debugPrint('[NOTIF-BGENGINE] decryptAndShow error: $e');
|
||||||
@@ -154,6 +160,29 @@ Future<bool> _bgSendReply(String? roomId, String? text) async {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// „Gelesen“ bei beendeter App: Lesebestätigung bis [eventId] setzen (reiner
|
||||||
|
/// HTTP-Aufruf, keine Verschlüsselung nötig).
|
||||||
|
Future<bool> _bgMarkRead(String? roomId, String? eventId) async {
|
||||||
|
if (roomId == null || eventId == null) return false;
|
||||||
|
final client = await _buildClient();
|
||||||
|
if (client == null) return false;
|
||||||
|
try {
|
||||||
|
final room = client.getRoomById(roomId) ?? Room(id: roomId, client: client);
|
||||||
|
await markRoomReadUpTo(
|
||||||
|
room,
|
||||||
|
eventId,
|
||||||
|
publicReceipt: await loadReadReceiptsPublic(),
|
||||||
|
).timeout(const Duration(seconds: 15));
|
||||||
|
debugPrint('[NOTIF-BGENGINE] read marker set for $roomId');
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[NOTIF-BGENGINE] markRead error: $e');
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
await client.dispose().catchError((_) {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Stores the raw callback handle of [notificationEngineMain] so native code can
|
/// Stores the raw callback handle of [notificationEngineMain] so native code can
|
||||||
/// bootstrap the background engine. Call once at app startup.
|
/// bootstrap the background engine. Call once at app startup.
|
||||||
Future<void> registerBgEngineHandle() async {
|
Future<void> registerBgEngineHandle() async {
|
||||||
@@ -182,18 +211,13 @@ Future<Client?> _buildClient() async {
|
|||||||
await vod.init();
|
await vod.init();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
final appDir = await getApplicationSupportDirectory();
|
// Gemeinsame Storage-Fassade: erkennt Klartext vs. SQLCipher am
|
||||||
final dbPath = p.join(appDir.path, 'pyramid.sqlite');
|
// Datei-Header, migriert aber NIE selbst (docs/SQLCIPHER_MIGRATION.md).
|
||||||
|
// null = gerade kein sicherer Zugriff (Migration läuft / DB oder
|
||||||
final db = await sqflite_native.databaseFactory
|
// Schlüssel fehlt) → Notification behält den nativen Platzhalter.
|
||||||
.openDatabase(dbPath)
|
final db =
|
||||||
.timeout(const Duration(seconds: 5));
|
await AppDatabase.openForPush().timeout(const Duration(seconds: 10));
|
||||||
|
if (db == null) return null;
|
||||||
// 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 (_) {}
|
|
||||||
|
|
||||||
final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db);
|
final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db);
|
||||||
|
|
||||||
@@ -224,20 +248,6 @@ Future<Client?> _buildClient() async {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _buildBody(Event event) {
|
|
||||||
final msgType = event.messageType;
|
|
||||||
final rawBody = event.body;
|
|
||||||
return switch (msgType) {
|
|
||||||
'm.text' => rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody,
|
|
||||||
'm.image' => '📷 Bild',
|
|
||||||
'm.video' => '🎥 Video',
|
|
||||||
'm.audio' => '🎵 Audio',
|
|
||||||
'm.file' => '📄 Datei',
|
|
||||||
'm.sticker' => '🏷️ Sticker',
|
|
||||||
_ => rawBody.isEmpty ? 'Neue Nachricht' : (rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Notification action handler ───────────────────────────────────────────────
|
// ── Notification action handler ───────────────────────────────────────────────
|
||||||
// Called in a fresh Dart isolate when the user presses Reply or Gelesen.
|
// Called in a fresh Dart isolate when the user presses Reply or Gelesen.
|
||||||
// Must return quickly — Android keeps the notification's "sending" spinner
|
// Must return quickly — Android keeps the notification's "sending" spinner
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
||||||
|
|
||||||
|
/// Anruf-Vordergrunddienst auf Android (android/.../CallService.kt).
|
||||||
|
///
|
||||||
|
/// * Solange ein Anruf läuft, hält der Dienst das Mikrofon auch im
|
||||||
|
/// Hintergrund aktiv (Android schaltet es sonst stumm) und zeigt eine
|
||||||
|
/// „Anruf läuft“-Benachrichtigung, über die man zurück in die App kommt.
|
||||||
|
/// * Für eine Bildschirmübertragung MUSS ab Android 14 vorher ein Dienst vom
|
||||||
|
/// Typ mediaProjection laufen – und der darf erst NACH der Zustimmung des
|
||||||
|
/// Nutzers starten. [prepareScreenCapture] erledigt beides in der richtigen
|
||||||
|
/// Reihenfolge; flutter_webrtc benutzt danach die gespeicherte Zustimmung.
|
||||||
|
///
|
||||||
|
/// Beide Anrufwege (1:1 über Matrix, Sprachkanäle über LiveKit) melden sich
|
||||||
|
/// mit eigenem Namen an; der Dienst endet erst, wenn keiner mehr läuft.
|
||||||
|
/// Auf anderen Plattformen passiert nichts.
|
||||||
|
class CallForeground {
|
||||||
|
CallForeground._();
|
||||||
|
|
||||||
|
static const _channel = MethodChannel('chat.pyramid.pyramid/call');
|
||||||
|
static final _owners = <String>{};
|
||||||
|
static final _screenOwners = <String>{};
|
||||||
|
static String _title = 'Pyramid';
|
||||||
|
|
||||||
|
static bool get _android => !kIsWeb && Platform.isAndroid;
|
||||||
|
|
||||||
|
/// true = Dienst läuft (bzw. wurde beendet) wie gewünscht.
|
||||||
|
static Future<bool> _apply() async {
|
||||||
|
if (!_android) return true;
|
||||||
|
try {
|
||||||
|
if (_owners.isEmpty) {
|
||||||
|
await _channel.invokeMethod('stop');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
final ok = await _channel.invokeMethod<bool>('start', {
|
||||||
|
'title': _title,
|
||||||
|
'screen': _screenOwners.isNotEmpty,
|
||||||
|
});
|
||||||
|
return ok ?? false;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[CallForeground] $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anruf von [owner] läuft (z. B. 'voip', 'livekit').
|
||||||
|
static Future<void> callStarted(String owner, {required String title}) async {
|
||||||
|
_owners.add(owner);
|
||||||
|
_title = title;
|
||||||
|
await _apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anruf von [owner] ist beendet.
|
||||||
|
static Future<void> callEnded(String owner) async {
|
||||||
|
final changed = _owners.remove(owner) | _screenOwners.remove(owner);
|
||||||
|
if (changed) await _apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vor einer Bildschirmübertragung auf Android: Zustimmung einholen, dann
|
||||||
|
/// den Dienst um „mediaProjection“ erweitern. false = Nutzer hat abgelehnt.
|
||||||
|
static Future<bool> prepareScreenCapture(String owner) async {
|
||||||
|
if (!_android) return true;
|
||||||
|
final granted = await rtc.Helper.requestCapturePermission();
|
||||||
|
if (!granted) return false;
|
||||||
|
_owners.add(owner);
|
||||||
|
_screenOwners.add(owner);
|
||||||
|
if (!await _apply()) {
|
||||||
|
// Ohne laufenden mediaProjection-Dienst würde der Mitschnitt die App
|
||||||
|
// abstürzen lassen – dann lieber gar nicht übertragen.
|
||||||
|
_screenOwners.remove(owner);
|
||||||
|
await _apply();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Übertragung von [owner] beendet – Dienst wieder ohne mediaProjection.
|
||||||
|
static Future<void> screenCaptureStopped(String owner) async {
|
||||||
|
if (_screenOwners.remove(owner)) await _apply();
|
||||||
|
}
|
||||||
|
}
|
||||||
+84
-32
@@ -1,37 +1,89 @@
|
|||||||
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
class LiveKitTokenGenerator {
|
import 'package:http/http.dart' as http;
|
||||||
// Your LiveKit Credentials
|
|
||||||
static const String apiKey = 'LKMatrixPi';
|
|
||||||
static const String apiSecret = 'rYUT2PRaKLedp5VLQCQE83eZG7fjuBWtFPXGiveBmIE';
|
|
||||||
|
|
||||||
static String generate({
|
/// LiveKit-Beitritts-Token vom Pyramid-Server holen.
|
||||||
required String roomName,
|
///
|
||||||
required String identity,
|
/// Früher hat die App die Tokens selbst signiert – dafür steckte das
|
||||||
String? displayName,
|
/// LiveKit-Secret im App-Code, und wer die App hatte, konnte sich für jeden
|
||||||
int ttlSeconds = 3600, // Valid for 1 hour by default
|
/// Raum und jede Identität ein Token ausstellen. Jetzt signiert nur noch der
|
||||||
}) {
|
/// Server (`/api/livekit-token` im Dashboard-Server auf dem Pi): Er prüft den
|
||||||
final jwt = JWT(
|
/// Matrix-Login per whoami und setzt die LiveKit-Identität selbst auf die
|
||||||
{
|
/// geprüfte Matrix-ID. Siehe docs/LIVEKIT_TOKEN_MIGRATION.md.
|
||||||
'video': {
|
class LiveKitTokenService {
|
||||||
'roomJoin': true,
|
static const endpoint =
|
||||||
'room': roomName,
|
'https://dashboard.steggi-matrix.work/api/livekit-token';
|
||||||
'canPublish': true,
|
static const fallbackUrl = 'wss://livekit.steggi-matrix.work';
|
||||||
'canSubscribe': true,
|
static const _timeout = Duration(seconds: 15);
|
||||||
'canPublishData': true,
|
|
||||||
},
|
|
||||||
'metadata': displayName ?? identity,
|
|
||||||
},
|
|
||||||
issuer: apiKey,
|
|
||||||
subject: identity,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sign the token with your secret
|
/// Liefert Token und LiveKit-Adresse für [room]. Wirft
|
||||||
final token = jwt.sign(
|
/// [LiveKitTokenException] mit einer verständlichen Meldung.
|
||||||
SecretKey(apiSecret),
|
static Future<({String token, String url})> fetch({
|
||||||
expiresIn: Duration(seconds: ttlSeconds),
|
required String room,
|
||||||
);
|
required String matrixToken,
|
||||||
|
http.Client? httpClient,
|
||||||
return token;
|
}) async {
|
||||||
|
final client = httpClient ?? http.Client();
|
||||||
|
try {
|
||||||
|
final res = await client
|
||||||
|
.post(
|
||||||
|
Uri.parse(endpoint),
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({'room': room, 'matrix_token': matrixToken}),
|
||||||
|
)
|
||||||
|
.timeout(_timeout);
|
||||||
|
if (res.statusCode == 401) {
|
||||||
|
throw const LiveKitTokenException(
|
||||||
|
'Anmeldung vom Anrufserver abgelehnt – bitte neu anmelden.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (res.statusCode != 200) {
|
||||||
|
throw LiveKitTokenException(
|
||||||
|
'Anrufserver antwortet nicht richtig (HTTP ${res.statusCode}).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final data = jsonDecode(res.body);
|
||||||
|
final token = data is Map ? data['token'] : null;
|
||||||
|
if (token is! String || token.isEmpty) {
|
||||||
|
throw const LiveKitTokenException(
|
||||||
|
'Anrufserver hat kein Zugangstoken geliefert.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final url = (data as Map)['url'];
|
||||||
|
return (
|
||||||
|
token: token,
|
||||||
|
url: url is String && url.startsWith('wss://') ? url : fallbackUrl,
|
||||||
|
);
|
||||||
|
} on LiveKitTokenException {
|
||||||
|
rethrow;
|
||||||
|
} on TimeoutException {
|
||||||
|
throw const LiveKitTokenException(
|
||||||
|
'Anrufserver nicht erreichbar (Zeitüberschreitung).',
|
||||||
|
);
|
||||||
|
} on SocketException {
|
||||||
|
throw const LiveKitTokenException(
|
||||||
|
'Anrufserver nicht erreichbar – Internetverbindung prüfen.',
|
||||||
|
);
|
||||||
|
} on http.ClientException {
|
||||||
|
throw const LiveKitTokenException(
|
||||||
|
'Anrufserver nicht erreichbar – Internetverbindung prüfen.',
|
||||||
|
);
|
||||||
|
} on FormatException {
|
||||||
|
throw const LiveKitTokenException(
|
||||||
|
'Anrufserver hat eine unlesbare Antwort geschickt.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (httpClient == null) client.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class LiveKitTokenException implements Exception {
|
||||||
|
final String message;
|
||||||
|
const LiveKitTokenException(this.message);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => message;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
@@ -7,40 +6,17 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|||||||
import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod;
|
import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod;
|
||||||
import 'package:matrix/encryption/utils/key_verification.dart';
|
import 'package:matrix/encryption/utils/key_verification.dart';
|
||||||
import 'package:matrix/matrix.dart';
|
import 'package:matrix/matrix.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:pyramid/core/app_database.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
|
||||||
import 'package:sqflite/sqflite.dart' as sqflite_native;
|
|
||||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
|
||||||
import 'package:pyramid/core/auth_log.dart';
|
import 'package:pyramid/core/auth_log.dart';
|
||||||
import 'package:pyramid/core/soft_logout_guard.dart';
|
import 'package:pyramid/core/soft_logout_guard.dart';
|
||||||
import 'package:pyramid/core/voip_manager.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 matrixClientProvider = FutureProvider<Client>((ref) async {
|
||||||
final DatabaseFactory factory;
|
// Factory-Wahl, Verschlüsselung (SQLCipher), Migration und PRAGMAs stecken
|
||||||
|
// komplett in der Storage-Fassade — siehe docs/SQLCIPHER_MIGRATION.md.
|
||||||
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
|
final db = await AppDatabase.open();
|
||||||
// Use the native sqflite plugin — Android/iOS have system SQLite built-in,
|
|
||||||
// no .so loading required.
|
|
||||||
factory = sqflite_native.databaseFactory;
|
|
||||||
} else if (!kIsWeb) {
|
|
||||||
sqfliteFfiInit();
|
|
||||||
factory = databaseFactoryFfi;
|
|
||||||
} else {
|
|
||||||
throw UnsupportedError('Web not supported');
|
|
||||||
}
|
|
||||||
|
|
||||||
final appDir = await getApplicationSupportDirectory();
|
|
||||||
final dbPath = p.join(appDir.path, 'pyramid.sqlite');
|
|
||||||
|
|
||||||
final db = await factory.openDatabase(dbPath);
|
|
||||||
|
|
||||||
// Optimization for Windows/FFI to prevent "database is locked" errors
|
|
||||||
try {
|
|
||||||
await db.execute('PRAGMA journal_mode=WAL');
|
|
||||||
await db.execute('PRAGMA busy_timeout = 5000');
|
|
||||||
} catch (e) {
|
|
||||||
if (kDebugMode) debugPrint('Could not set PRAGMAs: $e');
|
|
||||||
}
|
|
||||||
|
|
||||||
final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db);
|
final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db);
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import 'dart:io';
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:crypto/crypto.dart';
|
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,
|
/// 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,
|
/// avatars). Eviction is driven by a total byte budget plus a hard entry cap,
|
||||||
@@ -89,7 +89,7 @@ class MediaCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<Directory> _initDiskDir() async {
|
Future<Directory> _initDiskDir() async {
|
||||||
final support = await getApplicationSupportDirectory();
|
final support = await appSupportDir();
|
||||||
final dir = Directory(
|
final dir = Directory(
|
||||||
'${support.path}${Platform.pathSeparator}media_cache');
|
'${support.path}${Platform.pathSeparator}media_cache');
|
||||||
await dir.create(recursive: true);
|
await dir.create(recursive: true);
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:pyramid/core/media_cache.dart';
|
||||||
|
|
||||||
|
/// Was eine Nachrichten-Benachrichtigung zeigt – egal, ob sie aus der
|
||||||
|
/// laufenden App (`notification_service.dart`) oder dem Hintergrund-Isolate
|
||||||
|
/// bei beendeter App (`background_push.dart`) kommt. Beide Wege übergeben
|
||||||
|
/// [toNativeArgs] an `NotificationHelper.kt`, das daraus pro Raum einen
|
||||||
|
/// Chat-Verlauf (Android MessagingStyle) baut.
|
||||||
|
class MessageNotificationContent {
|
||||||
|
const MessageNotificationContent({
|
||||||
|
required this.roomId,
|
||||||
|
required this.eventId,
|
||||||
|
required this.senderId,
|
||||||
|
required this.senderName,
|
||||||
|
required this.roomName,
|
||||||
|
required this.body,
|
||||||
|
required this.timestamp,
|
||||||
|
this.isImage = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String roomId;
|
||||||
|
final String eventId;
|
||||||
|
final String senderId;
|
||||||
|
final String senderName;
|
||||||
|
|
||||||
|
/// `null` bei Direktnachrichten – dann zeigt Android nur den Absender.
|
||||||
|
final String? roomName;
|
||||||
|
final String body;
|
||||||
|
final DateTime timestamp;
|
||||||
|
|
||||||
|
/// Bild-Nachricht: Vorschaubild wird nachgeladen ([cacheNotificationImage]).
|
||||||
|
final bool isImage;
|
||||||
|
|
||||||
|
bool get isGroup => roomName != null;
|
||||||
|
|
||||||
|
/// Einzeiliger Titel für Plattformen ohne Chat-Verlauf (Windows).
|
||||||
|
String get title => roomName == null ? senderName : '$senderName · $roomName';
|
||||||
|
|
||||||
|
/// Liest den Inhalt aus einem (entschlüsselten) Event. `null`, wenn es
|
||||||
|
/// nichts Anzeigbares ist (nicht entschlüsselt, gelöscht, kein
|
||||||
|
/// Nachrichten-Event) – dann bleibt ein evtl. Platzhalter stehen.
|
||||||
|
/// Ist [showPreview] aus (Einstellung „Vorschau“), steht nur
|
||||||
|
/// [notificationPlaceholderBody] da und es wird kein Bild geladen.
|
||||||
|
static MessageNotificationContent? fromEvent(
|
||||||
|
Event event, {
|
||||||
|
bool showPreview = true,
|
||||||
|
}) {
|
||||||
|
final body = notificationBody(event);
|
||||||
|
if (body == null) return null;
|
||||||
|
final room = event.room;
|
||||||
|
final displayName = room
|
||||||
|
.unsafeGetUserFromMemoryOrFallback(event.senderId)
|
||||||
|
.displayName
|
||||||
|
?.trim();
|
||||||
|
return MessageNotificationContent(
|
||||||
|
roomId: room.id,
|
||||||
|
eventId: event.eventId,
|
||||||
|
senderId: event.senderId,
|
||||||
|
senderName: (displayName == null || displayName.isEmpty)
|
||||||
|
? _localpart(event.senderId)
|
||||||
|
: displayName,
|
||||||
|
roomName: room.isDirectChat ? null : room.getLocalizedDisplayname(),
|
||||||
|
body: showPreview ? body : notificationPlaceholderBody,
|
||||||
|
timestamp: event.originServerTs,
|
||||||
|
isImage: showPreview && event.messageType == MessageTypes.Image,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Argumente für den nativen Kanal (`showNativeNotification` bzw.
|
||||||
|
/// `showDecrypted`). `title`/`body` bleiben für ältere Native-Pfade drin.
|
||||||
|
Map<String, Object?> toNativeArgs({
|
||||||
|
required int notifId,
|
||||||
|
String? imagePath,
|
||||||
|
String? avatarPath,
|
||||||
|
}) => {
|
||||||
|
'title': title,
|
||||||
|
'body': body,
|
||||||
|
'roomId': roomId,
|
||||||
|
'notifId': notifId,
|
||||||
|
'eventId': eventId,
|
||||||
|
'senderId': senderId,
|
||||||
|
'senderName': senderName,
|
||||||
|
'roomName': roomName,
|
||||||
|
'isGroup': isGroup,
|
||||||
|
'timestamp': timestamp.millisecondsSinceEpoch,
|
||||||
|
'imagePath': imagePath,
|
||||||
|
'avatarPath': avatarPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text, den Android anzeigt, solange der echte Inhalt fehlt.
|
||||||
|
const notificationPlaceholderBody = 'Neue Nachricht';
|
||||||
|
|
||||||
|
/// Längere Texte werden gekürzt (aufgeklappt zeigt Android ohnehin nicht mehr).
|
||||||
|
const notificationBodyMaxChars = 1000;
|
||||||
|
|
||||||
|
/// Anzeigetext einer Nachricht für die Benachrichtigung, oder `null`, wenn
|
||||||
|
/// das Event nichts Anzeigbares enthält.
|
||||||
|
String? notificationBody(Event event) {
|
||||||
|
if (event.redacted) return null;
|
||||||
|
if (event.type == EventTypes.Sticker) return '🏷️ Sticker';
|
||||||
|
if (event.type == _pollStartType) return '📊 Umfrage';
|
||||||
|
if (event.type != EventTypes.Message) return null;
|
||||||
|
|
||||||
|
final content = event.content;
|
||||||
|
final caption = _caption(event);
|
||||||
|
final String? text = switch (event.messageType) {
|
||||||
|
MessageTypes.Text || MessageTypes.Notice => _plainText(event),
|
||||||
|
MessageTypes.Emote => '* ${_plainText(event)}',
|
||||||
|
MessageTypes.Image => caption == null ? '📷 Foto' : '📷 $caption',
|
||||||
|
MessageTypes.Video => caption == null ? '🎥 Video' : '🎥 $caption',
|
||||||
|
MessageTypes.Audio =>
|
||||||
|
content.containsKey(_voiceKey) ? '🎤 Sprachnachricht' : '🎵 Audio',
|
||||||
|
MessageTypes.File =>
|
||||||
|
'📎 ${content.tryGet<String>('filename') ?? event.body}',
|
||||||
|
MessageTypes.Location => '📍 Standort',
|
||||||
|
MessageTypes.Sticker => '🏷️ Sticker',
|
||||||
|
MessageTypes.BadEncrypted => null,
|
||||||
|
_ => _plainText(event),
|
||||||
|
};
|
||||||
|
final trimmed = text?.trim();
|
||||||
|
if (trimmed == null || trimmed.isEmpty) return null;
|
||||||
|
return trimmed.length > notificationBodyMaxChars
|
||||||
|
? '${trimmed.substring(0, notificationBodyMaxChars)}…'
|
||||||
|
: trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _pollStartType = 'org.matrix.msc3381.poll.start';
|
||||||
|
const _voiceKey = 'org.matrix.msc3245.voice';
|
||||||
|
|
||||||
|
/// Klartext ohne Antwort-Zitat („> <@x> …“) und ohne HTML.
|
||||||
|
String _plainText(Event event) => event.calcUnlocalizedBody(
|
||||||
|
hideReply: true,
|
||||||
|
hideEdit: true,
|
||||||
|
plaintextBody: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// MSC2530: Steht neben `filename` ein abweichender `body`, ist das die
|
||||||
|
/// Bildunterschrift.
|
||||||
|
String? _caption(Event event) {
|
||||||
|
final filename = event.content.tryGet<String>('filename');
|
||||||
|
final body = event.content.tryGet<String>('body')?.trim();
|
||||||
|
if (filename == null || body == null || body.isEmpty || body == filename) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _localpart(String userId) =>
|
||||||
|
userId.split(':').first.replaceFirst('@', '');
|
||||||
|
|
||||||
|
// ── Bilder für die Benachrichtigung ─────────────────────────────────────────
|
||||||
|
// Android zeigt Bilder in Benachrichtigungen nur über eine content://-URI.
|
||||||
|
// Die Dateien liegen deshalb unter <cache>/downloads/notif/ – genau dort gibt
|
||||||
|
// der FileProvider der App frei (res/xml: cache-path "downloads/").
|
||||||
|
|
||||||
|
const _maxImageBytes = 8 * 1024 * 1024;
|
||||||
|
const _maxAvatarBytes = 2 * 1024 * 1024;
|
||||||
|
const _keepFilesFor = Duration(days: 2);
|
||||||
|
|
||||||
|
/// Lädt das Vorschaubild einer Bild-Nachricht (bei E2EE entschlüsselt) in
|
||||||
|
/// den Cache und liefert den Dateipfad – oder `null`, wenn es nicht klappt.
|
||||||
|
Future<String?> cacheNotificationImage(Event event) async {
|
||||||
|
if (event.messageType != MessageTypes.Image) return null;
|
||||||
|
try {
|
||||||
|
final file = await event
|
||||||
|
.downloadAndDecryptAttachment(getThumbnail: event.hasThumbnail)
|
||||||
|
.timeout(const Duration(seconds: 20));
|
||||||
|
return await _writeNotifFile(
|
||||||
|
'img_${_hash(event.eventId)}',
|
||||||
|
file.bytes,
|
||||||
|
maxBytes: _maxImageBytes,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Avatar des Absenders als Datei (zuerst aus dem Bild-Cache der App).
|
||||||
|
Future<String?> cacheNotificationAvatar(Event event) async {
|
||||||
|
try {
|
||||||
|
final client = event.room.client;
|
||||||
|
final mxc = event.room
|
||||||
|
.unsafeGetUserFromMemoryOrFallback(event.senderId)
|
||||||
|
.avatarUrl;
|
||||||
|
if (mxc == null) return null;
|
||||||
|
final name = 'avatar_${_hash(mxc.toString())}';
|
||||||
|
final existing = File(p.join((await _notifDir()).path, name));
|
||||||
|
if (await existing.exists()) return existing.path;
|
||||||
|
|
||||||
|
final cacheKey = 'img:$mxc';
|
||||||
|
var bytes = await MediaCache.instance.getPersistent(cacheKey);
|
||||||
|
if (bytes == null) {
|
||||||
|
final headers = {'authorization': 'Bearer ${client.accessToken}'};
|
||||||
|
try {
|
||||||
|
final thumb = await mxc.getThumbnailUri(
|
||||||
|
client,
|
||||||
|
width: 128,
|
||||||
|
height: 128,
|
||||||
|
method: ThumbnailMethod.crop,
|
||||||
|
);
|
||||||
|
final res = await client.httpClient
|
||||||
|
.get(thumb, headers: headers)
|
||||||
|
.timeout(const Duration(seconds: 8));
|
||||||
|
if (res.statusCode == 200) bytes = res.bodyBytes;
|
||||||
|
} catch (_) {}
|
||||||
|
if (bytes == null) {
|
||||||
|
// Continuwuity kann keine Thumbnails → Original (Avatare sind klein).
|
||||||
|
final full = await mxc.getDownloadUri(client);
|
||||||
|
final res = await client.httpClient
|
||||||
|
.get(full, headers: headers)
|
||||||
|
.timeout(const Duration(seconds: 8));
|
||||||
|
if (res.statusCode == 200) bytes = res.bodyBytes;
|
||||||
|
}
|
||||||
|
if (bytes != null) {
|
||||||
|
await MediaCache.instance.putPersistent(cacheKey, bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bytes == null) return null;
|
||||||
|
return await _writeNotifFile(name, bytes, maxBytes: _maxAvatarBytes);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Directory> _notifDir() async {
|
||||||
|
final dir = Directory(
|
||||||
|
p.join((await getTemporaryDirectory()).path, 'downloads', 'notif'),
|
||||||
|
);
|
||||||
|
await dir.create(recursive: true);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _writeNotifFile(
|
||||||
|
String name,
|
||||||
|
Uint8List bytes, {
|
||||||
|
required int maxBytes,
|
||||||
|
}) async {
|
||||||
|
if (bytes.isEmpty || bytes.length > maxBytes) return null;
|
||||||
|
final dir = await _notifDir();
|
||||||
|
await _pruneOldFiles(dir);
|
||||||
|
final file = File(p.join(dir.path, name));
|
||||||
|
await file.writeAsBytes(bytes, flush: true);
|
||||||
|
return file.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pruneOldFiles(Directory dir) async {
|
||||||
|
try {
|
||||||
|
final cutoff = DateTime.now().subtract(_keepFilesFor);
|
||||||
|
await for (final entity in dir.list()) {
|
||||||
|
if (entity is File && (await entity.lastModified()).isBefore(cutoff)) {
|
||||||
|
await entity.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dateiname aus Event-ID/MXC (enthält `$`, `:` und `/`).
|
||||||
|
String _hash(String input) =>
|
||||||
|
sha1.convert(utf8.encode(input)).toString().substring(0, 20);
|
||||||
|
|
||||||
|
/// Zeigt eine Nachricht auf Android in zwei Schritten: erst sofort den Text
|
||||||
|
/// (damit nichts wartet), dann – nur falls es etwas gibt – lautlos dasselbe
|
||||||
|
/// mit Avatar und Bild. Native erkennt die Aktualisierung an der eventId und
|
||||||
|
/// ersetzt die Nachricht im Verlauf, statt sie doppelt anzuhängen.
|
||||||
|
Future<void> deliverMessageNotification(
|
||||||
|
Event event,
|
||||||
|
MessageNotificationContent content, {
|
||||||
|
required int notifId,
|
||||||
|
required Future<void> Function(Map<String, Object?> args) send,
|
||||||
|
}) async {
|
||||||
|
await send(content.toNativeArgs(notifId: notifId));
|
||||||
|
|
||||||
|
final avatarPath = await cacheNotificationAvatar(event);
|
||||||
|
final imagePath = content.isImage
|
||||||
|
? await cacheNotificationImage(event)
|
||||||
|
: null;
|
||||||
|
if (avatarPath == null && imagePath == null) return;
|
||||||
|
await send(
|
||||||
|
content.toNativeArgs(
|
||||||
|
notifId: notifId,
|
||||||
|
avatarPath: avatarPath,
|
||||||
|
imagePath: imagePath,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ import 'package:pyramid/core/app_state.dart';
|
|||||||
import 'package:pyramid/core/background_push.dart';
|
import 'package:pyramid/core/background_push.dart';
|
||||||
import 'package:pyramid/core/fcm_push_service.dart';
|
import 'package:pyramid/core/fcm_push_service.dart';
|
||||||
import 'package:pyramid/core/matrix_client.dart';
|
import 'package:pyramid/core/matrix_client.dart';
|
||||||
|
import 'package:pyramid/core/notification_content.dart';
|
||||||
|
import 'package:pyramid/core/read_markers.dart';
|
||||||
import 'package:pyramid/core/settings_prefs.dart';
|
import 'package:pyramid/core/settings_prefs.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:windows_taskbar/windows_taskbar.dart';
|
import 'package:windows_taskbar/windows_taskbar.dart';
|
||||||
@@ -197,6 +199,26 @@ Future<void> _initAndroid(WidgetRef ref) async {
|
|||||||
if (!share.isEmpty) {
|
if (!share.isEmpty) {
|
||||||
ref.read(pendingShareProvider.notifier).state = share;
|
ref.read(pendingShareProvider.notifier).state = share;
|
||||||
}
|
}
|
||||||
|
} else if (call.method == 'markReadFromNotification') {
|
||||||
|
// „Gelesen“ in der Benachrichtigung (ReplyReceiver, App lebt).
|
||||||
|
final roomId = (args is Map ? args['room_id'] : null) as String?;
|
||||||
|
final eventId = (args is Map ? args['event_id'] : null) as String?;
|
||||||
|
if (roomId != null && eventId != null) {
|
||||||
|
try {
|
||||||
|
final client = await ref.read(matrixClientProvider.future);
|
||||||
|
final room = client.getRoomById(roomId);
|
||||||
|
if (room != null) {
|
||||||
|
await markRoomReadUpTo(
|
||||||
|
room,
|
||||||
|
eventId,
|
||||||
|
publicReceipt: ref.read(privacyReadReceiptsProvider),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
debugPrint('[NOTIF-FG] markReadFromNotification: $roomId bis $eventId');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[NOTIF-FG] markReadFromNotification error: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (call.method == 'decryptAndUpdateNotification') {
|
} else if (call.method == 'decryptAndUpdateNotification') {
|
||||||
// Called by PushService.kt after showing a "Neue Nachricht" placeholder.
|
// Called by PushService.kt after showing a "Neue Nachricht" placeholder.
|
||||||
// We decrypt the event here (the Matrix client has the keys) and overwrite
|
// We decrypt the event here (the Matrix client has the keys) and overwrite
|
||||||
@@ -458,57 +480,37 @@ Future<void> _decryptAndUpdateNotification(
|
|||||||
final client = await ref
|
final client = await ref
|
||||||
.read(matrixClientProvider.future)
|
.read(matrixClientProvider.future)
|
||||||
.timeout(const Duration(seconds: 5));
|
.timeout(const Duration(seconds: 5));
|
||||||
var room = client.getRoomById(roomId);
|
if (eventId == null) return;
|
||||||
if (room == null) {
|
|
||||||
debugPrint('[NOTIF] decryptAndUpdate: room $roomId not found');
|
// Genau das gepushte Event holen (lokal oder vom Server, entschlüsselt) –
|
||||||
|
// nicht room.lastEvent, das kann inzwischen eine Reaktion o. Ä. sein.
|
||||||
|
final event = await client
|
||||||
|
.getEventByPushNotification(
|
||||||
|
PushNotification(roomId: roomId, eventId: eventId, devices: const []),
|
||||||
|
storeInDatabase: false,
|
||||||
|
returnNullIfSeen: false,
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 20));
|
||||||
|
if (event == null || event.senderId == client.userID) return;
|
||||||
|
|
||||||
|
final content = MessageNotificationContent.fromEvent(
|
||||||
|
event,
|
||||||
|
showPreview: ref.read(notifPreviewProvider),
|
||||||
|
);
|
||||||
|
if (content == null) {
|
||||||
|
debugPrint('[NOTIF] decryptAndUpdate: nichts Anzeigbares, Platzhalter bleibt');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The event might not have synced yet — wait for the next sync (max 12 s).
|
// Same eventId → NotificationHelper ersetzt den „Neue Nachricht“-
|
||||||
// We use lastEvent as a proxy: after sync it will be the event we want.
|
// Platzhalter im Verlauf, statt eine zweite Nachricht anzuhängen.
|
||||||
Event? event = room.lastEvent;
|
await deliverMessageNotification(
|
||||||
final bool alreadyHave =
|
event,
|
||||||
eventId == null || event?.eventId == eventId;
|
content,
|
||||||
if (!alreadyHave || event == null) {
|
notifId: notifId ?? _stableRoomId(roomId),
|
||||||
debugPrint('[NOTIF] decryptAndUpdate: waiting for sync to deliver $eventId');
|
send: (a) => _kInstallChannel.invokeMethod('showNativeNotification', a),
|
||||||
await client.onSync.stream.first.timeout(const Duration(seconds: 12));
|
);
|
||||||
room = client.getRoomById(roomId);
|
debugPrint('[NOTIF] decryptAndUpdate: updated notification for $eventId');
|
||||||
if (room == null) return;
|
|
||||||
event = room.lastEvent;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event == null) {
|
|
||||||
debugPrint('[NOTIF] decryptAndUpdate: event still null after sync');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.type != EventTypes.Message) return;
|
|
||||||
if (event.senderId == client.userID) return;
|
|
||||||
|
|
||||||
final rawBody = event.body;
|
|
||||||
// If decryption failed the body starts with "** Unable to decrypt"
|
|
||||||
if (rawBody.isEmpty || rawBody.startsWith('**')) {
|
|
||||||
debugPrint('[NOTIF] decryptAndUpdate: decryption failed, keeping placeholder');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final notifBody =
|
|
||||||
rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody;
|
|
||||||
final sender = room.unsafeGetUserFromMemoryOrFallback(event.senderId);
|
|
||||||
final displayName = sender.displayName ??
|
|
||||||
event.senderId.split(':').first.replaceFirst('@', '');
|
|
||||||
final title = room.isDirectChat
|
|
||||||
? displayName
|
|
||||||
: '$displayName · ${room.getLocalizedDisplayname()}';
|
|
||||||
|
|
||||||
// Overwrite the "Neue Nachricht" placeholder — same notifId so Android
|
|
||||||
// replaces the existing notification rather than stacking a new one.
|
|
||||||
await _kInstallChannel.invokeMethod('showNativeNotification', {
|
|
||||||
'title': title,
|
|
||||||
'body': notifBody,
|
|
||||||
'roomId': roomId,
|
|
||||||
'notifId': notifId ?? _stableRoomId(roomId),
|
|
||||||
});
|
|
||||||
debugPrint('[NOTIF] decryptAndUpdate: updated notification → "$title: $notifBody"');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[NOTIF] decryptAndUpdate error: $e');
|
debugPrint('[NOTIF] decryptAndUpdate error: $e');
|
||||||
}
|
}
|
||||||
@@ -516,27 +518,30 @@ Future<void> _decryptAndUpdateNotification(
|
|||||||
|
|
||||||
// ─── Show notification ────────────────────────────────────────────────────────
|
// ─── Show notification ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
Future<void> showMessageNotification({
|
Future<void> showMessageNotification(
|
||||||
required String roomId,
|
Event event,
|
||||||
String? roomName,
|
MessageNotificationContent content,
|
||||||
required String senderName,
|
) async {
|
||||||
required String body,
|
final roomId = content.roomId;
|
||||||
}) async {
|
|
||||||
final title = roomName != null ? '$senderName · $roomName' : senderName;
|
|
||||||
if (Platform.isAndroid && _androidInitialized) {
|
if (Platform.isAndroid && _androidInitialized) {
|
||||||
// Use native Kotlin notification so the reply PendingIntent targets
|
// Use native Kotlin notification so the reply PendingIntent targets
|
||||||
// ReplyReceiver directly — the app never opens when the user replies.
|
// ReplyReceiver directly — the app never opens when the user replies.
|
||||||
try {
|
try {
|
||||||
await _kInstallChannel.invokeMethod('showNativeNotification', {
|
await deliverMessageNotification(
|
||||||
'title': title,
|
event,
|
||||||
'body': body,
|
content,
|
||||||
'roomId': roomId,
|
notifId: _stableRoomId(roomId),
|
||||||
'notifId': _stableRoomId(roomId),
|
send: (a) => _kInstallChannel.invokeMethod('showNativeNotification', a),
|
||||||
});
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[NOTIF] showNativeNotification failed: $e');
|
debugPrint('[NOTIF] showNativeNotification failed: $e');
|
||||||
}
|
}
|
||||||
} else if (Platform.isWindows) {
|
} else if (Platform.isWindows) {
|
||||||
|
final title = content.title;
|
||||||
|
// Windows-Toasts zeigen ohnehin nur wenige Zeilen.
|
||||||
|
final body = content.body.length > 300
|
||||||
|
? '${content.body.substring(0, 300)}…'
|
||||||
|
: content.body;
|
||||||
// Close the previous notification for this room before showing a new one.
|
// Close the previous notification for this room before showing a new one.
|
||||||
_windowsNotifs.remove(roomId)?.close();
|
_windowsNotifs.remove(roomId)?.close();
|
||||||
|
|
||||||
@@ -624,23 +629,13 @@ void _watchEvents(Ref ref, Client client) {
|
|||||||
if (!mentionedIds.contains(myId) && !bodyMentionsUs) return;
|
if (!mentionedIds.contains(myId) && !bodyMentionsUs) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Build notification body ───────────────────────────────────────────
|
// ── Build notification content ────────────────────────────────────────
|
||||||
final rawBody = event.body;
|
final content = MessageNotificationContent.fromEvent(
|
||||||
final showPreview = ref.read(notifPreviewProvider);
|
event,
|
||||||
final notifBody = showPreview
|
showPreview: ref.read(notifPreviewProvider),
|
||||||
? (rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody)
|
|
||||||
: 'Neue Nachricht';
|
|
||||||
|
|
||||||
final sender = room.unsafeGetUserFromMemoryOrFallback(event.senderId);
|
|
||||||
final displayName = sender.displayName ??
|
|
||||||
event.senderId.split(':').first.replaceFirst('@', '');
|
|
||||||
|
|
||||||
showMessageNotification(
|
|
||||||
roomId: roomId,
|
|
||||||
roomName: room.isDirectChat ? null : room.getLocalizedDisplayname(),
|
|
||||||
senderName: displayName,
|
|
||||||
body: notifBody,
|
|
||||||
);
|
);
|
||||||
|
if (content == null) return;
|
||||||
|
showMessageNotification(event, content);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// SharedPreferences-Schlüssel der Einstellung „Lesebestätigungen senden“
|
||||||
|
/// (siehe privacyReadReceiptsProvider). Auch der Hintergrund-Isolate der
|
||||||
|
/// Benachrichtigungen liest ihn, dort gibt es keine Provider.
|
||||||
|
const readReceiptsPrefKey = 'privacy_read_receipts';
|
||||||
|
|
||||||
|
Future<bool> loadReadReceiptsPublic() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(readReceiptsPrefKey) ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Setzt Lesemarker und Lesebestätigung bis [eventId].
|
||||||
|
///
|
||||||
|
/// Mit [publicReceipt] = false (Einstellung „Lesebestätigungen senden“ aus)
|
||||||
|
/// geht nur die private Bestätigung (m.read.private) raus: Der Server zählt
|
||||||
|
/// Ungelesenes trotzdem herunter und andere eigene Geräte kennen den Stand,
|
||||||
|
/// Chatpartner sehen aber kein „gelesen“. Früher wurde in diesem Fall gar
|
||||||
|
/// nichts gesendet – Ungelesen-Zähler blieben dann stehen.
|
||||||
|
Future<void> markRoomReadUpTo(
|
||||||
|
Room room,
|
||||||
|
String eventId, {
|
||||||
|
required bool publicReceipt,
|
||||||
|
}) => room.setReadMarker(eventId, mRead: eventId, public: publicReceipt);
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
|
||||||
|
/// Raumtyp der Pyramid-Sprachkanäle (creation_content.type).
|
||||||
|
const voiceChannelRoomType = 'io.pyramid.voice_channel';
|
||||||
|
|
||||||
|
/// Ist [room] ein Sprachkanal (statt eines Text-Chats)?
|
||||||
|
bool isVoiceChannelRoom(Room room) =>
|
||||||
|
room.getState(EventTypes.RoomCreate)?.content['type'] == voiceChannelRoomType;
|
||||||
|
|
||||||
|
/// IDs aller Räume, die Kanal eines beigetretenen Servers (Space) sind –
|
||||||
|
/// auch über Unter-Spaces. Diese Räume stehen nur in ihrem Server, nicht
|
||||||
|
/// zusätzlich in „Chats“ (Bernd 2026-10-09: „Alles in Chats“).
|
||||||
|
Set<String> spaceChannelIds(Client client) {
|
||||||
|
final ids = <String>{};
|
||||||
|
for (final space in client.rooms) {
|
||||||
|
if (!space.isSpace || space.membership != Membership.join) continue;
|
||||||
|
for (final child in space.spaceChildren) {
|
||||||
|
final id = child.roomId;
|
||||||
|
if (id != null) ids.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gehört [room] in die gemeinsame Chatliste? Direktnachrichten und Gruppen,
|
||||||
|
/// die zu keinem beigetretenen Server gehören. [channelIds] stammt aus
|
||||||
|
/// [spaceChannelIds] (einmal pro Liste berechnen, nicht pro Raum).
|
||||||
|
bool isChatListRoom(Room room, Set<String> channelIds) {
|
||||||
|
if (room.isSpace) return false;
|
||||||
|
if (room.isDirectChat) return true;
|
||||||
|
return !channelIds.contains(room.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gruppenchat = kein Space, keine Direktnachricht, kein Sprachkanal.
|
||||||
|
bool isGroupChatRoom(Room room) =>
|
||||||
|
!room.isSpace && !room.isDirectChat && !isVoiceChannelRoom(room);
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
|
||||||
|
/// Kennung der Account-Daten, mit denen wir den laufenden Sync aufwecken.
|
||||||
|
const logoutWakeAccountDataType = 'io.pyramid.logout_wake';
|
||||||
|
|
||||||
|
/// Meldet [client] ab, ohne dass eine verspätete Server-Antwort die NÄCHSTE
|
||||||
|
/// Anmeldung wieder löscht.
|
||||||
|
///
|
||||||
|
/// Hintergrund (SDK-Wettlauf, ROADMAP M1, gefunden 2026-10-07): matrix 6.2
|
||||||
|
/// (und Upstream `main`) prüft bei einer FEHLER-Antwort des Sync-Long-Polls
|
||||||
|
/// nicht, ob die Anfrage noch aktuell ist. Läuft beim Abmelden noch ein
|
||||||
|
/// Long-Poll (bis 30 s) mit dem alten Token, kommt dessen 401
|
||||||
|
/// (M_UNKNOWN_TOKEN) später zurück und das SDK ruft `clear()` – meldet sich
|
||||||
|
/// der Nutzer in der Zwischenzeit auf demselben Client neu an (Pyramid
|
||||||
|
/// benutzt nach dem Logout dasselbe Client-Objekt weiter), wird die frische
|
||||||
|
/// Session gelöscht: ein „Random-Logout“ direkt nach dem Login.
|
||||||
|
///
|
||||||
|
/// Ablauf hier:
|
||||||
|
/// 1. Sync-Schleife anhalten (nach der laufenden Anfrage keine neue).
|
||||||
|
/// 2. Laufenden Long-Poll WECKEN: geänderte Account-Daten lässt der Server
|
||||||
|
/// sofort antworten – statt bis zu 30 s zu warten.
|
||||||
|
/// 3. Die laufende Anfrage regulär (mit gültigem Token) zu Ende gehen lassen.
|
||||||
|
/// 4. Erst dann serverseitig abmelden – es ist keine Anfrage mehr unterwegs.
|
||||||
|
///
|
||||||
|
/// Schritt 2/3 sind „best effort“: scheitern sie (offline), wird nach
|
||||||
|
/// höchstens ~8 s trotzdem abgemeldet – nie schlechter als das bisherige
|
||||||
|
/// direkte `logout()`.
|
||||||
|
///
|
||||||
|
/// Live gemessen (test/live_logout_race_test.dart, 2026-10-07): die
|
||||||
|
/// verspätete 401 tritt real auf, kommt aber meist an, BEVOR ein Mensch sich
|
||||||
|
/// neu angemeldet hat – das Risiko ist klein, dieser Weg schließt es aus.
|
||||||
|
Future<void> logoutWithoutStraySync(
|
||||||
|
Client client, {
|
||||||
|
Duration settleTimeout = const Duration(seconds: 5),
|
||||||
|
}) async {
|
||||||
|
client.backgroundSync = false;
|
||||||
|
// Liefert den laufenden Sync (oder startet einen kurzen ohne Long-Poll).
|
||||||
|
final inFlight = client.oneShotSync(timeout: Duration.zero);
|
||||||
|
final userId = client.userID;
|
||||||
|
if (userId != null) {
|
||||||
|
try {
|
||||||
|
await client
|
||||||
|
.setAccountData(userId, logoutWakeAccountDataType, {
|
||||||
|
'ts': DateTime.now().millisecondsSinceEpoch,
|
||||||
|
})
|
||||||
|
.timeout(const Duration(seconds: 3));
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[LOGOUT] Sync-Wecken fehlgeschlagen (egal): $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await inFlight.timeout(settleTimeout);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[LOGOUT] laufender Sync nicht rechtzeitig fertig: $e');
|
||||||
|
}
|
||||||
|
await client.logout();
|
||||||
|
}
|
||||||
+111
-10
@@ -2,11 +2,17 @@ import 'dart:convert';
|
|||||||
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:pyramid/core/appearance.dart';
|
||||||
|
import 'package:pyramid/core/read_markers.dart';
|
||||||
|
import 'package:pyramid/core/theme.dart';
|
||||||
|
|
||||||
// ── Generic SharedPreferences-backed bool notifier ──────────────────────────
|
// ── Generic SharedPreferences-backed bool notifier ──────────────────────────
|
||||||
|
|
||||||
class BoolPref extends StateNotifier<bool> {
|
class BoolPref extends StateNotifier<bool> {
|
||||||
final String _key;
|
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) {
|
BoolPref(this._key, bool defaultValue) : super(defaultValue) {
|
||||||
_load(defaultValue);
|
_load(defaultValue);
|
||||||
@@ -14,10 +20,11 @@ class BoolPref extends StateNotifier<bool> {
|
|||||||
|
|
||||||
Future<void> _load(bool def) async {
|
Future<void> _load(bool def) async {
|
||||||
final p = await SharedPreferences.getInstance();
|
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 {
|
Future<void> set(bool v) async {
|
||||||
|
_userSet = true;
|
||||||
state = v;
|
state = v;
|
||||||
final p = await SharedPreferences.getInstance();
|
final p = await SharedPreferences.getInstance();
|
||||||
await p.setBool(_key, v);
|
await p.setBool(_key, v);
|
||||||
@@ -30,9 +37,10 @@ class BoolPref extends StateNotifier<bool> {
|
|||||||
|
|
||||||
class StringSetPref extends StateNotifier<Set<String>> {
|
class StringSetPref extends StateNotifier<Set<String>> {
|
||||||
final String _key;
|
final String _key;
|
||||||
|
late final Future<void> _loaded;
|
||||||
|
|
||||||
StringSetPref(this._key) : super({}) {
|
StringSetPref(this._key) : super({}) {
|
||||||
_load();
|
_loaded = _load();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
@@ -42,20 +50,31 @@ class StringSetPref extends StateNotifier<Set<String>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> add(String v) async {
|
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};
|
state = {...state, v};
|
||||||
final p = await SharedPreferences.getInstance();
|
final p = await SharedPreferences.getInstance();
|
||||||
await p.setStringList(_key, state.toList());
|
await p.setStringList(_key, state.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> remove(String v) async {
|
Future<void> remove(String v) async {
|
||||||
if (!state.contains(v)) return;
|
await _loaded;
|
||||||
|
if (!mounted || !state.contains(v)) return;
|
||||||
state = {...state}..remove(v);
|
state = {...state}..remove(v);
|
||||||
final p = await SharedPreferences.getInstance();
|
final p = await SharedPreferences.getInstance();
|
||||||
await p.setStringList(_key, state.toList());
|
await p.setStringList(_key, state.toList());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Navigation (Handy) ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Handy: untere Leiste (Chats · Server · Du) statt Seitenmenü.
|
||||||
|
/// Standard: Seitenmenü (Bernd 2026-10-09: untere Leiste erst zum Vergleich).
|
||||||
|
final bottomNavProvider =
|
||||||
|
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('nav_bottom_bar', false));
|
||||||
|
|
||||||
// ── Notification preferences ─────────────────────────────────────────────────
|
// ── Notification preferences ─────────────────────────────────────────────────
|
||||||
|
|
||||||
final notifDesktopProvider =
|
final notifDesktopProvider =
|
||||||
@@ -76,34 +95,116 @@ final privacyPresenceProvider =
|
|||||||
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('privacy_presence', true));
|
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('privacy_presence', true));
|
||||||
|
|
||||||
final privacyReadReceiptsProvider =
|
final privacyReadReceiptsProvider =
|
||||||
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('privacy_read_receipts', true));
|
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref(readReceiptsPrefKey, true));
|
||||||
|
|
||||||
final privacyTypingProvider =
|
final privacyTypingProvider =
|
||||||
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('privacy_typing', true));
|
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('privacy_typing', true));
|
||||||
|
|
||||||
|
// ── 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 ────────────────────────────────────────────────
|
// ── Theme persistence helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
Future<void> saveThemePrefs({
|
Future<void> saveThemePrefs({
|
||||||
bool? isDark,
|
ThemeVariant? variant,
|
||||||
int? accentIdx,
|
int? accentIdx,
|
||||||
double? radius,
|
double? radius,
|
||||||
double? motion,
|
double? motion,
|
||||||
|
double? textScale,
|
||||||
|
bool resetTextScale = false,
|
||||||
|
MessageStyle? messageStyle,
|
||||||
}) async {
|
}) async {
|
||||||
final p = await SharedPreferences.getInstance();
|
final p = await SharedPreferences.getInstance();
|
||||||
if (isDark != null) await p.setBool('theme_dark', isDark);
|
if (variant != null) {
|
||||||
|
await p.setString('theme_variant', variant.name);
|
||||||
|
// Altes Feld mitpflegen (falls jemand auf eine ältere Version zurückgeht).
|
||||||
|
await p.setBool('theme_dark', variant.isDark);
|
||||||
|
}
|
||||||
if (accentIdx != null) await p.setInt('theme_accent', accentIdx);
|
if (accentIdx != null) await p.setInt('theme_accent', accentIdx);
|
||||||
if (radius != null) await p.setDouble('theme_radius', radius);
|
if (radius != null) await p.setDouble('theme_radius', radius);
|
||||||
if (motion != null) await p.setDouble('theme_motion', motion);
|
if (motion != null) await p.setDouble('theme_motion', motion);
|
||||||
|
if (resetTextScale) {
|
||||||
|
await p.remove('ui_text_scale');
|
||||||
|
} else if (textScale != null) {
|
||||||
|
await p.setDouble('ui_text_scale', textScale);
|
||||||
|
}
|
||||||
|
if (messageStyle != null) {
|
||||||
|
await p.setString('chat_message_style', messageStyle.name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<({bool isDark, int accentIdx, double radius, double motion})>
|
Future<
|
||||||
loadThemePrefs() async {
|
({
|
||||||
|
ThemeVariant variant,
|
||||||
|
int accentIdx,
|
||||||
|
double radius,
|
||||||
|
double motion,
|
||||||
|
double? textScale,
|
||||||
|
MessageStyle messageStyle,
|
||||||
|
})> loadThemePrefs() async {
|
||||||
final p = await SharedPreferences.getInstance();
|
final p = await SharedPreferences.getInstance();
|
||||||
|
// Vor 2026-10-07 gab es nur hell/dunkel ('theme_dark'). Wer dunkel hatte,
|
||||||
|
// bekommt jetzt das neue „Weiche Dunkel“ (Bernds Entscheidung).
|
||||||
|
final storedVariant = p.getString('theme_variant');
|
||||||
|
final variant = storedVariant != null
|
||||||
|
? ThemeVariant.fromName(storedVariant)
|
||||||
|
: (p.getBool('theme_dark') ?? true)
|
||||||
|
? ThemeVariant.soft
|
||||||
|
: ThemeVariant.light;
|
||||||
return (
|
return (
|
||||||
isDark: p.getBool('theme_dark') ?? true,
|
variant: variant,
|
||||||
accentIdx: p.getInt('theme_accent') ?? 0,
|
accentIdx: p.getInt('theme_accent') ?? 0,
|
||||||
radius: p.getDouble('theme_radius') ?? 12.0,
|
radius: p.getDouble('theme_radius') ?? 12.0,
|
||||||
motion: p.getDouble('theme_motion') ?? 1.0,
|
motion: p.getDouble('theme_motion') ?? 1.0,
|
||||||
|
textScale: p.getDouble('ui_text_scale'),
|
||||||
|
messageStyle: MessageStyle.fromName(p.getString('chat_message_style')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+123
-21
@@ -1,6 +1,103 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
// Pyramid design tokens — matching mockup CSS variables exactly
|
/// Farbschema der App (Einstellungen → Erscheinungsbild → Farbmodus).
|
||||||
|
enum ThemeVariant {
|
||||||
|
/// „Weiches Dunkel“ – Dunkelgrau statt Fast-Schwarz, mehr Kontrast bei
|
||||||
|
/// Text. Standard seit 2026-10-07 (Bernds Entscheidung).
|
||||||
|
soft,
|
||||||
|
|
||||||
|
/// Das bisherige Fast-Schwarz (spart auf OLED-Displays etwas Akku).
|
||||||
|
black,
|
||||||
|
|
||||||
|
/// Hell.
|
||||||
|
light;
|
||||||
|
|
||||||
|
bool get isDark => this != ThemeVariant.light;
|
||||||
|
|
||||||
|
String get label => switch (this) {
|
||||||
|
ThemeVariant.soft => 'Dunkel',
|
||||||
|
ThemeVariant.black => 'Schwarz',
|
||||||
|
ThemeVariant.light => 'Hell',
|
||||||
|
};
|
||||||
|
|
||||||
|
static ThemeVariant fromName(String? name) =>
|
||||||
|
ThemeVariant.values.firstWhere((v) => v.name == name,
|
||||||
|
orElse: () => ThemeVariant.soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Flächen- und Textfarben eines [ThemeVariant].
|
||||||
|
class PyramidPalette {
|
||||||
|
const PyramidPalette({
|
||||||
|
required this.bg0,
|
||||||
|
required this.bg1,
|
||||||
|
required this.bg2,
|
||||||
|
required this.bg3,
|
||||||
|
required this.bgHover,
|
||||||
|
required this.bgActive,
|
||||||
|
required this.border,
|
||||||
|
required this.borderStrong,
|
||||||
|
required this.fg,
|
||||||
|
required this.fgMuted,
|
||||||
|
required this.fgDim,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Color bg0, bg1, bg2, bg3, bgHover, bgActive, border, borderStrong;
|
||||||
|
final Color fg, fgMuted, fgDim;
|
||||||
|
|
||||||
|
/// Weiches Dunkel: alle Flächen ~10 % heller als „Schwarz“, Nebentexte
|
||||||
|
/// deutlich kontrastreicher (fgDim auf bg1 ≈ 5:1 statt ≈ 3,8:1).
|
||||||
|
static const soft = PyramidPalette(
|
||||||
|
bg0: Color(0xFF15161A),
|
||||||
|
bg1: Color(0xFF1B1C21),
|
||||||
|
bg2: Color(0xFF212228),
|
||||||
|
bg3: Color(0xFF282A31),
|
||||||
|
bgHover: Color(0xFF2F3139),
|
||||||
|
bgActive: Color(0xFF373945),
|
||||||
|
border: Color(0xFF2C2E36),
|
||||||
|
borderStrong: Color(0xFF3A3C46),
|
||||||
|
fg: Color(0xFFF2F2F5),
|
||||||
|
fgMuted: Color(0xFFB7B7C2),
|
||||||
|
fgDim: Color(0xFF8B8B99),
|
||||||
|
);
|
||||||
|
|
||||||
|
static const black = PyramidPalette(
|
||||||
|
bg0: PyramidColors.bg0,
|
||||||
|
bg1: PyramidColors.bg1,
|
||||||
|
bg2: PyramidColors.bg2,
|
||||||
|
bg3: PyramidColors.bg3,
|
||||||
|
bgHover: PyramidColors.bgHover,
|
||||||
|
bgActive: PyramidColors.bgActive,
|
||||||
|
border: PyramidColors.border,
|
||||||
|
borderStrong: PyramidColors.borderStrong,
|
||||||
|
fg: PyramidColors.fg,
|
||||||
|
fgMuted: PyramidColors.fgMuted,
|
||||||
|
fgDim: PyramidColors.fgDim,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const light = PyramidPalette(
|
||||||
|
bg0: PyramidColors.bg0Light,
|
||||||
|
bg1: PyramidColors.bg1Light,
|
||||||
|
bg2: PyramidColors.bg2Light,
|
||||||
|
bg3: PyramidColors.bg3Light,
|
||||||
|
bgHover: PyramidColors.bgHoverLight,
|
||||||
|
bgActive: PyramidColors.bgActiveLight,
|
||||||
|
border: PyramidColors.borderLight,
|
||||||
|
borderStrong: PyramidColors.borderStrongLight,
|
||||||
|
fg: PyramidColors.fgLight,
|
||||||
|
fgMuted: PyramidColors.fgMutedLight,
|
||||||
|
fgDim: PyramidColors.fgDimLight,
|
||||||
|
);
|
||||||
|
|
||||||
|
static PyramidPalette of(ThemeVariant variant) => switch (variant) {
|
||||||
|
ThemeVariant.soft => soft,
|
||||||
|
ThemeVariant.black => black,
|
||||||
|
ThemeVariant.light => light,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pyramid design tokens — matching mockup CSS variables exactly.
|
||||||
|
// Die dunklen Werte hier sind die Variante „Schwarz“; Widgets sollten die
|
||||||
|
// Farben über PyramidTheme.of(context) holen (folgt dem gewählten Schema).
|
||||||
class PyramidColors {
|
class PyramidColors {
|
||||||
const PyramidColors._();
|
const PyramidColors._();
|
||||||
|
|
||||||
@@ -49,7 +146,7 @@ class PyramidColors {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class PyramidTheme extends InheritedWidget {
|
class PyramidTheme extends InheritedWidget {
|
||||||
final bool isDark;
|
final ThemeVariant variant;
|
||||||
final Color accent;
|
final Color accent;
|
||||||
final double radius;
|
final double radius;
|
||||||
final double density;
|
final double density;
|
||||||
@@ -58,7 +155,7 @@ class PyramidTheme extends InheritedWidget {
|
|||||||
const PyramidTheme({
|
const PyramidTheme({
|
||||||
super.key,
|
super.key,
|
||||||
required super.child,
|
required super.child,
|
||||||
this.isDark = true,
|
this.variant = ThemeVariant.soft,
|
||||||
this.accent = PyramidColors.accentAmber,
|
this.accent = PyramidColors.accentAmber,
|
||||||
this.radius = 12,
|
this.radius = 12,
|
||||||
this.density = 1,
|
this.density = 1,
|
||||||
@@ -69,17 +166,20 @@ class PyramidTheme extends InheritedWidget {
|
|||||||
return context.dependOnInheritedWidgetOfExactType<PyramidTheme>()!;
|
return context.dependOnInheritedWidgetOfExactType<PyramidTheme>()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
Color get bg0 => isDark ? PyramidColors.bg0 : PyramidColors.bg0Light;
|
bool get isDark => variant.isDark;
|
||||||
Color get bg1 => isDark ? PyramidColors.bg1 : PyramidColors.bg1Light;
|
PyramidPalette get palette => PyramidPalette.of(variant);
|
||||||
Color get bg2 => isDark ? PyramidColors.bg2 : PyramidColors.bg2Light;
|
|
||||||
Color get bg3 => isDark ? PyramidColors.bg3 : PyramidColors.bg3Light;
|
Color get bg0 => palette.bg0;
|
||||||
Color get bgHover => isDark ? PyramidColors.bgHover : PyramidColors.bgHoverLight;
|
Color get bg1 => palette.bg1;
|
||||||
Color get bgActive => isDark ? PyramidColors.bgActive : PyramidColors.bgActiveLight;
|
Color get bg2 => palette.bg2;
|
||||||
Color get border => isDark ? PyramidColors.border : PyramidColors.borderLight;
|
Color get bg3 => palette.bg3;
|
||||||
Color get borderStrong => isDark ? PyramidColors.borderStrong : PyramidColors.borderStrongLight;
|
Color get bgHover => palette.bgHover;
|
||||||
Color get fg => isDark ? PyramidColors.fg : PyramidColors.fgLight;
|
Color get bgActive => palette.bgActive;
|
||||||
Color get fgMuted => isDark ? PyramidColors.fgMuted : PyramidColors.fgMutedLight;
|
Color get border => palette.border;
|
||||||
Color get fgDim => isDark ? PyramidColors.fgDim : PyramidColors.fgDimLight;
|
Color get borderStrong => palette.borderStrong;
|
||||||
|
Color get fg => palette.fg;
|
||||||
|
Color get fgMuted => palette.fgMuted;
|
||||||
|
Color get fgDim => palette.fgDim;
|
||||||
Color get danger => PyramidColors.danger;
|
Color get danger => PyramidColors.danger;
|
||||||
Color get online => PyramidColors.online;
|
Color get online => PyramidColors.online;
|
||||||
Color get away => PyramidColors.away;
|
Color get away => PyramidColors.away;
|
||||||
@@ -104,7 +204,7 @@ class PyramidTheme extends InheritedWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool updateShouldNotify(PyramidTheme oldWidget) =>
|
bool updateShouldNotify(PyramidTheme oldWidget) =>
|
||||||
isDark != oldWidget.isDark ||
|
variant != oldWidget.variant ||
|
||||||
accent != oldWidget.accent ||
|
accent != oldWidget.accent ||
|
||||||
radius != oldWidget.radius ||
|
radius != oldWidget.radius ||
|
||||||
density != oldWidget.density ||
|
density != oldWidget.density ||
|
||||||
@@ -114,9 +214,11 @@ class PyramidTheme extends InheritedWidget {
|
|||||||
// Inter ist als Asset gebündelt (pubspec `fonts:`) — kein Laufzeit-Download.
|
// Inter ist als Asset gebündelt (pubspec `fonts:`) — kein Laufzeit-Download.
|
||||||
TextStyle _base(Color color) => TextStyle(fontFamily: 'Inter', color: color);
|
TextStyle _base(Color color) => TextStyle(fontFamily: 'Inter', color: color);
|
||||||
|
|
||||||
ThemeData buildPyramidThemeData(bool isDark) {
|
ThemeData buildPyramidThemeData(ThemeVariant variant) {
|
||||||
final fg = isDark ? PyramidColors.fg : PyramidColors.fgLight;
|
final palette = PyramidPalette.of(variant);
|
||||||
final bg1 = isDark ? PyramidColors.bg1 : PyramidColors.bg1Light;
|
final isDark = variant.isDark;
|
||||||
|
final fg = palette.fg;
|
||||||
|
final bg1 = palette.bg1;
|
||||||
final accent = PyramidColors.accentAmber;
|
final accent = PyramidColors.accentAmber;
|
||||||
|
|
||||||
return ThemeData(
|
return ThemeData(
|
||||||
@@ -137,11 +239,11 @@ ThemeData buildPyramidThemeData(bool isDark) {
|
|||||||
),
|
),
|
||||||
textTheme: TextTheme(
|
textTheme: TextTheme(
|
||||||
bodyMedium: _base(fg).copyWith(fontSize: 14),
|
bodyMedium: _base(fg).copyWith(fontSize: 14),
|
||||||
bodySmall: _base(fg).copyWith(fontSize: 12, color: isDark ? PyramidColors.fgMuted : PyramidColors.fgMutedLight),
|
bodySmall: _base(fg).copyWith(fontSize: 12, color: palette.fgMuted),
|
||||||
labelSmall: _base(fg).copyWith(fontSize: 11, color: isDark ? PyramidColors.fgDim : PyramidColors.fgDimLight),
|
labelSmall: _base(fg).copyWith(fontSize: 11, color: palette.fgDim),
|
||||||
),
|
),
|
||||||
dividerTheme: DividerThemeData(
|
dividerTheme: DividerThemeData(
|
||||||
color: isDark ? PyramidColors.border : PyramidColors.borderLight,
|
color: palette.border,
|
||||||
thickness: 1,
|
thickness: 1,
|
||||||
space: 1,
|
space: 1,
|
||||||
),
|
),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
|||||||
|
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);
|
||||||
|
|
||||||
|
/// Spricht gerade jemand? (Pegel aus den WebRTC-Statistiken, für den
|
||||||
|
/// grünen Sprech-Ring.)
|
||||||
|
bool get isLocalSpeaking => false;
|
||||||
|
bool get isRemoteSpeaking => false;
|
||||||
|
|
||||||
|
/// Handy: Ton über den Lautsprecher (true) oder die Hörmuschel (false).
|
||||||
|
/// Auf dem PC ohne Bedeutung.
|
||||||
|
bool get isSpeakerOn => true;
|
||||||
|
Future<void> toggleSpeaker() async {}
|
||||||
|
|
||||||
|
/// Wendet die in den Einstellungen gewählte Ausgabelautstärke auf den
|
||||||
|
/// Remote-Stream an. Ohne [value] wird der Pref-Wert gelesen.
|
||||||
|
Future<void> applyOutputVolume([double? value]);
|
||||||
|
|
||||||
|
/// Führt eine Call-Aktion (answer/reject/…) fehlertolerant aus und stößt
|
||||||
|
/// danach die interne Zustands-/Renderer-Aktualisierung an.
|
||||||
|
Future<void> safeAction(Future<dynamic> Function() action);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Einziger Zugriffsweg der UI auf das 1:1-VoIP-Modul. Die Instanz wird beim
|
||||||
|
/// Client-Aufbau in `matrix_client.dart` erzeugt (existiert also, sobald die
|
||||||
|
/// eingeloggte Shell steht).
|
||||||
|
final callSignalingProvider =
|
||||||
|
ChangeNotifierProvider<CallSignalingService>((ref) {
|
||||||
|
return PyramidVoipManager.instance;
|
||||||
|
});
|
||||||
@@ -3,20 +3,30 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
||||||
import 'package:matrix/matrix.dart';
|
import 'package:matrix/matrix.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
import 'package:webrtc_interface/webrtc_interface.dart';
|
import 'package:webrtc_interface/webrtc_interface.dart';
|
||||||
|
import 'package:pyramid/core/call_foreground.dart';
|
||||||
import 'package:pyramid/core/ice_servers.dart';
|
import 'package:pyramid/core/ice_servers.dart';
|
||||||
|
import 'package:pyramid/core/settings_prefs.dart';
|
||||||
|
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
||||||
import 'package:pyramid/widgets/screen_share_picker.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? _instance;
|
||||||
static PyramidVoipManager get instance => _instance!;
|
static PyramidVoipManager get instance => _instance!;
|
||||||
|
|
||||||
final Client client;
|
final Client client;
|
||||||
late final VoIP voip;
|
late final VoIP voip;
|
||||||
|
late final Zone _sdkZone;
|
||||||
|
@override
|
||||||
CallSession? currentCall;
|
CallSession? currentCall;
|
||||||
|
|
||||||
|
@override
|
||||||
final rtc.RTCVideoRenderer localRenderer = rtc.RTCVideoRenderer();
|
final rtc.RTCVideoRenderer localRenderer = rtc.RTCVideoRenderer();
|
||||||
|
@override
|
||||||
final rtc.RTCVideoRenderer remoteRenderer = rtc.RTCVideoRenderer();
|
final rtc.RTCVideoRenderer remoteRenderer = rtc.RTCVideoRenderer();
|
||||||
bool _renderersInitialized = false;
|
bool _renderersInitialized = false;
|
||||||
|
|
||||||
@@ -27,18 +37,66 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
|||||||
bool _isDeafened = false;
|
bool _isDeafened = false;
|
||||||
bool _isUpdating = false;
|
bool _isUpdating = false;
|
||||||
String? _selectedSourceId;
|
String? _selectedSourceId;
|
||||||
|
|
||||||
|
// Handy: Lautsprecher statt Hörmuschel (wie Discord). Das SDK schaltet bei
|
||||||
|
// Sprachanrufen auf die Hörmuschel – hält man das Handy vor sich, hört man
|
||||||
|
// dann praktisch nichts (Bernds Test 2026-10-07).
|
||||||
|
bool _speakerOn = true;
|
||||||
|
bool get _isMobile =>
|
||||||
|
!kIsWeb &&
|
||||||
|
(defaultTargetPlatform == TargetPlatform.android ||
|
||||||
|
defaultTargetPlatform == TargetPlatform.iOS);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isSpeakerOn => _speakerOn;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> toggleSpeaker() async {
|
||||||
|
_speakerOn = !_speakerOn;
|
||||||
|
notifyListeners();
|
||||||
|
await _applySpeaker();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _applySpeaker() async {
|
||||||
|
if (!_isMobile) return;
|
||||||
|
try {
|
||||||
|
await rtc.Helper.setSpeakerphoneOn(_speakerOn);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[VOIP] Lautsprecher: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
String _currentQualityKey = 'hd';
|
String _currentQualityKey = 'hd';
|
||||||
|
|
||||||
|
@override
|
||||||
bool get isMicMuted => _isMicMuted;
|
bool get isMicMuted => _isMicMuted;
|
||||||
|
@override
|
||||||
bool get isCameraMuted => _isCameraMuted;
|
bool get isCameraMuted => _isCameraMuted;
|
||||||
|
@override
|
||||||
bool get isScreensharing => _isScreensharing;
|
bool get isScreensharing => _isScreensharing;
|
||||||
|
@override
|
||||||
bool get isRemoteScreensharing => _isRemoteScreensharing;
|
bool get isRemoteScreensharing => _isRemoteScreensharing;
|
||||||
|
@override
|
||||||
bool get isDeafened => _isDeafened;
|
bool get isDeafened => _isDeafened;
|
||||||
|
@override
|
||||||
String get currentQualityKey => _currentQualityKey;
|
String get currentQualityKey => _currentQualityKey;
|
||||||
|
|
||||||
PyramidVoipManager(this.client) {
|
PyramidVoipManager(this.client) {
|
||||||
_instance = this;
|
_instance = this;
|
||||||
voip = VoIP(client, this);
|
// VOR dem SDK anmelden: Kandidaten der Gegenseite puffern (siehe
|
||||||
|
// _bufferRemoteCandidates).
|
||||||
|
_callEventSub = client.onCallEvents.stream.listen(_bufferRemoteCandidates);
|
||||||
|
// Alles, was das Anruf-SDK anstößt, läuft in einer eigenen Zone: Fehler,
|
||||||
|
// die SDK/WebRTC-Plugin ins Leere werfen (z. B. Lautsprecher-Umschalten
|
||||||
|
// unter Windows, getRemoteDescription wirft statt null), werden
|
||||||
|
// protokolliert statt als „unbehandelt“ durchzuschlagen.
|
||||||
|
_sdkZone = Zone.current.fork(
|
||||||
|
specification: ZoneSpecification(
|
||||||
|
handleUncaughtError: (self, parent, zone, error, stack) {
|
||||||
|
debugPrint('[VOIP] SDK-Fehler (abgefangen): $error');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
voip = _sdkZone.run(() => VoIP(client, this));
|
||||||
// Suppress speakerphone call — not supported on Windows/Desktop
|
// Suppress speakerphone call — not supported on Windows/Desktop
|
||||||
try {
|
try {
|
||||||
(voip as dynamic).onConfigSpeakerphone = (bool _) async {};
|
(voip as dynamic).onConfigSpeakerphone = (bool _) async {};
|
||||||
@@ -46,6 +104,29 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
|||||||
_ensureRenderers();
|
_ensureRenderers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Führt [f] in der SDK-Zone aus. Ergebnis UND Fehler kommen über einen
|
||||||
|
/// Completer von außen zurück – Fehler können Zonengrenzen sonst nicht
|
||||||
|
/// überqueren, und der Aufrufer würde ewig warten.
|
||||||
|
Future<T> _inSdkZone<T>(Future<T> Function() f) {
|
||||||
|
final done = Completer<T>();
|
||||||
|
_sdkZone.run(() async {
|
||||||
|
try {
|
||||||
|
done.complete(await f());
|
||||||
|
} catch (e, s) {
|
||||||
|
done.completeError(e, s);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return done.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_callEventSub?.cancel();
|
||||||
|
_levelTimer?.cancel();
|
||||||
|
_renderRefreshTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _ensureRenderers() async {
|
Future<void> _ensureRenderers() async {
|
||||||
if (_renderersInitialized) return;
|
if (_renderersInitialized) return;
|
||||||
try {
|
try {
|
||||||
@@ -90,13 +171,88 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
|||||||
await _ensureRenderers();
|
await _ensureRenderers();
|
||||||
currentCall = call;
|
currentCall = call;
|
||||||
_isCameraMuted = call.type == CallType.kVoice;
|
_isCameraMuted = call.type == CallType.kVoice;
|
||||||
|
_speakerOn = true;
|
||||||
|
_isDeafened = false;
|
||||||
|
_isMicMuted = false;
|
||||||
_setupCallListeners(call);
|
_setupCallListeners(call);
|
||||||
_updateInternalStates();
|
_updateInternalStates();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
unawaited(_applyBufferedCandidates(call));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Netzwerk-Kandidaten der Gegenseite puffern ───────────────────────────
|
||||||
|
// Lücke im matrix-SDK (6.2): Kommen die Kandidaten des Anrufers an, bevor
|
||||||
|
// die Einladung fertig verarbeitet ist, verwirft das SDK sie endgültig
|
||||||
|
// („we do not have the call“); Kandidaten, die WÄHREND des Annehmens
|
||||||
|
// eintreffen, landen in einer Warteschlange, die nie mehr abgearbeitet
|
||||||
|
// wird. Dann kennt man keinen Weg zur Gegenseite – im Heimnetz rettet das
|
||||||
|
// oft noch ein direkter Prüfversuch der anderen Seite, über Mobilfunk/NAT
|
||||||
|
// dauert der Aufbau ewig oder scheitert (Bernd 2026-10-08, im Anruftest
|
||||||
|
// nachgestellt). Deshalb: alle Kandidaten mitschreiben und direkt an die
|
||||||
|
// Verbindung geben, sobald sie die Gegenseite kennt. Doppelte ignoriert
|
||||||
|
// WebRTC.
|
||||||
|
StreamSubscription<List<BasicEventWithSender>>? _callEventSub;
|
||||||
|
final _candidateBuffer = <String, List<Map<String, dynamic>>>{};
|
||||||
|
final _appliedCandidates = <String>{};
|
||||||
|
|
||||||
|
void _bufferRemoteCandidates(List<BasicEventWithSender> events) {
|
||||||
|
for (final e in events) {
|
||||||
|
if (e.type != EventTypes.CallCandidates || e.senderId == client.userID) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final callId = e.content.tryGet<String>('call_id');
|
||||||
|
final list = e.content.tryGetList<Map<String, dynamic>>('candidates');
|
||||||
|
if (callId == null || list == null) continue;
|
||||||
|
final party = e.content.tryGet<String>('party_id');
|
||||||
|
(_candidateBuffer[callId] ??= []).addAll(
|
||||||
|
list.map((c) => {...c, '_party': party}),
|
||||||
|
);
|
||||||
|
// Nur die letzten Anrufe behalten.
|
||||||
|
while (_candidateBuffer.length > 8) {
|
||||||
|
_candidateBuffer.remove(_candidateBuffer.keys.first);
|
||||||
|
}
|
||||||
|
final call = currentCall;
|
||||||
|
if (call != null && call.callId == callId) {
|
||||||
|
unawaited(_applyBufferedCandidates(call));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _applyBufferedCandidates(CallSession call) async {
|
||||||
|
final pc = call.pc;
|
||||||
|
final list = _candidateBuffer[call.callId];
|
||||||
|
if (pc == null || list == null || list.isEmpty) return;
|
||||||
|
try {
|
||||||
|
if (await pc.getRemoteDescription() == null) return;
|
||||||
|
} catch (_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (final c in List.of(list)) {
|
||||||
|
final cand = c['candidate'];
|
||||||
|
if (cand is! String || cand.isEmpty) continue;
|
||||||
|
final party = c['_party'];
|
||||||
|
final remoteParty = call.remotePartyId;
|
||||||
|
if (remoteParty != null && party != null && party != remoteParty) continue;
|
||||||
|
if (!_appliedCandidates.add('${call.callId}|$cand')) continue;
|
||||||
|
try {
|
||||||
|
await pc.addCandidate(rtc.RTCIceCandidate(
|
||||||
|
cand,
|
||||||
|
c['sdpMid']?.toString() ?? '',
|
||||||
|
(c['sdpMLineIndex'] as num?)?.round() ?? 0,
|
||||||
|
));
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[VOIP] Kandidat nachreichen: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Timer? _renderRefreshTimer;
|
Timer? _renderRefreshTimer;
|
||||||
|
String? _listenedCallId;
|
||||||
void _setupCallListeners(CallSession call) {
|
void _setupCallListeners(CallSession call) {
|
||||||
|
// Ausgehende Anrufe meldet das SDK zusätzlich über handleNewCall –
|
||||||
|
// Listener nur einmal je Anruf anhängen.
|
||||||
|
if (_listenedCallId == call.callId) return;
|
||||||
|
_listenedCallId = call.callId;
|
||||||
call.onCallStateChanged.stream.listen((state) {
|
call.onCallStateChanged.stream.listen((state) {
|
||||||
debugPrint('[VOIP] State: $state');
|
debugPrint('[VOIP] State: $state');
|
||||||
_updateInternalStates();
|
_updateInternalStates();
|
||||||
@@ -106,14 +262,36 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
|||||||
debugPrint('[VOIP] Ensuring camera is unmuted on connect');
|
debugPrint('[VOIP] Ensuring camera is unmuted on connect');
|
||||||
call.setLocalVideoMuted(false);
|
call.setLocalVideoMuted(false);
|
||||||
}
|
}
|
||||||
|
_applySpeaker();
|
||||||
|
_startLevelPolling();
|
||||||
|
// Android: Mikrofon im Hintergrund + „Anruf läuft“-Benachrichtigung.
|
||||||
|
CallForeground.callStarted('voip',
|
||||||
|
title: call.room.getLocalizedDisplayname());
|
||||||
|
}
|
||||||
|
if (state == CallState.kEnded) {
|
||||||
|
_stopLevelPolling();
|
||||||
|
CallForeground.callEnded('voip');
|
||||||
}
|
}
|
||||||
_updateRenderers();
|
_updateRenderers();
|
||||||
});
|
});
|
||||||
|
// Das SDK setzt bei jedem neuen eigenen Medienstrom die Hörmuschel –
|
||||||
|
// danach unsere Wahl erneut anwenden.
|
||||||
|
call.onCallEventChanged.stream.listen((event) {
|
||||||
|
if (event == CallStateChange.kFeedsChanged) _applySpeaker();
|
||||||
|
});
|
||||||
|
|
||||||
_renderRefreshTimer?.cancel();
|
_renderRefreshTimer?.cancel();
|
||||||
_renderRefreshTimer = Timer.periodic(
|
_renderRefreshTimer = Timer.periodic(
|
||||||
const Duration(milliseconds: 1000), // Faster polling to catch feeds
|
const Duration(milliseconds: 1000), // Faster polling to catch feeds
|
||||||
(_) => _updateRenderers(),
|
(_) {
|
||||||
|
_updateRenderers();
|
||||||
|
// Gepufferte Kandidaten nachreichen, sobald die Verbindung die
|
||||||
|
// Gegenseite kennt (nach Annehmen bzw. Antwort).
|
||||||
|
final c = currentCall;
|
||||||
|
if (c != null && c.state != CallState.kConnected) {
|
||||||
|
_applyBufferedCandidates(c);
|
||||||
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
_updateRenderers();
|
_updateRenderers();
|
||||||
@@ -130,99 +308,68 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _updateRenderers() async {
|
Future<void> _updateRenderers() async {
|
||||||
if (currentCall == null || _isUpdating) return;
|
final call = currentCall;
|
||||||
|
if (call == null || _isUpdating) return;
|
||||||
_isUpdating = true;
|
_isUpdating = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await _ensureRenderers();
|
await _ensureRenderers();
|
||||||
final dynamic c = currentCall;
|
// Typisierte SDK-Zugriffe. Früher lief das über ein dynamisches
|
||||||
rtc.MediaStream? remote;
|
// `getFeeds()` – das gibt es im SDK gar nicht; der Fehler wurde
|
||||||
rtc.MediaStream? local;
|
// geschluckt, und Bildschirmübertragungen wurden weder beim Empfänger
|
||||||
|
// angezeigt noch beim Sender als aktiv erkannt (Bernd 2026-10-08).
|
||||||
rtc.MediaStream? extract(dynamic obj) {
|
rtc.MediaStream? video(WrappedMediaStream? w, {bool respectMute = false}) {
|
||||||
if (obj == null) return null;
|
final s = w?.stream;
|
||||||
if (obj is rtc.MediaStream) return obj;
|
// stopped: Gegenseite hat die Übertragung beendet (das SDK lässt den
|
||||||
try { return obj.stream as rtc.MediaStream?; } catch (_) {}
|
// Strom stehen und markiert ihn nur).
|
||||||
return null;
|
if (w == null || w.stopped || s == null || s.getVideoTracks().isEmpty) {
|
||||||
}
|
return null;
|
||||||
|
|
||||||
final List<dynamic> feeds = [];
|
|
||||||
try {
|
|
||||||
final dynamic sdkFeeds = c.getFeeds();
|
|
||||||
if (sdkFeeds is Iterable) feeds.addAll(sdkFeeds.toList());
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
bool remoteScreenFound = false;
|
|
||||||
bool localScreenFound = false;
|
|
||||||
|
|
||||||
for (final dynamic f in feeds) {
|
|
||||||
final s = extract(f);
|
|
||||||
if (s == null) continue;
|
|
||||||
|
|
||||||
bool isLocal = false;
|
|
||||||
try { isLocal = f.isLocal == true || f.userId == client.userID; } catch (_) {}
|
|
||||||
|
|
||||||
String purpose = 'm.usermedia';
|
|
||||||
try { purpose = f.purpose ?? 'm.usermedia'; } catch (_) {}
|
|
||||||
|
|
||||||
if (isLocal) {
|
|
||||||
if (local == null || purpose == 'm.screenshare') {
|
|
||||||
local = s;
|
|
||||||
if (purpose == 'm.screenshare') localScreenFound = true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (remote == null || purpose == 'm.screenshare') {
|
|
||||||
remote = s;
|
|
||||||
if (purpose == 'm.screenshare') remoteScreenFound = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (respectMute && w.videoMuted) return null;
|
||||||
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
_isScreensharing = localScreenFound;
|
final remoteScreen = video(call.remoteScreenSharingStream);
|
||||||
_isRemoteScreensharing = remoteScreenFound;
|
final localScreen = video(call.localScreenSharingStream);
|
||||||
|
final remoteUser = call.remoteUserMediaStream?.stream;
|
||||||
|
|
||||||
if (remote == null) {
|
var changed = false;
|
||||||
try { remote = extract(c.remoteUserMediaStream); } catch (_) {}
|
final remoteSharing = remoteScreen != null;
|
||||||
}
|
final localSharing = call.localScreenSharingStream != null;
|
||||||
if (remote == null) {
|
if (remoteSharing != _isRemoteScreensharing ||
|
||||||
try { remote = extract(c.remoteStream); } catch (_) {}
|
localSharing != _isScreensharing) {
|
||||||
}
|
changed = true;
|
||||||
if (local == null) {
|
|
||||||
try { local = extract(c.localUserMediaStream); } catch (_) {}
|
|
||||||
}
|
}
|
||||||
|
_isRemoteScreensharing = remoteSharing;
|
||||||
|
_isScreensharing = localSharing;
|
||||||
|
|
||||||
// Ausgabelautstärke (Settings-Slider) auf den Remote-Stream anwenden —
|
if (_isDeafened) _applyDeafen(call);
|
||||||
|
|
||||||
|
// Ausgabelautstärke (Settings-Slider) auf den Ton der Gegenseite –
|
||||||
// nur einmal pro Stream, _updateRenderers läuft auch per Timer.
|
// nur einmal pro Stream, _updateRenderers läuft auch per Timer.
|
||||||
if (remote != null && remote.id != _volumeAppliedStreamId) {
|
if (remoteUser != null && remoteUser.id != _volumeAppliedStreamId) {
|
||||||
_volumeAppliedStreamId = remote.id;
|
_volumeAppliedStreamId = remoteUser.id;
|
||||||
_remoteAudioStream = remote;
|
_remoteAudioStream = remoteUser;
|
||||||
applyOutputVolume();
|
applyOutputVolume();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final check for tracks before assigning
|
// Bildschirm hat Vorrang vor der Kamera.
|
||||||
bool changed = false;
|
final remoteVideo =
|
||||||
if (remote != null && remote.getVideoTracks().isNotEmpty) {
|
remoteScreen ?? video(call.remoteUserMediaStream, respectMute: true);
|
||||||
if (remoteRenderer.srcObject?.id != remote.id) {
|
final localVideo =
|
||||||
debugPrint('[VOIP] Assigning Remote Renderer: ${remote.id} (${remote.getVideoTracks().length} video tracks)');
|
localScreen ?? video(call.localUserMediaStream, respectMute: true);
|
||||||
remoteRenderer.srcObject = remote;
|
|
||||||
changed = true;
|
if (remoteRenderer.srcObject?.id != remoteVideo?.id) {
|
||||||
}
|
debugPrint('[VOIP] Remote-Video: ${remoteVideo?.id} (Bildschirm: $remoteSharing)');
|
||||||
} else if (remoteRenderer.srcObject != null) {
|
remoteRenderer.srcObject = remoteVideo;
|
||||||
remoteRenderer.srcObject = null;
|
changed = true;
|
||||||
|
}
|
||||||
|
if (localRenderer.srcObject?.id != localVideo?.id) {
|
||||||
|
debugPrint('[VOIP] Lokales Video: ${localVideo?.id} (Bildschirm: $localSharing)');
|
||||||
|
localRenderer.srcObject = localVideo;
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (local != null && local.getVideoTracks().isNotEmpty) {
|
|
||||||
if (localRenderer.srcObject?.id != local.id) {
|
|
||||||
debugPrint('[VOIP] Assigning Local Renderer: ${local.id} (${local.getVideoTracks().length} video tracks)');
|
|
||||||
localRenderer.srcObject = local;
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
} else if (localRenderer.srcObject != null) {
|
|
||||||
localRenderer.srcObject = null;
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (changed) notifyListeners();
|
if (changed) notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[VOIP] Error in _updateRenderers: $e');
|
debugPrint('[VOIP] Error in _updateRenderers: $e');
|
||||||
@@ -231,11 +378,64 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Wer spricht gerade? (Pegel aus den WebRTC-Statistiken) ───────────────
|
||||||
|
// Zeigt den grünen Sprech-Ring wie bei Discord – und macht sichtbar, wo
|
||||||
|
// Ton verloren geht: leuchtet „Du“ beim Sprechen nicht, kommt vom eigenen
|
||||||
|
// Mikrofon nichts an; leuchtet die Gegenseite nicht, kommt nichts an.
|
||||||
|
Timer? _levelTimer;
|
||||||
|
bool _localSpeaking = false;
|
||||||
|
bool _remoteSpeaking = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isLocalSpeaking => _localSpeaking;
|
||||||
|
@override
|
||||||
|
bool get isRemoteSpeaking => _remoteSpeaking;
|
||||||
|
|
||||||
|
void _startLevelPolling() {
|
||||||
|
_levelTimer?.cancel();
|
||||||
|
_levelTimer = Timer.periodic(
|
||||||
|
const Duration(milliseconds: 300),
|
||||||
|
(_) => _pollLevels(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _stopLevelPolling() {
|
||||||
|
_levelTimer?.cancel();
|
||||||
|
_levelTimer = null;
|
||||||
|
_localSpeaking = false;
|
||||||
|
_remoteSpeaking = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pollLevels() async {
|
||||||
|
final pc = currentCall?.pc;
|
||||||
|
if (pc == null) return;
|
||||||
|
try {
|
||||||
|
double local = 0;
|
||||||
|
double remote = 0;
|
||||||
|
for (final r in await pc.getStats()) {
|
||||||
|
if (r.values['kind'] != 'audio') continue;
|
||||||
|
final level = (r.values['audioLevel'] as num?)?.toDouble() ?? 0;
|
||||||
|
if (r.type == 'media-source' && level > local) local = level;
|
||||||
|
if (r.type == 'inbound-rtp' && level > remote) remote = level;
|
||||||
|
}
|
||||||
|
// Hysterese: an ab 0,03, aus unter 0,01 – kein Flackern.
|
||||||
|
bool speaking(bool was, double level) => was ? level > 0.01 : level > 0.03;
|
||||||
|
final ls = speaking(_localSpeaking, _isMicMuted ? 0 : local);
|
||||||
|
final rs = speaking(_remoteSpeaking, remote);
|
||||||
|
if (ls != _localSpeaking || rs != _remoteSpeaking) {
|
||||||
|
_localSpeaking = ls;
|
||||||
|
_remoteSpeaking = rs;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> handleCallEnded(CallSession session) async {
|
Future<void> handleCallEnded(CallSession session) async {
|
||||||
if (currentCall?.callId == session.callId) {
|
if (currentCall?.callId == session.callId) {
|
||||||
_renderRefreshTimer?.cancel();
|
_renderRefreshTimer?.cancel();
|
||||||
_renderRefreshTimer = null;
|
_renderRefreshTimer = null;
|
||||||
|
_stopLevelPolling();
|
||||||
localRenderer.srcObject = null;
|
localRenderer.srcObject = null;
|
||||||
remoteRenderer.srcObject = null;
|
remoteRenderer.srcObject = null;
|
||||||
_remoteAudioStream = null;
|
_remoteAudioStream = null;
|
||||||
@@ -245,6 +445,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
|||||||
_isRemoteScreensharing = false;
|
_isRemoteScreensharing = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
CallForeground.callEnded('voip');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Ausgabelautstärke (Settings-Slider "voice_output_volume") ─────────────
|
// ── Ausgabelautstärke (Settings-Slider "voice_output_volume") ─────────────
|
||||||
@@ -253,12 +454,12 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
|||||||
|
|
||||||
/// Wendet die in den Einstellungen gewählte Ausgabelautstärke auf den
|
/// Wendet die in den Einstellungen gewählte Ausgabelautstärke auf den
|
||||||
/// Remote-Audio-Stream an. Ohne [value] wird der Pref-Wert gelesen.
|
/// Remote-Audio-Stream an. Ohne [value] wird der Pref-Wert gelesen.
|
||||||
|
@override
|
||||||
Future<void> applyOutputVolume([double? value]) async {
|
Future<void> applyOutputVolume([double? value]) async {
|
||||||
var v = value;
|
var v = value;
|
||||||
if (v == null) {
|
if (v == null) {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
v = await VoicePrefs.loadOutputVolume();
|
||||||
v = prefs.getDouble('voice_output_volume') ?? 1.0;
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
v = 1.0;
|
v = 1.0;
|
||||||
}
|
}
|
||||||
@@ -278,6 +479,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
|||||||
currentCall = null;
|
currentCall = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
CallForeground.callEnded('voip');
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -295,9 +497,10 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
|||||||
@override
|
@override
|
||||||
Future<void> registerListeners(CallSession session) async {}
|
Future<void> registerListeners(CallSession session) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
Future<void> safeAction(Future<dynamic> Function() action) async {
|
Future<void> safeAction(Future<dynamic> Function() action) async {
|
||||||
try {
|
try {
|
||||||
await action();
|
await _inSdkZone(action);
|
||||||
await Future.delayed(const Duration(milliseconds: 1000));
|
await Future.delayed(const Duration(milliseconds: 1000));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[VOIP] Action error: $e');
|
debugPrint('[VOIP] Action error: $e');
|
||||||
@@ -306,24 +509,35 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
|||||||
_updateRenderers();
|
_updateRenderers();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> toggleMic() async {
|
@override
|
||||||
if (currentCall == null) return;
|
Future<void> toggleMic() => _setMicMuted(!_isMicMuted);
|
||||||
_isMicMuted = !_isMicMuted;
|
|
||||||
|
Future<void> _setMicMuted(bool muted) async {
|
||||||
|
final c = currentCall;
|
||||||
|
if (c == null) return;
|
||||||
|
_isMicMuted = muted;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
try {
|
try {
|
||||||
await currentCall!.setMicrophoneMuted(_isMicMuted);
|
await _inSdkZone(() => c.setMicrophoneMuted(muted));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_isMicMuted = !_isMicMuted;
|
// Das SDK schaltet die Spur VOR dem Senden der Status-Info um. Schlägt
|
||||||
notifyListeners();
|
// nur das Senden fehl, ist das Mikro trotzdem (ent)stummt – früher
|
||||||
|
// wurde hier blind zurückgeschaltet, und der Knopf zeigte danach das
|
||||||
|
// Gegenteil des echten Zustands (Bernd 2026-10-08).
|
||||||
|
debugPrint('[VOIP] Stummschalten: $e');
|
||||||
}
|
}
|
||||||
|
_isMicMuted = c.isMicrophoneMuted;
|
||||||
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
Future<void> toggleCamera() async {
|
Future<void> toggleCamera() async {
|
||||||
if (currentCall == null) return;
|
if (currentCall == null) return;
|
||||||
_isCameraMuted = !_isCameraMuted;
|
_isCameraMuted = !_isCameraMuted;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
try {
|
try {
|
||||||
await currentCall!.setLocalVideoMuted(_isCameraMuted);
|
final c = currentCall!;
|
||||||
|
await _inSdkZone(() => c.setLocalVideoMuted(_isCameraMuted));
|
||||||
for (var i = 0; i < 5; i++) {
|
for (var i = 0; i < 5; i++) {
|
||||||
await Future.delayed(Duration(milliseconds: 300 + (i * 200)));
|
await Future.delayed(Duration(milliseconds: 300 + (i * 200)));
|
||||||
await _updateRenderers();
|
await _updateRenderers();
|
||||||
@@ -337,12 +551,14 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
|||||||
await _updateRenderers();
|
await _updateRenderers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
Future<void> toggleScreenSharing([BuildContext? context]) async {
|
Future<void> toggleScreenSharing([BuildContext? context]) async {
|
||||||
if (currentCall == null) return;
|
if (currentCall == null) return;
|
||||||
final call = currentCall!;
|
final call = currentCall!;
|
||||||
|
|
||||||
if (_isScreensharing) {
|
if (_isScreensharing) {
|
||||||
await safeAction(() => call.setScreensharingEnabled(false));
|
await safeAction(() => call.setScreensharingEnabled(false));
|
||||||
|
await CallForeground.screenCaptureStopped('voip');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,34 +575,63 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
Future<void> toggleDeafen() async {
|
Future<void> toggleDeafen() async {
|
||||||
|
// Wie Discord: taub = Gegenseite nicht mehr hören UND selbst stumm;
|
||||||
|
// beim Aufheben den Mikro-Zustand von vorher wiederherstellen. (Früher
|
||||||
|
// setzte der Knopf nur ein Häkchen ohne Wirkung.)
|
||||||
_isDeafened = !_isDeafened;
|
_isDeafened = !_isDeafened;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
final c = currentCall;
|
||||||
|
if (c == null) return;
|
||||||
|
_applyDeafen(c);
|
||||||
|
if (_isDeafened) {
|
||||||
|
_micMutedBeforeDeafen = _isMicMuted;
|
||||||
|
if (!_isMicMuted) await _setMicMuted(true);
|
||||||
|
} else if (!_micMutedBeforeDeafen && _isMicMuted) {
|
||||||
|
await _setMicMuted(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _micMutedBeforeDeafen = false;
|
||||||
|
|
||||||
|
/// Ton der Gegenseite an/aus (deaktivierte Empfangsspur = stumm).
|
||||||
|
void _applyDeafen(CallSession call) {
|
||||||
|
for (final w in call.getRemoteStreams) {
|
||||||
|
for (final t in w.stream?.getAudioTracks() ?? const <MediaStreamTrack>[]) {
|
||||||
|
if (t.enabled == _isDeafened) t.enabled = !_isDeafened;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
Future<void> hangup() async {
|
Future<void> hangup() async {
|
||||||
if (currentCall == null) return;
|
if (currentCall == null) return;
|
||||||
await safeAction(() async {
|
await safeAction(() async {
|
||||||
await currentCall!.hangup(reason: CallErrorCode.userHangup);
|
await currentCall!.hangup(reason: CallErrorCode.userHangup);
|
||||||
currentCall = null;
|
currentCall = null;
|
||||||
});
|
});
|
||||||
|
await CallForeground.callEnded('voip');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
Future<void> startCall(String roomId, {bool video = false}) async {
|
Future<void> startCall(String roomId, {bool video = false}) async {
|
||||||
final room = client.getRoomById(roomId);
|
final room = client.getRoomById(roomId);
|
||||||
if (room == null) return;
|
if (room == null) return;
|
||||||
await _ensureRenderers();
|
await _ensureRenderers();
|
||||||
_isCameraMuted = !video;
|
_isCameraMuted = !video;
|
||||||
|
|
||||||
|
_speakerOn = true;
|
||||||
debugPrint('[VOIP] Starting call in room $roomId (video=$video)');
|
debugPrint('[VOIP] Starting call in room $roomId (video=$video)');
|
||||||
final call = await voip.inviteToCall(room, video ? CallType.kVideo : CallType.kVoice);
|
final call = await _inSdkZone(
|
||||||
|
() => voip.inviteToCall(room, video ? CallType.kVideo : CallType.kVoice));
|
||||||
currentCall = call;
|
currentCall = call;
|
||||||
|
|
||||||
_setupCallListeners(call);
|
_setupCallListeners(call);
|
||||||
|
|
||||||
if (video) {
|
if (video) {
|
||||||
await Future.delayed(const Duration(milliseconds: 800));
|
await Future.delayed(const Duration(milliseconds: 800));
|
||||||
await call.setLocalVideoMuted(false);
|
await _inSdkZone(() => call.setLocalVideoMuted(false));
|
||||||
await _updateRenderers();
|
await _updateRenderers();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,6 +639,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
Future<void> changeQuality(String key) async {
|
Future<void> changeQuality(String key) async {
|
||||||
_currentQualityKey = key;
|
_currentQualityKey = key;
|
||||||
if (currentCall == null) {
|
if (currentCall == null) {
|
||||||
@@ -444,10 +690,10 @@ class MediaDevicesWrapper extends MediaDevices {
|
|||||||
// In den Einstellungen gewählte Geräte (voice_*_device Prefs) anwenden —
|
// In den Einstellungen gewählte Geräte (voice_*_device Prefs) anwenden —
|
||||||
// nur wenn kein Screenshare-Source gesetzt ist (sonst nicht anfassen).
|
// nur wenn kein Screenshare-Source gesetzt ist (sonst nicht anfassen).
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final devicePrefs = await VoicePrefs.loadDevices();
|
||||||
final mic = prefs.getString('voice_mic_device');
|
final mic = devicePrefs.mic;
|
||||||
final cam = prefs.getString('voice_camera_device');
|
final cam = devicePrefs.camera;
|
||||||
final speaker = prefs.getString('voice_speaker_device');
|
final speaker = devicePrefs.speaker;
|
||||||
if (mic != null &&
|
if (mic != null &&
|
||||||
improvedConstraints['audio'] != null &&
|
improvedConstraints['audio'] != null &&
|
||||||
improvedConstraints['audio'] != false) {
|
improvedConstraints['audio'] != false) {
|
||||||
@@ -503,6 +749,13 @@ class MediaDevicesWrapper extends MediaDevices {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<rtc.MediaStream> getDisplayMedia(Map<String, dynamic> constraints) async {
|
Future<rtc.MediaStream> getDisplayMedia(Map<String, dynamic> constraints) async {
|
||||||
|
// Android 14+: ohne laufenden mediaProjection-Dienst stürzt die App beim
|
||||||
|
// Mitschnitt ab – erst Zustimmung, dann Dienst, dann Mitschnitt.
|
||||||
|
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||||
|
if (!await CallForeground.prepareScreenCapture('voip')) {
|
||||||
|
throw Exception('Bildschirmübertragung wurde nicht erlaubt');
|
||||||
|
}
|
||||||
|
}
|
||||||
final String? sourceId = _manager._selectedSourceId;
|
final String? sourceId = _manager._selectedSourceId;
|
||||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.windows && sourceId != null) {
|
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.windows && sourceId != null) {
|
||||||
final desktopConstraints = {
|
final desktopConstraints = {
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
/// Layout der Anrufansicht als „aufgeblasene“ Formen (Bernd 2026-10-10,
|
||||||
|
/// Referenz: aufgepumpte Buchstaben in einer Kiste): Jede Person bekommt eine
|
||||||
|
/// Zelle, alle Zellen zusammen füllen den ganzen Anrufbereich bis an die
|
||||||
|
/// Ränder. Wo zwei Zellen aneinanderstoßen, sind sie plattgedrückt, außen
|
||||||
|
/// liegen sie am Fensterrand an, die Ecken sind weich gerundet ([outline]).
|
||||||
|
///
|
||||||
|
/// Technik: gewichtetes Voronoi-Diagramm (Potenzdiagramm) im Rechteck. Jede
|
||||||
|
/// Zelle hat einen Anteil an der Fläche ([InflatedCell.share], wer spricht
|
||||||
|
/// bekommt mehr). In jedem Schritt wandert der Mittelpunkt einer Zelle zu
|
||||||
|
/// ihrem Schwerpunkt (gleichmäßige, ruhige Formen) und ihr Gewicht wird so
|
||||||
|
/// nachgeführt, dass die Fläche dem Anteil entspricht. Weil das schrittweise
|
||||||
|
/// geschieht, sieht man es als federndes Auf- und Abpumpen. Je mehr Personen,
|
||||||
|
/// desto kleiner die Zellen.
|
||||||
|
class InflatedCell {
|
||||||
|
InflatedCell(this.id, this.site, this.weight, {this.share = 1});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
/// Mittelpunkt (Erzeuger) der Zelle.
|
||||||
|
Offset site;
|
||||||
|
|
||||||
|
/// Platz im Raster, zu dem die Zelle sanft gezogen wird (aufgeräumte
|
||||||
|
/// Anordnung); null = keiner (z. B. beim Gehen).
|
||||||
|
Offset? slot;
|
||||||
|
|
||||||
|
/// Gewicht im Potenzdiagramm (Einheit: Fläche, wie Radius²).
|
||||||
|
double weight;
|
||||||
|
|
||||||
|
/// Aktueller (animierter) und gewünschter Flächenanteil.
|
||||||
|
double share;
|
||||||
|
double targetShare = 1;
|
||||||
|
bool get leaving => targetShare == 0;
|
||||||
|
|
||||||
|
/// Ergebnis der letzten Berechnung.
|
||||||
|
List<Offset> polygon = const [];
|
||||||
|
double area = 0;
|
||||||
|
Offset centroid = Offset.zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
class InflatedLayout {
|
||||||
|
InflatedLayout({this.gap = 10, this.margin = 12});
|
||||||
|
|
||||||
|
/// Wie viel mehr Fläche bekommt, wer gerade spricht.
|
||||||
|
static const speakingShare = 1.7;
|
||||||
|
|
||||||
|
/// Abstand zwischen zwei Zellen bzw. zum Fensterrand.
|
||||||
|
final double gap;
|
||||||
|
final double margin;
|
||||||
|
|
||||||
|
final Map<String, InflatedCell> cells = {};
|
||||||
|
Size _size = Size.zero;
|
||||||
|
Size get size => _size;
|
||||||
|
|
||||||
|
Rect get bounds => Rect.fromLTWH(
|
||||||
|
margin,
|
||||||
|
margin,
|
||||||
|
math.max(0, _size.width - 2 * margin),
|
||||||
|
math.max(0, _size.height - 2 * margin),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Gleicht die Zellen mit den Teilnehmern ab ([speaking] = wer spricht).
|
||||||
|
void sync(List<String> ids, Set<String> speaking, Size size) {
|
||||||
|
final old = _size;
|
||||||
|
_size = size;
|
||||||
|
if (size != old && old.width > 0 && old.height > 0 && cells.isNotEmpty) {
|
||||||
|
final flipped = (old.width >= old.height) != (size.width >= size.height);
|
||||||
|
if (flipped) {
|
||||||
|
// Hochkant ↔ quer: neu anordnen, sonst bleiben z. B. zwei Zellen
|
||||||
|
// übereinander, obwohl nebeneinander viel besser passt.
|
||||||
|
_placeOnGrid(cells.values.toList());
|
||||||
|
} else {
|
||||||
|
final sx = size.width / old.width, sy = size.height / old.height;
|
||||||
|
for (final c in cells.values) {
|
||||||
|
c.site = Offset(c.site.dx * sx, c.site.dy * sy);
|
||||||
|
c.weight *= sx * sy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final present = ids.toSet();
|
||||||
|
for (final c in cells.values) {
|
||||||
|
if (!present.contains(c.id)) c.targetShare = 0;
|
||||||
|
}
|
||||||
|
final fresh = ids.where((id) => !cells.containsKey(id)).toList();
|
||||||
|
if (cells.isEmpty) {
|
||||||
|
// Erster Aufbau: gleich voll da, im Raster angeordnet.
|
||||||
|
final created = [for (final id in fresh) InflatedCell(id, Offset.zero, 0)];
|
||||||
|
_placeOnGrid(created);
|
||||||
|
for (final c in created) {
|
||||||
|
cells[c.id] = c;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (final id in fresh) {
|
||||||
|
// Neu dazu: entsteht in der größten Zelle und pumpt sich dort auf.
|
||||||
|
final host = cells.values.reduce((a, b) => a.area >= b.area ? a : b);
|
||||||
|
final h = id.hashCode;
|
||||||
|
final dir = Offset(math.cos(h % 360.0), math.sin(h % 360.0));
|
||||||
|
final spread = math.sqrt(math.max(host.area, 1)) * 0.2;
|
||||||
|
final site = _inside(host.centroid + dir * spread);
|
||||||
|
// Gewicht knapp über der Grenze, ab der die Zelle leer wäre
|
||||||
|
// (w ≤ w_host − d²): sie beginnt winzig und pumpt sich dann auf.
|
||||||
|
final d2 = (site - host.site).distanceSquared;
|
||||||
|
cells[id] = InflatedCell(
|
||||||
|
id,
|
||||||
|
site,
|
||||||
|
host.weight - d2 * 0.9,
|
||||||
|
share: 0.15,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (final id in ids) {
|
||||||
|
cells[id]!.targetShare = speaking.contains(id) ? speakingShare : 1.0;
|
||||||
|
}
|
||||||
|
final slots = _gridPoints(ids.length);
|
||||||
|
for (var i = 0; i < ids.length; i++) {
|
||||||
|
cells[ids[i]]!.slot = i < slots.length ? slots[i] : null;
|
||||||
|
}
|
||||||
|
for (final c in cells.values) {
|
||||||
|
if (c.leaving) c.slot = null;
|
||||||
|
}
|
||||||
|
_compute();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _placeOnGrid(List<InflatedCell> list) {
|
||||||
|
final points = _gridPoints(list.length);
|
||||||
|
for (var i = 0; i < list.length && i < points.length; i++) {
|
||||||
|
list[i]
|
||||||
|
..site = points[i]
|
||||||
|
..weight = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mittelpunkte eines Rasters für [n] Zellen: möglichst quadratische
|
||||||
|
/// Zellen, wenig Lücken; die letzte Reihe wird mittig verteilt.
|
||||||
|
List<Offset> _gridPoints(int n) {
|
||||||
|
final b = bounds;
|
||||||
|
if (n == 0 || b.isEmpty) return const [];
|
||||||
|
var cols = 1;
|
||||||
|
var best = double.infinity;
|
||||||
|
for (var c = 1; c <= n; c++) {
|
||||||
|
final r = (n / c).ceil();
|
||||||
|
final aspect = (b.width / c) / (b.height / r);
|
||||||
|
final score = math.log(aspect).abs() + 0.35 * (c * r - n);
|
||||||
|
if (score < best) {
|
||||||
|
best = score;
|
||||||
|
cols = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final rows = (n / cols).ceil();
|
||||||
|
return [
|
||||||
|
for (var i = 0; i < n; i++)
|
||||||
|
() {
|
||||||
|
final row = i ~/ cols;
|
||||||
|
final inRow = row == rows - 1 ? n - row * cols : cols;
|
||||||
|
final col = i % cols;
|
||||||
|
// Letzte (unvollständige) Reihe: gleichmäßig über die Breite.
|
||||||
|
final x = row == rows - 1 && inRow < cols
|
||||||
|
? b.left + b.width * (col + 0.5) / inRow
|
||||||
|
: b.left + b.width * (col + 0.5) / cols;
|
||||||
|
return Offset(x, b.top + b.height * (row + 0.5) / rows);
|
||||||
|
}(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
Offset _inside(Offset p) {
|
||||||
|
final b = bounds.deflate(1);
|
||||||
|
return Offset(p.dx.clamp(b.left, b.right), p.dy.clamp(b.top, b.bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Zeitschritt (Sekunden). `true`, solange sich noch etwas bewegt.
|
||||||
|
bool step(double dt) {
|
||||||
|
if (_size.isEmpty || cells.isEmpty) return false;
|
||||||
|
dt = dt.clamp(0.0, 1 / 20);
|
||||||
|
var moving = false;
|
||||||
|
|
||||||
|
final shareRate = 1 - math.exp(-dt * 6);
|
||||||
|
for (final c in cells.values) {
|
||||||
|
final d = c.targetShare - c.share;
|
||||||
|
if (d.abs() > 0.01) moving = true;
|
||||||
|
c.share += d * shareRate;
|
||||||
|
}
|
||||||
|
cells.removeWhere((_, c) => c.leaving && c.share < 0.03);
|
||||||
|
if (cells.isEmpty) return false;
|
||||||
|
|
||||||
|
_compute();
|
||||||
|
final total = cells.values.fold<double>(0, (s, c) => s + c.area);
|
||||||
|
final shareSum = cells.values.fold<double>(0, (s, c) => s + c.share);
|
||||||
|
final f = math.min(1.0, dt * 10);
|
||||||
|
final move = 1 - math.exp(-dt * 8);
|
||||||
|
for (final c in cells.values) {
|
||||||
|
// Fläche zum Anteil passend machen: Gewicht direkt um den Fehler
|
||||||
|
// verschieben (Gewicht und Fläche haben dieselbe Einheit; ein Plus an
|
||||||
|
// Gewicht schiebt alle Grenzen der Zelle nach außen). Gedämpft, damit
|
||||||
|
// es federnd statt ruckartig wirkt.
|
||||||
|
final target = total * c.share / shareSum;
|
||||||
|
final error = target - c.area;
|
||||||
|
if (error.abs() > target * 0.02) moving = true;
|
||||||
|
c.weight += 0.4 * f * error;
|
||||||
|
// … und den Mittelpunkt zu Schwerpunkt bzw. Rasterplatz ziehen.
|
||||||
|
if (c.area > 1) {
|
||||||
|
final goal = c.slot == null
|
||||||
|
? c.centroid
|
||||||
|
: Offset.lerp(c.centroid, c.slot!, 0.5)!;
|
||||||
|
final d = goal - c.site;
|
||||||
|
// Ruhe, sobald sich nichts mehr sichtbar bewegt (< ~3 px/s).
|
||||||
|
if (d.distance * move > 0.05) moving = true;
|
||||||
|
c.site = _inside(c.site + d * move);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Nur Unterschiede zählen – Mittelwert bei 0 halten.
|
||||||
|
final mean =
|
||||||
|
cells.values.fold<double>(0, (s, c) => s + c.weight) / cells.length;
|
||||||
|
for (final c in cells.values) {
|
||||||
|
c.weight -= mean;
|
||||||
|
}
|
||||||
|
_clampWeights();
|
||||||
|
_compute();
|
||||||
|
return moving;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Erzeuger muss in seiner eigenen Zelle liegen: w_j − w_i ≤ d².
|
||||||
|
void _clampWeights() {
|
||||||
|
final list = cells.values.toList();
|
||||||
|
for (var i = 0; i < list.length; i++) {
|
||||||
|
for (var j = 0; j < list.length; j++) {
|
||||||
|
if (i == j) continue;
|
||||||
|
final d2 = (list[i].site - list[j].site).distanceSquared;
|
||||||
|
if (list[j].weight - list[i].weight > d2) {
|
||||||
|
list[j].weight = list[i].weight + d2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _compute() {
|
||||||
|
final b = bounds;
|
||||||
|
final list = cells.values.toList();
|
||||||
|
for (final c in list) {
|
||||||
|
var poly = <Offset>[b.topLeft, b.topRight, b.bottomRight, b.bottomLeft];
|
||||||
|
for (final o in list) {
|
||||||
|
if (identical(o, c) || poly.isEmpty) continue;
|
||||||
|
final n = (o.site - c.site) * 2;
|
||||||
|
final len = n.distance;
|
||||||
|
if (len < 1e-6) continue;
|
||||||
|
// Grenze des Potenzdiagramms, um die halbe Lücke zu c hin versetzt.
|
||||||
|
final limit = o.site.distanceSquared -
|
||||||
|
c.site.distanceSquared +
|
||||||
|
c.weight -
|
||||||
|
o.weight -
|
||||||
|
gap / 2 * len;
|
||||||
|
poly = _clip(poly, n, limit);
|
||||||
|
}
|
||||||
|
c.polygon = poly;
|
||||||
|
_measure(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Polygon auf die Halbebene n·x ≤ limit beschneiden.
|
||||||
|
static List<Offset> _clip(List<Offset> poly, Offset n, double limit) {
|
||||||
|
final out = <Offset>[];
|
||||||
|
for (var i = 0; i < poly.length; i++) {
|
||||||
|
final a = poly[i], b = poly[(i + 1) % poly.length];
|
||||||
|
final da = n.dx * a.dx + n.dy * a.dy - limit;
|
||||||
|
final db = n.dx * b.dx + n.dy * b.dy - limit;
|
||||||
|
if (da <= 0) out.add(a);
|
||||||
|
if ((da <= 0) != (db <= 0)) {
|
||||||
|
out.add(a + (b - a) * (da / (da - db)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _measure(InflatedCell c) {
|
||||||
|
final p = c.polygon;
|
||||||
|
if (p.length < 3) {
|
||||||
|
c.area = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var a2 = 0.0, cx = 0.0, cy = 0.0;
|
||||||
|
for (var i = 0; i < p.length; i++) {
|
||||||
|
final q = p[i], r = p[(i + 1) % p.length];
|
||||||
|
final cross = q.dx * r.dy - r.dx * q.dy;
|
||||||
|
a2 += cross;
|
||||||
|
cx += (q.dx + r.dx) * cross;
|
||||||
|
cy += (q.dy + r.dy) * cross;
|
||||||
|
}
|
||||||
|
if (a2.abs() < 1e-6) {
|
||||||
|
c.area = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
c.area = a2.abs() / 2;
|
||||||
|
c.centroid = Offset(cx / (3 * a2), cy / (3 * a2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Umriss von [id]: die Zelle mit weich gerundeten Ecken („aufgeblasen“).
|
||||||
|
Path outline(String id) {
|
||||||
|
final c = cells[id];
|
||||||
|
final path = Path();
|
||||||
|
if (c == null) return path;
|
||||||
|
final p = <Offset>[];
|
||||||
|
for (final v in c.polygon) {
|
||||||
|
if (p.isEmpty || (v - p.last).distance > 0.5) p.add(v);
|
||||||
|
}
|
||||||
|
if (p.length > 2 && (p.first - p.last).distance <= 0.5) p.removeLast();
|
||||||
|
if (p.length < 3) return path;
|
||||||
|
final rcMax = math.min(72.0, math.sqrt(c.area) * 0.34);
|
||||||
|
for (var i = 0; i < p.length; i++) {
|
||||||
|
final v = p[i];
|
||||||
|
final prev = p[(i - 1 + p.length) % p.length];
|
||||||
|
final next = p[(i + 1) % p.length];
|
||||||
|
final lp = (prev - v).distance, ln = (next - v).distance;
|
||||||
|
final r = math.min(rcMax, math.min(lp, ln) * 0.5);
|
||||||
|
final a = v + (prev - v) / lp * r;
|
||||||
|
final b = v + (next - v) / ln * r;
|
||||||
|
if (i == 0) {
|
||||||
|
path.moveTo(a.dx, a.dy);
|
||||||
|
} else {
|
||||||
|
path.lineTo(a.dx, a.dy);
|
||||||
|
}
|
||||||
|
// Kubische Ecke (etwas voller als ein Viertelkreis – wirkt prall).
|
||||||
|
final c1 = a + (v - a) * 0.62, c2 = b + (v - b) * 0.62;
|
||||||
|
path.cubicTo(c1.dx, c1.dy, c2.dx, c2.dy, b.dx, b.dy);
|
||||||
|
}
|
||||||
|
path.close();
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rechteck um die Zelle (für Inhalte wie Video).
|
||||||
|
Rect cellBounds(String id) {
|
||||||
|
final p = cells[id]?.polygon ?? const [];
|
||||||
|
if (p.isEmpty) return Rect.zero;
|
||||||
|
var l = p.first.dx, t = p.first.dy, r = l, b = t;
|
||||||
|
for (final v in p) {
|
||||||
|
l = math.min(l, v.dx);
|
||||||
|
t = math.min(t, v.dy);
|
||||||
|
r = math.max(r, v.dx);
|
||||||
|
b = math.max(b, v.dy);
|
||||||
|
}
|
||||||
|
return Rect.fromLTRB(l, t, r, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-10
@@ -3,14 +3,14 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:pyramid/core/app_state.dart';
|
import 'package:pyramid/core/app_state.dart';
|
||||||
import 'package:pyramid/core/theme.dart';
|
import 'package:pyramid/core/theme.dart';
|
||||||
import 'package:pyramid/core/livekit_call_manager.dart';
|
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
||||||
import 'package:pyramid/core/voip_manager.dart';
|
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
|
||||||
import 'package:pyramid/widgets/screen_share_picker.dart';
|
import 'package:pyramid/widgets/screen_share_picker.dart';
|
||||||
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
||||||
import 'package:livekit_client/livekit_client.dart';
|
import 'package:livekit_client/livekit_client.dart';
|
||||||
|
|
||||||
class MiniCallWidget extends ConsumerStatefulWidget {
|
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});
|
const MiniCallWidget({super.key, required this.call});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -45,11 +45,11 @@ class _MiniCallWidgetState extends ConsumerState<MiniCallWidget> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final pt = PyramidTheme.of(context);
|
final pt = PyramidTheme.of(context);
|
||||||
final isVoip = widget.call is PyramidVoipManager;
|
final isVoip = widget.call is CallSignalingService;
|
||||||
final call = widget.call;
|
final call = widget.call;
|
||||||
|
|
||||||
final String displayName = isVoip
|
final String displayName = isVoip
|
||||||
? (call.currentCall?.room.getLocalizedDisplayname() ?? 'Unknown')
|
? (call.currentCall?.room.getLocalizedDisplayname() ?? 'Unbekannt')
|
||||||
: call.displayName;
|
: call.displayName;
|
||||||
|
|
||||||
final bool isConnecting = isVoip ? false : call.isConnecting;
|
final bool isConnecting = isVoip ? false : call.isConnecting;
|
||||||
@@ -95,7 +95,7 @@ class _MiniCallWidgetState extends ConsumerState<MiniCallWidget> {
|
|||||||
isStreaming ? call.localRenderer : call.remoteRenderer,
|
isStreaming ? call.localRenderer : call.remoteRenderer,
|
||||||
objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
|
objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
|
||||||
)
|
)
|
||||||
: _LiveKitPreview(manager: call as LiveKitCallManager),
|
: _LiveKitPreview(manager: call as VoiceChannelService),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Top bar of preview
|
// Top bar of preview
|
||||||
@@ -160,7 +160,7 @@ class _MiniCallWidgetState extends ConsumerState<MiniCallWidget> {
|
|||||||
const _LiveDot(),
|
const _LiveDot(),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
isConnecting ? 'CONNECTING...' : 'VOICE CONNECTED',
|
isConnecting ? 'CONNECTING...' : (isVoip ? 'ANRUF LÄUFT' : (call.isVoiceChannel ? 'SPRACHKANAL' : 'GRUPPENANRUF')),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: pt.online,
|
color: pt.online,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
@@ -259,9 +259,9 @@ class _MiniCallWidgetState extends ConsumerState<MiniCallWidget> {
|
|||||||
pt: pt,
|
pt: pt,
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
if (isVoip) {
|
if (isVoip) {
|
||||||
(call as PyramidVoipManager).toggleScreenSharing(context);
|
(call as CallSignalingService).toggleScreenSharing(context);
|
||||||
} else {
|
} else {
|
||||||
final lk = call as LiveKitCallManager;
|
final lk = call as VoiceChannelService;
|
||||||
if (lk.isScreenSharing) {
|
if (lk.isScreenSharing) {
|
||||||
await lk.stopScreenShare();
|
await lk.stopScreenShare();
|
||||||
} else {
|
} else {
|
||||||
@@ -351,7 +351,7 @@ class _ControlBtnState extends State<_ControlBtn> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _LiveKitPreview extends StatelessWidget {
|
class _LiveKitPreview extends StatelessWidget {
|
||||||
final LiveKitCallManager manager;
|
final VoiceChannelService manager;
|
||||||
const _LiveKitPreview({required this.manager});
|
const _LiveKitPreview({required this.manager});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
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.initial, 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.initial, 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;
|
||||||
|
// Handy: Lautsprecher/Hörmuschel statt „Ton aus“ (Platz in der Leiste).
|
||||||
|
final bool isSpeakerOn;
|
||||||
|
final VoidCallback? onToggleSpeaker;
|
||||||
|
|
||||||
|
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,
|
||||||
|
this.isSpeakerOn = true,
|
||||||
|
this.onToggleSpeaker,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final platform = Theme.of(context).platform;
|
||||||
|
final showSpeaker = onToggleSpeaker != null &&
|
||||||
|
(platform == TargetPlatform.android || platform == TargetPlatform.iOS);
|
||||||
|
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),
|
||||||
|
if (showSpeaker)
|
||||||
|
_VoiceCtrlBtn(
|
||||||
|
icon: isSpeakerOn ? Icons.volume_up_rounded : Icons.phone_in_talk_rounded,
|
||||||
|
active: isSpeakerOn,
|
||||||
|
pt: pt,
|
||||||
|
onTap: onToggleSpeaker!,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
_VoiceCtrlBtn(icon: isDeafened ? Icons.headset_off_rounded : Icons.headphones_rounded, danger: isDeafened, pt: pt, onTap: onToggleDeafen),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
_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.arrow_back_rounded),
|
||||||
|
tooltip: 'Zurück',
|
||||||
|
onPressed: () => ref.read(viewModeProvider.notifier).state = ViewMode.chat,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text('Kein Anruf', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.call_end_rounded, size: 48, color: pt.fgDim),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text('Gerade läuft kein Anruf.', style: TextStyle(color: pt.fgMuted, fontSize: 14)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => ref.read(viewModeProvider.notifier).state = ViewMode.chat,
|
||||||
|
style: FilledButton.styleFrom(backgroundColor: pt.accent, foregroundColor: pt.accentFg),
|
||||||
|
child: const Text('Zurück zum Chat'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,436 @@
|
|||||||
|
part of '../voice_channel.dart';
|
||||||
|
|
||||||
|
/// Anrufansicht als „aufgeblasene“ Formen (Layout: inflated_layout.dart,
|
||||||
|
/// Bernd 2026-10-10): Jede Person füllt ihre Zelle bis an die Nachbarn und
|
||||||
|
/// den Fensterrand, dargestellt als Kontur mit weichem Innenleuchten; in der
|
||||||
|
/// Mitte Profilbild und Name. Wer spricht, pumpt sich auf und leuchtet grün,
|
||||||
|
/// die anderen geben federnd nach. Kamera-Bild füllt die Form.
|
||||||
|
class _BubbleStage extends StatefulWidget {
|
||||||
|
final List<_ParticipantData> participants;
|
||||||
|
final Client? client;
|
||||||
|
final PyramidTheme pt;
|
||||||
|
final ValueChanged<_ParticipantData> onSelect;
|
||||||
|
|
||||||
|
const _BubbleStage({
|
||||||
|
super.key,
|
||||||
|
required this.participants,
|
||||||
|
required this.client,
|
||||||
|
required this.pt,
|
||||||
|
required this.onSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_BubbleStage> createState() => _BubbleStageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BubbleStageState extends State<_BubbleStage>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
final _layout = InflatedLayout();
|
||||||
|
// Letzter bekannter Stand je Person – auch für Gehende, die noch
|
||||||
|
// wegschrumpfen.
|
||||||
|
final Map<String, _ParticipantData> _data = {};
|
||||||
|
late final Ticker _ticker = createTicker(_tick);
|
||||||
|
Duration? _last;
|
||||||
|
double _time = 0;
|
||||||
|
bool _needsSync = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(_BubbleStage old) {
|
||||||
|
super.didUpdateWidget(old);
|
||||||
|
_needsSync = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_ticker.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _tick(Duration elapsed) {
|
||||||
|
final dt = _last == null
|
||||||
|
? 1 / 60
|
||||||
|
: (elapsed - _last!).inMicroseconds / Duration.microsecondsPerSecond;
|
||||||
|
_last = elapsed;
|
||||||
|
_time += dt;
|
||||||
|
final moving = _layout.step(dt);
|
||||||
|
_data.removeWhere((id, _) => !_layout.cells.containsKey(id));
|
||||||
|
final speaking = widget.participants.any((p) => p.isSpeaking);
|
||||||
|
setState(() {});
|
||||||
|
// Ruhe = keine Animation (spart Akku); Sprechen hält das Leuchten an.
|
||||||
|
if (!moving && !speaking) {
|
||||||
|
_ticker.stop();
|
||||||
|
_last = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _sync(Size size) {
|
||||||
|
for (final p in widget.participants) {
|
||||||
|
_data[p.id] = p;
|
||||||
|
}
|
||||||
|
_layout.sync(
|
||||||
|
widget.participants.map((p) => p.id).toList(),
|
||||||
|
widget.participants.where((p) => p.isSpeaking).map((p) => p.id).toSet(),
|
||||||
|
size,
|
||||||
|
);
|
||||||
|
if (!_ticker.isActive) {
|
||||||
|
_last = null;
|
||||||
|
_ticker.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final pt = widget.pt;
|
||||||
|
return LayoutBuilder(builder: (context, box) {
|
||||||
|
final size = box.biggest;
|
||||||
|
if (_needsSync || size != _layout.size) {
|
||||||
|
_needsSync = false;
|
||||||
|
_sync(size);
|
||||||
|
}
|
||||||
|
final glow = 0.5 + 0.5 * math.sin(_time * 5);
|
||||||
|
final shapes = <_CellShape>[];
|
||||||
|
for (final c in _layout.cells.values) {
|
||||||
|
final d = _data[c.id];
|
||||||
|
if (d == null || c.area < 16) continue;
|
||||||
|
shapes.add(_CellShape(
|
||||||
|
data: d,
|
||||||
|
path: _layout.outline(c.id),
|
||||||
|
bounds: _layout.cellBounds(c.id),
|
||||||
|
center: c.centroid,
|
||||||
|
opacity: c.share.clamp(0.0, 1.0),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ColoredBox(
|
||||||
|
color: pt.bg1,
|
||||||
|
child: Stack(
|
||||||
|
clipBehavior: Clip.hardEdge,
|
||||||
|
children: [
|
||||||
|
// Kamera-Bild füllt die Form.
|
||||||
|
for (final s in shapes)
|
||||||
|
if (s.data.hasVideo && s.data.coverVideoWidget != null)
|
||||||
|
Positioned.fill(
|
||||||
|
key: ValueKey('video:${s.data.id}'),
|
||||||
|
child: ClipPath(
|
||||||
|
clipper: _PathClipper(s.path),
|
||||||
|
child: Stack(children: [
|
||||||
|
Positioned.fromRect(
|
||||||
|
rect: s.bounds,
|
||||||
|
child: _MirrorWrapper(
|
||||||
|
isLocal: s.data.isLocal,
|
||||||
|
isScreenShare: s.data.isScreenShare,
|
||||||
|
child: s.data.coverVideoWidget!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Konturen (alle in einem Zeichenvorgang).
|
||||||
|
Positioned.fill(
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: CustomPaint(
|
||||||
|
painter: _ContourPainter(
|
||||||
|
shapes: shapes,
|
||||||
|
glow: glow,
|
||||||
|
speakingColor: pt.online,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Profilbild + Name in der Mitte jeder Form.
|
||||||
|
for (final s in shapes)
|
||||||
|
Positioned.fromRect(
|
||||||
|
key: ValueKey('content:${s.data.id}'),
|
||||||
|
rect: Rect.fromCenter(
|
||||||
|
center: s.data.hasVideo
|
||||||
|
? Offset(s.center.dx, s.bounds.bottom - 28)
|
||||||
|
: s.center,
|
||||||
|
width: s.bounds.width * 0.86,
|
||||||
|
height: s.data.hasVideo ? 40 : s.bounds.height * 0.82,
|
||||||
|
),
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: Opacity(
|
||||||
|
opacity: s.opacity,
|
||||||
|
child: _CellContent(
|
||||||
|
data: s.data,
|
||||||
|
client: widget.client,
|
||||||
|
pt: pt,
|
||||||
|
cellSize: s.bounds.size,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Antippen: Form mit Kamera-Bild groß zeigen.
|
||||||
|
Positioned.fill(
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
onTapUp: (details) {
|
||||||
|
for (final s in shapes) {
|
||||||
|
if (s.path.contains(details.localPosition)) {
|
||||||
|
widget.onSelect(s.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CellShape {
|
||||||
|
final _ParticipantData data;
|
||||||
|
final Path path;
|
||||||
|
final Rect bounds;
|
||||||
|
final Offset center;
|
||||||
|
final double opacity;
|
||||||
|
const _CellShape({
|
||||||
|
required this.data,
|
||||||
|
required this.path,
|
||||||
|
required this.bounds,
|
||||||
|
required this.center,
|
||||||
|
required this.opacity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Profilbild (rund, mit Kontur) und Name – skaliert sich in jede Zellgröße.
|
||||||
|
class _CellContent extends StatelessWidget {
|
||||||
|
final _ParticipantData data;
|
||||||
|
final Client? client;
|
||||||
|
final PyramidTheme pt;
|
||||||
|
final Size cellSize;
|
||||||
|
|
||||||
|
const _CellContent({
|
||||||
|
required this.data,
|
||||||
|
required this.client,
|
||||||
|
required this.pt,
|
||||||
|
required this.cellSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final avatar = (math.min(cellSize.width, cellSize.height) * 0.36)
|
||||||
|
.clamp(28.0, 132.0)
|
||||||
|
.toDouble();
|
||||||
|
final fontSize = (avatar * 0.15).clamp(11.0, 16.0).toDouble();
|
||||||
|
final nameTag = Container(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: fontSize * 0.7, vertical: fontSize * 0.25),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: data.hasVideo
|
||||||
|
? Colors.black.withValues(alpha: 0.5)
|
||||||
|
: Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (data.isMuted) ...[
|
||||||
|
Icon(Icons.mic_off_rounded, size: fontSize + 1, color: pt.danger),
|
||||||
|
SizedBox(width: fontSize * 0.3),
|
||||||
|
],
|
||||||
|
Text(
|
||||||
|
data.name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: data.hasVideo ? Colors.white : pt.fg,
|
||||||
|
fontSize: fontSize,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (data.hasVideo) {
|
||||||
|
return FittedBox(fit: BoxFit.scaleDown, child: nameTag);
|
||||||
|
}
|
||||||
|
return FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_OutlinedAvatar(
|
||||||
|
data: data,
|
||||||
|
client: client,
|
||||||
|
size: avatar,
|
||||||
|
ring: data.isSpeaking ? pt.online : data.avatarColor,
|
||||||
|
),
|
||||||
|
SizedBox(height: fontSize * 0.6),
|
||||||
|
nameTag,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rundes Profilbild mit Kontur; ohne Bild nur der Anfangsbuchstabe in der
|
||||||
|
/// Konturfarbe (passt zum reinen Linien-Look).
|
||||||
|
class _OutlinedAvatar extends StatelessWidget {
|
||||||
|
final _ParticipantData data;
|
||||||
|
final Client? client;
|
||||||
|
final double size;
|
||||||
|
final Color ring;
|
||||||
|
|
||||||
|
const _OutlinedAvatar({
|
||||||
|
required this.data,
|
||||||
|
required this.client,
|
||||||
|
required this.size,
|
||||||
|
required this.ring,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
Widget letter(BuildContext _) => Container(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
color: ring.withValues(alpha: 0.08),
|
||||||
|
// Gezeichnet in der 256er-Fläche, danach auf [size] skaliert.
|
||||||
|
child: Text(data.initial,
|
||||||
|
style: TextStyle(
|
||||||
|
color: ring, fontSize: 104, fontWeight: FontWeight.w600)),
|
||||||
|
);
|
||||||
|
final c = client;
|
||||||
|
final inner = (c != null && data.avatarUrl != null)
|
||||||
|
? MxcAvatar(
|
||||||
|
mxcUri: data.avatarUrl,
|
||||||
|
client: c,
|
||||||
|
size: 256,
|
||||||
|
borderRadius: BorderRadius.zero,
|
||||||
|
placeholder: letter,
|
||||||
|
)
|
||||||
|
: Builder(builder: letter);
|
||||||
|
return Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
padding: const EdgeInsets.all(3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: ring, width: 2),
|
||||||
|
),
|
||||||
|
child: ClipOval(
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
child: SizedBox(width: 256, height: 256, child: inner),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PathClipper extends CustomClipper<Path> {
|
||||||
|
final Path path;
|
||||||
|
_PathClipper(this.path);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Path getClip(Size size) => path;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldReclip(_PathClipper old) => !identical(old.path, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ContourPainter extends CustomPainter {
|
||||||
|
final List<_CellShape> shapes;
|
||||||
|
final double glow;
|
||||||
|
final Color speakingColor;
|
||||||
|
|
||||||
|
_ContourPainter({
|
||||||
|
required this.shapes,
|
||||||
|
required this.glow,
|
||||||
|
required this.speakingColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
for (final s in shapes) {
|
||||||
|
final speaking = s.data.isSpeaking;
|
||||||
|
final color = speaking ? speakingColor : s.data.avatarColor;
|
||||||
|
final o = s.opacity;
|
||||||
|
// Weiches Leuchten innen an der Kontur – wirkt „prall“, bleibt aber
|
||||||
|
// ein Linien-Look.
|
||||||
|
canvas.save();
|
||||||
|
canvas.clipPath(s.path);
|
||||||
|
canvas.drawPath(
|
||||||
|
s.path,
|
||||||
|
Paint()
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = speaking ? 30 : 22
|
||||||
|
..color = color.withValues(
|
||||||
|
alpha: o * (speaking ? 0.20 + 0.12 * glow : 0.10))
|
||||||
|
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 12),
|
||||||
|
);
|
||||||
|
canvas.restore();
|
||||||
|
if (speaking) {
|
||||||
|
canvas.drawPath(
|
||||||
|
s.path,
|
||||||
|
Paint()
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 8
|
||||||
|
..color = color.withValues(alpha: o * (0.25 + 0.25 * glow))
|
||||||
|
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
canvas.drawPath(
|
||||||
|
s.path,
|
||||||
|
Paint()
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = speaking ? 3 : 2
|
||||||
|
..strokeJoin = StrokeJoin.round
|
||||||
|
..color = color.withValues(alpha: o * (speaking ? 1.0 : 0.85)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(_ContourPainter old) => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vorführ-Ansicht der Anruf-Formen mit erfundenen Teilnehmern – nur für
|
||||||
|
/// Screenshots/Tests (UI-Tour), damit man die Ansicht mit mehreren Personen
|
||||||
|
/// sieht, ohne dass so viele Testkonten anrufen müssen.
|
||||||
|
@visibleForTesting
|
||||||
|
class CallBubblesDemo extends StatelessWidget {
|
||||||
|
final int count;
|
||||||
|
/// Wer gerade spricht (Index, -1 = niemand).
|
||||||
|
final int speaking;
|
||||||
|
const CallBubblesDemo({super.key, this.count = 5, this.speaking = 2});
|
||||||
|
|
||||||
|
static const _names = ['Du', 'Anna', 'Ben', 'Clara', 'David', 'Ela', 'Finn'];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final pt = PyramidTheme.of(context);
|
||||||
|
final participants = [
|
||||||
|
for (var i = 0; i < count.clamp(1, _names.length); i++)
|
||||||
|
_ParticipantData(
|
||||||
|
id: 'demo$i',
|
||||||
|
name: _names[i],
|
||||||
|
isLocal: i == 0,
|
||||||
|
isSpeaking: i == speaking,
|
||||||
|
isMuted: i == 3,
|
||||||
|
avatarColor: VoiceChannelView._avatarColors[i % VoiceChannelView._avatarColors.length],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
return _UnifiedCallView(
|
||||||
|
participants: participants,
|
||||||
|
client: null,
|
||||||
|
startedAt: DateTime.now().subtract(const Duration(minutes: 3, seconds: 12)),
|
||||||
|
isLiveKit: true,
|
||||||
|
displayName: 'Familie',
|
||||||
|
isConnecting: false,
|
||||||
|
onHangUp: () {},
|
||||||
|
onMinimize: () {},
|
||||||
|
onToggleMic: () {},
|
||||||
|
onToggleDeafen: () {},
|
||||||
|
onToggleCam: () {},
|
||||||
|
onToggleScreen: () {},
|
||||||
|
isMuted: false,
|
||||||
|
isDeafened: false,
|
||||||
|
isCameraOff: true,
|
||||||
|
isScreenSharing: false,
|
||||||
|
pt: pt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
part of '../voice_channel.dart';
|
||||||
|
|
||||||
|
/// Darstellung der Teilnehmer: Bubbles (Standard), Kacheln, oder ein großes
|
||||||
|
/// Bild (Bildschirm/Kamera) mit Leiste.
|
||||||
|
enum _CallLayout { bubbles, grid, hero }
|
||||||
|
|
||||||
|
class _UnifiedCallView extends StatefulWidget {
|
||||||
|
final List<_ParticipantData> participants;
|
||||||
|
final Client? client;
|
||||||
|
final DateTime startedAt;
|
||||||
|
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;
|
||||||
|
final bool isSpeakerOn;
|
||||||
|
final VoidCallback? onToggleSpeaker;
|
||||||
|
|
||||||
|
const _UnifiedCallView({
|
||||||
|
required this.participants,
|
||||||
|
required this.client,
|
||||||
|
required this.startedAt,
|
||||||
|
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,
|
||||||
|
this.isSpeakerOn = true,
|
||||||
|
this.onToggleSpeaker,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_UnifiedCallView> createState() => _UnifiedCallViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UnifiedCallViewState extends State<_UnifiedCallView> {
|
||||||
|
/// Zuletzt gewählte Ansicht (Bubbles/Kacheln) – bleibt beim Minimieren
|
||||||
|
/// und für den nächsten Anruf erhalten.
|
||||||
|
static _CallLayout _preferred = _CallLayout.bubbles;
|
||||||
|
_CallLayout _mode = _preferred;
|
||||||
|
String? _heroId;
|
||||||
|
bool _stripCollapsed = false;
|
||||||
|
Set<String> _knownScreens = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_followScreenShares();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(_UnifiedCallView oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
_followScreenShares();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Neue Bildschirmübertragung → groß zeigen. Verschwindet das große Bild
|
||||||
|
/// (Person weg, Kamera aus), zurück zu den Bubbles.
|
||||||
|
void _followScreenShares() {
|
||||||
|
final screens = widget.participants
|
||||||
|
.where((p) => p.isScreenShare)
|
||||||
|
.map((p) => p.id)
|
||||||
|
.toSet();
|
||||||
|
final fresh = screens.difference(_knownScreens);
|
||||||
|
if (fresh.isNotEmpty) {
|
||||||
|
_mode = _CallLayout.hero;
|
||||||
|
_heroId = fresh.first;
|
||||||
|
}
|
||||||
|
_knownScreens = screens;
|
||||||
|
if (_mode == _CallLayout.hero &&
|
||||||
|
!widget.participants.any((p) => p.id == _heroId && p.hasVideo)) {
|
||||||
|
_mode = _preferred;
|
||||||
|
_heroId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showLarge(_ParticipantData p) {
|
||||||
|
if (!p.hasVideo) return;
|
||||||
|
setState(() {
|
||||||
|
_mode = _CallLayout.hero;
|
||||||
|
_heroId = p.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 = _mode == _CallLayout.hero
|
||||||
|
? widget.participants.firstWhereOrNull((p) => p.id == _heroId)
|
||||||
|
: 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,
|
||||||
|
startedAt: widget.startedAt,
|
||||||
|
count: widget.participants.length,
|
||||||
|
layout: _mode,
|
||||||
|
onToggleView: () => setState(() {
|
||||||
|
_mode = _mode == _CallLayout.bubbles
|
||||||
|
? _CallLayout.grid
|
||||||
|
: _CallLayout.bubbles;
|
||||||
|
_preferred = _mode;
|
||||||
|
_heroId = null;
|
||||||
|
}),
|
||||||
|
onMinimize: widget.onMinimize,
|
||||||
|
pt: pt,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: AnimatedSwitcher(
|
||||||
|
duration: motionDuration(context, 300),
|
||||||
|
child: hero == null
|
||||||
|
? (_mode == _CallLayout.grid
|
||||||
|
? _ParticipantGrid(
|
||||||
|
key: const ValueKey('grid'),
|
||||||
|
participants: all,
|
||||||
|
pt: pt,
|
||||||
|
onSelect: _showLarge)
|
||||||
|
: _BubbleStage(
|
||||||
|
key: const ValueKey('bubbles'),
|
||||||
|
participants: widget.participants,
|
||||||
|
client: widget.client,
|
||||||
|
pt: pt,
|
||||||
|
onSelect: _showLarge))
|
||||||
|
: _HeroLayout(
|
||||||
|
key: const ValueKey('hero'),
|
||||||
|
hero: hero,
|
||||||
|
others: all.where((p) => p.id != hero.id).toList(),
|
||||||
|
stripCollapsed: _stripCollapsed,
|
||||||
|
onToggleStrip: () => setState(() => _stripCollapsed = !_stripCollapsed),
|
||||||
|
onSetStripCollapsed: (v) => setState(() => _stripCollapsed = v),
|
||||||
|
onSelectOther: (p) => p.hasVideo
|
||||||
|
? setState(() => _heroId = p.id)
|
||||||
|
: null,
|
||||||
|
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,
|
||||||
|
isSpeakerOn: widget.isSpeakerOn,
|
||||||
|
onToggleSpeaker: widget.onToggleSpeaker,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CallHeader extends StatelessWidget {
|
||||||
|
final String displayName;
|
||||||
|
final DateTime startedAt;
|
||||||
|
final int count;
|
||||||
|
final _CallLayout layout;
|
||||||
|
final VoidCallback onToggleView;
|
||||||
|
final VoidCallback onMinimize;
|
||||||
|
final PyramidTheme pt;
|
||||||
|
|
||||||
|
const _CallHeader({
|
||||||
|
required this.displayName,
|
||||||
|
required this.startedAt,
|
||||||
|
required this.count,
|
||||||
|
required this.layout,
|
||||||
|
required this.onToggleView,
|
||||||
|
required this.onMinimize,
|
||||||
|
required this.pt,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final toBubbles = layout != _CallLayout.bubbles;
|
||||||
|
return Container(
|
||||||
|
height: 56,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: pt.border))),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(displayName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Row(children: [
|
||||||
|
_LiveBadge(pt: pt),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_CallDuration(startedAt: startedAt, pt: pt),
|
||||||
|
Text(count == 1 ? ' · nur du' : ' · $count Personen',
|
||||||
|
style: TextStyle(color: pt.fgMuted, fontSize: 12)),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PyrIconBtn(
|
||||||
|
icon: Icon(toBubbles ? Icons.bubble_chart_rounded : Icons.grid_view_rounded, color: pt.fg),
|
||||||
|
tooltip: toBubbles ? 'Bubble-Ansicht' : 'Kachelansicht',
|
||||||
|
onPressed: onToggleView,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
PyrIconBtn(
|
||||||
|
icon: Icon(Icons.close_fullscreen_rounded, color: pt.fg),
|
||||||
|
tooltip: 'Minimieren',
|
||||||
|
onPressed: onMinimize,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gesprächsdauer „m:ss“ bzw. „h:mm:ss“, tickt jede Sekunde.
|
||||||
|
class _CallDuration extends StatefulWidget {
|
||||||
|
final DateTime startedAt;
|
||||||
|
final PyramidTheme pt;
|
||||||
|
const _CallDuration({required this.startedAt, required this.pt});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_CallDuration> createState() => _CallDurationState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CallDurationState extends State<_CallDuration> {
|
||||||
|
late final Timer _timer =
|
||||||
|
Timer.periodic(const Duration(seconds: 1), (_) => setState(() {}));
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_timer; // starten
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_timer.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final d = DateTime.now().difference(widget.startedAt);
|
||||||
|
String two(int n) => n.toString().padLeft(2, '0');
|
||||||
|
final text = d.inHours > 0
|
||||||
|
? '${d.inHours}:${two(d.inMinutes % 60)}:${two(d.inSeconds % 60)}'
|
||||||
|
: '${d.inMinutes}:${two(d.inSeconds % 60)}';
|
||||||
|
return Text(text,
|
||||||
|
style: TextStyle(
|
||||||
|
color: widget.pt.fgMuted,
|
||||||
|
fontSize: 12,
|
||||||
|
fontFeatures: const [FontFeature.tabularFigures()]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,425 @@
|
|||||||
|
part of '../voice_channel.dart';
|
||||||
|
|
||||||
|
class _ParticipantGrid extends StatelessWidget {
|
||||||
|
final List<_ParticipantData> participants;
|
||||||
|
final PyramidTheme pt;
|
||||||
|
final Function(_ParticipantData) onSelect;
|
||||||
|
|
||||||
|
const _ParticipantGrid({super.key, 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({
|
||||||
|
super.key,
|
||||||
|
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,138 @@
|
|||||||
|
part of '../voice_channel.dart';
|
||||||
|
|
||||||
|
void _handleLiveKitScreenShare(BuildContext context, VoiceChannelService call) async {
|
||||||
|
if (call.isScreenSharing) {
|
||||||
|
await call.stopScreenShare();
|
||||||
|
} else if (Theme.of(context).platform == TargetPlatform.android ||
|
||||||
|
Theme.of(context).platform == TargetPlatform.iOS) {
|
||||||
|
// Handy: ganzer Bildschirm über die System-Zustimmung (kein
|
||||||
|
// Fenster-Auswähler wie am PC).
|
||||||
|
await call.toggleScreenShare();
|
||||||
|
} else {
|
||||||
|
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,256 @@
|
|||||||
|
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;
|
||||||
|
/// Woraus der Anfangsbuchstabe im Avatar gebildet wird (Standard: [name]).
|
||||||
|
final String? initialFrom;
|
||||||
|
/// Profilbild (mxc://) der Person, falls bekannt.
|
||||||
|
final Uri? avatarUrl;
|
||||||
|
/// Bin das ich?
|
||||||
|
final bool isLocal;
|
||||||
|
/// Kamera-/Bildschirmbild formatfüllend (für die runden Bubbles).
|
||||||
|
final Widget? coverVideoWidget;
|
||||||
|
|
||||||
|
String get initial {
|
||||||
|
final src = (initialFrom ?? name).replaceFirst('@', '').trim();
|
||||||
|
return src.isEmpty ? '?' : src.characters.first.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
this.initialFrom,
|
||||||
|
this.avatarUrl,
|
||||||
|
this.isLocal = false,
|
||||||
|
this.coverVideoWidget,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Startzeit je Anruf (für die Dauer im Kopf). Schlüssel bleibt beim
|
||||||
|
/// Minimieren gleich, damit die Uhr nicht neu anfängt.
|
||||||
|
final _callStarts = <String, DateTime>{};
|
||||||
|
|
||||||
|
DateTime _callStartFor(String key) {
|
||||||
|
if (_callStarts.length > 20) _callStarts.clear();
|
||||||
|
return _callStarts.putIfAbsent(key, DateTime.now);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||||
|
|
||||||
|
if (voip.currentCall != null && voip.currentCall!.state != CallState.kEnded) {
|
||||||
|
return _UnifiedCallView(
|
||||||
|
participants: _getVoipParticipants(voip),
|
||||||
|
client: client,
|
||||||
|
startedAt: _callStartFor('voip:${voip.currentCall!.callId}'),
|
||||||
|
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,
|
||||||
|
isSpeakerOn: voip.isSpeakerOn,
|
||||||
|
onToggleSpeaker: voip.toggleSpeaker,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!call.isActive) {
|
||||||
|
return _NoCall(pt: pt, ref: ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _UnifiedCallView(
|
||||||
|
participants: _getLiveKitParticipants(call, client),
|
||||||
|
client: client,
|
||||||
|
startedAt: _callStartFor('lk:${call.currentRoomId ?? call.displayName}'),
|
||||||
|
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,
|
||||||
|
isSpeakerOn: call.isSpeakerOn,
|
||||||
|
onToggleSpeaker: call.toggleSpeaker,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<_ParticipantData> _getLiveKitParticipants(
|
||||||
|
VoiceChannelService call, Client? client) {
|
||||||
|
final room = call.room;
|
||||||
|
if (room == null) return [];
|
||||||
|
// LiveKit-Identität = Matrix-ID (vom Token-Server gesetzt) → Name und
|
||||||
|
// Profilbild aus dem Matrix-Raum des Anrufs.
|
||||||
|
final matrixRoomId = call.currentRoomId;
|
||||||
|
final matrixRoom =
|
||||||
|
matrixRoomId == null ? null : client?.getRoomById(matrixRoomId);
|
||||||
|
User? member(String id) {
|
||||||
|
if (!id.startsWith('@')) return null;
|
||||||
|
if (matrixRoom != null) {
|
||||||
|
return matrixRoom.unsafeGetUserFromMemoryOrFallback(id);
|
||||||
|
}
|
||||||
|
for (final r in client?.rooms ?? const []) {
|
||||||
|
final u = r.unsafeGetUserFromMemoryOrFallback(id);
|
||||||
|
if (u.avatarUrl != null || u.displayName != null) return u;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
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 (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
final isLocal = p.identity == room.localParticipant?.identity;
|
||||||
|
final user = member(p.identity);
|
||||||
|
final name = isLocal
|
||||||
|
? 'Du'
|
||||||
|
: (user?.calcDisplayname() ??
|
||||||
|
(p.name.isNotEmpty ? p.name : p.identity));
|
||||||
|
return _ParticipantData(
|
||||||
|
id: p.identity,
|
||||||
|
name: name,
|
||||||
|
initialFrom: isLocal ? user?.calcDisplayname() ?? p.identity : null,
|
||||||
|
avatarUrl: user?.avatarUrl,
|
||||||
|
isLocal: isLocal,
|
||||||
|
videoWidget: videoTrack != null ? VideoTrackRenderer(videoTrack, fit: VideoViewFit.contain) : null,
|
||||||
|
coverVideoWidget: videoTrack != null ? VideoTrackRenderer(videoTrack, fit: VideoViewFit.cover) : 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
final mroom = voip.currentCall?.room;
|
||||||
|
final partnerId = mroom?.directChatMatrixID;
|
||||||
|
final myId = mroom?.client.userID;
|
||||||
|
|
||||||
|
// 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() ?? 'Gegenseite',
|
||||||
|
videoWidget: hasRemoteVideo ? rtc.RTCVideoView(voip.remoteRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null,
|
||||||
|
coverVideoWidget: hasRemoteVideo ? rtc.RTCVideoView(voip.remoteRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitCover) : null,
|
||||||
|
avatarUrl: partnerId == null
|
||||||
|
? mroom?.avatar
|
||||||
|
: mroom?.unsafeGetUserFromMemoryOrFallback(partnerId).avatarUrl,
|
||||||
|
isMuted: false,
|
||||||
|
isSpeaking: voip.isRemoteSpeaking,
|
||||||
|
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',
|
||||||
|
initialFrom: voip.currentCall?.room.client.userID?.split(':').first,
|
||||||
|
videoWidget: hasLocalVideo ? rtc.RTCVideoView(voip.localRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null,
|
||||||
|
coverVideoWidget: hasLocalVideo ? rtc.RTCVideoView(voip.localRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitCover) : null,
|
||||||
|
avatarUrl: myId == null
|
||||||
|
? null
|
||||||
|
: mroom?.unsafeGetUserFromMemoryOrFallback(myId).avatarUrl,
|
||||||
|
isLocal: true,
|
||||||
|
isMuted: voip.isMicMuted,
|
||||||
|
isSpeaking: voip.isLocalSpeaking,
|
||||||
|
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,28 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/scheduler.dart' show Ticker;
|
||||||
|
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/core/matrix_client.dart';
|
||||||
|
import 'package:pyramid/features/call_ui/inflated_layout.dart';
|
||||||
|
import 'package:pyramid/widgets/mxc_image.dart';
|
||||||
|
import 'package:pyramid/widgets/spring_press.dart' show motionDuration;
|
||||||
|
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';
|
||||||
|
part 'view/bubble_stage.dart';
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
@@ -7,7 +8,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:matrix/matrix.dart';
|
import 'package:matrix/matrix.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:pyramid/core/settings_prefs.dart';
|
import 'package:pyramid/core/settings_prefs.dart';
|
||||||
|
import 'package:pyramid/core/appearance.dart';
|
||||||
import 'package:pyramid/core/theme.dart';
|
import 'package:pyramid/core/theme.dart';
|
||||||
|
import 'package:pyramid/features/rooms/rooms_provider.dart'
|
||||||
|
show spaceChannelIdsProvider;
|
||||||
|
import 'package:pyramid/widgets/spring_press.dart';
|
||||||
import 'package:pyramid/features/chat/attachment_dialog.dart';
|
import 'package:pyramid/features/chat/attachment_dialog.dart';
|
||||||
import 'package:pyramid/features/chat/emoji_picker.dart';
|
import 'package:pyramid/features/chat/emoji_picker.dart';
|
||||||
import 'package:pyramid/features/chat/gif_sticker_picker.dart';
|
import 'package:pyramid/features/chat/gif_sticker_picker.dart';
|
||||||
@@ -32,6 +37,10 @@ class ChatComposer extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ChatComposerState extends ConsumerState<ChatComposer> {
|
class _ChatComposerState extends ConsumerState<ChatComposer> {
|
||||||
|
/// Angefangene Nachrichten je Raum (nur im Speicher): Beim Raumwechsel
|
||||||
|
/// bleibt der Text beim richtigen Raum, statt im nächsten Chat zu landen.
|
||||||
|
static final _drafts = <String, String>{};
|
||||||
|
|
||||||
final _ctrl = TextEditingController();
|
final _ctrl = TextEditingController();
|
||||||
late final FocusNode _focus;
|
late final FocusNode _focus;
|
||||||
bool _canSend = false;
|
bool _canSend = false;
|
||||||
@@ -44,6 +53,12 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_focus = FocusNode(onKeyEvent: _handleKeyEvent);
|
_focus = FocusNode(onKeyEvent: _handleKeyEvent);
|
||||||
|
// Entwurf vor dem Listener setzen – sonst ginge sofort „tippt…“ raus.
|
||||||
|
final draft = _drafts.remove(widget.room.id);
|
||||||
|
if (draft != null) {
|
||||||
|
_ctrl.text = draft;
|
||||||
|
_canSend = draft.trim().isNotEmpty;
|
||||||
|
}
|
||||||
_ctrl.addListener(_onTextChanged);
|
_ctrl.addListener(_onTextChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +133,8 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
final text = _ctrl.text;
|
||||||
|
if (text.trim().isNotEmpty) _drafts[widget.room.id] = text;
|
||||||
_typingTimer?.cancel();
|
_typingTimer?.cancel();
|
||||||
if (_isTyping) widget.room.setTyping(false).catchError((_) {});
|
if (_isTyping) widget.room.setTyping(false).catchError((_) {});
|
||||||
_gifOverlay?.remove();
|
_gifOverlay?.remove();
|
||||||
@@ -300,22 +317,43 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
|
|||||||
final pt = PyramidTheme.of(context);
|
final pt = PyramidTheme.of(context);
|
||||||
final size = MediaQuery.sizeOf(context);
|
final size = MediaQuery.sizeOf(context);
|
||||||
final isSmall = size.width < 400;
|
final isSmall = size.width < 400;
|
||||||
|
// Handy: größere Symbole (vorher 12–13 px, Bernds Feedback 2026-10-07).
|
||||||
|
final mobile = isMobileLayout(size);
|
||||||
|
|
||||||
final roomName = widget.room.getLocalizedDisplayname();
|
final roomName = widget.room.getLocalizedDisplayname();
|
||||||
|
// „#“ nur bei Server-Kanälen; Gruppen heißen wie die Gruppe.
|
||||||
final placeholder = widget.room.isDirectChat
|
final placeholder = widget.room.isDirectChat
|
||||||
? 'Message…'
|
? 'Nachricht…'
|
||||||
: 'Message #$roomName';
|
: ref.watch(spaceChannelIdsProvider).contains(widget.room.id)
|
||||||
|
? 'Nachricht an #$roomName'
|
||||||
|
: 'Nachricht an $roomName';
|
||||||
|
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (widget.replyTo != null)
|
// Antwort-Leiste federt auf und klappt beim Schließen weich zu.
|
||||||
_ReplyBar(
|
AnimatedSwitcher(
|
||||||
event: widget.replyTo!,
|
duration: motionDuration(context, 300),
|
||||||
onClear: widget.onClearReply ?? () {},
|
reverseDuration: motionDuration(context, 160),
|
||||||
pt: pt,
|
switchInCurve: Curves.easeOutBack,
|
||||||
|
switchOutCurve: Curves.easeIn,
|
||||||
|
transitionBuilder: (child, anim) => SizeTransition(
|
||||||
|
sizeFactor: anim,
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: FadeTransition(opacity: anim, child: child),
|
||||||
),
|
),
|
||||||
|
child: widget.replyTo != null
|
||||||
|
? KeyedSubtree(
|
||||||
|
key: ValueKey('reply:${widget.replyTo!.eventId}'),
|
||||||
|
child: _ReplyBar(
|
||||||
|
event: widget.replyTo!,
|
||||||
|
onClear: widget.onClearReply ?? () {},
|
||||||
|
pt: pt,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox(key: ValueKey('reply:none'), width: double.infinity),
|
||||||
|
),
|
||||||
Padding(
|
Padding(
|
||||||
// Bottom inset matches the profile block in the rooms list (8px) so
|
// Bottom inset matches the profile block in the rooms list (8px) so
|
||||||
// the composer sits flush with it.
|
// the composer sits flush with it.
|
||||||
@@ -327,8 +365,10 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
|
|||||||
// block in the rooms list (~89px on the user's display). Grows for
|
// block in the rooms list (~89px on the user's display). Grows for
|
||||||
// multiline.
|
// multiline.
|
||||||
constraints: const BoxConstraints(minHeight: 53),
|
constraints: const BoxConstraints(minHeight: 53),
|
||||||
|
// Rahmen + Fokus-Ring außen (würden vom Zuschnitt sonst
|
||||||
|
// abgeschnitten), innen Milchglas: der Chat läuft unter dem
|
||||||
|
// schwebenden Feld durch und scheint unscharf durch.
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: pt.bg2,
|
|
||||||
borderRadius: BorderRadius.circular(pt.rBase + 4),
|
borderRadius: BorderRadius.circular(pt.rBase + 4),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: _focus.hasFocus ? pt.accent : pt.border,
|
color: _focus.hasFocus ? pt.accent : pt.border,
|
||||||
@@ -337,7 +377,13 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
|
|||||||
? [BoxShadow(color: pt.accentSoft, blurRadius: 0, spreadRadius: 3)]
|
? [BoxShadow(color: pt.accentSoft, blurRadius: 0, spreadRadius: 3)]
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
child: Focus(
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(pt.rBase + 3),
|
||||||
|
child: BackdropFilter(
|
||||||
|
filter: ui.ImageFilter.blur(sigmaX: 12, sigmaY: 12),
|
||||||
|
child: ColoredBox(
|
||||||
|
color: pt.bg2.withValues(alpha: 0.78),
|
||||||
|
child: Focus(
|
||||||
onFocusChange: (_) => setState(() {}),
|
onFocusChange: (_) => setState(() {}),
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
@@ -347,7 +393,7 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
|
|||||||
icon: Icons.add_rounded,
|
icon: Icons.add_rounded,
|
||||||
tooltip: 'Datei anhängen',
|
tooltip: 'Datei anhängen',
|
||||||
pt: pt,
|
pt: pt,
|
||||||
size: size.width < 600 ? 24 : 30,
|
size: mobile ? 42 : 30,
|
||||||
onTap: _pickAttachment,
|
onTap: _pickAttachment,
|
||||||
),
|
),
|
||||||
// Text input
|
// Text input
|
||||||
@@ -397,14 +443,14 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
|
|||||||
icon: Icons.gif_box_outlined,
|
icon: Icons.gif_box_outlined,
|
||||||
tooltip: 'GIF',
|
tooltip: 'GIF',
|
||||||
pt: pt,
|
pt: pt,
|
||||||
size: size.width < 600 ? 22 : 26,
|
size: mobile ? 40 : 26,
|
||||||
onTap: _toggleGifPicker,
|
onTap: _toggleGifPicker,
|
||||||
),
|
),
|
||||||
_ComposerIconBtn(
|
_ComposerIconBtn(
|
||||||
icon: Icons.tag_faces_rounded,
|
icon: Icons.tag_faces_rounded,
|
||||||
tooltip: 'Emoji',
|
tooltip: 'Emoji',
|
||||||
pt: pt,
|
pt: pt,
|
||||||
size: size.width < 600 ? 22 : 26,
|
size: mobile ? 40 : 26,
|
||||||
onTap: _toggleEmojiPicker,
|
onTap: _toggleEmojiPicker,
|
||||||
),
|
),
|
||||||
if (size.width > 600)
|
if (size.width > 600)
|
||||||
@@ -424,10 +470,14 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
|
|||||||
canSend: _canSend,
|
canSend: _canSend,
|
||||||
pt: pt,
|
pt: pt,
|
||||||
onTap: _send,
|
onTap: _send,
|
||||||
|
size: mobile ? 42 : 36,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -461,9 +511,7 @@ class _ComposerIconBtnState extends State<_ComposerIconBtn> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final platform = Theme.of(context).platform;
|
final isMobile = isMobileLayout(MediaQuery.sizeOf(context));
|
||||||
final isMobile =
|
|
||||||
platform == TargetPlatform.android || platform == TargetPlatform.iOS;
|
|
||||||
final touchSize = isMobile ? 44.0 : widget.size;
|
final touchSize = isMobile ? 44.0 : widget.size;
|
||||||
|
|
||||||
return MouseRegion(
|
return MouseRegion(
|
||||||
@@ -475,7 +523,9 @@ class _ComposerIconBtnState extends State<_ComposerIconBtn> {
|
|||||||
child: Tooltip(
|
child: Tooltip(
|
||||||
message: widget.tooltip,
|
message: widget.tooltip,
|
||||||
waitDuration: const Duration(milliseconds: 500),
|
waitDuration: const Duration(milliseconds: 500),
|
||||||
child: SizedBox(
|
child: SpringPress(
|
||||||
|
enabled: widget.onTap != null,
|
||||||
|
child: SizedBox(
|
||||||
width: touchSize,
|
width: touchSize,
|
||||||
height: touchSize,
|
height: touchSize,
|
||||||
child: Center(
|
child: Center(
|
||||||
@@ -497,6 +547,7 @@ class _ComposerIconBtnState extends State<_ComposerIconBtn> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -507,8 +558,14 @@ class _SendButton extends StatefulWidget {
|
|||||||
final bool canSend;
|
final bool canSend;
|
||||||
final PyramidTheme pt;
|
final PyramidTheme pt;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
final double size;
|
||||||
|
|
||||||
const _SendButton({required this.canSend, required this.pt, required this.onTap});
|
const _SendButton({
|
||||||
|
required this.canSend,
|
||||||
|
required this.pt,
|
||||||
|
required this.onTap,
|
||||||
|
this.size = 36,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_SendButton> createState() => _SendButtonState();
|
State<_SendButton> createState() => _SendButtonState();
|
||||||
@@ -528,11 +585,14 @@ class _SendButtonState extends State<_SendButton> {
|
|||||||
onExit: (_) => setState(() => _hovered = false),
|
onExit: (_) => setState(() => _hovered = false),
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: active ? widget.onTap : null,
|
onTap: active ? widget.onTap : null,
|
||||||
child: AnimatedContainer(
|
child: SpringPress(
|
||||||
|
enabled: active,
|
||||||
|
pressedScale: 0.82,
|
||||||
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 180),
|
duration: const Duration(milliseconds: 180),
|
||||||
curve: Curves.easeOutBack,
|
curve: Curves.easeOutBack,
|
||||||
width: 36,
|
width: widget.size,
|
||||||
height: 36,
|
height: widget.size,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: active ? pt.accent : pt.bg3,
|
color: active ? pt.accent : pt.bg3,
|
||||||
borderRadius: BorderRadius.circular(pt.rSm),
|
borderRadius: BorderRadius.circular(pt.rSm),
|
||||||
@@ -547,13 +607,22 @@ class _SendButtonState extends State<_SendButton> {
|
|||||||
: Matrix4.identity(),
|
: Matrix4.identity(),
|
||||||
transformAlignment: Alignment.center,
|
transformAlignment: Alignment.center,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Icon(
|
// Sobald gesendet werden kann, ploppt der Pfeil federnd auf.
|
||||||
Icons.send_rounded,
|
child: AnimatedScale(
|
||||||
size: 16,
|
scale: active ? 1 : 0.8,
|
||||||
color: active ? pt.accentFg : pt.fgDim,
|
duration: active
|
||||||
|
? motionDuration(context, 520)
|
||||||
|
: const Duration(milliseconds: 140),
|
||||||
|
curve: active ? Curves.elasticOut : Curves.easeOut,
|
||||||
|
child: Icon(
|
||||||
|
Icons.send_rounded,
|
||||||
|
size: widget.size * 0.45,
|
||||||
|
color: active ? pt.accentFg : pt.fgDim,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -599,9 +668,20 @@ class _ReplyBar extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// Handy: größeres X mit fingergerechter Trefferfläche.
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
onTap: onClear,
|
onTap: onClear,
|
||||||
child: Icon(Icons.close_rounded, size: 16, color: pt.fgDim),
|
child: SpringPress(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
child: Icon(
|
||||||
|
Icons.close_rounded,
|
||||||
|
size: isMobileLayout(MediaQuery.sizeOf(context)) ? 22 : 16,
|
||||||
|
color: pt.fgDim,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import 'package:pyramid/core/matrix_client.dart';
|
|||||||
class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
|
class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
|
||||||
Timeline? _timeline;
|
Timeline? _timeline;
|
||||||
StreamSubscription<String>? _roomUpdateSub;
|
StreamSubscription<String>? _roomUpdateSub;
|
||||||
|
StreamSubscription<SyncUpdate>? _syncSub;
|
||||||
Timer? _redecryptTimer;
|
Timer? _redecryptTimer;
|
||||||
|
String _receiptSig = '';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Timeline> build(String arg) async {
|
Future<Timeline> build(String arg) async {
|
||||||
@@ -21,6 +23,7 @@ class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
|
|||||||
|
|
||||||
_timeline?.cancelSubscriptions();
|
_timeline?.cancelSubscriptions();
|
||||||
_roomUpdateSub?.cancel();
|
_roomUpdateSub?.cancel();
|
||||||
|
_syncSub?.cancel();
|
||||||
_redecryptTimer?.cancel();
|
_redecryptTimer?.cancel();
|
||||||
|
|
||||||
final diag = ref.read(e2eeDiagnosticsProvider.notifier);
|
final diag = ref.read(e2eeDiagnosticsProvider.notifier);
|
||||||
@@ -49,6 +52,28 @@ class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Nach jedem Sync (erst dann sind Lesebestätigungen verarbeitet – das
|
||||||
|
// Raum-Update kommt im SDK VORHER):
|
||||||
|
// • Einladung angenommen (invite → join): Verlauf neu aufbauen; der beim
|
||||||
|
// Einladen gebaute blieb sonst leer, bis man die App neu startete.
|
||||||
|
// • Lesebestätigung eines anderen hat sich geändert: neu zeichnen, damit
|
||||||
|
// ✓✓ live erscheint. Neue Nachrichten allein lösen _onUpdate schon aus,
|
||||||
|
// eine Bestätigung ohne neue Nachricht bisher nicht.
|
||||||
|
final membershipAtBuild = room.membership;
|
||||||
|
_receiptSig = _receiptSignature(room);
|
||||||
|
_syncSub = client.onSync.stream.listen((_) {
|
||||||
|
if (membershipAtBuild != Membership.join &&
|
||||||
|
room.membership == Membership.join) {
|
||||||
|
ref.invalidateSelf();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final sig = _receiptSignature(room);
|
||||||
|
if (sig != _receiptSig) {
|
||||||
|
_receiptSig = sig;
|
||||||
|
_onUpdate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Ensure device keys are downloaded for all room members so outbound
|
// Ensure device keys are downloaded for all room members so outbound
|
||||||
// Megolm session key sharing works on the first send.
|
// Megolm session key sharing works on the first send.
|
||||||
if (room.encrypted && client.encryptionEnabled) {
|
if (room.encrypted && client.encryptionEnabled) {
|
||||||
@@ -57,6 +82,7 @@ class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
|
|||||||
|
|
||||||
ref.onDispose(() {
|
ref.onDispose(() {
|
||||||
_roomUpdateSub?.cancel();
|
_roomUpdateSub?.cancel();
|
||||||
|
_syncSub?.cancel();
|
||||||
_redecryptTimer?.cancel();
|
_redecryptTimer?.cancel();
|
||||||
_timeline?.cancelSubscriptions();
|
_timeline?.cancelSubscriptions();
|
||||||
_timeline = null;
|
_timeline = null;
|
||||||
@@ -70,6 +96,36 @@ class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Kurzer Fingerabdruck der Lesepositionen der anderen Mitglieder.
|
||||||
|
String _receiptSignature(Room room) {
|
||||||
|
final b = StringBuffer();
|
||||||
|
for (final users in [
|
||||||
|
room.receiptState.global.otherUsers,
|
||||||
|
room.receiptState.mainThread?.otherUsers ?? const {},
|
||||||
|
]) {
|
||||||
|
users.forEach((userId, data) => b
|
||||||
|
..write(userId)
|
||||||
|
..write('=')
|
||||||
|
..write(data.eventId)
|
||||||
|
..write(';'));
|
||||||
|
}
|
||||||
|
return b.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Einladung annehmen und warten, bis der Raum als beigetreten
|
||||||
|
/// synchronisiert ist – erst dann hat der Verlauf Inhalt.
|
||||||
|
Future<void> acceptInvite(WidgetRef ref, Room room) async {
|
||||||
|
final client = room.client;
|
||||||
|
// Vor dem Beitreten anfangen zu warten, sonst kann der Sync durchrutschen.
|
||||||
|
final synced = client
|
||||||
|
.waitForRoomInSync(room.id, join: true)
|
||||||
|
.timeout(const Duration(seconds: 20))
|
||||||
|
.then<void>((_) {}, onError: (_) {});
|
||||||
|
await room.join();
|
||||||
|
await synced;
|
||||||
|
ref.invalidate(timelineProvider(room.id));
|
||||||
|
}
|
||||||
|
|
||||||
final timelineProvider =
|
final timelineProvider =
|
||||||
AsyncNotifierProvider.family<TimelineNotifier, Timeline, String>(
|
AsyncNotifierProvider.family<TimelineNotifier, Timeline, String>(
|
||||||
TimelineNotifier.new,
|
TimelineNotifier.new,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:matrix/matrix.dart';
|
import 'package:matrix/matrix.dart';
|
||||||
import 'package:pyramid/core/app_state.dart';
|
import 'package:pyramid/core/app_state.dart';
|
||||||
|
import 'package:pyramid/core/appearance.dart' show isMobileLayout;
|
||||||
import 'package:pyramid/core/matrix_client.dart';
|
import 'package:pyramid/core/matrix_client.dart';
|
||||||
import 'package:pyramid/core/settings_prefs.dart';
|
import 'package:pyramid/core/settings_prefs.dart';
|
||||||
import 'package:pyramid/core/theme.dart';
|
import 'package:pyramid/core/theme.dart';
|
||||||
@@ -18,11 +19,19 @@ import 'package:pyramid/features/chat/chat_composer.dart';
|
|||||||
import 'package:pyramid/features/chat/chat_provider.dart';
|
import 'package:pyramid/features/chat/chat_provider.dart';
|
||||||
import 'package:pyramid/features/chat/message_group.dart';
|
import 'package:pyramid/features/chat/message_group.dart';
|
||||||
import 'package:pyramid/features/chat/pinned_messages_panel.dart';
|
import 'package:pyramid/features/chat/pinned_messages_panel.dart';
|
||||||
|
import 'package:pyramid/features/group_call/group_call.dart';
|
||||||
import 'package:pyramid/widgets/hover_region.dart';
|
import 'package:pyramid/widgets/hover_region.dart';
|
||||||
|
import 'package:pyramid/widgets/measure_size.dart';
|
||||||
|
import 'package:pyramid/widgets/spring_press.dart';
|
||||||
|
import 'package:pyramid/core/read_markers.dart';
|
||||||
|
import 'package:pyramid/core/room_kinds.dart';
|
||||||
|
import 'package:pyramid/features/rooms/rooms_provider.dart'
|
||||||
|
show spaceChannelIdsProvider;
|
||||||
import 'package:pyramid/widgets/mxc_image.dart';
|
import 'package:pyramid/widgets/mxc_image.dart';
|
||||||
|
|
||||||
import 'package:pyramid/core/notification_service.dart';
|
import 'package:pyramid/core/notification_service.dart';
|
||||||
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';
|
||||||
|
|
||||||
|
|
||||||
part 'view/chat_view_core.dart';
|
part 'view/chat_view_core.dart';
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import 'package:archive/archive.dart';
|
|||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:pdfx/pdfx.dart';
|
import 'package:pdfx/pdfx.dart';
|
||||||
|
|||||||
@@ -628,7 +628,7 @@ class _SectionLabel extends StatelessWidget {
|
|||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
|
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
|
||||||
child: Text(title,
|
child: Text(title,
|
||||||
style: TextStyle(color: PyramidColors.fgDim,
|
style: TextStyle(color: PyramidTheme.of(context).fgDim,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: 0.6)),
|
letterSpacing: 0.6)),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
@@ -419,6 +420,15 @@ class _ChunkyBarsPainter extends CustomPainter {
|
|||||||
//
|
//
|
||||||
// Variant: minimal — Controls immer sichtbar (overlay auf Video, kein Auto-Hide)
|
// Variant: minimal — Controls immer sichtbar (overlay auf Video, kein Auto-Hide)
|
||||||
|
|
||||||
|
/// Maximale Höhe für Bilder/Videos im Chat. Auf dem Handy bis zur halben
|
||||||
|
/// Bildschirmhöhe – vorher galten überall 320 px, wodurch Hochkant-Fotos auf
|
||||||
|
/// dem Handy nur ~180 px schmal waren (Bernds Feedback 2026-10-07).
|
||||||
|
double inlineMediaMaxHeight(BuildContext context) {
|
||||||
|
final screen = MediaQuery.sizeOf(context);
|
||||||
|
final isMobile = screen.width < 900 || screen.height < 500;
|
||||||
|
return isMobile ? (screen.height * 0.5).clamp(240.0, 560.0) : 320.0;
|
||||||
|
}
|
||||||
|
|
||||||
class PyramidVideoPlayer extends StatefulWidget {
|
class PyramidVideoPlayer extends StatefulWidget {
|
||||||
final Uint8List bytes;
|
final Uint8List bytes;
|
||||||
final String filename;
|
final String filename;
|
||||||
@@ -441,6 +451,10 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
|
|||||||
late final Player _player;
|
late final Player _player;
|
||||||
late final VideoController _controller;
|
late final VideoController _controller;
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
|
// Echtes Seitenverhältnis, sobald das Video seine Größe kennt (vorher fest
|
||||||
|
// 16:9 → Hochkant-Handyvideos klein zwischen schwarzen Balken).
|
||||||
|
double? _aspect;
|
||||||
|
final _subs = <StreamSubscription<int?>>[];
|
||||||
double _speed = 1.0;
|
double _speed = 1.0;
|
||||||
String _quality = 'Auto';
|
String _quality = 'Auto';
|
||||||
|
|
||||||
@@ -452,9 +466,19 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
_player = Player();
|
_player = Player();
|
||||||
_controller = VideoController(_player);
|
_controller = VideoController(_player);
|
||||||
|
_subs.add(_player.stream.width.listen((_) => _updateAspect()));
|
||||||
|
_subs.add(_player.stream.height.listen((_) => _updateAspect()));
|
||||||
_init();
|
_init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _updateAspect() {
|
||||||
|
final w = _player.state.width;
|
||||||
|
final h = _player.state.height;
|
||||||
|
if (w == null || h == null || w <= 0 || h <= 0) return;
|
||||||
|
final aspect = w / h;
|
||||||
|
if (mounted && aspect != _aspect) setState(() => _aspect = aspect);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _init() async {
|
Future<void> _init() async {
|
||||||
final ext = widget.filename.contains('.') ? widget.filename.split('.').last : 'mp4';
|
final ext = widget.filename.contains('.') ? widget.filename.split('.').last : 'mp4';
|
||||||
final dir = await getTemporaryDirectory();
|
final dir = await getTemporaryDirectory();
|
||||||
@@ -466,6 +490,9 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
for (final s in _subs) {
|
||||||
|
s.cancel();
|
||||||
|
}
|
||||||
_player.dispose();
|
_player.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -506,7 +533,10 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
|
|||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(top: 4, bottom: 4),
|
margin: const EdgeInsets.only(top: 4, bottom: 4),
|
||||||
constraints: const BoxConstraints(maxWidth: 420),
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: 420,
|
||||||
|
maxHeight: inlineMediaMaxHeight(context),
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.black,
|
color: Colors.black,
|
||||||
borderRadius: BorderRadius.circular(pt.rLg),
|
borderRadius: BorderRadius.circular(pt.rLg),
|
||||||
@@ -514,7 +544,7 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
|
|||||||
),
|
),
|
||||||
clipBehavior: Clip.hardEdge,
|
clipBehavior: Clip.hardEdge,
|
||||||
child: AspectRatio(
|
child: AspectRatio(
|
||||||
aspectRatio: 16 / 9,
|
aspectRatio: _aspect ?? 16 / 9,
|
||||||
child: StreamBuilder<bool>(
|
child: StreamBuilder<bool>(
|
||||||
stream: _player.stream.playing,
|
stream: _player.stream.playing,
|
||||||
builder: (_, playSnap) => StreamBuilder<Duration>(
|
builder: (_, playSnap) => StreamBuilder<Duration>(
|
||||||
|
|||||||
@@ -6,9 +6,17 @@ import 'package:path_provider/path_provider.dart';
|
|||||||
import 'package:pyramid/core/theme.dart';
|
import 'package:pyramid/core/theme.dart';
|
||||||
import 'package:pyramid/features/chat/media_player.dart';
|
import 'package:pyramid/features/chat/media_player.dart';
|
||||||
import 'package:pyramid/utils/gif_favorite_service.dart';
|
import 'package:pyramid/utils/gif_favorite_service.dart';
|
||||||
|
import 'package:pyramid/widgets/spring_press.dart' show motionDuration;
|
||||||
|
|
||||||
/// Overlay viewer for images, GIFs and videos.
|
/// Overlay viewer for images, GIFs and videos.
|
||||||
/// Uses a semi-transparent backdrop; tapping outside the content closes it.
|
///
|
||||||
|
/// Bedienung (Bernds Feedback 2026-10-07, wie man es von WhatsApp & Co. kennt):
|
||||||
|
/// * Handy: randlos auf schwarzem Grund (vorher 24-px-Rand, halbdurchsichtig).
|
||||||
|
/// * Tippen NEBEN das Bild schließt, Tippen AUFS Bild blendet die Leiste ein/aus.
|
||||||
|
/// * Nach oben/unten wischen schließt (nur ohne Zoom), der Hintergrund blendet
|
||||||
|
/// dabei aus.
|
||||||
|
/// * Doppeltipp zoomt an der Stelle hinein bzw. wieder heraus; mit zwei
|
||||||
|
/// Fingern aufziehen geht weiterhin.
|
||||||
class MediaViewer extends StatefulWidget {
|
class MediaViewer extends StatefulWidget {
|
||||||
final String senderName;
|
final String senderName;
|
||||||
final Uint8List? bytes;
|
final Uint8List? bytes;
|
||||||
@@ -40,10 +48,22 @@ class MediaViewer extends StatefulWidget {
|
|||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: true,
|
barrierDismissible: true,
|
||||||
barrierLabel: 'Schließen',
|
barrierLabel: 'Schließen',
|
||||||
barrierColor: Colors.black.withAlpha(178),
|
// Den Hintergrund zeichnet der Viewer selbst (damit er beim Wischen
|
||||||
transitionDuration: const Duration(milliseconds: 180),
|
// ausblenden kann).
|
||||||
|
barrierColor: Colors.transparent,
|
||||||
|
transitionDuration: motionDuration(context, 300),
|
||||||
transitionBuilder: (context, animation, secondaryAnimation, child) =>
|
transitionBuilder: (context, animation, secondaryAnimation, child) =>
|
||||||
FadeTransition(opacity: animation, child: child),
|
FadeTransition(
|
||||||
|
opacity: animation,
|
||||||
|
child: ScaleTransition(
|
||||||
|
scale: Tween(begin: 0.9, end: 1.0).animate(CurvedAnimation(
|
||||||
|
parent: animation,
|
||||||
|
curve: Curves.easeOutBack,
|
||||||
|
reverseCurve: Curves.easeIn,
|
||||||
|
)),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
pageBuilder: (context, animation, secondaryAnimation) => MediaViewer(
|
pageBuilder: (context, animation, secondaryAnimation) => MediaViewer(
|
||||||
senderName: senderName,
|
senderName: senderName,
|
||||||
filename: filename,
|
filename: filename,
|
||||||
@@ -59,21 +79,127 @@ class MediaViewer extends StatefulWidget {
|
|||||||
State<MediaViewer> createState() => _MediaViewerState();
|
State<MediaViewer> createState() => _MediaViewerState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MediaViewerState extends State<MediaViewer> {
|
class _MediaViewerState extends State<MediaViewer>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
/// Ab so viel Pixel Wischweg (oder entsprechend schnell) wird geschlossen.
|
||||||
|
static const _dismissDistance = 120.0;
|
||||||
|
static const _dismissVelocity = 1000.0;
|
||||||
|
static const _doubleTapScale = 2.5;
|
||||||
|
|
||||||
final _tc = TransformationController();
|
final _tc = TransformationController();
|
||||||
|
final _contentKey = GlobalKey();
|
||||||
|
late final AnimationController _zoomAnim;
|
||||||
|
Animation<Matrix4>? _zoomTween;
|
||||||
|
|
||||||
bool _controlsVisible = true;
|
bool _controlsVisible = true;
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
bool _savingSticker = false;
|
bool _savingSticker = false;
|
||||||
|
bool _zoomed = false;
|
||||||
|
bool _dragging = false;
|
||||||
|
double _dragOffset = 0;
|
||||||
|
Offset? _doubleTapGlobal;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_zoomAnim = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 220),
|
||||||
|
)..addListener(() {
|
||||||
|
final tween = _zoomTween;
|
||||||
|
if (tween != null) _tc.value = tween.value;
|
||||||
|
});
|
||||||
|
_tc.addListener(_onTransform);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_zoomAnim.dispose();
|
||||||
|
_tc.removeListener(_onTransform);
|
||||||
_tc.dispose();
|
_tc.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onTransform() {
|
||||||
|
final zoomed = _tc.value.getMaxScaleOnAxis() > 1.01;
|
||||||
|
if (zoomed != _zoomed) setState(() => _zoomed = zoomed);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _close() => Navigator.of(context).maybePop();
|
||||||
|
|
||||||
void _toggle() => setState(() => _controlsVisible = !_controlsVisible);
|
void _toggle() => setState(() => _controlsVisible = !_controlsVisible);
|
||||||
|
|
||||||
void _resetZoom() => _tc.value = Matrix4.identity();
|
void _animateTo(Matrix4 target) {
|
||||||
|
_zoomTween = Matrix4Tween(begin: _tc.value, end: target).animate(
|
||||||
|
CurvedAnimation(parent: _zoomAnim, curve: Curves.easeOutCubic),
|
||||||
|
);
|
||||||
|
_zoomAnim.forward(from: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _resetZoom() => _animateTo(Matrix4.identity());
|
||||||
|
|
||||||
|
/// Doppeltipp: an der getippten Stelle hineinzoomen bzw. ganz heraus.
|
||||||
|
void _toggleZoom() {
|
||||||
|
final global = _doubleTapGlobal;
|
||||||
|
final box = _contentKey.currentContext?.findRenderObject() as RenderBox?;
|
||||||
|
if (_zoomed || global == null || box == null) {
|
||||||
|
_resetZoom();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final p = box.globalToLocal(global);
|
||||||
|
_animateTo(Matrix4.identity()
|
||||||
|
..translateByDouble(
|
||||||
|
-p.dx * (_doubleTapScale - 1),
|
||||||
|
-p.dy * (_doubleTapScale - 1),
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
..scaleByDouble(_doubleTapScale, _doubleTapScale, 1, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wischen zum Schließen (über die InteractiveViewer-Callbacks, damit es
|
||||||
|
// nicht mit dem Zwei-Finger-Zoom um die Gesten konkurriert) ─────────────
|
||||||
|
|
||||||
|
void _onInteractionStart(ScaleStartDetails d) {
|
||||||
|
_dragging = !_zoomed && d.pointerCount == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onInteractionUpdate(ScaleUpdateDetails d) {
|
||||||
|
if (!_dragging) return;
|
||||||
|
if (d.pointerCount > 1) {
|
||||||
|
// Zweiter Finger dazu → das ist ein Zoom, kein Wischen.
|
||||||
|
setState(() {
|
||||||
|
_dragging = false;
|
||||||
|
_dragOffset = 0;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _dragOffset += d.focalPointDelta.dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onInteractionEnd(ScaleEndDetails d) {
|
||||||
|
if (!_dragging) return;
|
||||||
|
_dragging = false;
|
||||||
|
final fast = d.velocity.pixelsPerSecond.dy.abs() > _dismissVelocity;
|
||||||
|
if (_dragOffset.abs() > _dismissDistance || fast) {
|
||||||
|
_close();
|
||||||
|
} else {
|
||||||
|
setState(() => _dragOffset = 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video: kein InteractiveViewer, also normales vertikales Wischen.
|
||||||
|
void _onVideoDragUpdate(DragUpdateDetails d) =>
|
||||||
|
setState(() => _dragOffset += d.delta.dy);
|
||||||
|
|
||||||
|
void _onVideoDragEnd(DragEndDetails d) {
|
||||||
|
final fast = d.velocity.pixelsPerSecond.dy.abs() > _dismissVelocity;
|
||||||
|
if (_dragOffset.abs() > _dismissDistance || fast) {
|
||||||
|
_close();
|
||||||
|
} else {
|
||||||
|
setState(() => _dragOffset = 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _saveAsSticker() async {
|
Future<void> _saveAsSticker() async {
|
||||||
final event = widget.event;
|
final event = widget.event;
|
||||||
@@ -127,9 +253,16 @@ class _MediaViewerState extends State<MediaViewer> {
|
|||||||
final pt = PyramidTheme.of(context);
|
final pt = PyramidTheme.of(context);
|
||||||
final isVideo = widget.isVideo && widget.bytes != null;
|
final isVideo = widget.isVideo && widget.bytes != null;
|
||||||
final size = MediaQuery.sizeOf(context);
|
final size = MediaQuery.sizeOf(context);
|
||||||
|
final isMobile = size.width < 900 || size.height < 500;
|
||||||
|
|
||||||
|
// Beim Wischen blendet der Hintergrund aus und zeigt, dass gleich zu ist.
|
||||||
|
final dismissProgress = (_dragOffset.abs() / (_dismissDistance * 2.5)).clamp(0.0, 1.0);
|
||||||
|
final bgAlpha = (isMobile ? 1.0 : 0.82) * (1 - dismissProgress);
|
||||||
|
// Handy: randlos; Desktop: etwas Luft, damit das Fenster dahinter erkennbar bleibt.
|
||||||
|
final margin = isMobile ? 0.0 : 24.0;
|
||||||
|
|
||||||
final header = AnimatedOpacity(
|
final header = AnimatedOpacity(
|
||||||
opacity: _controlsVisible ? 1 : 0,
|
opacity: _controlsVisible && _dragOffset == 0 ? 1 : 0,
|
||||||
duration: const Duration(milliseconds: 180),
|
duration: const Duration(milliseconds: 180),
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
ignoring: !_controlsVisible,
|
ignoring: !_controlsVisible,
|
||||||
@@ -146,7 +279,7 @@ class _MediaViewerState extends State<MediaViewer> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: _close,
|
||||||
icon: const Icon(Icons.close_rounded, color: Colors.white),
|
icon: const Icon(Icons.close_rounded, color: Colors.white),
|
||||||
tooltip: 'Schließen',
|
tooltip: 'Schließen',
|
||||||
),
|
),
|
||||||
@@ -181,7 +314,7 @@ class _MediaViewerState extends State<MediaViewer> {
|
|||||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||||||
: const Icon(Icons.download_rounded, color: Colors.white),
|
: const Icon(Icons.download_rounded, color: Colors.white),
|
||||||
),
|
),
|
||||||
if (!isVideo)
|
if (!isVideo && _zoomed)
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: _resetZoom,
|
onPressed: _resetZoom,
|
||||||
tooltip: 'Zoom zurücksetzen',
|
tooltip: 'Zoom zurücksetzen',
|
||||||
@@ -194,61 +327,71 @@ class _MediaViewerState extends State<MediaViewer> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Content area — constrained so the transparent Material outside it lets
|
final Widget content;
|
||||||
// pointer events through to the barrier (which dismisses on tap).
|
|
||||||
final contentMaxW = (size.width - 48).clamp(200.0, 1200.0);
|
|
||||||
final contentMaxH = (size.height - 48).clamp(200.0, 900.0);
|
|
||||||
|
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
return Material(
|
content = GestureDetector(
|
||||||
type: MaterialType.transparency,
|
onVerticalDragUpdate: _onVideoDragUpdate,
|
||||||
|
onVerticalDragEnd: _onVideoDragEnd,
|
||||||
|
child: FullscreenVideoPlayer(bytes: widget.bytes!, filename: widget.filename),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Widget img;
|
||||||
|
if (widget.bytes != null) {
|
||||||
|
img = Image.memory(widget.bytes!, fit: BoxFit.contain, gaplessPlayback: true);
|
||||||
|
} else if (widget.networkUrl != null) {
|
||||||
|
img = Image.network(widget.networkUrl!, fit: BoxFit.contain);
|
||||||
|
} else {
|
||||||
|
img = Icon(Icons.broken_image_outlined, size: 64, color: pt.fgDim);
|
||||||
|
}
|
||||||
|
content = InteractiveViewer(
|
||||||
|
transformationController: _tc,
|
||||||
|
minScale: 1.0,
|
||||||
|
maxScale: 10.0,
|
||||||
|
// Ohne Zoom gibt es nichts zu verschieben – die Ein-Finger-Bewegung
|
||||||
|
// gehört dann dem Wischen zum Schließen.
|
||||||
|
panEnabled: _zoomed,
|
||||||
|
onInteractionStart: _onInteractionStart,
|
||||||
|
onInteractionUpdate: _onInteractionUpdate,
|
||||||
|
onInteractionEnd: _onInteractionEnd,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: SizedBox(
|
key: _contentKey,
|
||||||
width: contentMaxW,
|
// Nur das Bild selbst fängt Tipps ab; daneben greift das Schließen.
|
||||||
height: contentMaxH,
|
child: GestureDetector(
|
||||||
child: Stack(
|
onTap: _toggle,
|
||||||
fit: StackFit.expand,
|
onDoubleTapDown: (d) => _doubleTapGlobal = d.globalPosition,
|
||||||
children: [
|
onDoubleTap: _toggleZoom,
|
||||||
FullscreenVideoPlayer(bytes: widget.bytes!, filename: widget.filename),
|
child: img,
|
||||||
Positioned(top: 0, left: 0, right: 0, child: header),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget img;
|
|
||||||
if (widget.bytes != null) {
|
|
||||||
img = Image.memory(widget.bytes!, fit: BoxFit.contain, gaplessPlayback: true);
|
|
||||||
} else if (widget.networkUrl != null) {
|
|
||||||
img = Image.network(widget.networkUrl!, fit: BoxFit.contain);
|
|
||||||
} else {
|
|
||||||
img = Icon(Icons.broken_image_outlined, size: 64, color: pt.fgDim);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Material(
|
return Material(
|
||||||
type: MaterialType.transparency,
|
type: MaterialType.transparency,
|
||||||
child: Center(
|
child: Stack(
|
||||||
child: SizedBox(
|
fit: StackFit.expand,
|
||||||
width: contentMaxW,
|
children: [
|
||||||
height: contentMaxH,
|
// Hintergrund + „daneben tippen schließt“.
|
||||||
child: GestureDetector(
|
GestureDetector(
|
||||||
onTap: _toggle,
|
behavior: HitTestBehavior.opaque,
|
||||||
child: Stack(
|
onTap: _close,
|
||||||
fit: StackFit.expand,
|
child: ColoredBox(color: Colors.black.withValues(alpha: bgAlpha)),
|
||||||
children: [
|
),
|
||||||
InteractiveViewer(
|
Padding(
|
||||||
transformationController: _tc,
|
padding: EdgeInsets.all(margin),
|
||||||
minScale: 0.5,
|
child: Transform.translate(
|
||||||
maxScale: 10.0,
|
offset: Offset(0, _dragOffset),
|
||||||
child: Center(child: img),
|
child: GestureDetector(
|
||||||
),
|
// Tipps auf die leere Fläche um das Bild (innerhalb des
|
||||||
Positioned(top: 0, left: 0, right: 0, child: header),
|
// Inhaltsbereichs) schließen ebenfalls.
|
||||||
],
|
behavior: HitTestBehavior.translucent,
|
||||||
|
onTap: _close,
|
||||||
|
child: content,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
Positioned(top: 0, left: 0, right: 0, child: header),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
part of '../message_group.dart';
|
||||||
|
|
||||||
|
// Sprechblasen-Darstellung (Einstellung „Nachrichten: Sprechblasen“, auf dem
|
||||||
|
// Handy Standard – Bernds Entscheidung 2026-10-07). Eigene Nachrichten rechts
|
||||||
|
// in Akzentfarbe, andere links; in Gruppenräumen mit Name + Avatar, in
|
||||||
|
// Direktnachrichten ohne. Bilder/Videos ohne Blase, dafür in voller Größe.
|
||||||
|
// Inhalt (Text, Antwort-Zitat, Medien), Reaktionen, Auswahl, Hover-Aktionen
|
||||||
|
// und Wischen-zum-Antworten kommen aus denselben Bausteinen wie die Liste.
|
||||||
|
|
||||||
|
const _kBubbleMaxWidthFactor = 0.82;
|
||||||
|
const _kBubbleAvatarSize = 30.0;
|
||||||
|
const _kBubbleAvatarGap = 8.0;
|
||||||
|
|
||||||
|
extension _BubbleBuilder on _MessageGroupState {
|
||||||
|
Widget _buildBubbles(BuildContext context, PyramidTheme pt) {
|
||||||
|
final firstEvent = widget.events.first;
|
||||||
|
final isOwn = firstEvent.senderId == widget.currentUserId;
|
||||||
|
final isDm = widget.room?.isDirectChat ?? false;
|
||||||
|
// Name + Avatar nur bei fremden Nachrichten in Gruppenräumen.
|
||||||
|
final showSender = !isOwn && !isDm;
|
||||||
|
final sender = firstEvent.senderFromMemoryOrFallback;
|
||||||
|
final name = sender.calcDisplayname();
|
||||||
|
final initial = name.isNotEmpty ? name[0].toUpperCase() : '?';
|
||||||
|
final color = _colorFromId(firstEvent.senderId);
|
||||||
|
|
||||||
|
return LayoutBuilder(builder: (context, constraints) {
|
||||||
|
final rowWidth = constraints.maxWidth - 24; // 12 px Rand links/rechts
|
||||||
|
final senderSpace =
|
||||||
|
showSender ? _kBubbleAvatarSize + _kBubbleAvatarGap : 0.0;
|
||||||
|
final maxBubbleWidth =
|
||||||
|
((rowWidth - senderSpace) * _kBubbleMaxWidthFactor).clamp(120.0, 560.0);
|
||||||
|
|
||||||
|
final rows = <Widget>[];
|
||||||
|
for (var j = 0; j < widget.events.length; j++) {
|
||||||
|
final event = widget.events[j];
|
||||||
|
final isFirst = j == 0;
|
||||||
|
final isLast = j == widget.events.length - 1;
|
||||||
|
|
||||||
|
final bubbleColumn = Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
isOwn ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_Bubble(
|
||||||
|
event: event,
|
||||||
|
pt: pt,
|
||||||
|
isOwn: isOwn,
|
||||||
|
isFirst: isFirst,
|
||||||
|
isLast: isLast,
|
||||||
|
senderName: showSender && isFirst ? name : null,
|
||||||
|
senderColor: color,
|
||||||
|
maxWidth: maxBubbleWidth,
|
||||||
|
timeline: widget.timeline,
|
||||||
|
),
|
||||||
|
if (widget.timeline != null)
|
||||||
|
_ReactionsRow(
|
||||||
|
event: event,
|
||||||
|
timeline: widget.timeline!,
|
||||||
|
room: widget.room,
|
||||||
|
currentUserId: widget.currentUserId,
|
||||||
|
pt: pt,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final line = Row(
|
||||||
|
mainAxisAlignment:
|
||||||
|
isOwn ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (showSender) ...[
|
||||||
|
SizedBox(
|
||||||
|
width: _kBubbleAvatarSize,
|
||||||
|
child: isFirst
|
||||||
|
? _Avatar(
|
||||||
|
initial: initial,
|
||||||
|
color: color,
|
||||||
|
pt: pt,
|
||||||
|
size: _kBubbleAvatarSize,
|
||||||
|
avatarUri: sender.avatarUrl,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(width: _kBubbleAvatarGap),
|
||||||
|
],
|
||||||
|
Flexible(child: bubbleColumn),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
rows.add(_selectableRow(
|
||||||
|
event,
|
||||||
|
pt,
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
top: isFirst ? pt.msgGap / 2 : 3,
|
||||||
|
left: 12,
|
||||||
|
right: 12,
|
||||||
|
),
|
||||||
|
child: line,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
if (widget.readEventIds.contains(event.eventId)) {
|
||||||
|
rows.add(_ReadMarker(pt: pt));
|
||||||
|
}
|
||||||
|
if (widget.fullyReadId == event.eventId && !isLast) {
|
||||||
|
rows.add(_InlineUnreadDivider(key: widget.unreadDividerKey, pt: pt, fading: widget.unreadFading));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widget.showStatusIndicator &&
|
||||||
|
isOwn &&
|
||||||
|
widget.room != null &&
|
||||||
|
!widget.readEventIds.contains(widget.events.last.eventId)) {
|
||||||
|
rows.add(Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 16, bottom: 1),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: _MessageStatus(lastEvent: widget.events.last, pt: pt),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
rows.add(const SizedBox(height: 2));
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: rows,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine einzelne Sprechblase (bzw. bei Bildern/Videos: das Medium ohne Blase).
|
||||||
|
class _Bubble extends StatelessWidget {
|
||||||
|
final Event event;
|
||||||
|
final PyramidTheme pt;
|
||||||
|
final bool isOwn;
|
||||||
|
final bool isFirst;
|
||||||
|
final bool isLast;
|
||||||
|
final String? senderName;
|
||||||
|
final Color senderColor;
|
||||||
|
final double maxWidth;
|
||||||
|
final Timeline? timeline;
|
||||||
|
|
||||||
|
const _Bubble({
|
||||||
|
required this.event,
|
||||||
|
required this.pt,
|
||||||
|
required this.isOwn,
|
||||||
|
required this.isFirst,
|
||||||
|
required this.isLast,
|
||||||
|
required this.senderName,
|
||||||
|
required this.senderColor,
|
||||||
|
required this.maxWidth,
|
||||||
|
this.timeline,
|
||||||
|
});
|
||||||
|
|
||||||
|
Event get _displayEvent =>
|
||||||
|
timeline != null ? event.getDisplayEvent(timeline!) : event;
|
||||||
|
|
||||||
|
bool get _isVisualMedia {
|
||||||
|
final e = _displayEvent;
|
||||||
|
if (e.type == EventTypes.Sticker) return true;
|
||||||
|
return e.type == EventTypes.Message &&
|
||||||
|
(e.messageType == MessageTypes.Image ||
|
||||||
|
e.messageType == MessageTypes.Video);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eigene Blase: Akzentfarbe, mit der Fläche verrechnet (bleibt lesbar).
|
||||||
|
static Color ownColor(PyramidTheme pt) => Color.alphaBlend(
|
||||||
|
pt.accent.withValues(alpha: pt.isDark ? 0.30 : 0.24),
|
||||||
|
pt.bg1,
|
||||||
|
);
|
||||||
|
|
||||||
|
BorderRadius get _radius {
|
||||||
|
const big = Radius.circular(18);
|
||||||
|
const small = Radius.circular(6);
|
||||||
|
// Aufeinanderfolgende Blasen „kleben“ auf der Absenderseite zusammen.
|
||||||
|
final topSide = isFirst ? big : small;
|
||||||
|
final bottomSide = isLast ? big : small;
|
||||||
|
return isOwn
|
||||||
|
? BorderRadius.only(
|
||||||
|
topLeft: big, bottomLeft: big, topRight: topSide, bottomRight: bottomSide)
|
||||||
|
: BorderRadius.only(
|
||||||
|
topRight: big, bottomRight: big, topLeft: topSide, bottomLeft: bottomSide);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final time = Text(
|
||||||
|
_formatMessageTime(event.originServerTs),
|
||||||
|
style: TextStyle(color: pt.fgDim, fontSize: 11),
|
||||||
|
);
|
||||||
|
final failed = event.status.isError
|
||||||
|
? _SendFailedRow(event: event, pt: pt)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (_isVisualMedia) {
|
||||||
|
// Ohne Blase: Bilder/Videos sollen so groß wie möglich sein.
|
||||||
|
final box = _reservedImageSize(
|
||||||
|
_displayEvent,
|
||||||
|
maxW: maxWidth.clamp(80.0, 420.0),
|
||||||
|
maxH: inlineMediaMaxHeight(context),
|
||||||
|
);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
isOwn ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (senderName != null) _senderLabel(),
|
||||||
|
SizedBox(
|
||||||
|
width: box?.width ?? maxWidth,
|
||||||
|
child: _MessageContent(event: event, pt: pt, timeline: timeline),
|
||||||
|
),
|
||||||
|
?failed,
|
||||||
|
Padding(padding: const EdgeInsets.only(top: 2), child: time),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 7, 12, 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isOwn ? ownColor(pt) : pt.bg3,
|
||||||
|
borderRadius: _radius,
|
||||||
|
),
|
||||||
|
// Außen „end“: Inhalt (breitestes Kind) füllt die Blase, die Uhrzeit
|
||||||
|
// sitzt unten rechts – ohne die Blase auf volle Breite zu ziehen.
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (senderName != null) _senderLabel(),
|
||||||
|
_MessageContent(event: event, pt: pt, timeline: timeline),
|
||||||
|
?failed,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
time,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _senderLabel() => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 2),
|
||||||
|
child: Text(
|
||||||
|
senderName!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: senderColor,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
part of '../message_group.dart';
|
part of '../message_group.dart';
|
||||||
|
|
||||||
|
/// Bereits federnd eingeblendete Nachrichten (begrenzt, älteste fallen raus).
|
||||||
|
final _poppedMessageIds = <String>{};
|
||||||
|
|
||||||
|
void _rememberPopped(String id) {
|
||||||
|
_poppedMessageIds.add(id);
|
||||||
|
if (_poppedMessageIds.length > 300) {
|
||||||
|
_poppedMessageIds.remove(_poppedMessageIds.first);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
String _formatMessageTime(DateTime t) {
|
String _formatMessageTime(DateTime t) {
|
||||||
final h = t.hour.toString().padLeft(2, '0');
|
final h = t.hour.toString().padLeft(2, '0');
|
||||||
final m = t.minute.toString().padLeft(2, '0');
|
final m = t.minute.toString().padLeft(2, '0');
|
||||||
@@ -22,12 +32,19 @@ class MessageGroup extends StatefulWidget {
|
|||||||
/// When non-null and pointing to a non-last event in this group, an inline
|
/// When non-null and pointing to a non-last event in this group, an inline
|
||||||
/// "Neue Nachrichten" divider is rendered after that event's row.
|
/// "Neue Nachrichten" divider is rendered after that event's row.
|
||||||
final String? fullyReadId;
|
final String? fullyReadId;
|
||||||
|
/// Ausblenden des Inline-Trenners + Schlüssel für den Sprung zu ihm.
|
||||||
|
final bool unreadFading;
|
||||||
|
final GlobalKey? unreadDividerKey;
|
||||||
/// Jump-to-message (from search): the row matching [jumpEventId] gets
|
/// Jump-to-message (from search): the row matching [jumpEventId] gets
|
||||||
/// [jumpKey] attached (for ensureVisible) and [highlightEventId] is tinted.
|
/// [jumpKey] attached (for ensureVisible) and [highlightEventId] is tinted.
|
||||||
final String? jumpEventId;
|
final String? jumpEventId;
|
||||||
final GlobalKey? jumpKey;
|
final GlobalKey? jumpKey;
|
||||||
final String? highlightEventId;
|
final String? highlightEventId;
|
||||||
final bool highlightOn;
|
final bool highlightOn;
|
||||||
|
/// Nachrichten, die nach diesem Zeitpunkt eintreffen (oder gerade
|
||||||
|
/// gesendet werden), ploppen beim ersten Erscheinen federnd auf. null = aus
|
||||||
|
/// (z. B. solange der Chat gerade erst geöffnet wird).
|
||||||
|
final DateTime? popInAfter;
|
||||||
|
|
||||||
const MessageGroup({
|
const MessageGroup({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -41,10 +58,13 @@ class MessageGroup extends StatefulWidget {
|
|||||||
this.showStatusIndicator = false,
|
this.showStatusIndicator = false,
|
||||||
this.readEventIds = const {},
|
this.readEventIds = const {},
|
||||||
this.fullyReadId,
|
this.fullyReadId,
|
||||||
|
this.unreadFading = false,
|
||||||
|
this.unreadDividerKey,
|
||||||
this.jumpEventId,
|
this.jumpEventId,
|
||||||
this.jumpKey,
|
this.jumpKey,
|
||||||
this.highlightEventId,
|
this.highlightEventId,
|
||||||
this.highlightOn = false,
|
this.highlightOn = false,
|
||||||
|
this.popInAfter,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -64,7 +84,23 @@ class _MessageGroupState extends State<MessageGroup> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final pt = PyramidTheme.of(context);
|
// Darstellung laut Einstellung (Erscheinungsbild → Nachrichten):
|
||||||
|
// Sprechblasen (message_bubbles.dart) oder Liste wie bisher.
|
||||||
|
return Consumer(builder: (context, ref, _) {
|
||||||
|
final style = ref.watch(messageStyleProvider);
|
||||||
|
final pt = PyramidTheme.of(context);
|
||||||
|
final bubbles =
|
||||||
|
style.useBubbles(mobile: isMobileLayout(MediaQuery.sizeOf(context)));
|
||||||
|
return _withSwipeToReply(
|
||||||
|
pt,
|
||||||
|
bubbles ? _buildBubbles(context, pt) : _buildList(context, pt),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen-Darstellung (Discord-Stil): Avatar + Name + Zeit, darunter die
|
||||||
|
// Nachrichten des Absenders bündig untereinander.
|
||||||
|
Widget _buildList(BuildContext context, PyramidTheme pt) {
|
||||||
final firstEvent = widget.events.first;
|
final firstEvent = widget.events.first;
|
||||||
final sender = firstEvent.senderFromMemoryOrFallback;
|
final sender = firstEvent.senderFromMemoryOrFallback;
|
||||||
final name = sender.calcDisplayname();
|
final name = sender.calcDisplayname();
|
||||||
@@ -124,7 +160,7 @@ class _MessageGroupState extends State<MessageGroup> {
|
|||||||
// Inline unread divider after the first event when it's the fullyRead
|
// Inline unread divider after the first event when it's the fullyRead
|
||||||
// marker and there are more events in this group.
|
// marker and there are more events in this group.
|
||||||
if (widget.fullyReadId == firstEvent.eventId && widget.events.length > 1) {
|
if (widget.fullyReadId == firstEvent.eventId && widget.events.length > 1) {
|
||||||
rows.add(_InlineUnreadDivider(pt: pt));
|
rows.add(_InlineUnreadDivider(key: widget.unreadDividerKey, pt: pt, fading: widget.unreadFading));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Continuation event rows (siblings, same indentation) ──
|
// ── Continuation event rows (siblings, same indentation) ──
|
||||||
@@ -143,7 +179,7 @@ class _MessageGroupState extends State<MessageGroup> {
|
|||||||
if (widget.readEventIds.contains(event.eventId)) rows.add(_ReadMarker(pt: pt));
|
if (widget.readEventIds.contains(event.eventId)) rows.add(_ReadMarker(pt: pt));
|
||||||
// Inline divider after this event if it's the fullyRead and not the last.
|
// Inline divider after this event if it's the fullyRead and not the last.
|
||||||
if (widget.fullyReadId == event.eventId && j < widget.events.length - 1) {
|
if (widget.fullyReadId == event.eventId && j < widget.events.length - 1) {
|
||||||
rows.add(_InlineUnreadDivider(pt: pt));
|
rows.add(_InlineUnreadDivider(key: widget.unreadDividerKey, pt: pt, fading: widget.unreadFading));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,11 +204,14 @@ class _MessageGroupState extends State<MessageGroup> {
|
|||||||
|
|
||||||
// Hover highlight + actions are now per-row (see _selectableRow), so the
|
// Hover highlight + actions are now per-row (see _selectableRow), so the
|
||||||
// group is just the column of rows.
|
// group is just the column of rows.
|
||||||
Widget content = Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: rows,
|
children: rows,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handy: nach links wischen = antworten (beide Darstellungen).
|
||||||
|
Widget _withSwipeToReply(PyramidTheme pt, Widget content) {
|
||||||
if (!_isMobile) return content;
|
if (!_isMobile) return content;
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
@@ -282,15 +321,39 @@ class _MessageGroupState extends State<MessageGroup> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (_isMobile) return row;
|
if (_isMobile) return _popIn(event, row);
|
||||||
|
|
||||||
// Desktop: per-row hover highlight + actions.
|
// Desktop: per-row hover highlight + actions.
|
||||||
return MouseRegion(
|
return _popIn(event, MouseRegion(
|
||||||
onEnter: (_) => setState(() => _hoveredEventId = event.eventId),
|
onEnter: (_) => setState(() => _hoveredEventId = event.eventId),
|
||||||
onExit: (_) => setState(() {
|
onExit: (_) => setState(() {
|
||||||
if (_hoveredEventId == event.eventId) _hoveredEventId = null;
|
if (_hoveredEventId == event.eventId) _hoveredEventId = null;
|
||||||
}),
|
}),
|
||||||
child: row,
|
child: row,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neu angekommene oder gerade gesendete Nachricht: federnd einblenden.
|
||||||
|
// Der Schlüssel (Transaktions-ID bleibt beim Senden gleich) hält den
|
||||||
|
// Zustand der Zeile, die Merkliste verhindert ein zweites Abspielen, falls
|
||||||
|
// die Gruppe neu aufgebaut wird (z. B. lokale Kopie → Server-Event).
|
||||||
|
Widget _popIn(Event event, Widget row) {
|
||||||
|
final id = event.transactionId ?? event.eventId;
|
||||||
|
final after = widget.popInAfter;
|
||||||
|
final fresh = after != null &&
|
||||||
|
!_poppedMessageIds.contains(id) &&
|
||||||
|
(event.status.isSending ||
|
||||||
|
(event.originServerTs.isAfter(after) &&
|
||||||
|
DateTime.now().difference(event.originServerTs) <
|
||||||
|
const Duration(minutes: 2)));
|
||||||
|
if (fresh) _rememberPopped(id);
|
||||||
|
return PopIn(
|
||||||
|
key: ValueKey('row:$id'),
|
||||||
|
play: fresh,
|
||||||
|
alignment: event.senderId == widget.currentUserId
|
||||||
|
? Alignment.bottomRight
|
||||||
|
: Alignment.bottomLeft,
|
||||||
|
child: row,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -159,7 +159,18 @@ class _ReplyPreview extends StatelessWidget {
|
|||||||
// matching WhatsApp/Discord. Falls back to stripped text for m.text.
|
// matching WhatsApp/Discord. Falls back to stripped text for m.text.
|
||||||
final (IconData? icon, String body) = _previewFor(replyEvent);
|
final (IconData? icon, String body) = _previewFor(replyEvent);
|
||||||
|
|
||||||
return Padding(
|
// Antippen springt zur zitierten Nachricht und lässt sie kurz aufleuchten
|
||||||
|
// (gleicher Mechanismus wie der Sprung aus der Suche).
|
||||||
|
return GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: () => ProviderScope.containerOf(context, listen: false)
|
||||||
|
.read(pendingJumpEventProvider.notifier)
|
||||||
|
.state = replyEvent.eventId,
|
||||||
|
child: MouseRegion(
|
||||||
|
cursor: SystemMouseCursors.click,
|
||||||
|
child: SpringPress(
|
||||||
|
pressedScale: 0.97,
|
||||||
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 4),
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
child: IntrinsicHeight(
|
child: IntrinsicHeight(
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
@@ -204,6 +215,9 @@ class _ReplyPreview extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ class _ImageContentState extends ConsumerState<_ImageContent> {
|
|||||||
final maxW = constraints.maxWidth.isFinite
|
final maxW = constraints.maxWidth.isFinite
|
||||||
? constraints.maxWidth.clamp(80.0, 420.0)
|
? constraints.maxWidth.clamp(80.0, 420.0)
|
||||||
: 420.0;
|
: 420.0;
|
||||||
const maxH = 320.0;
|
final maxH = inlineMediaMaxHeight(context);
|
||||||
// Reserve the final display box up-front from the event's info dimensions,
|
// Reserve the final display box up-front from the event's info dimensions,
|
||||||
// so the layout doesn't shift (and yank the scroll position) when the image
|
// so the layout doesn't shift (and yank the scroll position) when the image
|
||||||
// finishes loading. Null when dimensions are absent.
|
// finishes loading. Null when dimensions are absent.
|
||||||
@@ -378,7 +378,7 @@ class _MediaContentState extends ConsumerState<_MediaContent> {
|
|||||||
final pt = widget.pt;
|
final pt = widget.pt;
|
||||||
final isSticker = widget.event.type == EventTypes.Sticker;
|
final isSticker = widget.event.type == EventTypes.Sticker;
|
||||||
final maxW = isSticker ? 160.0 : 420.0;
|
final maxW = isSticker ? 160.0 : 420.0;
|
||||||
final maxH = isSticker ? 160.0 : 320.0;
|
final maxH = isSticker ? 160.0 : inlineMediaMaxHeight(context);
|
||||||
|
|
||||||
return FutureBuilder<_MediaResult>(
|
return FutureBuilder<_MediaResult>(
|
||||||
future: _future,
|
future: _future,
|
||||||
|
|||||||
@@ -22,11 +22,15 @@ class _ReactionsRow extends StatelessWidget {
|
|||||||
|
|
||||||
// Group by emoji key → list of senders
|
// Group by emoji key → list of senders
|
||||||
final Map<String, List<String>> grouped = {};
|
final Map<String, List<String>> grouped = {};
|
||||||
|
final newest = <String, DateTime>{};
|
||||||
for (final r in reactionEvents) {
|
for (final r in reactionEvents) {
|
||||||
final key = r.content.tryGetMap<String, dynamic>('m.relates_to')?['key'] as String?;
|
final key = r.content.tryGetMap<String, dynamic>('m.relates_to')?['key'] as String?;
|
||||||
if (key == null || key.isEmpty) continue;
|
if (key == null || key.isEmpty) continue;
|
||||||
(grouped[key] ??= []).add(r.senderId);
|
(grouped[key] ??= []).add(r.senderId);
|
||||||
|
final ts = r.originServerTs;
|
||||||
|
if (newest[key] == null || ts.isAfter(newest[key]!)) newest[key] = ts;
|
||||||
}
|
}
|
||||||
|
final now = DateTime.now();
|
||||||
if (grouped.isEmpty) return const SizedBox.shrink();
|
if (grouped.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
@@ -37,6 +41,9 @@ class _ReactionsRow extends StatelessWidget {
|
|||||||
children: grouped.entries.map((e) {
|
children: grouped.entries.map((e) {
|
||||||
final myReaction = e.value.contains(currentUserId);
|
final myReaction = e.value.contains(currentUserId);
|
||||||
return _ReactionChip(
|
return _ReactionChip(
|
||||||
|
key: ValueKey(e.key),
|
||||||
|
// Gerade erst vergeben → federnd aufploppen.
|
||||||
|
fresh: now.difference(newest[e.key]!) < const Duration(seconds: 4),
|
||||||
emoji: e.key,
|
emoji: e.key,
|
||||||
count: e.value.length,
|
count: e.value.length,
|
||||||
mine: myReaction,
|
mine: myReaction,
|
||||||
@@ -69,8 +76,11 @@ class _ReactionChip extends StatefulWidget {
|
|||||||
final bool mine;
|
final bool mine;
|
||||||
final PyramidTheme pt;
|
final PyramidTheme pt;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
final bool fresh;
|
||||||
|
|
||||||
const _ReactionChip({
|
const _ReactionChip({
|
||||||
|
super.key,
|
||||||
|
this.fresh = false,
|
||||||
required this.emoji,
|
required this.emoji,
|
||||||
required this.count,
|
required this.count,
|
||||||
required this.mine,
|
required this.mine,
|
||||||
@@ -88,12 +98,22 @@ class _ReactionChipState extends State<_ReactionChip> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final pt = widget.pt;
|
final pt = widget.pt;
|
||||||
return MouseRegion(
|
return PopIn(
|
||||||
|
play: widget.fresh,
|
||||||
|
fromScale: 0.3,
|
||||||
|
offsetY: 0,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
milliseconds: 520,
|
||||||
|
child: SpringBump(
|
||||||
|
value: widget.count,
|
||||||
|
child: MouseRegion(
|
||||||
cursor: SystemMouseCursors.click,
|
cursor: SystemMouseCursors.click,
|
||||||
onEnter: (_) => setState(() => _hovered = true),
|
onEnter: (_) => setState(() => _hovered = true),
|
||||||
onExit: (_) => setState(() => _hovered = false),
|
onExit: (_) => setState(() => _hovered = false),
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: widget.onTap,
|
onTap: widget.onTap,
|
||||||
|
child: SpringPress(
|
||||||
|
pressedScale: 0.85,
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 120),
|
duration: const Duration(milliseconds: 120),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
||||||
@@ -122,6 +142,9 @@ class _ReactionChipState extends State<_ReactionChip> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,11 +93,19 @@ class _InitialBox extends StatelessWidget {
|
|||||||
// fullyRead event is in the middle of the group (same sender, <5 min window).
|
// fullyRead event is in the middle of the group (same sender, <5 min window).
|
||||||
class _InlineUnreadDivider extends StatelessWidget {
|
class _InlineUnreadDivider extends StatelessWidget {
|
||||||
final PyramidTheme pt;
|
final PyramidTheme pt;
|
||||||
const _InlineUnreadDivider({required this.pt});
|
final bool fading;
|
||||||
|
const _InlineUnreadDivider({super.key, required this.pt, this.fading = false});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
// Blendet aus wie der Gruppen-Trenner (vorher blieb dieser „Neue
|
||||||
|
// Nachrichten“-Trenner mitten in einer Gruppe bis zum erneuten Öffnen
|
||||||
|
// stehen – Bernd 2026-10-08).
|
||||||
|
return AnimatedOpacity(
|
||||||
|
opacity: fading ? 0.0 : 1.0,
|
||||||
|
duration: const Duration(milliseconds: 700),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(0, 8, 0, 4),
|
padding: const EdgeInsets.fromLTRB(0, 8, 0, 4),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -112,6 +120,7 @@ class _InlineUnreadDivider extends StatelessWidget {
|
|||||||
Expanded(child: Divider(color: pt.accent.withAlpha(160), height: 1)),
|
Expanded(child: Divider(color: pt.accent.withAlpha(160), height: 1)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ class _MessageContent extends StatelessWidget {
|
|||||||
final displayEvent = _displayEvent;
|
final displayEvent = _displayEvent;
|
||||||
final isEdited = displayEvent.eventId != event.eventId;
|
final isEdited = displayEvent.eventId != event.eventId;
|
||||||
|
|
||||||
|
if (GroupCallNotice.matches(displayEvent)) {
|
||||||
|
return GroupCallNotice(event: displayEvent);
|
||||||
|
}
|
||||||
|
|
||||||
// Still encrypted (decryption pending or failed)
|
// Still encrypted (decryption pending or failed)
|
||||||
if (displayEvent.type == EventTypes.Encrypted) {
|
if (displayEvent.type == EventTypes.Encrypted) {
|
||||||
return Padding(
|
return Padding(
|
||||||
@@ -213,7 +217,7 @@ class _MatrixHtmlText extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final spans = _parseHtml(html, baseStyle, linkColor);
|
final spans = _parseHtml(html, baseStyle, linkColor);
|
||||||
return SelectionArea(
|
return SelectionArea(
|
||||||
child: RichText(text: TextSpan(children: spans, style: baseStyle)),
|
child: Text.rich(TextSpan(children: spans, style: baseStyle)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,7 +392,7 @@ class _PlainLinkText extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return SelectionArea(
|
return SelectionArea(
|
||||||
child: RichText(text: TextSpan(children: spans, style: baseStyle)),
|
child: Text.rich(TextSpan(children: spans, style: baseStyle)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import 'package:pyramid/features/chat/media_player.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:matrix/matrix.dart';
|
import 'package:matrix/matrix.dart';
|
||||||
|
import 'package:pyramid/core/app_state.dart'
|
||||||
|
show messageStyleProvider, pendingJumpEventProvider;
|
||||||
|
import 'package:pyramid/core/appearance.dart';
|
||||||
import 'package:pyramid/core/matrix_client.dart';
|
import 'package:pyramid/core/matrix_client.dart';
|
||||||
import 'package:pyramid/core/media_cache.dart';
|
import 'package:pyramid/core/media_cache.dart';
|
||||||
import 'package:pyramid/core/theme.dart';
|
import 'package:pyramid/core/theme.dart';
|
||||||
@@ -17,11 +20,14 @@ import 'package:flutter_svg/flutter_svg.dart';
|
|||||||
import 'package:pyramid/features/chat/document_viewer.dart';
|
import 'package:pyramid/features/chat/document_viewer.dart';
|
||||||
import 'package:pyramid/features/chat/media_viewer.dart';
|
import 'package:pyramid/features/chat/media_viewer.dart';
|
||||||
import 'package:pyramid/features/chat/reaction_service.dart';
|
import 'package:pyramid/features/chat/reaction_service.dart';
|
||||||
|
import 'package:pyramid/features/group_call/group_call.dart';
|
||||||
import 'package:pyramid/utils/gif_favorite_service.dart';
|
import 'package:pyramid/utils/gif_favorite_service.dart';
|
||||||
import 'package:pyramid/widgets/mxc_image.dart';
|
import 'package:pyramid/widgets/mxc_image.dart';
|
||||||
|
import 'package:pyramid/widgets/spring_press.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
part 'message/message_group_core.dart';
|
part 'message/message_group_core.dart';
|
||||||
|
part 'message/message_bubbles.dart';
|
||||||
part 'message/message_text.dart';
|
part 'message/message_text.dart';
|
||||||
part 'message/message_link_preview.dart';
|
part 'message/message_link_preview.dart';
|
||||||
part 'message/message_media.dart';
|
part 'message/message_media.dart';
|
||||||
|
|||||||
@@ -26,10 +26,19 @@ class _ChatHeader extends ConsumerWidget {
|
|||||||
final name = room?.getLocalizedDisplayname() ?? roomId;
|
final name = room?.getLocalizedDisplayname() ?? roomId;
|
||||||
final isDm = room?.isDirectChat ?? false;
|
final isDm = room?.isDirectChat ?? false;
|
||||||
final isEncrypted = room?.encrypted ?? false;
|
final isEncrypted = room?.encrypted ?? false;
|
||||||
final topic = room?.topic ?? (isDm ? '' : 'Welcome to #$name');
|
final topic = room?.topic ?? '';
|
||||||
final client = ref.watch(matrixClientProvider).valueOrNull;
|
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||||
final size = MediaQuery.sizeOf(context);
|
final size = MediaQuery.sizeOf(context);
|
||||||
final isMobile = size.width < 900 || size.height < 500;
|
final isMobile = size.width < 900 || size.height < 500;
|
||||||
|
// Gruppenchat (kein Server-Kanal, kein Sprachkanal): Kopf mit Avatar +
|
||||||
|
// Mitgliederzahl und Gruppenanruf (UI-Audit S3, Bernd 2026-10-09).
|
||||||
|
final isGroup = room != null &&
|
||||||
|
isGroupChatRoom(room!) &&
|
||||||
|
!ref.watch(spaceChannelIdsProvider).contains(room!.id);
|
||||||
|
final groupCallRunning = isGroup &&
|
||||||
|
otherCallParticipants(
|
||||||
|
ref.watch(voiceParticipantsProvider(room!.id)), room!)
|
||||||
|
.isNotEmpty;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
height: 52,
|
height: 52,
|
||||||
@@ -42,15 +51,19 @@ class _ChatHeader extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
// Back button on mobile
|
// Back button on mobile
|
||||||
if (isMobile) ...[
|
if (isMobile) ...[
|
||||||
GestureDetector(
|
PyrIconBtn(
|
||||||
onTap: () => ref.read(activeRoomIdProvider.notifier).state = null,
|
icon: Icon(Icons.arrow_back_rounded, color: pt.fg),
|
||||||
child: Icon(Icons.arrow_back_rounded, size: 22, color: pt.fg),
|
tooltip: 'Zurück',
|
||||||
|
onPressed: () =>
|
||||||
|
ref.read(activeRoomIdProvider.notifier).state = null,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 4),
|
||||||
],
|
],
|
||||||
// Room icon / avatar
|
// Room icon / avatar
|
||||||
if (isDm && client != null) ...[
|
if (isDm && client != null) ...[
|
||||||
_DmHeaderAvatar(room: room, name: name, client: client),
|
_DmHeaderAvatar(room: room, name: name, client: client),
|
||||||
|
] else if (isGroup && client != null) ...[
|
||||||
|
_GroupHeaderAvatar(room: room!, client: client, pt: pt),
|
||||||
] else if (!isDm) ...[
|
] else if (!isDm) ...[
|
||||||
Icon(
|
Icon(
|
||||||
isEncrypted ? Icons.lock_outline_rounded : Icons.tag_rounded,
|
isEncrypted ? Icons.lock_outline_rounded : Icons.tag_rounded,
|
||||||
@@ -83,6 +96,28 @@ class _ChatHeader extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
else if (isGroup)
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
_groupSubtitle(room!),
|
||||||
|
style: TextStyle(color: pt.fgDim, fontSize: 12),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
else
|
else
|
||||||
Text(
|
Text(
|
||||||
name,
|
name,
|
||||||
@@ -90,7 +125,9 @@ class _ChatHeader extends ConsumerWidget {
|
|||||||
color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600),
|
color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
if (!isDm && topic.isNotEmpty) ...[
|
if (isGroup)
|
||||||
|
const SizedBox.shrink()
|
||||||
|
else if (!isDm && topic.isNotEmpty) ...[
|
||||||
Container(width: 1, height: 18, color: pt.border),
|
Container(width: 1, height: 18, color: pt.border),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -105,18 +142,25 @@ class _ChatHeader extends ConsumerWidget {
|
|||||||
// Right actions
|
// Right actions
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
if (isDm) ...[
|
if (isDm || isGroup) ...[
|
||||||
PyrIconBtn(
|
PyrIconBtn(
|
||||||
size: 28,
|
size: 28,
|
||||||
icon: const Icon(Icons.phone_rounded),
|
icon: Icon(Icons.phone_rounded,
|
||||||
tooltip: 'Voice call',
|
color: groupCallRunning ? pt.online : null),
|
||||||
onPressed: () => _startCall(ref, room, cam: false),
|
tooltip: groupCallRunning
|
||||||
|
? 'Gruppenanruf beitreten'
|
||||||
|
: (isGroup ? 'Gruppenanruf' : 'Anrufen'),
|
||||||
|
onPressed: () => isGroup
|
||||||
|
? startOrJoinGroupCall(context, ref, room!)
|
||||||
|
: _startCall(ref, room, cam: false),
|
||||||
),
|
),
|
||||||
PyrIconBtn(
|
PyrIconBtn(
|
||||||
size: 28,
|
size: 28,
|
||||||
icon: const Icon(Icons.videocam_rounded),
|
icon: const Icon(Icons.videocam_rounded),
|
||||||
tooltip: 'Video call',
|
tooltip: 'Videoanruf',
|
||||||
onPressed: () => _startCall(ref, room, cam: true),
|
onPressed: () => isGroup
|
||||||
|
? startOrJoinGroupCall(context, ref, room!, video: true)
|
||||||
|
: _startCall(ref, room, cam: true),
|
||||||
),
|
),
|
||||||
if (size.width > 700)
|
if (size.width > 700)
|
||||||
Container(
|
Container(
|
||||||
@@ -127,12 +171,6 @@ class _ChatHeader extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (size.width > 800) ...[
|
if (size.width > 800) ...[
|
||||||
PyrIconBtn(
|
|
||||||
size: 28,
|
|
||||||
icon: const Icon(Icons.inbox_rounded),
|
|
||||||
tooltip: 'Threads',
|
|
||||||
onPressed: () {},
|
|
||||||
),
|
|
||||||
PyrIconBtn(
|
PyrIconBtn(
|
||||||
size: 28,
|
size: 28,
|
||||||
icon: const Icon(Icons.push_pin_rounded),
|
icon: const Icon(Icons.push_pin_rounded),
|
||||||
@@ -141,19 +179,27 @@ class _ChatHeader extends ConsumerWidget {
|
|||||||
onPressed: onTogglePinned,
|
onPressed: onTogglePinned,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
PyrIconBtn(
|
if (isMobile)
|
||||||
size: 28,
|
_ChatOverflowMenu(
|
||||||
icon: const Icon(Icons.search_rounded),
|
pt: pt,
|
||||||
tooltip: 'Search',
|
onSearch: onOpenSearch,
|
||||||
onPressed: onOpenSearch,
|
onMembers: onToggleMembers,
|
||||||
),
|
)
|
||||||
PyrIconBtn(
|
else ...[
|
||||||
size: 28,
|
PyrIconBtn(
|
||||||
icon: const Icon(Icons.group_rounded),
|
size: 28,
|
||||||
tooltip: 'Members',
|
icon: const Icon(Icons.search_rounded),
|
||||||
active: membersOpen,
|
tooltip: 'Suchen',
|
||||||
onPressed: onToggleMembers,
|
onPressed: onOpenSearch,
|
||||||
),
|
),
|
||||||
|
PyrIconBtn(
|
||||||
|
size: 28,
|
||||||
|
icon: const Icon(Icons.group_rounded),
|
||||||
|
tooltip: 'Mitglieder',
|
||||||
|
active: membersOpen,
|
||||||
|
onPressed: onToggleMembers,
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -167,17 +213,19 @@ class _ChatHeader extends ConsumerWidget {
|
|||||||
if (room.isDirectChat) {
|
if (room.isDirectChat) {
|
||||||
// Use native Matrix VoIP for 1:1 DMs
|
// Use native Matrix VoIP for 1:1 DMs
|
||||||
ref.read(viewModeProvider.notifier).state = ViewMode.voice;
|
ref.read(viewModeProvider.notifier).state = ViewMode.voice;
|
||||||
await PyramidVoipManager.instance.startCall(room.id, video: cam);
|
await ref.read(callSignalingProvider).startCall(room.id, video: cam);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use LiveKit for group voice rooms
|
// Use LiveKit for group voice rooms
|
||||||
ref.read(viewModeProvider.notifier).state = ViewMode.voice;
|
ref.read(viewModeProvider.notifier).state = ViewMode.voice;
|
||||||
await ref.read(callStateProvider).startCall(
|
await ref.read(voiceChannelProvider).startCall(
|
||||||
roomName: roomId,
|
roomName: roomId,
|
||||||
roomDisplayName: room.getLocalizedDisplayname(),
|
roomDisplayName: room.getLocalizedDisplayname(),
|
||||||
identity: 'user_${DateTime.now().millisecondsSinceEpoch}',
|
identity: room.client.userID ?? '',
|
||||||
audioOnly: !cam,
|
audioOnly: !cam,
|
||||||
|
// Für das Beitritts-Token (Server prüft den Matrix-Login).
|
||||||
|
matrixClient: room.client,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -222,6 +270,20 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
|
|||||||
|
|
||||||
Profile? _profile;
|
Profile? _profile;
|
||||||
String? _bannerMxcUri;
|
String? _bannerMxcUri;
|
||||||
|
|
||||||
|
// Breite der Symbolleiste (gemessen nach dem Zeichnen) – eingeklappt reicht
|
||||||
|
// der Name genau bis davor.
|
||||||
|
final _actionsKey = GlobalKey();
|
||||||
|
double _actionsWidth = 140;
|
||||||
|
|
||||||
|
void _measureActions() {
|
||||||
|
final box = _actionsKey.currentContext?.findRenderObject() as RenderBox?;
|
||||||
|
if (box == null || !box.hasSize) return;
|
||||||
|
final w = box.size.width;
|
||||||
|
if ((w - _actionsWidth).abs() > 0.5 && mounted) {
|
||||||
|
setState(() => _actionsWidth = w);
|
||||||
|
}
|
||||||
|
}
|
||||||
String? _statusMsg;
|
String? _statusMsg;
|
||||||
bool _online = false;
|
bool _online = false;
|
||||||
|
|
||||||
@@ -291,6 +353,7 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _measureActions());
|
||||||
final pt = widget.pt;
|
final pt = widget.pt;
|
||||||
final expanded = widget.expanded;
|
final expanded = widget.expanded;
|
||||||
final size = MediaQuery.sizeOf(context);
|
final size = MediaQuery.sizeOf(context);
|
||||||
@@ -361,198 +424,222 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
// Profilbereich: gleitet beim Aufklappen unter die oberste Zeile und
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
// nutzt dann die volle Breite (vorher teilte er sie mit den Symbolen –
|
||||||
child: Row(
|
// aufgeklappt wurde der Name zu „p…“ gekürzt).
|
||||||
children: [
|
AnimatedPositioned(
|
||||||
if (isMobile) ...[
|
duration: _animDuration,
|
||||||
GestureDetector(
|
curve: _animCurve,
|
||||||
onTap: () =>
|
left: expanded ? 16 : (isMobile ? 54 : 16),
|
||||||
ref.read(activeRoomIdProvider.notifier).state = null,
|
right: expanded ? 16 : _actionsWidth + (isMobile ? 14 : 24),
|
||||||
child:
|
top: expanded ? 44 : 0,
|
||||||
Icon(Icons.arrow_back_rounded, size: 22, color: pt.fg),
|
bottom: 0,
|
||||||
),
|
child: MouseRegion(
|
||||||
const SizedBox(width: 12),
|
cursor: SystemMouseCursors.click,
|
||||||
],
|
child: GestureDetector(
|
||||||
// Klickbarer Profilbereich: Avatar + Name + Details
|
behavior: HitTestBehavior.opaque,
|
||||||
Expanded(
|
onTap: widget.onToggle,
|
||||||
child: MouseRegion(
|
child: Row(
|
||||||
cursor: SystemMouseCursors.click,
|
children: [
|
||||||
child: GestureDetector(
|
// Avatar — skaliert mit (rund, wie im Mockup)
|
||||||
behavior: HitTestBehavior.opaque,
|
AnimatedContainer(
|
||||||
onTap: widget.onToggle,
|
duration: _animDuration,
|
||||||
child: Row(
|
curve: _animCurve,
|
||||||
children: [
|
width: avatarSize,
|
||||||
// Avatar — skaliert mit (rund, wie im Mockup)
|
height: avatarSize,
|
||||||
AnimatedContainer(
|
decoration: BoxDecoration(
|
||||||
duration: _animDuration,
|
shape: BoxShape.circle,
|
||||||
curve: _animCurve,
|
color: pt.bg3,
|
||||||
width: avatarSize,
|
),
|
||||||
height: avatarSize,
|
clipBehavior: Clip.antiAlias,
|
||||||
decoration: BoxDecoration(
|
child: avatarUrl != null && client != null
|
||||||
shape: BoxShape.circle,
|
? MxcImage(
|
||||||
color: pt.bg3,
|
mxcUri: avatarUrl.toString(),
|
||||||
),
|
client: client,
|
||||||
clipBehavior: Clip.antiAlias,
|
fit: BoxFit.cover,
|
||||||
child: avatarUrl != null && client != null
|
placeholder:
|
||||||
? MxcImage(
|
_initialBox(initial, pt, avatarSize),
|
||||||
mxcUri: avatarUrl.toString(),
|
)
|
||||||
client: client,
|
: _initialBox(initial, pt, avatarSize),
|
||||||
fit: BoxFit.cover,
|
),
|
||||||
placeholder: _initialBox(initial, pt),
|
AnimatedContainer(
|
||||||
)
|
duration: _animDuration,
|
||||||
: _initialBox(initial, pt),
|
curve: _animCurve,
|
||||||
),
|
width: expanded ? 20 : 10,
|
||||||
AnimatedContainer(
|
),
|
||||||
duration: _animDuration,
|
Expanded(
|
||||||
curve: _animCurve,
|
child: Column(
|
||||||
width: expanded ? 20 : 10,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Expanded(
|
children: [
|
||||||
child: Column(
|
// Name skaliert mit; Status blendet daneben ein
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
// Name skaliert mit; Status blendet daneben ein
|
Flexible(
|
||||||
Row(
|
child: AnimatedDefaultTextStyle(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
duration: _animDuration,
|
||||||
children: [
|
curve: _animCurve,
|
||||||
Flexible(
|
style: TextStyle(
|
||||||
child: AnimatedDefaultTextStyle(
|
color: pt.fg,
|
||||||
duration: _animDuration,
|
fontSize: expanded ? 24 : 15,
|
||||||
curve: _animCurve,
|
fontWeight: expanded
|
||||||
style: TextStyle(
|
? FontWeight.w700
|
||||||
color: pt.fg,
|
: FontWeight.w600,
|
||||||
fontSize: expanded ? 24 : 15,
|
|
||||||
fontWeight: expanded
|
|
||||||
? FontWeight.w700
|
|
||||||
: FontWeight.w600,
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
name,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
AnimatedOpacity(
|
child: Text(
|
||||||
opacity: expanded ? 1 : 0,
|
name,
|
||||||
duration: _animDuration,
|
maxLines: 1,
|
||||||
child: Padding(
|
overflow: TextOverflow.ellipsis,
|
||||||
padding:
|
|
||||||
const EdgeInsets.only(left: 8),
|
|
||||||
child: Text(
|
|
||||||
_online ? 'online' : 'offline',
|
|
||||||
style: TextStyle(
|
|
||||||
color: _online
|
|
||||||
? pt.online
|
|
||||||
: pt.fgMuted,
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
// Eingeklappt: "zuletzt online vor…",
|
AnimatedOpacity(
|
||||||
// aufgeklappt: Handle + Biographie.
|
opacity: expanded ? 1 : 0,
|
||||||
AnimatedCrossFade(
|
|
||||||
duration: _animDuration,
|
duration: _animDuration,
|
||||||
sizeCurve: _animCurve,
|
child: Padding(
|
||||||
crossFadeState: expanded
|
padding:
|
||||||
? CrossFadeState.showSecond
|
const EdgeInsets.only(left: 8),
|
||||||
: CrossFadeState.showFirst,
|
child: Text(
|
||||||
firstChild: client != null
|
_online ? 'online' : 'offline',
|
||||||
? _PresenceLine(
|
style: TextStyle(
|
||||||
userId: _partnerId,
|
color: _online
|
||||||
client: client,
|
? pt.online
|
||||||
pt: pt,
|
: pt.fgMuted,
|
||||||
)
|
fontSize: 13,
|
||||||
: const SizedBox.shrink(),
|
fontWeight: FontWeight.w500,
|
||||||
secondChild: Column(
|
|
||||||
crossAxisAlignment:
|
|
||||||
CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
userId,
|
|
||||||
style: TextStyle(
|
|
||||||
color: pt.fgMuted, fontSize: 13),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
),
|
||||||
if (_statusMsg != null &&
|
),
|
||||||
_statusMsg!.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
_statusMsg!,
|
|
||||||
style: TextStyle(
|
|
||||||
color: pt.fgMuted,
|
|
||||||
fontSize: 13,
|
|
||||||
height: 1.35),
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
// Eingeklappt: "zuletzt online vor…",
|
||||||
],
|
// aufgeklappt: Handle + Biographie.
|
||||||
|
AnimatedCrossFade(
|
||||||
|
duration: _animDuration,
|
||||||
|
sizeCurve: _animCurve,
|
||||||
|
crossFadeState: expanded
|
||||||
|
? CrossFadeState.showSecond
|
||||||
|
: CrossFadeState.showFirst,
|
||||||
|
firstChild: client != null
|
||||||
|
? _PresenceLine(
|
||||||
|
userId: _partnerId,
|
||||||
|
client: client,
|
||||||
|
pt: pt,
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
secondChild: Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
userId,
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.fgMuted, fontSize: 13),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (_statusMsg != null &&
|
||||||
|
_statusMsg!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
_statusMsg!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.fgMuted,
|
||||||
|
fontSize: 13,
|
||||||
|
height: 1.35),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Zurück-Pfeil und Aktionen: fest in der obersten 52-px-Zeile –
|
||||||
|
// sie rutschen beim Aufklappen nicht mehr in die Mitte.
|
||||||
|
if (isMobile)
|
||||||
|
Positioned(
|
||||||
|
left: 6,
|
||||||
|
top: 0,
|
||||||
|
height: 52,
|
||||||
|
child: Center(
|
||||||
|
child: PyrIconBtn(
|
||||||
|
icon: Icon(Icons.arrow_back_rounded, color: pt.fg),
|
||||||
|
tooltip: 'Zurück',
|
||||||
|
onPressed: () =>
|
||||||
|
ref.read(activeRoomIdProvider.notifier).state = null,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
),
|
||||||
// Aktionen — bleiben immer sichtbar
|
),
|
||||||
Row(
|
Positioned(
|
||||||
|
right: isMobile ? 6 : 16,
|
||||||
|
top: 0,
|
||||||
|
height: 52,
|
||||||
|
child: KeyedSubtree(
|
||||||
|
key: _actionsKey,
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
PyrIconBtn(
|
PyrIconBtn(
|
||||||
size: 28,
|
size: 28,
|
||||||
icon: const Icon(Icons.phone_rounded),
|
icon: const Icon(Icons.phone_rounded),
|
||||||
tooltip: 'Voice call',
|
tooltip: 'Anrufen',
|
||||||
onPressed: () => _startCall(cam: false),
|
onPressed: () => _startCall(cam: false),
|
||||||
),
|
),
|
||||||
PyrIconBtn(
|
PyrIconBtn(
|
||||||
size: 28,
|
size: 28,
|
||||||
icon: const Icon(Icons.videocam_rounded),
|
icon: const Icon(Icons.videocam_rounded),
|
||||||
tooltip: 'Video call',
|
tooltip: 'Videoanruf',
|
||||||
onPressed: () => _startCall(cam: true),
|
onPressed: () => _startCall(cam: true),
|
||||||
),
|
),
|
||||||
if (size.width > 700)
|
if (size.width > 700)
|
||||||
Container(
|
Container(
|
||||||
width: 1,
|
width: 1,
|
||||||
height: 20,
|
height: 20,
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
color: pt.border,
|
color: pt.border,
|
||||||
),
|
),
|
||||||
if (size.width > 800)
|
if (size.width > 800)
|
||||||
PyrIconBtn(
|
PyrIconBtn(
|
||||||
size: 28,
|
size: 28,
|
||||||
icon: const Icon(Icons.push_pin_rounded),
|
icon: const Icon(Icons.push_pin_rounded),
|
||||||
tooltip: 'Angepinnte Nachrichten',
|
tooltip: 'Angepinnte Nachrichten',
|
||||||
active: widget.pinnedOpen,
|
active: widget.pinnedOpen,
|
||||||
onPressed: widget.onTogglePinned,
|
onPressed: widget.onTogglePinned,
|
||||||
),
|
),
|
||||||
PyrIconBtn(
|
if (isMobile)
|
||||||
size: 28,
|
_ChatOverflowMenu(
|
||||||
icon: const Icon(Icons.search_rounded),
|
pt: pt,
|
||||||
tooltip: 'Search',
|
onSearch: widget.onOpenSearch,
|
||||||
onPressed: widget.onOpenSearch,
|
onMembers: widget.onToggleMembers,
|
||||||
),
|
)
|
||||||
PyrIconBtn(
|
else ...[
|
||||||
size: 28,
|
PyrIconBtn(
|
||||||
icon: const Icon(Icons.group_rounded),
|
size: 28,
|
||||||
tooltip: 'Members',
|
icon: const Icon(Icons.search_rounded),
|
||||||
active: widget.membersOpen,
|
tooltip: 'Suchen',
|
||||||
onPressed: widget.onToggleMembers,
|
onPressed: widget.onOpenSearch,
|
||||||
),
|
),
|
||||||
],
|
PyrIconBtn(
|
||||||
|
size: 28,
|
||||||
|
icon: const Icon(Icons.group_rounded),
|
||||||
|
tooltip: 'Mitglieder',
|
||||||
|
active: widget.membersOpen,
|
||||||
|
onPressed: widget.onToggleMembers,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -560,15 +647,25 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _initialBox(String initial, PyramidTheme pt) => Center(
|
// Kürzel exakt mittig: Schrift wächst mit dem Avatar mit (vorher feste
|
||||||
child: FittedBox(
|
// 28 px in FittedBox mit Innenabstand – wirkte aufgeklappt verschoben).
|
||||||
fit: BoxFit.scaleDown,
|
Widget _initialBox(String initial, PyramidTheme pt, double avatarSize) =>
|
||||||
child: Padding(
|
Center(
|
||||||
padding: const EdgeInsets.all(6),
|
child: AnimatedDefaultTextStyle(
|
||||||
child: Text(
|
duration: _animDuration,
|
||||||
initial,
|
curve: _animCurve,
|
||||||
style: TextStyle(
|
textAlign: TextAlign.center,
|
||||||
color: pt.fg, fontSize: 28, fontWeight: FontWeight.w700),
|
style: TextStyle(
|
||||||
|
color: pt.fg,
|
||||||
|
fontSize: avatarSize * 0.42,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
height: 1.0,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
initial,
|
||||||
|
textHeightBehavior: const TextHeightBehavior(
|
||||||
|
applyHeightToFirstAscent: false,
|
||||||
|
applyHeightToLastDescent: false,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -576,7 +673,44 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
|
|||||||
|
|
||||||
void _startCall({required bool cam}) async {
|
void _startCall({required bool cam}) async {
|
||||||
ref.read(viewModeProvider.notifier).state = ViewMode.voice;
|
ref.read(viewModeProvider.notifier).state = ViewMode.voice;
|
||||||
await PyramidVoipManager.instance.startCall(widget.room.id, video: cam);
|
await ref.read(callSignalingProvider).startCall(widget.room.id, video: cam);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Untertitel im Gruppen-Kopf: Mitgliederzahl, dahinter das Thema.
|
||||||
|
String _groupSubtitle(Room room) {
|
||||||
|
final count = room.summary.mJoinedMemberCount ?? room.getParticipants().length;
|
||||||
|
final members = count == 1 ? '1 Mitglied' : '$count Mitglieder';
|
||||||
|
final topic = room.topic.trim();
|
||||||
|
return topic.isEmpty ? members : '$members · $topic';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runder Gruppen-Avatar (Raumbild oder Gruppen-Symbol).
|
||||||
|
class _GroupHeaderAvatar extends StatelessWidget {
|
||||||
|
final Room room;
|
||||||
|
final Client client;
|
||||||
|
final PyramidTheme pt;
|
||||||
|
const _GroupHeaderAvatar(
|
||||||
|
{required this.room, required this.client, required this.pt});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
const size = 32.0;
|
||||||
|
Widget fallback(BuildContext _) => Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration:
|
||||||
|
BoxDecoration(color: pt.accentSoft, shape: BoxShape.circle),
|
||||||
|
child: Icon(Icons.groups_rounded, size: 18, color: pt.accent),
|
||||||
|
);
|
||||||
|
if (room.avatar == null) return Builder(builder: fallback);
|
||||||
|
return MxcAvatar(
|
||||||
|
mxcUri: room.avatar,
|
||||||
|
client: client,
|
||||||
|
size: size,
|
||||||
|
borderRadius: BorderRadius.circular(size / 2),
|
||||||
|
placeholder: fallback,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -733,3 +867,48 @@ class _PresenceLineState extends State<_PresenceLine> {
|
|||||||
return 'vor ${(diff.inDays / 365).floor()}j';
|
return 'vor ${(diff.inDays / 365).floor()}j';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handy: Suche und Mitglieder im ⋮-Menü statt als eigene Symbole
|
||||||
|
/// (Bernds Feedback 2026-10-07 – mehr Platz, größere Hauptsymbole).
|
||||||
|
class _ChatOverflowMenu extends StatelessWidget {
|
||||||
|
final PyramidTheme pt;
|
||||||
|
final VoidCallback onSearch;
|
||||||
|
final VoidCallback onMembers;
|
||||||
|
|
||||||
|
const _ChatOverflowMenu({
|
||||||
|
required this.pt,
|
||||||
|
required this.onSearch,
|
||||||
|
required this.onMembers,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
PopupMenuItem<VoidCallback> item(
|
||||||
|
IconData icon, String label, VoidCallback action) =>
|
||||||
|
PopupMenuItem(
|
||||||
|
value: action,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 20, color: pt.fgMuted),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(label, style: TextStyle(color: pt.fg, fontSize: 14)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return PopupMenuButton<VoidCallback>(
|
||||||
|
tooltip: 'Mehr',
|
||||||
|
icon: Icon(Icons.more_vert_rounded, size: 22, color: pt.fgMuted),
|
||||||
|
color: pt.bg2,
|
||||||
|
elevation: 6,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(pt.rBase),
|
||||||
|
side: BorderSide(color: pt.border),
|
||||||
|
),
|
||||||
|
onSelected: (action) => action(),
|
||||||
|
itemBuilder: (_) => [
|
||||||
|
item(Icons.search_rounded, 'Suchen', onSearch),
|
||||||
|
item(Icons.group_rounded, 'Mitglieder', onMembers),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ class _MessageList extends StatelessWidget {
|
|||||||
final String? highlightEventId;
|
final String? highlightEventId;
|
||||||
final bool highlightOn;
|
final bool highlightOn;
|
||||||
|
|
||||||
|
/// Höhe der darüber schwebenden Eingabeleiste: so viel Platz bleibt unten
|
||||||
|
/// frei, damit die neueste Nachricht ganz über die Leiste scrollen kann.
|
||||||
|
final double bottomInset;
|
||||||
|
|
||||||
|
/// Siehe MessageGroup.popInAfter.
|
||||||
|
final DateTime? popInAfter;
|
||||||
|
|
||||||
const _MessageList({
|
const _MessageList({
|
||||||
required this.timeline,
|
required this.timeline,
|
||||||
required this.scrollCtrl,
|
required this.scrollCtrl,
|
||||||
@@ -35,6 +42,8 @@ class _MessageList extends StatelessWidget {
|
|||||||
this.jumpKey,
|
this.jumpKey,
|
||||||
this.highlightEventId,
|
this.highlightEventId,
|
||||||
this.highlightOn = false,
|
this.highlightOn = false,
|
||||||
|
this.bottomInset = 0,
|
||||||
|
this.popInAfter,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -56,7 +65,7 @@ class _MessageList extends StatelessWidget {
|
|||||||
Icon(Icons.forum_outlined, size: 48, color: pt.fgDim),
|
Icon(Icons.forum_outlined, size: 48, color: pt.fgDim),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'No messages yet',
|
'Noch keine Nachrichten – schreib die erste!',
|
||||||
style: TextStyle(color: pt.fgMuted, fontSize: 14),
|
style: TextStyle(color: pt.fgMuted, fontSize: 14),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -207,10 +216,11 @@ class _MessageList extends StatelessWidget {
|
|||||||
// Pass fullyReadId to the group only when the event is mid-group
|
// Pass fullyReadId to the group only when the event is mid-group
|
||||||
// (not the last event) so MessageGroup can render an inline divider.
|
// (not the last event) so MessageGroup can render an inline divider.
|
||||||
// groupContainsFullyRead already guarantees fullyReadId != null.
|
// groupContainsFullyRead already guarantees fullyReadId != null.
|
||||||
final passedFullyReadId =
|
final passedFullyReadId = (hasUnread &&
|
||||||
(groupContainsFullyRead && group.last.eventId != fullyReadId)
|
groupContainsFullyRead &&
|
||||||
? fullyReadId
|
group.last.eventId != fullyReadId)
|
||||||
: null;
|
? fullyReadId
|
||||||
|
: null;
|
||||||
itemFullyReadIds.add(passedFullyReadId);
|
itemFullyReadIds.add(passedFullyReadId);
|
||||||
|
|
||||||
if (isLastOfDay) {
|
if (isLastOfDay) {
|
||||||
@@ -241,7 +251,7 @@ class _MessageList extends StatelessWidget {
|
|||||||
controller: scrollCtrl,
|
controller: scrollCtrl,
|
||||||
reverse: true,
|
reverse: true,
|
||||||
physics: const ClampingScrollPhysics(),
|
physics: const ClampingScrollPhysics(),
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
padding: EdgeInsets.only(bottom: 8 + bottomInset),
|
||||||
scrollCacheExtent: const ScrollCacheExtent.pixels(2500),
|
scrollCacheExtent: const ScrollCacheExtent.pixels(2500),
|
||||||
itemCount: itemKeys.length,
|
itemCount: itemKeys.length,
|
||||||
findChildIndexCallback: (Key key) {
|
findChildIndexCallback: (Key key) {
|
||||||
@@ -283,10 +293,14 @@ class _MessageList extends StatelessWidget {
|
|||||||
showStatusIndicator: showStatus,
|
showStatusIndicator: showStatus,
|
||||||
readEventIds: readEventIds,
|
readEventIds: readEventIds,
|
||||||
fullyReadId: itemFullyReadIds[i],
|
fullyReadId: itemFullyReadIds[i],
|
||||||
|
unreadFading: unreadFading,
|
||||||
|
unreadDividerKey:
|
||||||
|
itemFullyReadIds[i] != null ? unreadDividerKey : null,
|
||||||
jumpEventId: jumpEventId,
|
jumpEventId: jumpEventId,
|
||||||
jumpKey: jumpKey,
|
jumpKey: jumpKey,
|
||||||
highlightEventId: highlightEventId,
|
highlightEventId: highlightEventId,
|
||||||
highlightOn: highlightOn,
|
highlightOn: highlightOn,
|
||||||
|
popInAfter: popInAfter,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -115,12 +115,12 @@ class NoChatSelected extends StatelessWidget {
|
|||||||
Icon(Icons.forum_outlined, size: 64, color: pt.fgDim),
|
Icon(Icons.forum_outlined, size: 64, color: pt.fgDim),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'Select a room to start chatting',
|
'Wähle einen Chat aus',
|
||||||
style: TextStyle(color: pt.fgMuted, fontSize: 16),
|
style: TextStyle(color: pt.fgMuted, fontSize: 16),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Choose from the sidebar on the left',
|
'Links findest du alle Chats und Räume.',
|
||||||
style: TextStyle(color: pt.fgDim, fontSize: 13),
|
style: TextStyle(color: pt.fgDim, fontSize: 13),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -129,3 +129,232 @@ class NoChatSelected extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fläche der schwebenden Eingabeleiste: um das Eingabefeld herum völlig
|
||||||
|
/// klar – der Chat ist dort normal zu sehen –, fängt aber alle Berührungen
|
||||||
|
/// ab (ColoredBox ist auch transparent „opak“ für Treffer), damit dort nur
|
||||||
|
/// die Eingabe reagiert und nicht der Chat dahinter. Die Unschärfe sitzt nur
|
||||||
|
/// hinter dem Eingabefeld selbst (chat_composer.dart).
|
||||||
|
class _FloatingComposerArea extends StatelessWidget {
|
||||||
|
final PyramidTheme pt;
|
||||||
|
final Widget child;
|
||||||
|
const _FloatingComposerArea({required this.pt, required this.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ColoredBox(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Einladung in diesen Chat (z. B. aus einer Benachrichtigung geöffnet):
|
||||||
|
/// Annehmen/Ablehnen direkt hier statt eines leeren Verlaufs – vorher ging
|
||||||
|
/// das nur in der Chatliste (Bernd 2026-10-07).
|
||||||
|
class _InvitePanel extends ConsumerStatefulWidget {
|
||||||
|
final Room room;
|
||||||
|
final PyramidTheme pt;
|
||||||
|
const _InvitePanel({required this.room, required this.pt});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<_InvitePanel> createState() => _InvitePanelState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InvitePanelState extends ConsumerState<_InvitePanel> {
|
||||||
|
bool _busy = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
String get _inviter {
|
||||||
|
final me = widget.room.client.userID;
|
||||||
|
final sender = me == null
|
||||||
|
? null
|
||||||
|
: widget.room.getState(EventTypes.RoomMember, me)?.senderId;
|
||||||
|
if (sender == null) return 'Jemand';
|
||||||
|
return widget.room.unsafeGetUserFromMemoryOrFallback(sender).calcDisplayname();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _accept() async {
|
||||||
|
setState(() {
|
||||||
|
_busy = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await acceptInvite(ref, widget.room);
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_busy = false;
|
||||||
|
_error = 'Annehmen hat nicht geklappt – bitte noch einmal versuchen.';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _decline() async {
|
||||||
|
setState(() {
|
||||||
|
_busy = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await widget.room.leave();
|
||||||
|
if (mounted) ref.read(activeRoomIdProvider.notifier).state = null;
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_busy = false;
|
||||||
|
_error = 'Ablehnen hat nicht geklappt – bitte noch einmal versuchen.';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final pt = widget.pt;
|
||||||
|
final isDm = widget.room.isDirectChat;
|
||||||
|
return Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(28),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
isDm ? Icons.person_add_alt_1_rounded : Icons.group_add_rounded,
|
||||||
|
size: 48,
|
||||||
|
color: pt.accent,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
isDm
|
||||||
|
? '$_inviter möchte mit dir chatten'
|
||||||
|
: '$_inviter lädt dich in „${widget.room.getLocalizedDisplayname()}“ ein',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.fg,
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Nach dem Annehmen siehst du die Nachrichten und kannst antworten.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: pt.fgMuted, fontSize: 13.5),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
if (_busy)
|
||||||
|
SizedBox(
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2.5, color: pt.accent),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Wrap(
|
||||||
|
spacing: 12,
|
||||||
|
runSpacing: 12,
|
||||||
|
alignment: WrapAlignment.center,
|
||||||
|
children: [
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: _decline,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: pt.fgMuted,
|
||||||
|
side: BorderSide(color: pt.border),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 14),
|
||||||
|
),
|
||||||
|
child: const Text('Ablehnen'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _accept,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: pt.accent,
|
||||||
|
foregroundColor: pt.accentFg,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 26, vertical: 14),
|
||||||
|
),
|
||||||
|
child: const Text('Annehmen'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_error != null) ...[
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Text(_error!,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: pt.danger, fontSize: 13)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handy: Läuft ein Anruf, während ein Chat offen ist, zeigt diese Leiste den
|
||||||
|
/// Weg zurück (am PC übernimmt das die Anrufleiste in der Seitenleiste).
|
||||||
|
/// Vorher war im Chat nirgends zu sehen, dass/wie man zurück in den Anruf
|
||||||
|
/// kommt (Bernd 2026-10-07).
|
||||||
|
class _ActiveCallBar extends ConsumerWidget {
|
||||||
|
const _ActiveCallBar();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
if (!isMobileLayout(MediaQuery.sizeOf(context))) return const SizedBox.shrink();
|
||||||
|
final voip = ref.watch(callSignalingProvider);
|
||||||
|
final lk = ref.watch(voiceChannelProvider);
|
||||||
|
final voipCall = voip.currentCall;
|
||||||
|
final String? name;
|
||||||
|
// Klingeln zählt nicht als „läuft“ – dafür gibt es das Annehmen-Fenster.
|
||||||
|
if (voipCall != null &&
|
||||||
|
voipCall.state != CallState.kEnded &&
|
||||||
|
voipCall.state != CallState.kRinging) {
|
||||||
|
name = voipCall.room.getLocalizedDisplayname();
|
||||||
|
} else if (lk.isActive || lk.isConnecting) {
|
||||||
|
name = lk.displayName;
|
||||||
|
} else {
|
||||||
|
name = null;
|
||||||
|
}
|
||||||
|
final pt = PyramidTheme.of(context);
|
||||||
|
return AnimatedSize(
|
||||||
|
duration: motionDuration(context, 220),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
child: name == null
|
||||||
|
? const SizedBox(width: double.infinity)
|
||||||
|
: Material(
|
||||||
|
color: pt.online.withValues(alpha: 0.16),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () =>
|
||||||
|
ref.read(viewModeProvider.notifier).state = ViewMode.voice,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.call_rounded, size: 18, color: pt.online),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Anruf läuft · $name',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.fg,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text('Zurück',
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.online,
|
||||||
|
fontSize: 13.5,
|
||||||
|
fontWeight: FontWeight.w700)),
|
||||||
|
Icon(Icons.chevron_right_rounded, size: 20, color: pt.online),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,9 +28,25 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
// Fade-out of the "Neue Nachrichten" divider once the user reaches the bottom.
|
// Fade-out of the "Neue Nachrichten" divider once the user reaches the bottom.
|
||||||
bool _unreadFading = false;
|
bool _unreadFading = false;
|
||||||
Timer? _unreadFadeTimer;
|
Timer? _unreadFadeTimer;
|
||||||
|
// So lange bleibt der Trenner stehen, nachdem man bei den neuesten
|
||||||
|
// Nachrichten angekommen ist (Bernd 2026-10-07: verschwand beim Öffnen aus
|
||||||
|
// der Benachrichtigung sofort).
|
||||||
|
static const _unreadDwell = Duration(seconds: 6);
|
||||||
|
Timer? _unreadDwellTimer;
|
||||||
|
// Zuletzt von hier als gelesen gemeldetes Event. Verhindert, dass jeder
|
||||||
|
// Neuaufbau erneut eine Lesebestätigung schickt, und markiert nach dem
|
||||||
|
// Zurückkehren in die App, ab wo „Neue Nachrichten“ beginnen.
|
||||||
|
String? _lastMarkedEventId;
|
||||||
|
// Was danach eintrifft, ploppt federnd auf (beim Öffnen gesetzt, damit die
|
||||||
|
// schon vorhandenen Nachrichten ruhig erscheinen).
|
||||||
|
DateTime? _popInAfter;
|
||||||
|
|
||||||
// Jump-to-message (from search): scroll to + briefly highlight a target event.
|
// Jump-to-message (from search): scroll to + briefly highlight a target event.
|
||||||
final _jumpKey = GlobalKey();
|
final _jumpKey = GlobalKey();
|
||||||
|
|
||||||
|
// Höhe der schwebenden Eingabeleiste (gemessen, wächst mit Antwort-Leiste
|
||||||
|
// oder mehrzeiligem Text).
|
||||||
|
double _composerHeight = 72;
|
||||||
String? _jumpEventId;
|
String? _jumpEventId;
|
||||||
String? _highlightEventId;
|
String? _highlightEventId;
|
||||||
bool _highlightOn = false;
|
bool _highlightOn = false;
|
||||||
@@ -77,11 +93,25 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
setState(() { _jumpInProgress = false; _highlightEventId = null; _jumpEventId = null; _highlightOn = false; });
|
setState(() { _jumpInProgress = false; _highlightEventId = null; _jumpEventId = null; _highlightOn = false; });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Not built yet — load older history toward the target every few tries.
|
// Not built yet. Die Liste baut nur sichtbare Einträge (lazy, reverse):
|
||||||
|
// Ist das Ziel schon geladen, Richtung ältere Nachrichten scrollen, bis
|
||||||
|
// es gezeichnet ist (Antwort-Zitate zeigen fast immer nach oben); sonst
|
||||||
|
// zusätzlich ältere Historie nachladen.
|
||||||
final timeline = ref.read(timelineProvider(widget.roomId)).valueOrNull;
|
final timeline = ref.read(timelineProvider(widget.roomId)).valueOrNull;
|
||||||
if (timeline != null && timeline.canRequestHistory && attempt % 3 == 0) {
|
final loaded =
|
||||||
|
timeline?.events.any((e) => e.eventId == _jumpEventId) ?? false;
|
||||||
|
if (!loaded &&
|
||||||
|
timeline != null &&
|
||||||
|
timeline.canRequestHistory &&
|
||||||
|
attempt % 3 == 0) {
|
||||||
timeline.requestHistory(historyCount: 100).catchError((_) {});
|
timeline.requestHistory(historyCount: 100).catchError((_) {});
|
||||||
}
|
}
|
||||||
|
if (_scrollCtrl.hasClients && (loaded || attempt % 3 == 1)) {
|
||||||
|
final pos = _scrollCtrl.position;
|
||||||
|
final step = pos.viewportDimension * 0.8;
|
||||||
|
final target = (pos.pixels + step).clamp(0.0, pos.maxScrollExtent);
|
||||||
|
if (target > pos.pixels) _scrollCtrl.jumpTo(target);
|
||||||
|
}
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
Future.delayed(const Duration(milliseconds: 120), () => _attemptJump(attempt + 1));
|
Future.delayed(const Duration(milliseconds: 120), () => _attemptJump(attempt + 1));
|
||||||
});
|
});
|
||||||
@@ -93,6 +123,9 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
|
|
||||||
bool get _inSelectionMode => _selectedIds.isNotEmpty;
|
bool get _inSelectionMode => _selectedIds.isNotEmpty;
|
||||||
|
|
||||||
|
double get _scrollBtnSize =>
|
||||||
|
isMobileLayout(MediaQuery.sizeOf(context)) ? 44 : 36;
|
||||||
|
|
||||||
void _toggleSelect(String eventId) {
|
void _toggleSelect(String eventId) {
|
||||||
setState(() {
|
setState(() {
|
||||||
if (_selectedIds.contains(eventId)) {
|
if (_selectedIds.contains(eventId)) {
|
||||||
@@ -109,12 +142,22 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_scrollCtrl.addListener(_onScroll);
|
_scrollCtrl.addListener(_onScroll);
|
||||||
|
// App/Fenster wieder im Vordergrund: was inzwischen kam, jetzt als
|
||||||
|
// gelesen melden – mit „Neue Nachrichten“-Trenner davor.
|
||||||
|
ref.listenManual<bool>(windowFocusedProvider, (prev, focused) {
|
||||||
|
if (focused && prev == false) _onFocusRegained();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(ChatView oldWidget) {
|
void didUpdateWidget(ChatView oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
if (oldWidget.roomId != widget.roomId) {
|
if (oldWidget.roomId != widget.roomId) {
|
||||||
|
// Antwort/Auswahl gehören zum alten Raum – sonst ginge eine Antwort
|
||||||
|
// auf eine Nachricht aus Raum A in Raum B raus.
|
||||||
|
_replyTo = null;
|
||||||
|
_selectedIds.clear();
|
||||||
|
_showScrollDown = false;
|
||||||
_profileExpanded = false;
|
_profileExpanded = false;
|
||||||
_sessionFullyReadId = null;
|
_sessionFullyReadId = null;
|
||||||
_sessionHasUnread = false;
|
_sessionHasUnread = false;
|
||||||
@@ -124,6 +167,9 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
_historyExhausted = false;
|
_historyExhausted = false;
|
||||||
_unreadFading = false;
|
_unreadFading = false;
|
||||||
_unreadFadeTimer?.cancel();
|
_unreadFadeTimer?.cancel();
|
||||||
|
_unreadDwellTimer?.cancel();
|
||||||
|
_lastMarkedEventId = null;
|
||||||
|
_popInAfter = null;
|
||||||
_jumpEventId = null;
|
_jumpEventId = null;
|
||||||
_highlightEventId = null;
|
_highlightEventId = null;
|
||||||
_highlightOn = false;
|
_highlightOn = false;
|
||||||
@@ -137,6 +183,7 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_unreadFadeTimer?.cancel();
|
_unreadFadeTimer?.cancel();
|
||||||
|
_unreadDwellTimer?.cancel();
|
||||||
_highlightTimer?.cancel();
|
_highlightTimer?.cancel();
|
||||||
_scrollCtrl.dispose();
|
_scrollCtrl.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -174,6 +221,7 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_sessionFullyReadId = newFullyReadId;
|
_sessionFullyReadId = newFullyReadId;
|
||||||
_sessionHasUnread = newHasUnread;
|
_sessionHasUnread = newHasUnread;
|
||||||
|
_popInAfter = events.firstOrNull?.originServerTs ?? DateTime.now();
|
||||||
});
|
});
|
||||||
if (newHasUnread) {
|
if (newHasUnread) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToUnread(timeline));
|
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToUnread(timeline));
|
||||||
@@ -206,10 +254,12 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
duration: const Duration(milliseconds: 250),
|
duration: const Duration(milliseconds: 250),
|
||||||
curve: Curves.easeOut,
|
curve: Curves.easeOut,
|
||||||
);
|
);
|
||||||
|
_scheduleUnreadFadeIfAtNewest();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (attempt >= 24) {
|
if (attempt >= 24) {
|
||||||
_didScrollToUnread = true; // give up — stay at the newest messages
|
_didScrollToUnread = true; // give up — stay at the newest messages
|
||||||
|
_scheduleUnreadFadeIfAtNewest();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (attempt > 0 && attempt % 4 == 0 && _scrollCtrl.hasClients) {
|
if (attempt > 0 && attempt % 4 == 0 && _scrollCtrl.hasClients) {
|
||||||
@@ -224,21 +274,100 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _markRead([Timeline? tl]) {
|
void _markRead([Timeline? tl]) {
|
||||||
|
// Nur als gelesen melden, was wirklich zu sehen ist: Läuft die App im
|
||||||
|
// Hintergrund (oder hat das Fenster keinen Fokus), bleibt alles ungelesen
|
||||||
|
// und die Benachrichtigung stehen – vorher wurde sie beim nächsten
|
||||||
|
// Neuaufbau sofort wieder entfernt und die Nachricht als gelesen gemeldet.
|
||||||
|
if (!ref.read(windowFocusedProvider)) return;
|
||||||
|
|
||||||
// Cancel any pending notification for this room (user is now reading it).
|
// Cancel any pending notification for this room (user is now reading it).
|
||||||
cancelNotificationForRoom(widget.roomId);
|
cancelNotificationForRoom(widget.roomId);
|
||||||
|
|
||||||
final sendReceipts = ref.read(privacyReadReceiptsProvider);
|
|
||||||
if (!sendReceipts) return;
|
|
||||||
final room = ref.read(roomProvider(widget.roomId));
|
final room = ref.read(roomProvider(widget.roomId));
|
||||||
if (room == null) return;
|
if (room == null || room.membership != Membership.join) return;
|
||||||
|
if (room.markedUnread) room.markUnread(false).catchError((_) {});
|
||||||
final timeline = tl ?? ref.read(timelineProvider(widget.roomId)).valueOrNull;
|
final timeline = tl ?? ref.read(timelineProvider(widget.roomId)).valueOrNull;
|
||||||
final latest = timeline?.events.firstOrNull;
|
final latest = timeline?.events
|
||||||
if (latest != null && latest.status.isSynced) {
|
.where((e) => e.status.isSynced)
|
||||||
room.setReadMarker(latest.eventId, mRead: latest.eventId)
|
.firstOrNull;
|
||||||
.catchError((_) {});
|
if (latest == null || latest.eventId == _lastMarkedEventId) return;
|
||||||
} else {
|
_lastMarkedEventId = latest.eventId;
|
||||||
room.markUnread(false).catchError((_) {});
|
markRoomReadUpTo(
|
||||||
|
room,
|
||||||
|
latest.eventId,
|
||||||
|
publicReceipt: ref.read(privacyReadReceiptsProvider),
|
||||||
|
).catchError((_) {
|
||||||
|
// Beim nächsten Anlass erneut versuchen.
|
||||||
|
if (_lastMarkedEventId == latest.eventId) _lastMarkedEventId = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zurück in der App mit diesem Chat offen: Kamen inzwischen Nachrichten
|
||||||
|
/// von anderen, erscheint der „Neue Nachrichten“-Trenner hinter dem zuletzt
|
||||||
|
/// gelesenen Stand; danach wird bis zur neuesten Nachricht gelesen gemeldet.
|
||||||
|
void _onFocusRegained() {
|
||||||
|
if (!mounted || !_sessionCaptured) return;
|
||||||
|
final timeline = ref.read(timelineProvider(widget.roomId)).valueOrNull;
|
||||||
|
if (timeline == null) return;
|
||||||
|
final since = _lastMarkedEventId;
|
||||||
|
if (since != null && !_sessionHasUnread) {
|
||||||
|
final me = timeline.room.client.userID;
|
||||||
|
final events = timeline.events; // newest-first
|
||||||
|
final idx = events.indexWhere((e) => e.eventId == since);
|
||||||
|
final hasNew = idx > 0 &&
|
||||||
|
events
|
||||||
|
.take(idx)
|
||||||
|
.any((e) => _MessageList._isVisible(e) && e.senderId != me);
|
||||||
|
if (hasNew) {
|
||||||
|
_unreadFadeTimer?.cancel();
|
||||||
|
_unreadDwellTimer?.cancel();
|
||||||
|
setState(() {
|
||||||
|
_sessionFullyReadId = since;
|
||||||
|
_sessionHasUnread = true;
|
||||||
|
_unreadFading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (_sessionHasUnread) _scheduleUnreadFadeIfAtNewest();
|
||||||
|
_markRead(timeline);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neueste Nachrichten sind zu sehen: höchstens eine halbe Bildschirmhöhe
|
||||||
|
// hochgescrollt. (Nach dem Sprung zum Trenner steht die Liste meist ein
|
||||||
|
// Stück oberhalb des Endes – mit der strengen 60-px-Grenze blieb der
|
||||||
|
// Trenner dann für immer stehen.)
|
||||||
|
bool get _atNewest {
|
||||||
|
if (!_scrollCtrl.hasClients) return true;
|
||||||
|
final pos = _scrollCtrl.position;
|
||||||
|
return pos.pixels < 60 || pos.pixels < pos.viewportDimension * 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// „Neue Nachrichten“-Trenner nach [_unreadDwell] weich ausblenden – nur
|
||||||
|
/// wenn man dann noch bei den neuesten Nachrichten ist. Wer zwischendurch
|
||||||
|
/// hochscrollt, behält ihn; unten angekommen beginnt die Wartezeit neu.
|
||||||
|
void _scheduleUnreadFade() {
|
||||||
|
if (!_sessionHasUnread || _unreadFading) return;
|
||||||
|
if (_unreadDwellTimer?.isActive ?? false) return;
|
||||||
|
_unreadDwellTimer = Timer(_unreadDwell, () {
|
||||||
|
if (!mounted || !_sessionHasUnread) return;
|
||||||
|
// App im Hintergrund oder weggescrollt: stehen lassen, beim nächsten
|
||||||
|
// Scrollen/Zurückkommen beginnt die Wartezeit neu.
|
||||||
|
if (!_atNewest || !ref.read(windowFocusedProvider)) return;
|
||||||
|
setState(() => _unreadFading = true);
|
||||||
|
_unreadFadeTimer?.cancel();
|
||||||
|
_unreadFadeTimer = Timer(const Duration(milliseconds: 900), () {
|
||||||
|
if (mounted) setState(() { _sessionHasUnread = false; _unreadFading = false; });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nach dem Sprung zum Trenner (Animation ~250 ms) prüfen: Passt alles
|
||||||
|
// Ungelesene auf den Bildschirm, scrollt niemand mehr – dann läuft die
|
||||||
|
// Verweildauer trotzdem an.
|
||||||
|
void _scheduleUnreadFadeIfAtNewest() {
|
||||||
|
Future.delayed(const Duration(milliseconds: 400), () {
|
||||||
|
if (mounted && _atNewest) _scheduleUnreadFade();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onScroll() async {
|
Future<void> _onScroll() async {
|
||||||
@@ -246,18 +375,10 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
final pixels = _scrollCtrl.position.pixels;
|
final pixels = _scrollCtrl.position.pixels;
|
||||||
final maxExtent = _scrollCtrl.position.maxScrollExtent;
|
final maxExtent = _scrollCtrl.position.maxScrollExtent;
|
||||||
|
|
||||||
if (pixels < 60) {
|
if (pixels < 60) _markRead(null);
|
||||||
_markRead(null);
|
// Bei den neuesten Nachrichten angekommen → Trenner nach einer
|
||||||
// Reached the newest messages → the "Neue Nachrichten" divider is no
|
// Verweildauer ausblenden.
|
||||||
// longer useful. Fade it out, then remove it.
|
if (_atNewest) _scheduleUnreadFade();
|
||||||
if (_sessionHasUnread && !_unreadFading) {
|
|
||||||
setState(() => _unreadFading = true);
|
|
||||||
_unreadFadeTimer?.cancel();
|
|
||||||
_unreadFadeTimer = Timer(const Duration(milliseconds: 900), () {
|
|
||||||
if (mounted) setState(() { _sessionHasUnread = false; _unreadFading = false; });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
final shouldShow = pixels > 400;
|
final shouldShow = pixels > 400;
|
||||||
if (shouldShow != _showScrollDown) {
|
if (shouldShow != _showScrollDown) {
|
||||||
setState(() => _showScrollDown = shouldShow);
|
setState(() => _showScrollDown = shouldShow);
|
||||||
@@ -313,7 +434,9 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
// listen) so it also fires when this ChatView is freshly mounted for a
|
// listen) so it also fires when this ChatView is freshly mounted for a
|
||||||
// DIFFERENT room — listen would miss a value set before registration.
|
// DIFFERENT room — listen would miss a value set before registration.
|
||||||
final pendingJump = ref.watch(pendingJumpEventProvider);
|
final pendingJump = ref.watch(pendingJumpEventProvider);
|
||||||
if (pendingJump != null && !_jumpInProgress && pendingJump != _jumpEventId) {
|
// (Kein Vergleich mit _jumpEventId mehr: dasselbe Zitat darf zweimal
|
||||||
|
// hintereinander angetippt werden – _startJump verbraucht die Anfrage.)
|
||||||
|
if (pendingJump != null && !_jumpInProgress) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted &&
|
if (mounted &&
|
||||||
!_jumpInProgress &&
|
!_jumpInProgress &&
|
||||||
@@ -324,6 +447,9 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final isDesktop = Platform.isWindows || Platform.isMacOS || Platform.isLinux;
|
final isDesktop = Platform.isWindows || Platform.isMacOS || Platform.isLinux;
|
||||||
|
// Noch nicht beigetreten (Einladung): statt eines leeren Verlaufs gleich
|
||||||
|
// hier Annehmen/Ablehnen anbieten.
|
||||||
|
final isInvite = room?.membership == Membership.invite;
|
||||||
final body = Stack(
|
final body = Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
@@ -394,8 +520,13 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
setState(() => _pinnedOpen = !_pinnedOpen),
|
setState(() => _pinnedOpen = !_pinnedOpen),
|
||||||
),
|
),
|
||||||
const _ConnectionBanner(),
|
const _ConnectionBanner(),
|
||||||
|
const _ActiveCallBar(),
|
||||||
|
if (room != null && !isInvite)
|
||||||
|
GroupCallBanner(room: room),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Stack(
|
child: isInvite
|
||||||
|
? _InvitePanel(room: room!, pt: pt)
|
||||||
|
: Stack(
|
||||||
children: [
|
children: [
|
||||||
timelineAsync.when(
|
timelineAsync.when(
|
||||||
loading: () => Center(
|
loading: () => Center(
|
||||||
@@ -406,7 +537,7 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
),
|
),
|
||||||
error: (e, _) => Center(
|
error: (e, _) => Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Could not load messages',
|
'Nachrichten konnten nicht geladen werden.',
|
||||||
style: TextStyle(color: pt.fgDim, fontSize: 14),
|
style: TextStyle(color: pt.fgDim, fontSize: 14),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -429,28 +560,36 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
jumpKey: _jumpKey,
|
jumpKey: _jumpKey,
|
||||||
highlightEventId: _highlightEventId,
|
highlightEventId: _highlightEventId,
|
||||||
highlightOn: _highlightOn,
|
highlightOn: _highlightOn,
|
||||||
|
bottomInset: room != null ? _composerHeight : 0,
|
||||||
|
popInAfter: _popInAfter,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (_showScrollDown)
|
Positioned(
|
||||||
Positioned(
|
|
||||||
right: 16,
|
right: 16,
|
||||||
bottom: 12,
|
bottom: (room != null ? _composerHeight : 0) + 12,
|
||||||
child: AnimatedOpacity(
|
child: IgnorePointer(
|
||||||
opacity: _showScrollDown ? 1 : 0,
|
ignoring: !_showScrollDown,
|
||||||
duration: const Duration(milliseconds: 150),
|
child: AnimatedScale(
|
||||||
|
scale: _showScrollDown ? 1 : 0,
|
||||||
|
duration: motionDuration(
|
||||||
|
context, _showScrollDown ? 520 : 180),
|
||||||
|
curve: _showScrollDown
|
||||||
|
? Curves.elasticOut
|
||||||
|
: Curves.easeInBack,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () => _scrollCtrl.animateTo(
|
onTap: () => _scrollCtrl.animateTo(
|
||||||
0,
|
0,
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
curve: Curves.easeOut,
|
curve: Curves.easeOut,
|
||||||
),
|
),
|
||||||
|
child: SpringPress(
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 36,
|
width: _scrollBtnSize,
|
||||||
height: 36,
|
height: _scrollBtnSize,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: pt.bg0,
|
color: pt.bg0,
|
||||||
borderRadius: BorderRadius.circular(18),
|
borderRadius: BorderRadius.circular(_scrollBtnSize / 2),
|
||||||
border: Border.all(color: pt.border),
|
border: Border.all(color: pt.border),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
@@ -460,7 +599,49 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: Icon(Icons.keyboard_arrow_down_rounded, size: 20, color: pt.fgMuted),
|
child: Icon(Icons.keyboard_arrow_down_rounded,
|
||||||
|
size: _scrollBtnSize * 0.56, color: pt.fgMuted),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Schwebende Eingabeleiste (Bernds Wunsch 2026-10-07):
|
||||||
|
// der Chat läuft optisch darunter weiter
|
||||||
|
// (durchscheinend + Unschärfe), die ganze Fläche
|
||||||
|
// fängt aber Berührungen ab – dort reagiert nur
|
||||||
|
// die Eingabe, nicht der Chat dahinter.
|
||||||
|
if (room != null)
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: MeasureSize(
|
||||||
|
onChange: (size) {
|
||||||
|
if (mounted &&
|
||||||
|
(size.height - _composerHeight).abs() >
|
||||||
|
0.5) {
|
||||||
|
setState(
|
||||||
|
() => _composerHeight = size.height);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: _FloatingComposerArea(
|
||||||
|
pt: pt,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_TypingIndicator(room: room, pt: pt),
|
||||||
|
ChatComposer(
|
||||||
|
// Je Raum ein eigenes Eingabefeld
|
||||||
|
// (Text, „tippt…“, Entwurf).
|
||||||
|
key: ValueKey('composer:${room.id}'),
|
||||||
|
room: room,
|
||||||
|
replyTo: _replyTo,
|
||||||
|
onClearReply: () =>
|
||||||
|
setState(() => _replyTo = null),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -468,13 +649,6 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (room != null) _TypingIndicator(room: room, pt: pt),
|
|
||||||
if (room != null)
|
|
||||||
ChatComposer(
|
|
||||||
room: room,
|
|
||||||
replyTo: _replyTo,
|
|
||||||
onClearReply: () => setState(() => _replyTo = null),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,362 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
import 'package:pyramid/core/app_state.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:pyramid/widgets/mxc_image.dart';
|
||||||
|
import 'package:pyramid/widgets/spring_press.dart';
|
||||||
|
|
||||||
|
/// Gruppenanrufe in Gruppenchats (Bernd 2026-10-09: „Anruf + Beitreten-Karte“).
|
||||||
|
///
|
||||||
|
/// - Technik wie bei Sprachkanälen: LiveKit-Raum = Matrix-Raum-ID, Anwesenheit
|
||||||
|
/// über `io.pyramid.voice.presence`-State-Events (Herzschlag alle 30 s).
|
||||||
|
/// - Wer einen Anruf eröffnet (sonst niemand drin), schickt die Nachricht
|
||||||
|
/// „📞 Gruppenanruf gestartet“ mit [groupCallContentKey]. Sie löst bei den
|
||||||
|
/// Mitgliedern eine normale Push-Benachrichtigung aus (eine m.notice würde
|
||||||
|
/// von den Standard-Push-Regeln stumm geschaltet) und erscheint in Pyramid
|
||||||
|
/// als Anruf-Karte; andere Apps zeigen den Text.
|
||||||
|
/// - Damit alle Mitglieder ihre Anwesenheit schreiben dürfen, wird die nötige
|
||||||
|
/// Berechtigung für dieses eine State-Event auf „alle“ gesenkt – nur wer
|
||||||
|
/// Rechte ändern darf (neue Gruppen bekommen das schon beim Erstellen).
|
||||||
|
const groupCallContentKey = 'io.pyramid.group_call';
|
||||||
|
|
||||||
|
/// Erlaubt allen Mitgliedern von [roomId], ihre Anruf-Anwesenheit zu
|
||||||
|
/// schreiben (sonst sieht man im Anruf nur Admins). Ändert nur diesen einen
|
||||||
|
/// Eintrag der Berechtigungen.
|
||||||
|
Future<void> allowVoicePresenceForAll(Client client, String roomId) async {
|
||||||
|
final plRaw =
|
||||||
|
await client.getRoomStateWithKey(roomId, EventTypes.RoomPowerLevels, '');
|
||||||
|
final pl = Map<String, dynamic>.from(plRaw);
|
||||||
|
final events = Map<String, dynamic>.from(pl['events'] as Map? ?? {});
|
||||||
|
final usersDefault = pl['users_default'] is int ? pl['users_default'] as int : 0;
|
||||||
|
if (events[kVoicePresenceType] == usersDefault) return;
|
||||||
|
events[kVoicePresenceType] = usersDefault;
|
||||||
|
pl['events'] = events;
|
||||||
|
await client.setRoomStateWithKey(roomId, EventTypes.RoomPowerLevels, '', pl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie [allowVoicePresenceForAll], aber nur wenn nötig UND erlaubt.
|
||||||
|
Future<void> ensureVoicePresenceWritable(Room room) async {
|
||||||
|
final pl = room.getState(EventTypes.RoomPowerLevels)?.content;
|
||||||
|
if (pl == null) return; // keine Rechte-Tabelle: jeder darf
|
||||||
|
final usersDefault = pl['users_default'] is int ? pl['users_default'] as int : 0;
|
||||||
|
if (room.powerForChangingStateEvent(kVoicePresenceType) <= usersDefault) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!room.canChangePowerLevel) return;
|
||||||
|
await allowVoicePresenceForAll(room.client, room.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bin ich gerade (oder gleich) im Gruppenanruf von [roomId]?
|
||||||
|
bool isInGroupCall(VoiceChannelService call, String roomId) =>
|
||||||
|
(call.isActive || call.isConnecting) && call.currentRoomId == roomId;
|
||||||
|
|
||||||
|
/// Teilnehmer des Anrufs in [room] ohne mich selbst.
|
||||||
|
List<Map<String, dynamic>> otherCallParticipants(
|
||||||
|
List<Map<String, dynamic>> all, Room room) =>
|
||||||
|
all.where((p) => p['userId'] != room.client.userID).toList();
|
||||||
|
|
||||||
|
/// Anruf-Knopf im Gruppenchat und „Beitreten“: läuft schon ein Anruf, tritt
|
||||||
|
/// man bei; sonst startet man ihn und benachrichtigt die Mitglieder.
|
||||||
|
Future<void> startOrJoinGroupCall(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
Room room, {
|
||||||
|
bool video = false,
|
||||||
|
}) async {
|
||||||
|
final call = ref.read(voiceChannelProvider);
|
||||||
|
if (isInGroupCall(call, room.id)) {
|
||||||
|
ref.read(viewModeProvider.notifier).state = ViewMode.voice;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final voip = ref.read(callSignalingProvider).currentCall;
|
||||||
|
if (voip != null && voip.state != CallState.kEnded) {
|
||||||
|
ScaffoldMessenger.maybeOf(context)?.showSnackBar(const SnackBar(
|
||||||
|
content: Text('Beende zuerst den laufenden Anruf.')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final othersBefore = otherCallParticipants(
|
||||||
|
ref.read(voiceParticipantsProvider(room.id)), room)
|
||||||
|
.length;
|
||||||
|
if (call.isActive || call.isConnecting) await call.hangUp();
|
||||||
|
|
||||||
|
ref.read(activeVoiceRoomIdProvider.notifier).state = room.id;
|
||||||
|
ref.read(viewModeProvider.notifier).state = ViewMode.voice;
|
||||||
|
try {
|
||||||
|
await ensureVoicePresenceWritable(room);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[GroupCall] Berechtigung für Anwesenheit: $e');
|
||||||
|
}
|
||||||
|
await call.startCall(
|
||||||
|
roomName: room.id,
|
||||||
|
roomDisplayName: room.getLocalizedDisplayname(),
|
||||||
|
identity: room.client.userID ?? '',
|
||||||
|
audioOnly: !video,
|
||||||
|
matrixClient: room.client,
|
||||||
|
matrixRoomId: room.id,
|
||||||
|
);
|
||||||
|
if (othersBefore == 0 &&
|
||||||
|
call.isActive &&
|
||||||
|
call.error == null &&
|
||||||
|
call.currentRoomId == room.id) {
|
||||||
|
try {
|
||||||
|
await room.sendEvent({
|
||||||
|
'msgtype': MessageTypes.Text,
|
||||||
|
'body': video ? '📹 Videoanruf gestartet' : '📞 Gruppenanruf gestartet',
|
||||||
|
groupCallContentKey: {'action': 'start', 'video': video},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[GroupCall] Benachrichtigung: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Avatare der Teilnehmer, leicht überlappend (max. [max]).
|
||||||
|
class CallAvatarStack extends StatelessWidget {
|
||||||
|
final List<Map<String, dynamic>> participants;
|
||||||
|
final Client client;
|
||||||
|
final double size;
|
||||||
|
final int max;
|
||||||
|
final Color ringColor;
|
||||||
|
|
||||||
|
const CallAvatarStack({
|
||||||
|
super.key,
|
||||||
|
required this.participants,
|
||||||
|
required this.client,
|
||||||
|
required this.ringColor,
|
||||||
|
this.size = 30,
|
||||||
|
this.max = 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final pt = PyramidTheme.of(context);
|
||||||
|
final shown = participants.take(max).toList();
|
||||||
|
final step = size * 0.62;
|
||||||
|
return SizedBox(
|
||||||
|
width: shown.isEmpty ? 0 : size + step * (shown.length - 1),
|
||||||
|
height: size,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
for (var i = shown.length - 1; i >= 0; i--)
|
||||||
|
Positioned(
|
||||||
|
left: step * i,
|
||||||
|
child: Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: ringColor, width: 2),
|
||||||
|
),
|
||||||
|
child: ClipOval(child: _avatar(shown[i], pt)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _avatar(Map<String, dynamic> p, PyramidTheme pt) {
|
||||||
|
final name = (p['displayName'] as String? ?? '?').replaceFirst('@', '');
|
||||||
|
final initial = name.isEmpty ? '?' : name.characters.first.toUpperCase();
|
||||||
|
Widget fallback(BuildContext _) => Container(
|
||||||
|
color: pt.accentSoft,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(initial,
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.accent,
|
||||||
|
fontSize: size * 0.42,
|
||||||
|
fontWeight: FontWeight.w700)),
|
||||||
|
);
|
||||||
|
final url = p['avatarUrl'] as String?;
|
||||||
|
if (url == null || url.isEmpty) return Builder(builder: fallback);
|
||||||
|
return MxcAvatar(
|
||||||
|
mxcUri: Uri.tryParse(url),
|
||||||
|
client: client,
|
||||||
|
size: size,
|
||||||
|
borderRadius: BorderRadius.circular(size / 2),
|
||||||
|
placeholder: fallback,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _participantSummary(List<Map<String, dynamic>> others) {
|
||||||
|
final names = others
|
||||||
|
.map((p) => (p['displayName'] as String? ?? '').split(' ').first)
|
||||||
|
.where((n) => n.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
if (names.isEmpty) return '${others.length} dabei';
|
||||||
|
if (names.length <= 2) return '${names.join(' und ')} ${names.length == 1 ? 'ist' : 'sind'} dabei';
|
||||||
|
return '${names.take(2).join(', ')} und ${names.length - 2} weitere';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Karte über dem Chatverlauf: „Gruppenanruf läuft · N dabei · Beitreten“.
|
||||||
|
/// Unsichtbar, solange niemand im Anruf ist oder man selbst schon drin ist.
|
||||||
|
class GroupCallBanner extends ConsumerWidget {
|
||||||
|
final Room room;
|
||||||
|
const GroupCallBanner({super.key, required this.room});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final pt = PyramidTheme.of(context);
|
||||||
|
final others =
|
||||||
|
otherCallParticipants(ref.watch(voiceParticipantsProvider(room.id)), room);
|
||||||
|
final call = ref.watch(voiceChannelProvider);
|
||||||
|
final show = !room.isDirectChat &&
|
||||||
|
others.isNotEmpty &&
|
||||||
|
!isInGroupCall(call, room.id);
|
||||||
|
|
||||||
|
return AnimatedSize(
|
||||||
|
duration: motionDuration(context, 260),
|
||||||
|
curve: springyCurve,
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: !show
|
||||||
|
? const SizedBox(width: double.infinity)
|
||||||
|
: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 10, 12, 4),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 10, 10, 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(colors: [
|
||||||
|
pt.online.withValues(alpha: 0.20),
|
||||||
|
pt.online.withValues(alpha: 0.08),
|
||||||
|
]),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: pt.online.withValues(alpha: 0.35)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
CallAvatarStack(
|
||||||
|
participants: others,
|
||||||
|
client: room.client,
|
||||||
|
ringColor: pt.bg1,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text('Gruppenanruf läuft',
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.fg,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700)),
|
||||||
|
const SizedBox(height: 1),
|
||||||
|
Text(_participantSummary(others),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style:
|
||||||
|
TextStyle(color: pt.fgMuted, fontSize: 12.5)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
SpringPress(
|
||||||
|
child: FilledButton.icon(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: pt.online,
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 14, vertical: 10),
|
||||||
|
shape: const StadiumBorder(),
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.call_rounded, size: 18),
|
||||||
|
label: const Text('Beitreten',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.w700)),
|
||||||
|
onPressed: () => startOrJoinGroupCall(context, ref, room),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Darstellung der „Gruppenanruf gestartet“-Nachricht im Verlauf.
|
||||||
|
class GroupCallNotice extends ConsumerWidget {
|
||||||
|
final Event event;
|
||||||
|
const GroupCallNotice({super.key, required this.event});
|
||||||
|
|
||||||
|
/// Ist [event] eine Gruppenanruf-Nachricht von Pyramid?
|
||||||
|
static bool matches(Event event) =>
|
||||||
|
event.content[groupCallContentKey] is Map;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final pt = PyramidTheme.of(context);
|
||||||
|
final room = event.room;
|
||||||
|
final info = event.content[groupCallContentKey] as Map;
|
||||||
|
final video = info['video'] == true;
|
||||||
|
final all = ref.watch(voiceParticipantsProvider(room.id));
|
||||||
|
final call = ref.watch(voiceChannelProvider);
|
||||||
|
final inThis = isInGroupCall(call, room.id);
|
||||||
|
// Alte Karten (gestern und früher) gehören zu längst beendeten Anrufen.
|
||||||
|
final recent = DateTime.now().difference(event.originServerTs) <
|
||||||
|
const Duration(hours: 12);
|
||||||
|
final running = recent && (all.isNotEmpty || inThis);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
padding: const EdgeInsets.fromLTRB(10, 8, 8, 8),
|
||||||
|
constraints: const BoxConstraints(maxWidth: 340),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: running ? pt.online.withValues(alpha: 0.12) : pt.bg2,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(
|
||||||
|
color: running ? pt.online.withValues(alpha: 0.35) : pt.border),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: running ? pt.online : pt.bg3,
|
||||||
|
),
|
||||||
|
child: Icon(video ? Icons.videocam_rounded : Icons.call_rounded,
|
||||||
|
size: 18, color: running ? Colors.black : pt.fgMuted),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Flexible(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(video ? 'Videoanruf gestartet' : 'Gruppenanruf gestartet',
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.fg,
|
||||||
|
fontSize: 13.5,
|
||||||
|
fontWeight: FontWeight.w700)),
|
||||||
|
Text(
|
||||||
|
running
|
||||||
|
? (inThis ? 'Du bist dabei' : 'Läuft · ${all.length} dabei')
|
||||||
|
: 'Beendet',
|
||||||
|
style: TextStyle(color: pt.fgMuted, fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (running && !inThis) ...[
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
TextButton(
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: pt.online,
|
||||||
|
textStyle: const TextStyle(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
onPressed: () =>
|
||||||
|
startOrJoinGroupCall(context, ref, room, video: video),
|
||||||
|
child: const Text('Beitreten'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:matrix/matrix.dart';
|
import 'package:matrix/matrix.dart';
|
||||||
import 'package:pyramid/core/matrix_client.dart';
|
import 'package:pyramid/core/matrix_client.dart';
|
||||||
import 'package:pyramid/core/theme.dart';
|
import 'package:pyramid/core/theme.dart';
|
||||||
|
import 'package:pyramid/widgets/mxc_image.dart';
|
||||||
import 'package:pyramid/widgets/pyramid_logo.dart';
|
import 'package:pyramid/widgets/pyramid_logo.dart';
|
||||||
|
|
||||||
class MembersPanel extends ConsumerWidget {
|
class MembersPanel extends ConsumerWidget {
|
||||||
@@ -43,7 +44,7 @@ class MembersPanel extends ConsumerWidget {
|
|||||||
Icon(Icons.group_rounded, size: 16, color: pt.fg),
|
Icon(Icons.group_rounded, size: 16, color: pt.fg),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'Members',
|
'Mitglieder',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: pt.fg,
|
color: pt.fg,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
@@ -94,7 +95,7 @@ class MembersPanel extends ConsumerWidget {
|
|||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(top: 24),
|
padding: const EdgeInsets.only(top: 24),
|
||||||
child: Text(
|
child: Text(
|
||||||
'No members loaded',
|
'Keine Mitglieder geladen',
|
||||||
style: TextStyle(color: pt.fgDim, fontSize: 12),
|
style: TextStyle(color: pt.fgDim, fontSize: 12),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -177,19 +178,22 @@ class _MemberItemState extends State<_MemberItem> {
|
|||||||
opacity: widget.isOffline ? 0.5 : 1,
|
opacity: widget.isOffline ? 0.5 : 1,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
// Rund wie überall sonst, mit echtem Profilbild.
|
||||||
width: 28,
|
MxcAvatar(
|
||||||
height: 28,
|
mxcUri: member.avatarUrl,
|
||||||
decoration: BoxDecoration(
|
client: member.room.client,
|
||||||
color: color,
|
size: 32,
|
||||||
borderRadius: BorderRadius.circular(pt.rSm),
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
placeholder: (_) => Container(
|
||||||
child: Center(
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||||
|
alignment: Alignment.center,
|
||||||
child: Text(
|
child: Text(
|
||||||
initial,
|
initial,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 11,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -214,7 +218,7 @@ class _MemberItemState extends State<_MemberItem> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
name,
|
member.id == member.room.client.userID ? '$name (Du)' : name,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: widget.isOffline ? pt.fgMuted : pt.fg,
|
color: widget.isOffline ? pt.fgMuted : pt.fg,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class _Header extends ConsumerWidget {
|
|||||||
final isRealSpace = spaceId != null && spaceId != 'dms' && spaceId != 'rooms';
|
final isRealSpace = spaceId != null && spaceId != 'dms' && spaceId != 'rooms';
|
||||||
final size = MediaQuery.sizeOf(context);
|
final size = MediaQuery.sizeOf(context);
|
||||||
final isMobile = size.width < 900 || size.height < 500;
|
final isMobile = size.width < 900 || size.height < 500;
|
||||||
|
final bottomNav = ref.watch(bottomNavProvider);
|
||||||
|
|
||||||
final spaces = ref.watch(spacesProvider).valueOrNull ?? [];
|
final spaces = ref.watch(spacesProvider).valueOrNull ?? [];
|
||||||
Room? activeSpace;
|
Room? activeSpace;
|
||||||
@@ -29,7 +30,7 @@ class _Header extends ConsumerWidget {
|
|||||||
|
|
||||||
final name = isRealSpace
|
final name = isRealSpace
|
||||||
? (activeSpace?.getLocalizedDisplayname() ?? 'Space')
|
? (activeSpace?.getLocalizedDisplayname() ?? 'Space')
|
||||||
: (spaceId == 'dms' ? 'Chats' : 'Rooms');
|
: 'Chats';
|
||||||
|
|
||||||
void handleMenu(_SpaceMenuItem item) {
|
void handleMenu(_SpaceMenuItem item) {
|
||||||
switch (item) {
|
switch (item) {
|
||||||
@@ -101,7 +102,7 @@ class _Header extends ConsumerWidget {
|
|||||||
).then((confirmed) {
|
).then((confirmed) {
|
||||||
if (confirmed == true) {
|
if (confirmed == true) {
|
||||||
space.leave().then((_) {
|
space.leave().then((_) {
|
||||||
ref.read(activeSpaceIdProvider.notifier).state = 'rooms';
|
ref.read(activeSpaceIdProvider.notifier).state = 'dms';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -110,7 +111,7 @@ class _Header extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<PopupMenuEntry<_SpaceMenuItem>> buildMenuItems() {
|
List<PopupMenuEntry<_SpaceMenuItem>> buildMenuItems() {
|
||||||
if (spaceId == 'dms' || spaceId == null) {
|
if (!isRealSpace) {
|
||||||
return [
|
return [
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: _SpaceMenuItem.newDm,
|
value: _SpaceMenuItem.newDm,
|
||||||
@@ -119,15 +120,10 @@ class _Header extends ConsumerWidget {
|
|||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: _SpaceMenuItem.createGroup,
|
value: _SpaceMenuItem.createGroup,
|
||||||
child: _MenuRow(Icons.group_add_rounded, 'Gruppe erstellen', pt)),
|
child: _MenuRow(Icons.group_add_rounded, 'Gruppe erstellen', pt)),
|
||||||
];
|
const PopupMenuDivider(),
|
||||||
} else if (spaceId == 'rooms') {
|
|
||||||
return [
|
|
||||||
PopupMenuItem(
|
|
||||||
value: _SpaceMenuItem.createRoom,
|
|
||||||
child: _MenuRow(Icons.add_rounded, 'Raum erstellen', pt)),
|
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: _SpaceMenuItem.joinRoom,
|
value: _SpaceMenuItem.joinRoom,
|
||||||
child: _MenuRow(Icons.login_rounded, 'Beitreten', pt)),
|
child: _MenuRow(Icons.login_rounded, 'Raum beitreten', pt)),
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: _SpaceMenuItem.discoverRooms,
|
value: _SpaceMenuItem.discoverRooms,
|
||||||
child: _MenuRow(Icons.explore_rounded, 'Entdecken', pt)),
|
child: _MenuRow(Icons.explore_rounded, 'Entdecken', pt)),
|
||||||
@@ -167,7 +163,18 @@ class _Header extends ConsumerWidget {
|
|||||||
|
|
||||||
Widget controlsRow = Row(
|
Widget controlsRow = Row(
|
||||||
children: [
|
children: [
|
||||||
if (isMobile)
|
if (isMobile && bottomNav) ...[
|
||||||
|
if (isRealSpace)
|
||||||
|
PyrIconBtn(
|
||||||
|
icon: Icon(Icons.arrow_back_rounded,
|
||||||
|
color: hasBanner ? Colors.white : null),
|
||||||
|
tooltip: 'Alle Server',
|
||||||
|
onPressed: () =>
|
||||||
|
ref.read(activeSpaceIdProvider.notifier).state = null,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const SizedBox(width: 2),
|
||||||
|
] else if (isMobile)
|
||||||
PyrIconBtn(
|
PyrIconBtn(
|
||||||
icon: const Icon(Icons.menu_rounded),
|
icon: const Icon(Icons.menu_rounded),
|
||||||
tooltip: 'Spaces',
|
tooltip: 'Spaces',
|
||||||
@@ -176,7 +183,7 @@ class _Header extends ConsumerWidget {
|
|||||||
else ...[
|
else ...[
|
||||||
PyrIconBtn(
|
PyrIconBtn(
|
||||||
icon: const Icon(Icons.menu_rounded),
|
icon: const Icon(Icons.menu_rounded),
|
||||||
tooltip: 'Toggle sidebar',
|
tooltip: 'Seitenleiste ein/aus',
|
||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
ref.read(railCollapsedProvider.notifier).update((s) => !s),
|
ref.read(railCollapsedProvider.notifier).update((s) => !s),
|
||||||
),
|
),
|
||||||
@@ -316,7 +323,7 @@ class _Header extends ConsumerWidget {
|
|||||||
height: isRealSpace ? 56 : 52,
|
height: isRealSpace ? 56 : 52,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isRealSpace ? pt.bg0 : pt.bg1,
|
color: isRealSpace && !isMobile ? pt.bg0 : pt.bg1,
|
||||||
border: Border(bottom: BorderSide(color: pt.border)),
|
border: Border(bottom: BorderSide(color: pt.border)),
|
||||||
),
|
),
|
||||||
child: controlsRow,
|
child: controlsRow,
|
||||||
@@ -458,9 +465,13 @@ class _NotificationsButton extends ConsumerWidget {
|
|||||||
class _FilterInput extends StatelessWidget {
|
class _FilterInput extends StatelessWidget {
|
||||||
final TextEditingController controller;
|
final TextEditingController controller;
|
||||||
final PyramidTheme pt;
|
final PyramidTheme pt;
|
||||||
|
final String hint;
|
||||||
final ValueChanged<String> onChanged;
|
final ValueChanged<String> onChanged;
|
||||||
const _FilterInput(
|
const _FilterInput(
|
||||||
{required this.controller, required this.pt, required this.onChanged});
|
{required this.controller,
|
||||||
|
required this.pt,
|
||||||
|
required this.hint,
|
||||||
|
required this.onChanged});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -469,18 +480,17 @@ class _FilterInput extends StatelessWidget {
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(left: 8),
|
|
||||||
child: Icon(Icons.search_rounded, size: 14, color: pt.fgDim),
|
|
||||||
),
|
|
||||||
TextField(
|
TextField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
onChanged: onChanged,
|
onChanged: onChanged,
|
||||||
style: TextStyle(color: pt.fg, fontSize: 13),
|
style: TextStyle(color: pt.fg, fontSize: 13),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Filter rooms',
|
hintText: hint,
|
||||||
hintStyle: TextStyle(color: pt.fgDim, fontSize: 13),
|
hintStyle: TextStyle(color: pt.fgDim, fontSize: 13),
|
||||||
contentPadding: const EdgeInsets.fromLTRB(28, 7, 10, 7),
|
prefixIcon: Icon(Icons.search_rounded, size: 16, color: pt.fgDim),
|
||||||
|
prefixIconConstraints:
|
||||||
|
const BoxConstraints(minWidth: 32, minHeight: 20),
|
||||||
|
contentPadding: const EdgeInsets.fromLTRB(0, 7, 10, 7),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: pt.bg2,
|
fillColor: pt.bg2,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class _InviteItemState extends ConsumerState<_InviteItem> {
|
|||||||
Future<void> _accept() async {
|
Future<void> _accept() async {
|
||||||
setState(() => _accepting = true);
|
setState(() => _accepting = true);
|
||||||
try {
|
try {
|
||||||
await widget.room.join();
|
await acceptInvite(ref, widget.room);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ref.read(activeRoomIdProvider.notifier).state = widget.room.id;
|
ref.read(activeRoomIdProvider.notifier).state = widget.room.id;
|
||||||
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
|
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||||
|
|||||||
@@ -59,56 +59,66 @@ class _DraggableRoomItemState extends State<_DraggableRoomItem> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Filter der gemeinsamen Chatliste (Chips über der Liste).
|
||||||
|
enum _ChatFilter { all, unread, people, groups }
|
||||||
|
|
||||||
|
final _chatFilterProvider =
|
||||||
|
StateProvider<_ChatFilter>((ref) => _ChatFilter.all);
|
||||||
|
|
||||||
|
/// „Chats“: Direktnachrichten und Gruppen zusammen, nach letzter Aktivität
|
||||||
|
/// (Bernd 2026-10-09). Kanäle eines Servers stehen nur in ihrem Server.
|
||||||
class _RoomsList extends ConsumerWidget {
|
class _RoomsList extends ConsumerWidget {
|
||||||
final List<Room> rooms;
|
final List<Room> rooms;
|
||||||
final String filter;
|
final String filter;
|
||||||
final String? spaceId;
|
|
||||||
final Map<String, bool> collapsed;
|
|
||||||
final PyramidTheme pt;
|
final PyramidTheme pt;
|
||||||
final ValueChanged<String> onToggleSection;
|
|
||||||
|
|
||||||
const _RoomsList({
|
const _RoomsList({
|
||||||
required this.rooms,
|
required this.rooms,
|
||||||
required this.filter,
|
required this.filter,
|
||||||
required this.spaceId,
|
|
||||||
required this.collapsed,
|
|
||||||
required this.pt,
|
required this.pt,
|
||||||
required this.onToggleSection,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final activeRoomId = ref.watch(activeRoomIdProvider);
|
final activeRoomId = ref.watch(activeRoomIdProvider);
|
||||||
final isDms = spaceId == 'dms';
|
final channels = ref.watch(spaceChannelIdsProvider);
|
||||||
|
final chatFilter = ref.watch(_chatFilterProvider);
|
||||||
|
final query = filter.toLowerCase();
|
||||||
|
|
||||||
final invites = rooms
|
final invites =
|
||||||
.where((r) =>
|
rooms.where((r) => r.membership == Membership.invite).toList();
|
||||||
r.membership == Membership.invite &&
|
|
||||||
isDms == r.isDirectChat)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
final filtered = rooms.where((r) {
|
bool matches(Room r) {
|
||||||
if (r.membership == Membership.invite) return false;
|
switch (chatFilter) {
|
||||||
if (isDms != r.isDirectChat) return false;
|
case _ChatFilter.all:
|
||||||
if (filter.isEmpty) return true;
|
break;
|
||||||
return r.getLocalizedDisplayname()
|
case _ChatFilter.unread:
|
||||||
.toLowerCase()
|
if (!r.isUnread) return false;
|
||||||
.contains(filter.toLowerCase());
|
case _ChatFilter.people:
|
||||||
}).toList();
|
if (!r.isDirectChat) return false;
|
||||||
|
case _ChatFilter.groups:
|
||||||
if (filtered.isEmpty && invites.isEmpty) {
|
if (r.isDirectChat) return false;
|
||||||
return Center(
|
}
|
||||||
child:
|
if (query.isEmpty) return true;
|
||||||
Text('No rooms', style: TextStyle(color: pt.fgDim, fontSize: 13)));
|
return r.getLocalizedDisplayname().toLowerCase().contains(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
final sectionTitle = isDms ? 'Direct Messages' : 'Rooms';
|
final chats = rooms
|
||||||
final sectionId = isDms ? 'dms' : 'rooms';
|
.where((r) =>
|
||||||
final isCollapsed = collapsed[sectionId] ?? false;
|
r.membership == Membership.join && isChatListRoom(r, channels))
|
||||||
|
.toList();
|
||||||
|
final filtered = chats.where(matches).toList();
|
||||||
|
final unreadCount = chats.where((r) => r.isUnread).length;
|
||||||
|
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(8, 4, 8, 12),
|
padding: const EdgeInsets.fromLTRB(8, 0, 8, 12),
|
||||||
children: [
|
children: [
|
||||||
|
_ChatFilterChips(
|
||||||
|
value: chatFilter,
|
||||||
|
unreadCount: unreadCount,
|
||||||
|
pt: pt,
|
||||||
|
onChanged: (f) => ref.read(_chatFilterProvider.notifier).state = f,
|
||||||
|
),
|
||||||
if (invites.isNotEmpty) ...[
|
if (invites.isNotEmpty) ...[
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 2),
|
padding: const EdgeInsets.fromLTRB(8, 6, 8, 2),
|
||||||
@@ -124,111 +134,204 @@ class _RoomsList extends ConsumerWidget {
|
|||||||
child: _InviteItem(room: r, pt: pt))),
|
child: _InviteItem(room: r, pt: pt))),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
],
|
],
|
||||||
_SectionHeader(
|
if (filtered.isEmpty)
|
||||||
title: sectionTitle,
|
_ChatListEmpty(
|
||||||
count: filtered.length,
|
filter: chatFilter,
|
||||||
isCollapsed: isCollapsed,
|
searching: query.isNotEmpty,
|
||||||
pt: pt,
|
hasInvites: invites.isNotEmpty,
|
||||||
onToggle: () => onToggleSection(sectionId),
|
pt: pt,
|
||||||
onAddTap: isDms
|
)
|
||||||
? () => showDialog(
|
else
|
||||||
context: context, builder: (_) => const NewDmDialog())
|
...filtered.map((room) {
|
||||||
: () => showDialog(
|
final isVoice = _isVoiceRoom(room);
|
||||||
context: context, builder: (_) => const CreateJoinDialog()),
|
return _RoomItem(
|
||||||
),
|
key: ValueKey('chat:${room.id}'),
|
||||||
if (!isCollapsed)
|
room: room,
|
||||||
...filtered.map((room) => _RoomItem(
|
isActive: isVoice ? false : activeRoomId == room.id,
|
||||||
room: room,
|
isInVoice:
|
||||||
isActive: activeRoomId == room.id,
|
isVoice && ref.read(activeVoiceRoomIdProvider) == room.id,
|
||||||
pt: pt,
|
pt: pt,
|
||||||
onTap: () {
|
// 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(activeRoomIdProvider.notifier).state = room.id;
|
||||||
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
|
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||||
},
|
}
|
||||||
)),
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SectionHeader extends StatefulWidget {
|
class _ChatFilterChips extends StatelessWidget {
|
||||||
final String title;
|
final _ChatFilter value;
|
||||||
final int count;
|
final int unreadCount;
|
||||||
final bool isCollapsed;
|
|
||||||
final PyramidTheme pt;
|
final PyramidTheme pt;
|
||||||
final VoidCallback onToggle;
|
final ValueChanged<_ChatFilter> onChanged;
|
||||||
final VoidCallback? onAddTap;
|
|
||||||
|
|
||||||
const _SectionHeader({
|
const _ChatFilterChips({
|
||||||
required this.title,
|
required this.value,
|
||||||
required this.count,
|
required this.unreadCount,
|
||||||
required this.isCollapsed,
|
|
||||||
required this.pt,
|
required this.pt,
|
||||||
required this.onToggle,
|
required this.onChanged,
|
||||||
this.onAddTap,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
|
||||||
State<_SectionHeader> createState() => _SectionHeaderState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SectionHeaderState extends State<_SectionHeader> {
|
|
||||||
bool _hovered = false;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final pt = widget.pt;
|
Widget chip(_ChatFilter f, String label) {
|
||||||
return MouseRegion(
|
final selected = value == f;
|
||||||
onEnter: (_) => setState(() => _hovered = true),
|
return Padding(
|
||||||
onExit: (_) => setState(() => _hovered = false),
|
padding: const EdgeInsets.only(right: 6),
|
||||||
child: Container(
|
child: Semantics(
|
||||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 4),
|
button: true,
|
||||||
child: Row(children: [
|
selected: selected,
|
||||||
Expanded(
|
child: GestureDetector(
|
||||||
child: GestureDetector(
|
onTap: () => onChanged(selected ? _ChatFilter.all : f),
|
||||||
onTap: widget.onToggle,
|
child: AnimatedContainer(
|
||||||
child: Row(children: [
|
duration: const Duration(milliseconds: 180),
|
||||||
AnimatedRotation(
|
curve: Curves.easeOutCubic,
|
||||||
turns: widget.isCollapsed ? -0.25 : 0,
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
duration: const Duration(milliseconds: 200),
|
decoration: BoxDecoration(
|
||||||
curve: Curves.easeOutBack,
|
color: selected ? pt.accentSoft : pt.bg2,
|
||||||
child: Icon(Icons.keyboard_arrow_down_rounded,
|
borderRadius: BorderRadius.circular(999),
|
||||||
size: 14,
|
border: Border.all(
|
||||||
color: _hovered ? pt.fgMuted : pt.fgDim),
|
color: selected ? pt.accent.withAlpha(120) : pt.border),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
child: Text(label,
|
||||||
Text(widget.title.toUpperCase(),
|
style: TextStyle(
|
||||||
style: TextStyle(
|
color: selected ? pt.accent : pt.fgMuted,
|
||||||
color: _hovered ? pt.fgMuted : pt.fgDim,
|
fontSize: 12.5,
|
||||||
fontSize: 11,
|
fontWeight: selected ? FontWeight.w700 : FontWeight.w500)),
|
||||||
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,
|
final chips = [
|
||||||
child: SizedBox(
|
chip(_ChatFilter.all, 'Alle'),
|
||||||
width: 18,
|
chip(_ChatFilter.unread,
|
||||||
height: 18,
|
unreadCount > 0 ? 'Ungelesen · $unreadCount' : 'Ungelesen'),
|
||||||
child: Center(
|
chip(_ChatFilter.people, 'Personen'),
|
||||||
child: Icon(Icons.add, size: 12, color: pt.fgMuted)),
|
chip(_ChatFilter.groups, 'Gruppen'),
|
||||||
),
|
];
|
||||||
),
|
// Handy: wischbare Zeile; PC (schmale Spalte): umbrechen statt
|
||||||
] else
|
// abschneiden – mit der Maus wischt niemand seitwärts.
|
||||||
const SizedBox(width: 22),
|
if (!isMobileLayout(MediaQuery.sizeOf(context))) {
|
||||||
]),
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(4, 2, 4, 6),
|
||||||
|
child: Wrap(runSpacing: 6, children: chips),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return SizedBox(
|
||||||
|
height: 40,
|
||||||
|
child: ListView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.fromLTRB(4, 2, 4, 6),
|
||||||
|
children: chips,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Leerzustand der Chatliste – mit Handlung statt nur „nichts da“.
|
||||||
|
class _ChatListEmpty extends StatelessWidget {
|
||||||
|
final _ChatFilter filter;
|
||||||
|
final bool searching;
|
||||||
|
final bool hasInvites;
|
||||||
|
final PyramidTheme pt;
|
||||||
|
|
||||||
|
const _ChatListEmpty({
|
||||||
|
required this.filter,
|
||||||
|
required this.searching,
|
||||||
|
required this.hasInvites,
|
||||||
|
required this.pt,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (hasInvites && filter == _ChatFilter.all && !searching) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
final (IconData icon, String text) = searching
|
||||||
|
? (Icons.search_off_rounded, 'Nichts gefunden')
|
||||||
|
: switch (filter) {
|
||||||
|
_ChatFilter.unread => (Icons.done_all_rounded, 'Alles gelesen'),
|
||||||
|
_ChatFilter.people => (
|
||||||
|
Icons.person_outline_rounded,
|
||||||
|
'Noch keine Unterhaltungen'
|
||||||
|
),
|
||||||
|
_ChatFilter.groups => (Icons.groups_outlined, 'Noch keine Gruppen'),
|
||||||
|
_ChatFilter.all => (
|
||||||
|
Icons.chat_bubble_outline_rounded,
|
||||||
|
'Noch keine Chats'
|
||||||
|
),
|
||||||
|
};
|
||||||
|
final showAction = !searching && filter != _ChatFilter.unread;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 40, 16, 16),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 34, color: pt.fgDim),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(text, style: TextStyle(color: pt.fgMuted, fontSize: 14)),
|
||||||
|
if (showAction) ...[
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
FilledButton.tonalIcon(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: pt.accentSoft,
|
||||||
|
foregroundColor: pt.accent,
|
||||||
|
),
|
||||||
|
icon: Icon(filter == _ChatFilter.groups
|
||||||
|
? Icons.group_add_rounded
|
||||||
|
: Icons.edit_rounded, size: 18),
|
||||||
|
label: Text(filter == _ChatFilter.groups
|
||||||
|
? 'Gruppe erstellen'
|
||||||
|
: 'Neue Unterhaltung'),
|
||||||
|
onPressed: () => showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => filter == _ChatFilter.groups
|
||||||
|
? const CreateJoinDialog()
|
||||||
|
: const NewDmDialog(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 _RoomItem extends ConsumerStatefulWidget {
|
class _RoomItem extends ConsumerStatefulWidget {
|
||||||
final Room room;
|
final Room room;
|
||||||
final bool isActive;
|
final bool isActive;
|
||||||
@@ -477,6 +580,31 @@ class _RoomItemState extends ConsumerState<_RoomItem> {
|
|||||||
final voiceParticipants = isVoice ? ref.watch(voiceParticipantsProvider(room.id)) : <Map<String, dynamic>>[];
|
final voiceParticipants = isVoice ? ref.watch(voiceParticipantsProvider(room.id)) : <Map<String, dynamic>>[];
|
||||||
final client = ref.watch(matrixClientProvider).valueOrNull;
|
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||||
|
|
||||||
|
// Handy: Sprachkanal als Kachel mit Teilnehmern (rooms_mobile_tile.dart).
|
||||||
|
if (isVoice && isMobileLayout(MediaQuery.sizeOf(context))) {
|
||||||
|
return _MobileVoiceTile(
|
||||||
|
room: room,
|
||||||
|
inVoice: inVoice,
|
||||||
|
participants: voiceParticipants,
|
||||||
|
client: client,
|
||||||
|
pt: pt,
|
||||||
|
onTap: widget.onTap,
|
||||||
|
onLongPress: _showContextMenu,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handy: Messenger-Kachel mit Vorschau (rooms_mobile_tile.dart).
|
||||||
|
if (!isVoice && isMobileLayout(MediaQuery.sizeOf(context))) {
|
||||||
|
return _MobileRoomTile(
|
||||||
|
room: room,
|
||||||
|
isActive: active,
|
||||||
|
pt: pt,
|
||||||
|
myUserId: client?.userID,
|
||||||
|
onTap: widget.onTap,
|
||||||
|
onLongPress: _showContextMenu,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Color textColor = (active || inVoice || unread > 0 || _hovered) ? pt.fg : pt.fgMuted;
|
Color textColor = (active || inVoice || unread > 0 || _hovered) ? pt.fg : pt.fgMuted;
|
||||||
|
|
||||||
return MouseRegion(
|
return MouseRegion(
|
||||||
@@ -526,6 +654,11 @@ class _RoomItemState extends ConsumerState<_RoomItem> {
|
|||||||
children: [
|
children: [
|
||||||
if (isDm) ...[
|
if (isDm) ...[
|
||||||
_DmAvatar(room: room, pt: pt),
|
_DmAvatar(room: room, pt: pt),
|
||||||
|
] else if (!isVoice &&
|
||||||
|
!ref
|
||||||
|
.watch(spaceChannelIdsProvider)
|
||||||
|
.contains(room.id)) ...[
|
||||||
|
_RoomTileAvatar(room: room, pt: pt, size: 22),
|
||||||
] else ...[
|
] else ...[
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 16,
|
width: 16,
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
part of '../rooms_panel.dart';
|
||||||
|
|
||||||
|
// Chatlisten-Eintrag im Handy-Layout (Bernds Feedback 2026-10-07: die Handy-App
|
||||||
|
// soll sich wie ein gewohnter Messenger anfühlen): großer Avatar, Name mit
|
||||||
|
// Uhrzeit der letzten Nachricht, darunter die letzte Nachricht als Vorschau
|
||||||
|
// und der Ungelesen-Zähler. Am PC bleibt die kompakte Discord-Liste
|
||||||
|
// (_RoomItem); Voice-Kanäle behalten ihre eigene Darstellung.
|
||||||
|
|
||||||
|
const _kTileAvatar = 46.0;
|
||||||
|
|
||||||
|
class _MobileRoomTile extends StatelessWidget {
|
||||||
|
final Room room;
|
||||||
|
final bool isActive;
|
||||||
|
final PyramidTheme pt;
|
||||||
|
final String? myUserId;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final VoidCallback onLongPress;
|
||||||
|
|
||||||
|
const _MobileRoomTile({
|
||||||
|
required this.room,
|
||||||
|
required this.isActive,
|
||||||
|
required this.pt,
|
||||||
|
required this.myUserId,
|
||||||
|
required this.onTap,
|
||||||
|
required this.onLongPress,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final name = room.getLocalizedDisplayname();
|
||||||
|
final unread = room.notificationCount;
|
||||||
|
final mention = room.highlightCount > 0;
|
||||||
|
final last = room.lastEvent;
|
||||||
|
final preview = _roomPreview(room, myUserId);
|
||||||
|
final time = last == null ? null : chatListTime(last.originServerTs);
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: onTap,
|
||||||
|
onLongPress: onLongPress,
|
||||||
|
child: Container(
|
||||||
|
color: isActive ? pt.accentSoft : Colors.transparent,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
room.isDirectChat
|
||||||
|
? _DmAvatar(room: room, pt: pt, size: _kTileAvatar)
|
||||||
|
: _RoomTileAvatar(room: room, pt: pt),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.fg,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight:
|
||||||
|
unread > 0 ? FontWeight.w700 : FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (time != null) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
time,
|
||||||
|
style: TextStyle(
|
||||||
|
color: unread > 0 ? pt.accent : pt.fgDim,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight:
|
||||||
|
unread > 0 ? FontWeight.w600 : FontWeight.w400,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
preview ?? '',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: unread > 0 ? pt.fgMuted : pt.fgDim,
|
||||||
|
fontSize: 13.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Immer im Baum, damit der Zähler beim Erscheinen und
|
||||||
|
// Hochzählen federnd aufploppen kann.
|
||||||
|
SpringBump(
|
||||||
|
value: unread,
|
||||||
|
child: unread > 0
|
||||||
|
? Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 8),
|
||||||
|
child: _UnreadBadge(
|
||||||
|
count: unread, isMention: mention, pt: pt),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Avatar für Räume (kein DM): Raumbild oder Symbol. Gruppen (kein
|
||||||
|
/// Server-Kanal) sind rund wie Personen, Server-Kanäle abgerundet mit „#“.
|
||||||
|
class _RoomTileAvatar extends ConsumerWidget {
|
||||||
|
final Room room;
|
||||||
|
final PyramidTheme pt;
|
||||||
|
final double size;
|
||||||
|
const _RoomTileAvatar(
|
||||||
|
{required this.room, required this.pt, this.size = _kTileAvatar});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||||
|
final isChannel = ref.watch(spaceChannelIdsProvider).contains(room.id);
|
||||||
|
final radius = BorderRadius.circular(isChannel ? size * 0.3 : size / 2);
|
||||||
|
final fallback = Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isChannel ? pt.bg3 : pt.accentSoft,
|
||||||
|
borderRadius: radius,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
isChannel
|
||||||
|
? (room.encrypted ? Icons.lock_outline_rounded : Icons.tag_rounded)
|
||||||
|
: Icons.groups_rounded,
|
||||||
|
size: size * 0.5,
|
||||||
|
color: isChannel ? pt.fgMuted : pt.accent,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (client == null || room.avatar == null) return fallback;
|
||||||
|
return MxcAvatar(
|
||||||
|
mxcUri: room.avatar,
|
||||||
|
client: client,
|
||||||
|
size: size,
|
||||||
|
borderRadius: radius,
|
||||||
|
placeholder: (_) => fallback,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vorschau der letzten Nachricht: gleiche Texte wie in Benachrichtigungen
|
||||||
|
/// (Medien als „📷 Foto“ usw., Antwort-Zitat entfernt), in Gruppen mit
|
||||||
|
/// Absender, eigene Nachrichten mit „Du:“.
|
||||||
|
String? _roomPreview(Room room, String? myUserId) {
|
||||||
|
final e = room.lastEvent;
|
||||||
|
if (e == null) return null;
|
||||||
|
if (e.type == EventTypes.Encrypted) return '🔒 Verschlüsselte Nachricht';
|
||||||
|
final body = notificationBody(e);
|
||||||
|
if (body == null) return null;
|
||||||
|
if (e.senderId == myUserId) return 'Du: $body';
|
||||||
|
if (room.isDirectChat) return body;
|
||||||
|
return '${e.senderFromMemoryOrFallback.calcDisplayname()}: $body';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sprachkanal im Handy-Layout: Kachel wie ein Chat, darunter wer gerade drin
|
||||||
|
/// ist (Avatare + Namen) bzw. „Antippen zum Beitreten“.
|
||||||
|
class _MobileVoiceTile extends StatelessWidget {
|
||||||
|
final Room room;
|
||||||
|
final bool inVoice;
|
||||||
|
final List<Map<String, dynamic>> participants;
|
||||||
|
final Client? client;
|
||||||
|
final PyramidTheme pt;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final VoidCallback onLongPress;
|
||||||
|
|
||||||
|
const _MobileVoiceTile({
|
||||||
|
required this.room,
|
||||||
|
required this.inVoice,
|
||||||
|
required this.participants,
|
||||||
|
required this.client,
|
||||||
|
required this.pt,
|
||||||
|
required this.onTap,
|
||||||
|
required this.onLongPress,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final green = pt.online;
|
||||||
|
final names = participants
|
||||||
|
.map((p) => (p['displayName'] as String? ?? '').split(' ').first)
|
||||||
|
.where((n) => n.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
final subtitle = inVoice
|
||||||
|
? 'Du bist drin · antippen zum Verlassen'
|
||||||
|
: participants.isEmpty
|
||||||
|
? 'Sprachkanal · antippen zum Beitreten'
|
||||||
|
: names.length <= 2
|
||||||
|
? '${names.join(' und ')} ${names.length == 1 ? 'ist' : 'sind'} drin'
|
||||||
|
: '${names.take(2).join(', ')} und ${names.length - 2} weitere';
|
||||||
|
return GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: onTap,
|
||||||
|
onLongPress: onLongPress,
|
||||||
|
child: Container(
|
||||||
|
color: inVoice ? green.withValues(alpha: 0.10) : Colors.transparent,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: _kTileAvatar,
|
||||||
|
height: _kTileAvatar,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: green.withValues(alpha: inVoice ? 0.30 : 0.14),
|
||||||
|
borderRadius: BorderRadius.circular(_kTileAvatar * 0.3),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
participants.isEmpty
|
||||||
|
? Icons.volume_up_rounded
|
||||||
|
: Icons.graphic_eq_rounded,
|
||||||
|
size: 22,
|
||||||
|
color: green,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(room.getLocalizedDisplayname(),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.fg,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Text(subtitle,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: participants.isEmpty && !inVoice
|
||||||
|
? pt.fgDim
|
||||||
|
: green,
|
||||||
|
fontSize: 13.5)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (participants.isNotEmpty && client != null) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
CallAvatarStack(
|
||||||
|
participants: participants,
|
||||||
|
client: client!,
|
||||||
|
ringColor: pt.bg1,
|
||||||
|
size: 26,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user