Compare commits
@@ -46,3 +46,6 @@ app.*.map.json
|
||||
autopilot.log
|
||||
.claude/worktrees/
|
||||
*.bak
|
||||
|
||||
# Skript-Backups vor riskanten Aenderungen (bleiben lokal als Rollback)
|
||||
*.bak-*
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Bernds Entscheidungen – Richtungsfragen aus PROGRESS.md beantwortet
|
||||
|
||||
Diese Datei beantwortet die über mehrere Sessions in PROGRESS.md gesammelten
|
||||
„Fragen an Bernd". Sie ist ab jetzt **maßgeblich** – die betroffenen Punkte in
|
||||
ROADMAP.md/PROGRESS.md gelten als entschieden.
|
||||
Zuletzt aktualisiert: **2026-07-05** (Bernd + Claude am PC).
|
||||
|
||||
## 1. SQLCipher (lokale DB + Access-Token verschlüsseln) → VOR M2 priorisieren
|
||||
- **Entscheidung:** Ja. Die Verschlüsselung der lokalen `pyramid.sqlite` hat Vorrang
|
||||
vor dem M2-Refactoring. Betrifft den M1-Punkt „Sichere Speicherung von Access-Token
|
||||
& Krypto-DB".
|
||||
- Umsetzung nach `docs/SQLCIPHER_MIGRATION.md`. **Wichtig:** Die Migration bestehender
|
||||
Klartext-DBs (Utas Gerät!) ist der riskante Teil und braucht einen echten
|
||||
PC-/Gerätetest. Auf dem Pi (kein GUI) nur so weit vorbereiten, wie es OHNE GUI sicher
|
||||
testbar ist; alles Verhaltensrelevante klar als UNGETESTET markieren und erst nach
|
||||
`docs/PC_TESTPLAN.md` als erledigt abhaken. Grundsatz „Kein Datenverlust" – im Zweifel
|
||||
für den PC-Termin liegen lassen.
|
||||
|
||||
## 2. M2 – erstes Umbau-Modul → Call-Pilot zuerst
|
||||
- **Entscheidung:** Wenn M2 startet (nach SQLCipher), zuerst die Call-Schicht entwirren
|
||||
(Call-Pilot / gemeinsame Fassade über `voip_manager.dart` + `livekit_call_manager.dart`),
|
||||
wie in `docs/M2_MODULE_SCHNITT.md` vorgeschlagen. **Nicht** zuerst `settings_modal.dart`
|
||||
aufsplitten.
|
||||
- **Wichtig:** heiliger Call-Pfad → braucht zwingend echten Call-Test auf einem Gerät.
|
||||
Auf dem Pi nicht blind umbauen; Vorbereitung/Analyse ok, Umsetzung + Test am PC.
|
||||
|
||||
## 3. Dashboard-Fernzugriff (stats.html) → Heimnetz-only lassen
|
||||
- **Entscheidung:** KEIN Fernzugriff. Das Interims-Gate (nur Heimnetz/localhost) bleibt so.
|
||||
Die offene A/B-Frage (Cloudflare Access vs. DASH_TOKEN) aus
|
||||
`docs/DASHBOARD_AUTH_HARDENING.md` ist damit **geschlossen** – nicht weiter daran
|
||||
arbeiten. Unterwegs per WireGuard erreichbar.
|
||||
|
||||
## 4. Git-History / Admin-Token → vorerst NICHTS tun
|
||||
- **Entscheidung:** Kein Git-History-Rewrite und aktuell keine Token-Rotation durch den
|
||||
Autopilot. Der Admin-Token ist bereits aus dem App-Code heraus (eigener `DIAG_TOKEN`).
|
||||
Eine spätere Rotation bleibt Bernds manuelle Sache – nicht eigenmächtig anstoßen.
|
||||
|
||||
---
|
||||
|
||||
**Kurzfassung fürs Arbeiten:** SQLCipher vor M2, dann Call-Pilot als erstes M2-Modul.
|
||||
Dashboard bleibt Heimnetz-only. History unangetastet. Riskante/GUI-relevante Teile
|
||||
warten auf den PC-Termin (`docs/PC_TESTPLAN.md`); auf dem Pi nur sicher Vorbereitbares.
|
||||
+293
@@ -1199,3 +1199,296 @@
|
||||
2026-07-03 17:51 [Write] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid_push.md
|
||||
2026-07-03 17:52 [Write] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
|
||||
2026-07-03 17:52 [Write] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/MEMORY.md
|
||||
2026-07-03 18:16 [Write] /home/steggi/pyramid/test/soft_logout_guard_test.dart
|
||||
2026-07-03 18:17 [Write] /home/steggi/pyramid/test/power_levels_safety_test.dart
|
||||
2026-07-03 22:21 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-03 22:22 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-03 22:23 [Write] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
|
||||
2026-07-03 22:23 [Edit] /home/steggi/pyramid/docs/PC_TESTPLAN.md
|
||||
2026-07-03 22:48 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-03 22:50 [Write] /home/steggi/pyramid/test/auth_log_test.dart
|
||||
2026-07-03 22:50 [Edit] /home/steggi/pyramid/pubspec.yaml
|
||||
2026-07-03 22:51 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-03 22:52 [Write] /home/steggi/pyramid/test/settings_prefs_test.dart
|
||||
2026-07-03 22:53 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-03 22:53 [Edit] /home/steggi/pyramid/docs/PC_TESTPLAN.md
|
||||
2026-07-03 22:54 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
|
||||
2026-07-03 23:18 [Edit] /home/steggi/pyramid/test/settings_prefs_test.dart
|
||||
2026-07-03 23:18 [Edit] /home/steggi/pyramid/test/settings_prefs_test.dart
|
||||
2026-07-03 23:19 [Edit] /home/steggi/pyramid/lib/core/settings_prefs.dart
|
||||
2026-07-03 23:20 [Edit] /home/steggi/pyramid/lib/core/settings_prefs.dart
|
||||
2026-07-03 23:21 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-03 23:21 [Edit] /home/steggi/pyramid/docs/PC_TESTPLAN.md
|
||||
2026-07-03 23:22 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 03:28 [Edit] /home/steggi/pyramid/scripts/release.ps1
|
||||
2026-07-04 03:31 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 03:32 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 03:32 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||
2026-07-04 04:01 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 04:02 [Edit] /home/steggi/pyramid/lib/widgets/settings/settings_encryption.dart
|
||||
2026-07-04 04:03 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||
2026-07-04 04:03 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 04:06 [Write] /home/steggi/pyramid/docs/LIVEKIT_TOKEN_MIGRATION.md
|
||||
2026-07-04 04:06 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||
2026-07-04 04:06 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 04:07 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
|
||||
2026-07-04 04:30 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 04:30 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 04:31 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 04:32 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 04:33 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 04:33 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 04:33 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 08:42 [Write] /home/steggi/pyramid/docs/DASHBOARD_AUTH_HARDENING.md
|
||||
2026-07-04 08:42 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 08:43 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 08:43 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||
2026-07-04 09:14 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 09:14 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 09:15 [Edit] /home/steggi/pyramid/docs/DASHBOARD_AUTH_HARDENING.md
|
||||
2026-07-04 09:15 [Edit] /home/steggi/pyramid/docs/DASHBOARD_AUTH_HARDENING.md
|
||||
2026-07-04 09:16 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||
2026-07-04 09:16 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||
2026-07-04 09:17 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 09:17 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 09:18 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
|
||||
2026-07-04 09:43 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 09:43 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 09:43 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 13:46 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 13:46 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 13:50 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 13:51 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 13:51 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||
2026-07-04 13:51 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
|
||||
2026-07-04 14:17 [Write] /home/steggi/matrix/server.py
|
||||
2026-07-04 14:19 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 14:20 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 14:20 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||
2026-07-04 14:43 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 14:44 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 14:44 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 14:44 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 14:44 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 14:45 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 14:46 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 14:46 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||
2026-07-04 14:47 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
|
||||
2026-07-04 18:54 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 18:54 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 18:54 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 18:54 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 18:54 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 18:56 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 18:56 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 18:56 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||
2026-07-04 18:57 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
|
||||
2026-07-04 19:19 [Edit] /home/steggi/matrix/server.py
|
||||
2026-07-04 19:20 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 19:21 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 19:21 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||
2026-07-04 19:22 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
|
||||
2026-07-04 19:44 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 19:44 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-04 19:44 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||
2026-07-04 19:45 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
|
||||
2026-07-05 14:51 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-05 14:52 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-05 14:52 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||
2026-07-05 14:52 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||
2026-07-05 14:52 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||
2026-07-05 14:52 [Edit] /home/steggi/pyramid/docs/DASHBOARD_AUTH_HARDENING.md
|
||||
2026-07-05 14:52 [Edit] /home/steggi/pyramid/docs/DASHBOARD_AUTH_HARDENING.md
|
||||
2026-07-05 14:52 [Edit] /home/steggi/pyramid/docs/DASHBOARD_AUTH_HARDENING.md
|
||||
2026-07-05 14:53 [Edit] /home/steggi/pyramid/docs/DASHBOARD_AUTH_HARDENING.md
|
||||
2026-07-05 14:53 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-05 14:54 [Write] /home/steggi/.claude/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand-haupt.md
|
||||
2026-07-05 14:54 [Edit] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
|
||||
2026-07-05 14:54 [Edit] /home/steggi/.claude/projects/-home-steggi-pyramid/memory/MEMORY.md
|
||||
2026-07-05 15:18 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-05 15:18 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-05 18:40 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-05 18:41 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-05 19:03 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-05 19:03 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-05 19:03 [Edit] /home/steggi/.claude/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand-haupt.md
|
||||
2026-07-06 11:56 [Edit] /home/steggi/pyramid/autopilot.sh
|
||||
2026-07-06 11:57 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-06 11:57 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-06 11:57 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-06 11:57 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-06 11:58 [Edit] /home/steggi/.claude/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand-haupt.md
|
||||
2026-07-06 11:58 [Edit] /home/steggi/.claude/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand-haupt.md
|
||||
2026-07-06 12:16 [Write] C:\Users\nordm\pyramid - Kopie\lib\core\app_database.dart
|
||||
2026-07-06 12:17 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_database.dart
|
||||
2026-07-06 12:18 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_database.dart
|
||||
2026-07-06 12:18 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\matrix_client.dart
|
||||
2026-07-06 12:18 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart
|
||||
2026-07-06 12:18 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\background_push.dart
|
||||
2026-07-06 12:18 [Edit] C:\Users\nordm\pyramid - Kopie\pubspec.yaml
|
||||
2026-07-06 12:22 [Write] C:\Users\nordm\pyramid - Kopie\test\app_database_test.dart
|
||||
2026-07-06 12:24 [Edit] C:\Users\nordm\pyramid - Kopie\test\app_database_test.dart
|
||||
2026-07-06 12:24 [Edit] C:\Users\nordm\pyramid - Kopie\test\app_database_test.dart
|
||||
2026-07-06 12:24 [Edit] C:\Users\nordm\pyramid - Kopie\test\app_database_test.dart
|
||||
2026-07-06 12:29 [Edit] C:\Users\nordm\pyramid - Kopie\docs\SQLCIPHER_MIGRATION.md
|
||||
2026-07-06 12:30 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
|
||||
2026-07-06 12:31 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||
2026-07-06 12:33 [Write] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\call_signaling_service.dart
|
||||
2026-07-06 12:33 [Write] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\voice_channel_service.dart
|
||||
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
|
||||
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
|
||||
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
|
||||
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
|
||||
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
|
||||
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
|
||||
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
|
||||
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
|
||||
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
|
||||
2026-07-06 12:34 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
|
||||
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 12:35 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 12:36 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_state.dart
|
||||
2026-07-06 12:36 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_state.dart
|
||||
2026-07-06 12:36 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\matrix_client.dart
|
||||
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart
|
||||
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart
|
||||
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart
|
||||
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\mini_call_widget.dart
|
||||
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\mini_call_widget.dart
|
||||
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\mini_call_widget.dart
|
||||
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\mini_call_widget.dart
|
||||
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart
|
||||
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart
|
||||
2026-07-06 12:37 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart
|
||||
2026-07-06 12:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart
|
||||
2026-07-06 12:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart
|
||||
2026-07-06 12:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\voice_channel.dart
|
||||
2026-07-06 12:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\mini_call_widget.dart
|
||||
2026-07-06 12:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call\mini_call_widget.dart
|
||||
2026-07-06 12:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\view\chat_header.dart
|
||||
2026-07-06 12:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\view\chat_header.dart
|
||||
2026-07-06 12:38 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart
|
||||
2026-07-06 12:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart
|
||||
2026-07-06 12:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
|
||||
2026-07-06 12:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
|
||||
2026-07-06 12:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\chat\chat_view.dart
|
||||
2026-07-06 12:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\call_signaling_service.dart
|
||||
2026-07-06 12:41 [Edit] C:\Users\nordm\pyramid - Kopie\docs\M2_MODULE_SCHNITT.md
|
||||
2026-07-06 12:41 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
|
||||
2026-07-06 12:42 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||
2026-07-06 12:44 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
|
||||
2026-07-06 12:44 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
|
||||
2026-07-06 12:45 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||
2026-07-06 16:58 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
|
||||
2026-07-06 16:59 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||
2026-07-06 17:01 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\rooms_panel.dart
|
||||
2026-07-06 17:01 [Edit] C:\Users\nordm\pyramid - Kopie\lib\layout\app_shell.dart
|
||||
2026-07-06 17:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\settings_prefs.dart
|
||||
2026-07-06 17:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
|
||||
2026-07-06 17:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
|
||||
2026-07-06 17:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 17:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_signaling\voip_manager.dart
|
||||
2026-07-06 17:05 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 17:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
|
||||
2026-07-06 17:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
|
||||
2026-07-06 17:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
|
||||
2026-07-06 17:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
|
||||
2026-07-06 17:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
|
||||
2026-07-06 17:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
|
||||
2026-07-06 17:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
|
||||
2026-07-06 17:06 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart
|
||||
2026-07-06 17:10 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
|
||||
2026-07-06 17:10 [Edit] C:\Users\nordm\pyramid - Kopie\docs\M2_MODULE_SCHNITT.md
|
||||
2026-07-06 17:10 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||
2026-07-06 17:12 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\179608a1-16b3-4b6b-a9ed-c7969b88b37f\scratchpad\split_voice_channel.ps1
|
||||
2026-07-06 17:13 [Edit] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\179608a1-16b3-4b6b-a9ed-c7969b88b37f\scratchpad\split_voice_channel.ps1
|
||||
2026-07-06 17:15 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
|
||||
2026-07-06 17:15 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||
2026-07-06 17:16 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
|
||||
2026-07-06 17:41 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\0954ef5a-3f8c-4521-ac42-f4ee83e4873b\scratchpad\split_encryption.ps1
|
||||
2026-07-06 17:41 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings_modal.dart
|
||||
2026-07-06 17:44 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
|
||||
2026-07-06 17:44 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
|
||||
2026-07-06 17:45 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||
2026-07-06 17:47 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_state.dart
|
||||
2026-07-06 17:49 [Write] C:\Users\nordm\pyramid - Kopie\docs\M2_STATE_MANAGEMENT.md
|
||||
2026-07-06 17:49 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
|
||||
2026-07-06 17:49 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||
2026-07-06 17:51 [Edit] C:\Users\nordm\pyramid - Kopie\docs\M2_STATE_MANAGEMENT.md
|
||||
2026-07-06 17:51 [Edit] C:\Users\nordm\pyramid - Kopie\docs\M2_STATE_MANAGEMENT.md
|
||||
2026-07-06 17:51 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
|
||||
2026-07-06 17:53 [Edit] C:\Users\nordm\pyramid - Kopie\test\settings_prefs_test.dart
|
||||
2026-07-06 17:54 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
|
||||
2026-07-06 17:54 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||
2026-07-06 18:19 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
|
||||
2026-07-06 18:20 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||
2026-07-06 18:22 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\b2380513-3ca6-4879-b9df-23f8ba907354\scratchpad\make_plain_db.dart
|
||||
2026-07-06 18:27 [Edit] C:\Users\nordm\pyramid - Kopie\docs\SQLCIPHER_MIGRATION.md
|
||||
2026-07-06 18:27 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
|
||||
2026-07-06 18:28 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||
2026-07-06 18:29 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
|
||||
2026-07-06 18:29 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||
2026-07-06 18:48 [Write] C:\Users\nordm\pyramid - Kopie\lib\core\app_dirs.dart
|
||||
2026-07-06 18:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_database.dart
|
||||
2026-07-06 18:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_database.dart
|
||||
2026-07-06 18:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\app_database.dart
|
||||
2026-07-06 18:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\auth_log.dart
|
||||
2026-07-06 18:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\auth_log.dart
|
||||
2026-07-06 18:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\media_cache.dart
|
||||
2026-07-06 18:48 [Edit] C:\Users\nordm\pyramid - Kopie\lib\core\media_cache.dart
|
||||
2026-07-06 18:50 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\5be37250-1cea-473c-b62c-923f3d071abd\scratchpad\ui.ps1
|
||||
2026-07-06 22:04 [Write] C:\Users\nordm\pyramid - Kopie\test\live_encryption_e2e_test.dart
|
||||
2026-07-06 22:08 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_encryption_e2e_test.dart
|
||||
2026-07-06 22:08 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_encryption_e2e_test.dart
|
||||
2026-07-06 22:10 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
|
||||
2026-07-06 22:11 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||
2026-07-06 22:15 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 22:15 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 22:15 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\call_ui\view\quality_menu.dart
|
||||
2026-07-06 22:16 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\voice_channel_service.dart
|
||||
2026-07-06 22:16 [Write] C:\Users\nordm\pyramid - Kopie\test\streaming_presets_test.dart
|
||||
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 22:23 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\voice_channel\livekit_call_manager.dart
|
||||
2026-07-06 22:24 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\panel\rooms_panel_core.dart
|
||||
2026-07-06 22:26 [Write] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
|
||||
2026-07-06 22:26 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
|
||||
2026-07-06 22:27 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
|
||||
2026-07-06 22:29 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
|
||||
2026-07-06 22:29 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
|
||||
2026-07-06 22:29 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
|
||||
2026-07-06 22:29 [Edit] C:\Users\nordm\pyramid - Kopie\test\live_call_setup_test.dart
|
||||
2026-07-06 22:31 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\6aa736cb-f5e2-471d-9167-7695c1c38655\scratchpad\win_tools.ps1
|
||||
2026-07-06 22:39 [Edit] C:\Users\nordm\pyramid - Kopie\lib\features\rooms\panel\rooms_list.dart
|
||||
2026-07-06 22:50 [Edit] C:\Users\nordm\pyramid - Kopie\PROGRESS.md
|
||||
2026-07-06 22:50 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
|
||||
2026-07-06 22:50 [Edit] C:\Users\nordm\pyramid - Kopie\ROADMAP.md
|
||||
2026-07-06 22:51 [Write] C:\Users\nordm\.claude-nordmann\projects\C--Users-nordm-pyramid---Kopie\memory\test-accounts-gui.md
|
||||
2026-07-06 22:51 [Write] C:\Users\nordm\.claude-nordmann\projects\C--Users-nordm-pyramid---Kopie\memory\MEMORY.md
|
||||
2026-07-06 23:13 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
|
||||
2026-07-06 23:23 [Write] C:\Users\nordm\pyramid - Kopie\tool\device_probe.dart
|
||||
2026-07-07 03:07 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
|
||||
2026-07-07 03:07 [Edit] C:\Users\nordm\pyramid - Kopie\docs\PC_TESTPLAN.md
|
||||
2026-07-07 03:16 [Edit] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\fdefa80f-c201-47b8-8bd3-ea082af15729\scratchpad\probe154\windows\runner\main.cpp
|
||||
2026-07-07 03:19 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\fdefa80f-c201-47b8-8bd3-ea082af15729\scratchpad\mmprobe.cpp
|
||||
2026-07-07 03:23 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\fdefa80f-c201-47b8-8bd3-ea082af15729\scratchpad\probe154\lib\main.dart
|
||||
2026-07-07 03:24 [Write] C:\Users\nordm\pyramid - Kopie\tool\device_probe.dart
|
||||
2026-07-07 03:25 [Edit] C:\Users\nordm\pyramid - Kopie\lib\widgets\settings\settings_voice.dart
|
||||
2026-07-07 03:29 [Write] C:\Users\nordm\AppData\Local\Temp\claude\C--Users-nordm-pyramid---Kopie\fdefa80f-c201-47b8-8bd3-ea082af15729\scratchpad\win_tools.ps1
|
||||
|
||||
@@ -9,6 +9,9 @@ Einsatz (u. a. Uta) – Stabilität und Datensicherheit gehen vor Feature-Tempo.
|
||||
(`C:\Users\nordm\pyramid` und `C:\Users\nordm\MatrixPi\pyramid` sind veraltete
|
||||
April-Schnappschüsse – dort NIE arbeiten.)
|
||||
|
||||
> **Bernds Entscheidungen zu den offenen Richtungsfragen stehen in `ANTWORTEN_BERND.md`
|
||||
> (zuletzt 2026-07-05) – vor dem Abarbeiten von ROADMAP-Punkten lesen, sie sind maßgeblich.**
|
||||
|
||||
## Infrastruktur
|
||||
|
||||
- Homeserver: Continuwuity auf dem Pi5 (`steggi-matrix.work`)
|
||||
|
||||
+2107
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 run -d windows # schnellster Praxistest während der Entwicklung
|
||||
flutter analyze # muss sauber bleiben
|
||||
pwsh scripts/test.ps1 # Analyse + Tests (-Live, -Bench, -All: siehe docs/TESTS_UND_BENCHMARKS.md)
|
||||
```
|
||||
|
||||
Unterstützte Zielplattformen: Android, Windows, Linux, iOS, macOS (Ordner
|
||||
@@ -52,5 +53,6 @@ lib/
|
||||
|
||||
- `CLAUDE.md` – Arbeitsregeln und Infrastruktur für die Zusammenarbeit mit Claude
|
||||
- `ROADMAP.md` / `PROGRESS.md` – aktueller Stand und nächste Schritte
|
||||
- `docs/TESTS_UND_BENCHMARKS.md` – Tests, Live-Tests, Benchmarks (ein Befehl)
|
||||
- `docs/NOTIFICATIONS.md` – Push-Architektur (native Anzeige vs. Hintergrund-Entschlüsselung)
|
||||
- `docs/matrix-sdk/` – Hinweise zum verwendeten Matrix-SDK
|
||||
|
||||
+240
-3
@@ -42,6 +42,30 @@ Punkte abhaken (`[x]`), wenn erledigt UND in `PROGRESS.md` protokolliert.
|
||||
+ Memory „Pyramid Push" erneut konsultieren und die dort gelisteten 3
|
||||
Fallstricke der Hintergrund-Entschlüsselung gegenprüfen.
|
||||
- [ ] **Sichere Speicherung von Access-Token & Krypto-DB: LÜCKE GEFUNDEN.**
|
||||
**Stand 2026-07-06: UMGESETZT + auf Windows praxisgetestet** – neue
|
||||
Storage-Fassade `lib/core/app_database.dart` (SQLCipher auf
|
||||
Android/Windows/Linux, Schlüssel im Keystore, Klartext-Migration mit
|
||||
Backup/Verifikation/atomarem Tausch, Push-Isolate-Marker). Bernds echte
|
||||
Windows-DB wurde erfolgreich migriert (PROGRESS.md 2026-07-06), Tests in
|
||||
`test/app_database_test.dart` fahren die Migration mit echtem SQLCipher.
|
||||
**Nachtrag 2026-07-06 (vierte PC-Session): Android-EMULATOR-Test grün**
|
||||
(API 36: Neuanlage verschlüsselt, Klartext-Migration beim ersten Start,
|
||||
Neustart mit Keystore-Schlüssel ohne Re-Migration – Details im
|
||||
Status-Kopf von `docs/SQLCIPHER_MIGRATION.md`).
|
||||
**Nachtrag 2026-07-06 (Nacht-Session): LIVE-E2E-Test mit ECHTEM Login grün**
|
||||
(`test/live_encryption_e2e_test.dart`, Test-Account pyramidtest1 auf
|
||||
steggi-matrix.work, Gate `PYRAMID_LIVE_TEST=1`): Platte nach echtem
|
||||
Login/Nachricht byte-geprüft (kein SQLite-Header, Access-Token und
|
||||
Nachrichtentext NICHT im Klartext auffindbar, Positiv-Kontrolle in
|
||||
Klartext-DB findet beides), Neustart restauriert Session aus der
|
||||
verschlüsselten DB, Logout→Re-Login→alte Nachricht lesbar, und die
|
||||
Klartext-Migration mit einer ECHTEN eingeloggten Session erhält
|
||||
Session+Daten. **Bleibt offen:**
|
||||
echtes ARM-Gerät mit eingeloggter Session + Push-Isolate-Test
|
||||
(FCM liest verschlüsselte DB, secure_storage im Background-Isolate) –
|
||||
Testplan Punkt 4 in `docs/SQLCIPHER_MIGRATION.md`;
|
||||
erst nach grünem Gerätetest Release + Utas Gerät (vorher ihr Key-Backup
|
||||
prüfen!), dann abhaken.
|
||||
`sqlcipher_flutter_libs` steht zwar in `pubspec.yaml`, wird aber nirgends
|
||||
benutzt – `matrix_client.dart` und `background_push.dart` öffnen die
|
||||
`pyramid.sqlite` (Access-Token, gepickelter Olm-Account, Nachrichten) über
|
||||
@@ -60,10 +84,123 @@ Punkte abhaken (`[x]`), wenn erledigt UND in `PROGRESS.md` protokolliert.
|
||||
- [x] Geräte-/Sessionverwaltung in den Einstellungen: bereits vorhanden
|
||||
(`_SessionsSection` in `settings_modal.dart` – Liste, Umbenennen, Verifizieren
|
||||
per SAS/QR, Abmelden, Massen-Abmeldung mit Passwort-Reauth)
|
||||
- [x] **SICHERHEIT: Dashboard-Admin-Endpoints – ERLEDIGT: Loch geschlossen,
|
||||
Heimnetz-only ist laut Bernd der Endzustand** (Fable-5-Review (o);
|
||||
Entscheidung `ANTWORTEN_BERND.md` Nr. 3, 2026-07-05).
|
||||
`server.py` nimmt `/api/ban`, `/api/unban`, `/api/toggle-registration`,
|
||||
`/api/create-invite`, `/api/run-stats` jetzt NUR noch aus Heimnetz/localhost an
|
||||
(Socket-IP + Cloudflare-Header-Check); Fremde aus dem Internet bekommen 403.
|
||||
Headless verifiziert (öffentlich 403, Heimnetz ok, App-Endpoints unverändert),
|
||||
siehe PROGRESS.md 2026-07-04. **Bernds Dashboard läuft im Heimnetz unverändert:
|
||||
`http://192.168.178.71:8080/stats.html`.** Offen bleibt: Will Bernd die
|
||||
Admin-Buttons auch von unterwegs? Dann Weg A (Cloudflare Access, empfohlen –
|
||||
schließt auch die weiterhin öffentliche Nutzerlisten-Ansicht) oder Weg B
|
||||
(`DASH_TOKEN`), Plan + Restrisiken in `docs/DASHBOARD_AUTH_HARDENING.md`.
|
||||
Zusatz (Fable-5-Review (p), 2026-07-04): `server.py` läuft jetzt multithreaded
|
||||
mit 30-s-Socket-Timeout – ein einzelner langsamer Client kann den Server
|
||||
(und damit `/api/livekit-token`) nicht mehr blockieren; headless verifiziert.
|
||||
Zusatz (Fable-5-Review (q), 2026-07-04): Der durch (p) eingeführte
|
||||
Nebenläufigkeits-Race in den Admin-Routen (Read-Modify-Write auf `conduit.toml`,
|
||||
`send_admin`-Readback) ist mit `ADMIN_LOCK` geschlossen, das NUR die
|
||||
Admin-Mutationen serialisiert – die öffentlichen Routen bleiben nebenläufig;
|
||||
headless verifiziert (2× parallel = 2× Laufzeit, öffentlicher Pfad 11 ms).
|
||||
Zusatz (Fable-5-Review (r), 2026-07-04): drei Restbefunde gefixt –
|
||||
`registration-status`-Leser jetzt unter ADMIN_LOCK (Truncate-Write-Race),
|
||||
negative/übergroße `Content-Length` überall abgelehnt (kein Thread-Hänger,
|
||||
ban/unban 4-KB-Deckel), Einladungs-Token per `secrets` statt `random`.
|
||||
Zusatz (Fable-5-Review (s), 2026-07-04): ban/unban-Body wird jetzt VOR dem
|
||||
ADMIN_LOCK gelesen (hängender Body-Read konnte den Lock 30 s festhalten),
|
||||
und die öffentlichen Routen antworten bei internen Fehlern generisch statt
|
||||
mit Exception-Text (Info-Leak); Details ins Journal. Headless verifiziert.
|
||||
Zusatz (Fable-5-Review (t), 2026-07-04): auch der öffentliche GET-Leser
|
||||
`registration-status` antwortet bei internen Fehlern jetzt generisch
|
||||
statt mit `str(e)` (hätte den `conduit.toml`-Pfad geleakt); headless
|
||||
verifiziert inkl. 500-Beweis.
|
||||
Zusatz (Fable-5-Review (u), 2026-07-04): (t)-Fix gegengelesen – korrekt,
|
||||
kein Befund; die server.py-Härtungskette ist damit KONVERGIERT (alle
|
||||
verbliebenen `str(e)`-Antworten LAN-gegated, absichtlich).
|
||||
**Abschluss (2026-07-05, `ANTWORTEN_BERND.md` Nr. 3): KEIN Fernzugriff
|
||||
gewünscht – die A/B-Frage ist GESCHLOSSEN, das Heimnetz-Gate ist der
|
||||
Endzustand (unterwegs nutzt Bernd WireGuard). Die Restrisiken des Gates
|
||||
(öffentliche Nutzerlisten-Ansicht, theoretisches LAN-CSRF) sind bewusst
|
||||
akzeptiert und in `docs/DASHBOARD_AUTH_HARDENING.md` dokumentiert.
|
||||
Nicht weiter daran arbeiten.**
|
||||
- [x] **LiveKit-Token-Route server-seitig gebaut + verifiziert** (Fable-5-Review (n),
|
||||
PROGRESS.md 2026-07-04). `POST /api/livekit-token` läuft live in `server.py`
|
||||
(whoami-Auth, stdlib-HS256-Minting aus `livekit.yaml`), headless geprüft
|
||||
(401/400/413/200, JWT-Signatur unabhängig gegen `livekit.yaml` gültig, Identität =
|
||||
geprüfte user_id). **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
|
||||
(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!)
|
||||
|
||||
@@ -86,7 +223,33 @@ lassen, ohne dass andere Module angefasst werden müssen.
|
||||
Bernds Go für den Call-Pilot (Frage in PROGRESS.md).
|
||||
- [ ] Call-Schicht entwirren: EINE klare Zuständigkeit pro Klasse (Signalisierung /
|
||||
LiveKit-Verbindung / UI-State sauber getrennt)
|
||||
**Teilschritt erledigt (2026-07-06, M2-Pilot laut Entscheidung 2):**
|
||||
Module `call_signaling` und `voice_channel` mit Fassaden-Interfaces +
|
||||
Providern existieren (`lib/features/call_signaling/`,
|
||||
`lib/features/voice_channel/`), alle Konsumenten gehen nur noch über
|
||||
`callSignalingProvider`/`voiceChannelProvider` – kein UI-Import der
|
||||
Implementierungen mehr (Details/Abweichungen: Status-Kopf in
|
||||
`docs/M2_MODULE_SCHNITT.md`). App startet, 29 Tests grün, Sync/E2EE
|
||||
laufen. **UNGETESTET:** echter 1:1-Call und Voice-Channel-Join per
|
||||
Klick (braucht Gerät bzw. interaktiven Test) – deshalb nicht abgehakt.
|
||||
**Weitere Teilschritte erledigt (2026-07-06, zweite PC-Session):**
|
||||
`call_ui` ist jetzt ein eigenes Modul (`lib/features/call_ui/`,
|
||||
b2335da) und die voice_*-Prefs laufen über die `VoicePrefs`-Fassade
|
||||
in `core/settings_prefs.dart` statt direktem SharedPreferences-Zugriff
|
||||
(d22f9c2) – beides verhaltensgleich, analyze/Tests grün, App-Start
|
||||
geprüft. **Bewusst zurückgestellt bis Bernds Klick-Test des Piloten
|
||||
(PC_TESTPLAN Abschnitt 8) grün ist:** UI-State aus den Managern
|
||||
herauslösen – kein zweiter ungetesteter Logik-Umbau auf dem heiligen
|
||||
Call-Pfad.
|
||||
- [ ] Einheitliches State-Management (Riverpod konsequent, keine Misch-Patterns)
|
||||
**Ist-Analyse + Umbauplan liegen in `docs/M2_STATE_MANAGEMENT.md`**
|
||||
(2026-07-06): Riverpod ist schon dominant (~50 Provider); 4
|
||||
Misch-Patterns identifiziert, Schrittfolge definiert. Schritte 1–2
|
||||
(Call-Manager-UI-State, Voice-Provider-Umzug) GESPERRT bis Bernds
|
||||
Klick-Test (PC_TESTPLAN Abschnitt 8); MediaCache-Umstellung
|
||||
zurückgestellt (hängt an der Chat-Timeline, Korrektur im Doc).
|
||||
Push bleibt bewusst wie er ist (Isolate-Zwang). Toter
|
||||
`incomingCallProvider` bereits entfernt (41bcaf2).
|
||||
- [x] Toten Code & Duplikate entfernen, Ordnerstruktur vereinheitlichen
|
||||
(PROGRESS.md 2026-07-03: `message_bubble.dart` tot entfernt, doppelte
|
||||
`_formatTime` in `message_group.dart` zusammengeführt; alle 6 in der
|
||||
@@ -103,9 +266,18 @@ lassen, ohne dass andere Module angefasst werden müssen.
|
||||
`features/chat/document/`) – jeweils verifiziert per automatisiertem
|
||||
Blockvergleich gegen das Original und `flutter analyze`, siehe
|
||||
PROGRESS.md-Einträge. `features/call/voice_channel.dart` (1123 Zeilen)
|
||||
und `layout/app_shell.dart` (1073 Zeilen) bewusst NICHT mit angefasst –
|
||||
beide hängen eng mit der noch unentschiedenen Call-Fassade bzw. dem
|
||||
Bootstrap/Login-Pfad zusammen, siehe „Call-Schicht entwirren" unten.
|
||||
und `layout/app_shell.dart` (1073 Zeilen) waren zunächst bewusst NICHT
|
||||
mit angefasst – beide hängen eng mit der damals unentschiedenen
|
||||
Call-Fassade bzw. dem Bootstrap/Login-Pfad zusammen.
|
||||
**Nachtrag 2026-07-06:** Nach dem Call-Piloten ist
|
||||
`call_ui/voice_channel.dart` jetzt ebenfalls geteilt (5 part-Dateien
|
||||
unter `call_ui/view/`, zeichenidentisch verifiziert, 95d9b07);
|
||||
`app_shell.dart` bleibt wegen des Bootstrap/Login-Pfads (heilig)
|
||||
weiter unangetastet. Ebenso `settings/settings_encryption.dart`
|
||||
(1470 Zeilen, größte verbliebene Teildatei) in 4 part-Dateien unter
|
||||
`settings/encryption/` geteilt (zeichenidentisch verifiziert,
|
||||
6128953) – Vorbereitung für das spätere verification-Modul
|
||||
(Schnitt-Doc Schritt 3), Code selbst unangetastet.
|
||||
UNGETESTET (Pi) im Sinne von „nicht in echter UI gesehen" – nächster
|
||||
Windows-Lauf sollte alle 6 betroffenen Bereiche einmal durchklicken
|
||||
(zusätzlich zu den 5 unten: eine PDF/Text/Archiv/Bild-Datei im Chat
|
||||
@@ -113,11 +285,58 @@ lassen, ohne dass andere Module angefasst werden müssen.
|
||||
prüfen), siehe „Fragen an Bernd"/Nächster-Schritt in den jeweiligen
|
||||
PROGRESS.md-Einträgen)
|
||||
- [ ] Nach JEDEM Schritt: App läuft, Kernflows getestet, Commit
|
||||
- [x] Test-/Benchmark-Gerüst: `scripts/test.ps1` (Analyse, Unit-, Live-Tests,
|
||||
Benchmarks mit einem Befehl), `benchmark/` mit Baseline, lokale Suche
|
||||
als testbares Modul (`lib/features/search/`). Siehe
|
||||
`docs/TESTS_UND_BENCHMARKS.md`, PROGRESS.md 2026-10-07.
|
||||
- [ ] **Abhängigkeiten aktualisieren (3 Monate Stand, 119 Pakete veraltet)** –
|
||||
einzeln, jeweils mit `scripts/test.ps1 -All` abgesichert:
|
||||
- **`sqlcipher_flutter_libs` ist EOL:** 0.7.0 ist ein leerer Platzhalter
|
||||
(pub.dev). Unsere Bindung `^0.6.8` schließt ihn aus – NIEMALS per
|
||||
`pub upgrade --major-versions` mitziehen, sonst fehlt SQLCipher und
|
||||
die App kann die verschlüsselte DB nicht mehr öffnen
|
||||
(`AppDatabase` wirft dann bewusst, kein Datenverlust, aber Aussperren).
|
||||
Nachfolger: `sqlite3` 3.x mit `hooks: user_defines: sqlite3: source:
|
||||
sqlcipher` – Kompatibilität mit `sqflite_common_ffi` und Android
|
||||
vorher klären; Migrations-/DB-Tests + Gerätetest Pflicht.
|
||||
- `matrix` 6.2 → 13.x (großer Sprung, Breaking Changes; heiliger
|
||||
Login-/Krypto-Pfad), `flutter_vodozemac` 0.5 → 0.8 gehört dazu.
|
||||
- `livekit_client` 2.7 → 2.13 (innerhalb der Bindung möglich),
|
||||
`flutter_webrtc`-Override 1.4.1 → 1.6.2: der Windows-Audiogeräte-Fix
|
||||
(libwebrtc #142) ist auch in 1.6.2+hotfix.4 NICHT enthalten
|
||||
(Changelog geprüft 2026-10-07) → Dummy-PeerConnection-Workaround
|
||||
bleibt nötig.
|
||||
- Android-Build: Gradle 8.13 → 8.14.3 ERLEDIGT (2026-10-07). „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)
|
||||
|
||||
- [ ] **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,
|
||||
klarer Zustand wer drin ist, sauberes Verhalten bei Verbindungsabbruch
|
||||
**Teilfortschritt 2026-07-06 (Nacht, mit 2 echten Test-Accounts durchgeklickt):**
|
||||
Standalone-Voice-Channel in der Raumliste war per Klick gar nicht
|
||||
beitretbar → gefixt (`_toggleVoiceChannel` in rooms_list.dart, wie
|
||||
Space-Liste). Zustands-Härtung in livekit_call_manager.dart:
|
||||
`_callGeneration` verhindert Geisterverbindungen bei Auflegen/Wechsel
|
||||
während `connect`; Fehlerpfade setzen `error` sichtbar statt still zu
|
||||
verschwinden; rooms_panel_core räumt `activeVoiceRoomIdProvider` auf,
|
||||
wenn der Service inaktiv wird, + Fehler-SnackBar. REAL getestet:
|
||||
Beitreten (beide), Teilnehmerliste, bidirektionaler Screenshare,
|
||||
Live-Qualitätswechsel, sauberes Verlassen. **UNGETESTET:** echtes
|
||||
2-Peer-Audio (beide auf 1 PC), Netz-Abbruch mitten im Call (nur im
|
||||
Code abgesichert). Nicht abgehakt.
|
||||
- [ ] Call-UI aufräumen: aufgeräumter, intuitiver, konsistent (Mini-Call-Widget, Vollansicht)
|
||||
- [ ] **Windows: Audiogeräte-Erkennung reparieren** (Mikrofon/Ausgabe erkennen und in den
|
||||
Einstellungen auswählbar machen – geht aktuell gar nicht)
|
||||
@@ -131,7 +350,19 @@ lassen, ohne dass andere Module angefasst werden müssen.
|
||||
|
||||
- [ ] **Framerate verbessern:** Ursache messen (Encoder? Auflösung? Simulcast-Settings?
|
||||
Software- statt Hardware-Encoding?), dann gezielt fixen
|
||||
**Teilfortschritt 2026-07-06 (Nacht, `docs/STREAMING_60FPS.md`, ffd70ca):**
|
||||
Vier Ruckel-Ursachen adressiert – FPS-Deckel (jetzt hd60/fhd60 mit
|
||||
echten 60 FPS), zu niedrige Bitrate (5/8 Mbit/s), Capture blieb auf
|
||||
1080p/15 (Preset wird jetzt bis in ScreenShareCaptureOptions
|
||||
durchgereicht), degradationPreference (60-FPS-Modi
|
||||
maintainFramerate, kein Simulcast). contentHint='motion' in
|
||||
flutter_webrtc 1.4.1 aus Dart nicht setzbar (dokumentiert). REAL
|
||||
getestet: Umschalten auf Full HD 60fps live, Screenshare läuft weiter.
|
||||
**UNGETESTET:** subjektives Ruckel-Empfinden + realer 60-FPS-RTP
|
||||
(Sender/Empfänger auf 1 PC = nicht beurteilbar), braucht 2 Geräte+Netz.
|
||||
- [ ] Auflösung/Qualität wählbar für den **Streamer** (z. B. 720p/1080p + FPS-Wahl)
|
||||
**2026-07-06:** FPS-Wahl umgesetzt – SENDEN-Menü hat jetzt „HD 60fps"
|
||||
und „Full HD 60fps" zusätzlich zu sd/hd/fhd/4k (ffd70ca).
|
||||
- [ ] Qualität wählbar für den **Empfänger** (LiveKit-Simulcast-Layer auswählen)
|
||||
- [ ] Sauberes Degradieren bei schwacher Bandbreite statt Ruckel-Chaos
|
||||
|
||||
@@ -152,6 +383,12 @@ lassen, ohne dass andere Module angefasst werden müssen.
|
||||
|
||||
## M7 – Design & Feinschliff
|
||||
|
||||
- [x] **Beta-Feedback 2026-10-07 umgesetzt (UNGETESTET auf echtem Handy):**
|
||||
Push als Chat-Verlauf mit Bildvorschau + echtem „Gelesen“, Statusleiste
|
||||
überdeckt nichts mehr, Bild-Vollansicht mit Gesten, weiches Dunkel,
|
||||
Schriftgrößen-Regler, Sprechblasen umschaltbar, Messenger-Chatliste,
|
||||
deutsche Beschriftungen. Test-Version 0.1.37 (siehe PROGRESS.md).
|
||||
|
||||
- [ ] Rooms-Icon überarbeiten – passt stilistisch nicht zu den anderen Buttons
|
||||
- [ ] Design-Konsistenz-Pass über alle Icons/Buttons
|
||||
- [ ] Onboarding: erster Start erklärt Server-Wahl und Verifikation verständlich
|
||||
|
||||
@@ -4,4 +4,10 @@
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<application>
|
||||
<!-- NUR Debug: Benachrichtigungen per adb auslösen (DebugNotifyReceiver.kt). -->
|
||||
<receiver
|
||||
android:name=".DebugNotifyReceiver"
|
||||
android:exported="true" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package chat.pyramid.pyramid
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* NUR IM DEBUG-BUILD (src/debug): löst Nachrichten-Benachrichtigungen per adb
|
||||
* aus, um NotificationHelper ohne Login/Push auf dem Emulator zu prüfen.
|
||||
* Ist nicht im Release-APK enthalten.
|
||||
*
|
||||
* adb shell am broadcast -n chat.pyramid.pyramid/.DebugNotifyReceiver \
|
||||
* --es sender Uta --es body "Hallo" [--es roomName Familie] \
|
||||
* [--es event e1] [--ez image true] [--ez avatar true] [--es room !r:x]
|
||||
*/
|
||||
class DebugNotifyReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val roomId = intent.getStringExtra("room") ?: "!debug:pyramid.local"
|
||||
val sender = intent.getStringExtra("sender") ?: "Testnutzer"
|
||||
val roomName = intent.getStringExtra("roomName")
|
||||
val eventId = intent.getStringExtra("event") ?: "\$debug${System.nanoTime()}"
|
||||
val args = mapOf(
|
||||
"roomId" to roomId,
|
||||
"notifId" to NotificationHelper.stableId(roomId),
|
||||
"eventId" to eventId,
|
||||
"senderId" to "@${sender.lowercase()}:pyramid.local",
|
||||
"senderName" to sender,
|
||||
"roomName" to roomName,
|
||||
"isGroup" to (roomName != null),
|
||||
"body" to (intent.getStringExtra("body") ?: NotificationHelper.PLACEHOLDER_BODY),
|
||||
"timestamp" to System.currentTimeMillis(),
|
||||
"imagePath" to if (intent.getBooleanExtra("image", false)) testImage(context, "img_debug", 960, 640) else null,
|
||||
"avatarPath" to if (intent.getBooleanExtra("avatar", false)) testImage(context, "avatar_$sender", 256, 256) else null,
|
||||
)
|
||||
NotificationHelper.showFromArgs(context, args)
|
||||
}
|
||||
|
||||
/** Erzeugt ein buntes Testbild unter cache/downloads/notif/ (FileProvider-Pfad). */
|
||||
private fun testImage(context: Context, name: String, w: Int, h: Int): String {
|
||||
val dir = File(context.cacheDir, "downloads/notif").apply { mkdirs() }
|
||||
val file = File(dir, name)
|
||||
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
val c = Canvas(bmp)
|
||||
c.drawColor(Color.rgb(0x3B, 0x6D, 0x11))
|
||||
val p = Paint().apply { color = Color.rgb(0xF5, 0xA6, 0x14); isAntiAlias = true }
|
||||
c.drawCircle(w * 0.5f, h * 0.5f, minOf(w, h) * 0.3f, p)
|
||||
file.outputStream().use { bmp.compress(Bitmap.CompressFormat.JPEG, 90, it) }
|
||||
return file.absolutePath
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
|
||||
<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_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<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.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
|
||||
|
||||
<!-- Kein Backup/Umzug der App-Daten: siehe res/xml/data_extraction_rules.xml
|
||||
(verschlüsselte DB ohne Keystore-Schlüssel = App startet nicht). -->
|
||||
<application
|
||||
android:label="pyramid"
|
||||
android:label="Pyramid"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:allowBackup="false"
|
||||
android:fullBackupContent="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules">
|
||||
<!-- 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
|
||||
android:name="com.cloudwebrtc.webrtc.FlutterForegroundService"
|
||||
android:name=".CallService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaProjection" />
|
||||
android:foregroundServiceType="microphone|mediaProjection" />
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
||||
@@ -35,14 +35,17 @@ object BgEngine {
|
||||
private val fallbacks = mutableMapOf<Int, Runnable>()
|
||||
|
||||
/** Schedule a placeholder shown after [delayMs] unless decryption beats it. */
|
||||
fun scheduleFallback(context: Context, title: String, roomId: String, notifId: Int, delayMs: Long) {
|
||||
fun scheduleFallback(
|
||||
context: Context, meta: PushService.PushMeta, roomId: String, notifId: Int,
|
||||
eventId: String?, delayMs: Long,
|
||||
) {
|
||||
val ctx = context.applicationContext
|
||||
appContext = ctx
|
||||
fallbackHandler.post {
|
||||
cancelFallback(notifId)
|
||||
val r = Runnable {
|
||||
fallbacks.remove(notifId)
|
||||
NotificationHelper.show(ctx, title, "Neue Nachricht", roomId, notifId)
|
||||
meta.showPlaceholder(ctx, roomId, notifId, eventId)
|
||||
Log.d(TAG, "fallback placeholder shown for $roomId (decrypt too slow)")
|
||||
}
|
||||
fallbacks[notifId] = r
|
||||
@@ -114,16 +117,19 @@ object BgEngine {
|
||||
}
|
||||
// Dart finished decrypting — show the real content using the SAME
|
||||
// native notification (keeps the reliable MainActivity reply action).
|
||||
// May arrive twice per event: first the text, then silently
|
||||
// the same event with avatar/image (NotificationHelper merges).
|
||||
"showDecrypted" -> {
|
||||
val title = call.argument<String>("title") ?: ""
|
||||
val body = call.argument<String>("body") ?: "Neue Nachricht"
|
||||
val roomId = call.argument<String>("room_id") ?: ""
|
||||
val notifId = call.argument<Int>("notif_id") ?: NotificationHelper.stableId(roomId)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val args = (call.arguments as? Map<String, Any?>) ?: emptyMap()
|
||||
val roomId = (args["roomId"] ?: args["room_id"]) as? String ?: ""
|
||||
val notifId = ((args["notifId"] ?: args["notif_id"]) as? Number)?.toInt()
|
||||
?: NotificationHelper.stableId(roomId)
|
||||
val ctx = appContext
|
||||
if (ctx != null && roomId.isNotEmpty()) {
|
||||
// Beat the fallback placeholder to it — show decrypted directly.
|
||||
cancelFallback(notifId)
|
||||
NotificationHelper.show(ctx, title, body, roomId, notifId)
|
||||
NotificationHelper.showFromArgs(ctx, args)
|
||||
Log.d(TAG, "showDecrypted → shown decrypted notification for $roomId")
|
||||
}
|
||||
result.success(null)
|
||||
|
||||
@@ -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)
|
||||
// Cache so ReplyReceiver can call back into Dart without starting a new engine.
|
||||
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)
|
||||
.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
@@ -206,12 +239,11 @@ class MainActivity : FlutterActivity() {
|
||||
result.success(null)
|
||||
}
|
||||
"showNativeNotification" -> {
|
||||
val title = call.argument<String>("title") ?: ""
|
||||
val body = call.argument<String>("body") ?: ""
|
||||
val roomId = call.argument<String>("roomId") ?: ""
|
||||
val notifId = call.argument<Int>("notifId") ?: NotificationHelper.stableId(roomId)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val args = (call.arguments as? Map<String, Any?>) ?: emptyMap()
|
||||
val roomId = args["roomId"] as? String ?: ""
|
||||
if (roomId.isNotEmpty()) {
|
||||
NotificationHelper.show(this, title, body, roomId, notifId)
|
||||
NotificationHelper.showFromArgs(this, args)
|
||||
result.success(null)
|
||||
} else {
|
||||
result.error("INVALID_ARG", "roomId missing", null)
|
||||
|
||||
@@ -5,13 +5,79 @@ import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.Person
|
||||
import androidx.core.app.RemoteInput
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Native Nachrichten-Benachrichtigungen (alle Pfade: Platzhalter aus
|
||||
* PushService, Klartext aus der Haupt-Engine oder dem BgEngine-Isolate).
|
||||
*
|
||||
* Pro Raum gibt es EINE Benachrichtigung (notifId = stableId(roomId)), die als
|
||||
* Chat-Verlauf (MessagingStyle) die letzten [MAX_MESSAGES] Nachrichten zeigt –
|
||||
* wie WhatsApp/Element. Der Verlauf liegt in SharedPreferences, weil bei
|
||||
* beendeter App jeder Push in einem neuen Prozess ankommt. Er wird nur
|
||||
* fortgeführt, solange die Benachrichtigung noch sichtbar ist: hat der Nutzer
|
||||
* sie weggewischt oder den Raum in der App geöffnet (Dart cancelt), beginnt
|
||||
* der nächste Push mit einem leeren Verlauf.
|
||||
*
|
||||
* Gleiche eventId = Aktualisierung (Platzhalter → Klartext, Text → Text+Bild):
|
||||
* die Nachricht wird ersetzt statt doppelt angehängt, und es klingelt nicht
|
||||
* erneut. Nur eine wirklich neue Nachricht alarmiert.
|
||||
*/
|
||||
object NotificationHelper {
|
||||
const val CHANNEL_ID = "pyramid_messages"
|
||||
const val PLACEHOLDER_BODY = "Neue Nachricht"
|
||||
private const val TAG = "PYRAMID-NOTIF"
|
||||
private const val STORE = "pyramid_notif_history"
|
||||
private const val MAX_MESSAGES = 8
|
||||
private const val SYSTEM_UI = "com.android.systemui"
|
||||
|
||||
/** Eine Nachricht im Verlauf einer Raum-Benachrichtigung. */
|
||||
data class Msg(
|
||||
val eventId: String?,
|
||||
val senderKey: String,
|
||||
val senderName: String,
|
||||
val body: String,
|
||||
val timestamp: Long,
|
||||
val imagePath: String? = null,
|
||||
val avatarPath: String? = null,
|
||||
) {
|
||||
val isPlaceholder get() = body == PLACEHOLDER_BODY
|
||||
|
||||
fun toJson(): JSONObject = JSONObject().apply {
|
||||
put("eventId", eventId ?: JSONObject.NULL)
|
||||
put("senderKey", senderKey)
|
||||
put("senderName", senderName)
|
||||
put("body", body)
|
||||
put("timestamp", timestamp)
|
||||
put("imagePath", imagePath ?: JSONObject.NULL)
|
||||
put("avatarPath", avatarPath ?: JSONObject.NULL)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromJson(o: JSONObject) = Msg(
|
||||
eventId = o.optStringOrNull("eventId"),
|
||||
senderKey = o.optString("senderKey"),
|
||||
senderName = o.optString("senderName"),
|
||||
body = o.optString("body"),
|
||||
timestamp = o.optLong("timestamp"),
|
||||
imagePath = o.optStringOrNull("imagePath"),
|
||||
avatarPath = o.optStringOrNull("avatarPath"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Deterministic notification ID — same algorithm as Dart's _stableRoomId. */
|
||||
fun stableId(roomId: String): Int {
|
||||
@@ -32,15 +98,226 @@ object NotificationHelper {
|
||||
}
|
||||
}
|
||||
|
||||
fun show(context: Context, title: String, body: String, roomId: String, notifId: Int) {
|
||||
/**
|
||||
* Platzhalter/Alt-Aufrufer mit fertigem Titel „Absender · Raum“ (bzw. nur
|
||||
* „Absender“ bei Direktnachrichten).
|
||||
*/
|
||||
fun show(
|
||||
context: Context,
|
||||
title: String,
|
||||
body: String,
|
||||
roomId: String,
|
||||
notifId: Int,
|
||||
eventId: String? = null,
|
||||
) {
|
||||
val parts = title.split(" · ", limit = 2)
|
||||
val sender = parts[0].ifBlank { "Pyramid" }
|
||||
val roomName = parts.getOrNull(1)?.takeIf { it.isNotBlank() }
|
||||
showMessage(
|
||||
context, roomId, notifId, roomName, isGroup = roomName != null,
|
||||
msg = Msg(eventId, sender, sender, body, System.currentTimeMillis()),
|
||||
)
|
||||
}
|
||||
|
||||
/** Strukturierter Aufruf aus Dart (`toNativeArgs` in notification_content.dart). */
|
||||
fun showFromArgs(context: Context, args: Map<String, Any?>) {
|
||||
val roomId = (args["roomId"] ?: args["room_id"]) as? String ?: return
|
||||
if (roomId.isEmpty()) return
|
||||
val notifId = ((args["notifId"] ?: args["notif_id"]) as? Number)?.toInt()
|
||||
?: stableId(roomId)
|
||||
val senderName = args["senderName"] as? String
|
||||
if (senderName == null) {
|
||||
// Alter Aufrufer ohne strukturierte Felder.
|
||||
show(
|
||||
context, args["title"] as? String ?: "",
|
||||
args["body"] as? String ?: PLACEHOLDER_BODY,
|
||||
roomId, notifId, args["eventId"] as? String,
|
||||
)
|
||||
return
|
||||
}
|
||||
val roomName = (args["roomName"] as? String)?.takeIf { it.isNotBlank() }
|
||||
showMessage(
|
||||
context, roomId, notifId, roomName,
|
||||
isGroup = (args["isGroup"] as? Boolean) ?: (roomName != null),
|
||||
msg = Msg(
|
||||
eventId = args["eventId"] as? String,
|
||||
senderKey = args["senderId"] as? String ?: senderName,
|
||||
senderName = senderName,
|
||||
body = args["body"] as? String ?: PLACEHOLDER_BODY,
|
||||
timestamp = (args["timestamp"] as? Number)?.toLong() ?: System.currentTimeMillis(),
|
||||
imagePath = args["imagePath"] as? String,
|
||||
avatarPath = args["avatarPath"] as? String,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Fügt [msg] dem Verlauf des Raums hinzu (oder aktualisiert sie) und zeigt ihn. */
|
||||
fun showMessage(
|
||||
context: Context,
|
||||
roomId: String,
|
||||
notifId: Int,
|
||||
roomName: String?,
|
||||
isGroup: Boolean,
|
||||
msg: Msg,
|
||||
) {
|
||||
ensureChannel(context)
|
||||
val (history, isNew) = mergeIntoHistory(context, roomId, notifId, msg)
|
||||
val latest = history.last()
|
||||
|
||||
val mutFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||
PendingIntent.FLAG_MUTABLE else 0
|
||||
val style = NotificationCompat.MessagingStyle(
|
||||
Person.Builder().setName("Du").setKey("me").build()
|
||||
)
|
||||
if (isGroup && roomName != null) {
|
||||
style.conversationTitle = roomName
|
||||
style.isGroupConversation = true
|
||||
}
|
||||
for (m in history) {
|
||||
val person = Person.Builder()
|
||||
.setName(m.senderName)
|
||||
.setKey(m.senderKey)
|
||||
.apply {
|
||||
contentUri(context, m.avatarPath)?.let { setIcon(IconCompat.createWithContentUri(it)) }
|
||||
}
|
||||
.build()
|
||||
val message = NotificationCompat.MessagingStyle.Message(m.body, m.timestamp, person)
|
||||
contentUri(context, m.imagePath)?.let { message.setData("image/jpeg", it) }
|
||||
style.addMessage(message)
|
||||
}
|
||||
|
||||
// Reply action — BroadcastReceiver so replying NEVER opens the app.
|
||||
// ReplyReceiver decrypts/sends via the background engine when killed.
|
||||
// (ReplyReceiver has multiple RemoteInput key fallbacks for OEM quirks.)
|
||||
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setColor(Color.rgb(0x7B, 0x61, 0xFF))
|
||||
.setStyle(style)
|
||||
// Fallback-Texte für Geräte/Ansichten ohne MessagingStyle.
|
||||
.setContentTitle(if (isGroup && roomName != null) roomName else latest.senderName)
|
||||
.setContentText(latest.body)
|
||||
.setWhen(latest.timestamp)
|
||||
.setShowWhen(true)
|
||||
.setNumber(history.size)
|
||||
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
// Nur eine wirklich neue Nachricht klingelt; Aktualisierungen
|
||||
// (Platzhalter → Klartext, Bild nachgeladen) ersetzen still.
|
||||
.setOnlyAlertOnce(!isNew)
|
||||
.setContentIntent(tapIntent(context, roomId, notifId))
|
||||
.setAutoCancel(true)
|
||||
.addAction(replyAction(context, roomId, notifId))
|
||||
.addAction(markReadAction(context, roomId, notifId, latest.eventId))
|
||||
.setGroup(CHANNEL_ID)
|
||||
if (!isGroup) {
|
||||
avatarBitmap(latest.avatarPath)?.let { builder.setLargeIcon(it) }
|
||||
}
|
||||
|
||||
try {
|
||||
nm(context).notify(notifId, builder.build())
|
||||
} catch (e: Exception) {
|
||||
// Sicherheitsnetz: lieber ohne Bilder/Avatare als gar nicht.
|
||||
Log.w(TAG, "notify failed (${e.message}) — retrying as plain text")
|
||||
builder.setLargeIcon(null as Bitmap?)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(latest.body))
|
||||
nm(context).notify(notifId, builder.build())
|
||||
}
|
||||
}
|
||||
|
||||
/** Vergisst den Verlauf eines Raums (nach Antworten/„Gelesen“). */
|
||||
@Synchronized
|
||||
fun clearHistory(context: Context, roomId: String) {
|
||||
prefs(context).edit().remove(roomId).apply()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun mergeIntoHistory(
|
||||
context: Context, roomId: String, notifId: Int, msg: Msg,
|
||||
): Pair<List<Msg>, Boolean> {
|
||||
val prefs = prefs(context)
|
||||
val list = if (isActive(context, notifId)) load(prefs.getString(roomId, null)) else mutableListOf()
|
||||
val index = msg.eventId?.let { id -> list.indexOfFirst { it.eventId == id } } ?: -1
|
||||
val isNew = index < 0
|
||||
if (isNew) {
|
||||
list.add(msg)
|
||||
} else {
|
||||
val old = list[index]
|
||||
// Ein später eintreffender Platzhalter überschreibt nie echten Text.
|
||||
val keepOld = msg.isPlaceholder && !old.isPlaceholder
|
||||
list[index] = Msg(
|
||||
eventId = old.eventId,
|
||||
senderKey = if (keepOld) old.senderKey else msg.senderKey,
|
||||
senderName = if (keepOld) old.senderName else msg.senderName,
|
||||
body = if (keepOld) old.body else msg.body,
|
||||
timestamp = if (old.isPlaceholder) msg.timestamp else old.timestamp,
|
||||
imagePath = msg.imagePath ?: old.imagePath,
|
||||
avatarPath = msg.avatarPath ?: old.avatarPath,
|
||||
)
|
||||
}
|
||||
list.sortBy { it.timestamp }
|
||||
while (list.size > MAX_MESSAGES) list.removeAt(0)
|
||||
prefs.edit().putString(roomId, JSONArray(list.map { it.toJson() }).toString()).apply()
|
||||
return list to isNew
|
||||
}
|
||||
|
||||
private fun load(json: String?): MutableList<Msg> {
|
||||
if (json == null) return mutableListOf()
|
||||
return try {
|
||||
val arr = JSONArray(json)
|
||||
MutableList(arr.length()) { Msg.fromJson(arr.getJSONObject(it)) }
|
||||
} catch (_: Exception) {
|
||||
mutableListOf()
|
||||
}
|
||||
}
|
||||
|
||||
/** Ist die Benachrichtigung noch sichtbar? (Sonst beginnt ein neuer Verlauf.) */
|
||||
private fun isActive(context: Context, notifId: Int): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
|
||||
return try {
|
||||
nm(context).activeNotifications.any { it.id == notifId }
|
||||
} catch (_: Exception) {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* content://-URI für eine Datei unter cache/downloads/ (FileProvider). Die
|
||||
* System-UI zeichnet die Benachrichtigung und braucht dafür Leserecht.
|
||||
*/
|
||||
private fun contentUri(context: Context, path: String?): Uri? {
|
||||
if (path.isNullOrEmpty()) return null
|
||||
val file = File(path)
|
||||
if (!file.exists()) return null
|
||||
return try {
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||
context.grantUriPermission(SYSTEM_UI, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
uri
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "contentUri failed for $path: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** Kleines Avatar-Bitmap (≈192 px) für das große Icon bei Direktnachrichten. */
|
||||
private fun avatarBitmap(path: String?): Bitmap? {
|
||||
if (path.isNullOrEmpty() || !File(path).exists()) return null
|
||||
return try {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(path, bounds)
|
||||
var sample = 1
|
||||
while (bounds.outWidth / (sample * 2) >= 192 && bounds.outHeight / (sample * 2) >= 192) {
|
||||
sample *= 2
|
||||
}
|
||||
BitmapFactory.decodeFile(path, BitmapFactory.Options().apply { inSampleSize = sample })
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Aktionen ──────────────────────────────────────────────────────────────
|
||||
|
||||
private fun mutableFlag() =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
|
||||
|
||||
// Reply action — BroadcastReceiver so replying NEVER opens the app.
|
||||
// ReplyReceiver decrypts/sends via the background engine when killed.
|
||||
// (ReplyReceiver has multiple RemoteInput key fallbacks for OEM quirks.)
|
||||
private fun replyAction(context: Context, roomId: String, notifId: Int): NotificationCompat.Action {
|
||||
val replyInput = RemoteInput.Builder(ReplyReceiver.REMOTE_INPUT_KEY)
|
||||
.setLabel("Antworten…").build()
|
||||
val replyIntent = Intent(context, ReplyReceiver::class.java).apply {
|
||||
@@ -49,59 +326,57 @@ object NotificationHelper {
|
||||
putExtra(ReplyReceiver.EXTRA_NOTIF_ID, notifId)
|
||||
}
|
||||
val replyPi = PendingIntent.getBroadcast(
|
||||
context, notifId,
|
||||
replyIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or mutFlag,
|
||||
context, notifId, replyIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag(),
|
||||
)
|
||||
val replyAction = NotificationCompat.Action.Builder(0, "Antworten", replyPi)
|
||||
return NotificationCompat.Action.Builder(0, "Antworten", replyPi)
|
||||
.addRemoteInput(replyInput)
|
||||
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
|
||||
.setShowsUserInterface(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
// Dismiss action — also points to ReplyReceiver so notification is cancelled.
|
||||
val dismissIntent = Intent(context, ReplyReceiver::class.java).apply {
|
||||
// „Gelesen“ — schließt die Benachrichtigung UND setzt die Lesebestätigung
|
||||
// bis zur neuesten Nachricht (ReplyReceiver → Haupt-Engine bzw. BgEngine).
|
||||
private fun markReadAction(
|
||||
context: Context, roomId: String, notifId: Int, eventId: String?,
|
||||
): NotificationCompat.Action {
|
||||
val intent = Intent(context, ReplyReceiver::class.java).apply {
|
||||
action = ReplyReceiver.ACTION_DISMISS
|
||||
putExtra(ReplyReceiver.EXTRA_ROOM_ID, roomId)
|
||||
putExtra(ReplyReceiver.EXTRA_NOTIF_ID, notifId)
|
||||
putExtra(ReplyReceiver.EXTRA_EVENT_ID, eventId)
|
||||
}
|
||||
val dismissPi = PendingIntent.getBroadcast(
|
||||
context, notifId + 1_000_000,
|
||||
dismissIntent,
|
||||
val pi = PendingIntent.getBroadcast(
|
||||
context, notifId + 1_000_000, intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
return NotificationCompat.Action.Builder(0, "Gelesen", pi)
|
||||
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ)
|
||||
.setShowsUserInterface(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
// Tap action — opens MainActivity to the right room.
|
||||
// Tap action — opens MainActivity to the right room.
|
||||
private fun tapIntent(context: Context, roomId: String, notifId: Int): PendingIntent {
|
||||
val tapIntent = Intent(context, MainActivity::class.java).apply {
|
||||
action = Intent.ACTION_MAIN
|
||||
addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
putExtra("room_id", roomId)
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
}
|
||||
val tapPi = PendingIntent.getActivity(
|
||||
context, notifId + 2_000_000,
|
||||
tapIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or mutFlag,
|
||||
return PendingIntent.getActivity(
|
||||
context, notifId + 2_000_000, tapIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag(),
|
||||
)
|
||||
|
||||
val notif = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setColor(Color.rgb(0x7B, 0x61, 0xFF))
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
// Only buzz/heads-up on the first show — the decrypted update reuses
|
||||
// the same notifId and replaces the text silently (no second alert).
|
||||
.setOnlyAlertOnce(true)
|
||||
.setContentIntent(tapPi)
|
||||
.setAutoCancel(true)
|
||||
.addAction(replyAction)
|
||||
.addAction(NotificationCompat.Action(0, "Gelesen", dismissPi))
|
||||
.setGroup(CHANNEL_ID)
|
||||
.build()
|
||||
|
||||
nm(context).notify(notifId, notif)
|
||||
}
|
||||
|
||||
private fun prefs(context: Context) =
|
||||
context.getSharedPreferences(STORE, Context.MODE_PRIVATE)
|
||||
|
||||
private fun nm(context: Context) =
|
||||
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
}
|
||||
|
||||
private fun JSONObject.optStringOrNull(key: String): String? =
|
||||
if (!has(key) || isNull(key)) null else optString(key).takeIf { it.isNotEmpty() }
|
||||
|
||||
@@ -41,23 +41,23 @@ class PushService : FirebaseMessagingService() {
|
||||
val token = (prefs.getString("flutter.notif_access_token", "") ?: "")
|
||||
|
||||
// Fetch sender + room name on a background thread (main thread forbids I/O).
|
||||
var title = "Pyramid"
|
||||
var meta = PushMeta()
|
||||
if (hs.isNotEmpty() && token.isNotEmpty() && eventId != null) {
|
||||
val latch = CountDownLatch(1)
|
||||
Thread {
|
||||
try { title = fetchTitle(hs, token, roomId, eventId) ?: "Pyramid" }
|
||||
try { meta = fetchMeta(hs, token, roomId, eventId) ?: PushMeta() }
|
||||
finally { latch.countDown() }
|
||||
}.start()
|
||||
latch.await(12L, TimeUnit.SECONDS)
|
||||
}
|
||||
|
||||
Log.d(TAG, "handling push: title=$title roomId=$roomId")
|
||||
Log.d(TAG, "handling push: sender=${meta.senderName} room=${meta.roomName} roomId=$roomId")
|
||||
|
||||
val engine = FlutterEngineCache.getInstance().get("main")
|
||||
if (engine != null) {
|
||||
// App backgrounded but alive — show placeholder immediately, then the
|
||||
// warm main client decrypts and updates it (fast, barely visible).
|
||||
NotificationHelper.show(this, title, "Neue Nachricht", roomId, notifId)
|
||||
meta.showPlaceholder(this, roomId, notifId, eventId)
|
||||
if (eventId != null) {
|
||||
val capturedRoomId = roomId
|
||||
val capturedEventId = eventId
|
||||
@@ -82,7 +82,7 @@ class PushService : FirebaseMessagingService() {
|
||||
// real content directly. The "Neue Nachricht" placeholder is only a
|
||||
// fallback if decryption takes too long or fails.
|
||||
Log.d(TAG, "main engine gone — decrypt-first via background engine for $roomId")
|
||||
BgEngine.scheduleFallback(this, title, roomId, notifId, 6_000L)
|
||||
BgEngine.scheduleFallback(this, meta, roomId, notifId, eventId, 6_000L)
|
||||
BgEngine.run(this, "decryptAndShow", mapOf(
|
||||
"room_id" to roomId,
|
||||
"event_id" to eventId,
|
||||
@@ -90,7 +90,7 @@ class PushService : FirebaseMessagingService() {
|
||||
))
|
||||
} else {
|
||||
// No event id to decrypt — just show the placeholder.
|
||||
NotificationHelper.show(this, title, "Neue Nachricht", roomId, notifId)
|
||||
meta.showPlaceholder(this, roomId, notifId, null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,11 +102,31 @@ class PushService : FirebaseMessagingService() {
|
||||
.apply()
|
||||
}
|
||||
|
||||
/** Absender + Raumname aus unverschlüsselten Room-States (für den Platzhalter). */
|
||||
data class PushMeta(
|
||||
val senderId: String = "",
|
||||
val senderName: String = "Pyramid",
|
||||
val roomName: String? = null,
|
||||
) {
|
||||
fun showPlaceholder(context: android.content.Context, roomId: String, notifId: Int, eventId: String?) {
|
||||
NotificationHelper.showMessage(
|
||||
context, roomId, notifId, roomName, isGroup = roomName != null,
|
||||
msg = NotificationHelper.Msg(
|
||||
eventId = eventId,
|
||||
senderKey = senderId.ifEmpty { senderName },
|
||||
senderName = senderName,
|
||||
body = NotificationHelper.PLACEHOLDER_BODY,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── HTTP helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
private fun fetchTitle(
|
||||
private fun fetchMeta(
|
||||
hs: String, token: String, roomId: String, eventId: String
|
||||
): String? {
|
||||
): PushMeta? {
|
||||
return try {
|
||||
val encRoom = java.net.URLEncoder.encode(roomId, "UTF-8")
|
||||
val encEvent = java.net.URLEncoder.encode(eventId, "UTF-8")
|
||||
@@ -128,8 +148,11 @@ class PushService : FirebaseMessagingService() {
|
||||
val nameState = httpGet("$hs/_matrix/client/v3/rooms/$encRoom/state/m.room.name", token)
|
||||
val roomName = nameState?.optString("name")
|
||||
|
||||
return if (!roomName.isNullOrEmpty()) "$senderName · $roomName"
|
||||
else senderName.ifEmpty { null }
|
||||
return PushMeta(
|
||||
senderId = senderId,
|
||||
senderName = senderName.ifEmpty { "Pyramid" },
|
||||
roomName = roomName?.takeIf { it.isNotEmpty() },
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "fetchTitle failed: ${e.message}")
|
||||
null
|
||||
|
||||
@@ -16,6 +16,7 @@ class ReplyReceiver : BroadcastReceiver() {
|
||||
const val ACTION_DISMISS = "chat.pyramid.pyramid.DISMISS"
|
||||
const val EXTRA_ROOM_ID = "room_id"
|
||||
const val EXTRA_NOTIF_ID = "notif_id"
|
||||
const val EXTRA_EVENT_ID = "event_id"
|
||||
const val REMOTE_INPUT_KEY = "reply_text"
|
||||
}
|
||||
|
||||
@@ -23,12 +24,13 @@ class ReplyReceiver : BroadcastReceiver() {
|
||||
val roomId = intent.getStringExtra(EXTRA_ROOM_ID) ?: return
|
||||
val notifId = intent.getIntExtra(EXTRA_NOTIF_ID, 0)
|
||||
|
||||
// Dismiss the notification immediately.
|
||||
// Dismiss the notification immediately; its message history goes too.
|
||||
(context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
|
||||
.cancel(notifId)
|
||||
NotificationHelper.clearHistory(context, roomId)
|
||||
|
||||
if (intent.action == ACTION_DISMISS) {
|
||||
Log.d("PYRAMID-REPLY", "dismiss action for room $roomId")
|
||||
markRead(context, roomId, intent.getStringExtra(EXTRA_EVENT_ID))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -81,4 +83,30 @@ class ReplyReceiver : BroadcastReceiver() {
|
||||
try { pending.finish() } catch (_: Exception) {}
|
||||
}, 15_000L)
|
||||
}
|
||||
|
||||
/**
|
||||
* „Gelesen“: Lesebestätigung bis [eventId] setzen – über die laufende
|
||||
* Haupt-Engine oder (App beendet) still über die BgEngine. Ohne eventId
|
||||
* (alter Platzhalter) bleibt es beim Schließen.
|
||||
*/
|
||||
private fun markRead(context: Context, roomId: String, eventId: String?) {
|
||||
Log.d("PYRAMID-REPLY", "mark-read action for room $roomId event=$eventId")
|
||||
if (eventId.isNullOrEmpty()) return
|
||||
val args = mapOf("room_id" to roomId, "event_id" to eventId)
|
||||
val engine = FlutterEngineCache.getInstance().get("main")
|
||||
if (engine != null) {
|
||||
try {
|
||||
MethodChannel(engine.dartExecutor.binaryMessenger, "chat.pyramid.pyramid/install")
|
||||
.invokeMethod("markReadFromNotification", args)
|
||||
return
|
||||
} catch (e: Exception) {
|
||||
Log.w("PYRAMID-REPLY", "markRead via main engine failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
val pending = goAsync()
|
||||
BgEngine.run(context, "markRead", args)
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
|
||||
try { pending.finish() } catch (_: Exception) {}
|
||||
}, 10_000L)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Kein Cloud-Backup und keine Gerät-zu-Gerät-Übertragung der App-Daten
|
||||
(Android 12+). Grund: Die lokale Datenbank ist mit SQLCipher verschlüsselt,
|
||||
ihr Schlüssel liegt im Android-Keystore und zieht NICHT mit um – eine
|
||||
übertragene Datenbank ließe sich auf dem neuen Gerät nicht öffnen
|
||||
(AppDatabase bricht dann bewusst ab, statt Daten zu überschreiben).
|
||||
Außerdem enthalten die SharedPreferences den Zugangsschlüssel für den
|
||||
Push-Dienst. Matrix-Weg auf neuem Gerät: neu anmelden + Schlüssel mit dem
|
||||
Wiederherstellungsschlüssel aus dem Server-Backup holen.
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<exclude domain="root" path="." />
|
||||
<exclude domain="file" path="." />
|
||||
<exclude domain="database" path="." />
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="external" path="." />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="root" path="." />
|
||||
<exclude domain="file" path="." />
|
||||
<exclude domain="database" path="." />
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="external" path="." />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
@@ -1,2 +1,6 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||
android.builtInKotlin=false
|
||||
# This newDsl flag was added automatically by Flutter migrator
|
||||
android.newDsl=false
|
||||
|
||||
+1
-1
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
|
||||
|
||||
+24
-7
@@ -1,17 +1,23 @@
|
||||
#!/bin/bash
|
||||
# Pyramid-Autopilot (Pi/Linux): startet Claude-Sessions in Endlosschleife.
|
||||
# Läuft eine Session ins 5h-Limit, wird alle 20 Minuten neu versucht.
|
||||
# Laeuft eine Session ins Limit, wird alle 20 Minuten neu versucht.
|
||||
# Start (am besten in tmux): tmux new -d -s pyramid ./autopilot.sh
|
||||
# Wieder reinschauen: tmux attach -t pyramid (verlassen: Strg+B, dann D)
|
||||
# PAUSIEREN: touch ~/pyramid/PAUSE (greift vor der nächsten Session)
|
||||
# PAUSIEREN: touch ~/pyramid/PAUSE (greift vor der naechsten Session)
|
||||
# FORTSETZEN: rm ~/pyramid/PAUSE
|
||||
# GANZ STOPPEN: tmux kill-session -t pyramid
|
||||
cd "$(dirname "$0")"
|
||||
export PATH="$HOME/.local/bin:$HOME/flutter/bin:$PATH"
|
||||
|
||||
# Pyramid laeuft auf eigenem Claude-Konto (Profil ~/.claude-pyramid),
|
||||
# das Standard-Profil ~/.claude bleibt fuer die Webseiten-Arbeit.
|
||||
export CLAUDE_CONFIG_DIR="$HOME/.claude-pyramid"
|
||||
# --- NUR EIN Konto: das nordmann-Konto (Profil ~/.claude = Standard-Profil) ---
|
||||
# Bewusst KEINE Konto-Rotation (Bernd, 2026-07-06): der Pyramid-Autopilot arbeitet
|
||||
# ausschliesslich mit dem nordmann-Konto. Das Standard-Profil liegt unter
|
||||
# ~/.claude / ~/.claude.json -> CLAUDE_CONFIG_DIR NICHT setzen (sonst sucht Claude
|
||||
# die Config faelschlich unter ~/.claude/.claude.json).
|
||||
# (Die E-Mail-Adresse des Kontos steht absichtlich NICHT hier: das Gitea-Repo
|
||||
# ist oeffentlich, PII gehoert nicht hinein.)
|
||||
apply_account() { unset CLAUDE_CONFIG_DIR; }
|
||||
ACCOUNT_NAME="nordmann"
|
||||
|
||||
# --- Matrix-Statusberichte (Raum "🔺 Pyramid Autopilot", Token vom Gatus-Bot) ---
|
||||
MATRIX_URL="http://127.0.0.1:6167"
|
||||
@@ -36,9 +42,11 @@ while true; do
|
||||
sleep 600
|
||||
continue
|
||||
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)
|
||||
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)
|
||||
if [ -n "$BEFORE" ] && [ "$BEFORE" != "$AFTER" ]; then
|
||||
COMMITS=$(git log --oneline --no-decorate "$BEFORE..$AFTER" | head -15)
|
||||
@@ -50,6 +58,15 @@ $COMMITS
|
||||
|
||||
$NEXT"
|
||||
fi
|
||||
# Limit erkannt? -> 20 Minuten warten (KEINE Konto-Rotation mehr)
|
||||
if grep -qiE "hit your.*limit|usage limit|session limit" "$SESSION_OUT"; then
|
||||
rm -f "$SESSION_OUT"
|
||||
echo "===== $(date '+%F %T') nordmann-Konto am Limit - naechster Versuch in 20 Minuten =====" | tee -a autopilot.log
|
||||
sleep 1200
|
||||
continue
|
||||
fi
|
||||
rm -f "$SESSION_OUT"
|
||||
|
||||
echo "===== $(date '+%F %T') Session beendet - Neustart in 20 Minuten (Strg+C zum Stoppen) =====" | tee -a autopilot.log
|
||||
sleep 1200
|
||||
done
|
||||
|
||||
@@ -0,0 +1,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)
|
||||
|
||||
> **STATUS 2026-07-06: Pilot (Schritt 1) UMGESETZT.** Die Module
|
||||
> `call_signaling` (`lib/features/call_signaling/`: `CallSignalingService`
|
||||
> + `callSignalingProvider` + Implementierung `voip_manager.dart`) und
|
||||
> `voice_channel` (`lib/features/voice_channel/`: `VoiceChannelService` +
|
||||
> `voiceChannelProvider` + Implementierung `livekit_call_manager.dart`)
|
||||
> existieren; ALLE Konsumenten (Shell, Call-UI, Rooms-Panel, Chat-Header,
|
||||
> Settings) gehen nur noch über die Provider/Interfaces, kein direkter
|
||||
> Implementierungs-Import mehr (einzige dokumentierte Ausnahme:
|
||||
> Kompositions-Punkt `matrix_client.dart`). Bewusste Abweichungen vom
|
||||
> Entwurf unten (Verhaltensgleichheit vor Schönheit, heiliger Call-Pfad):
|
||||
> ChangeNotifier statt Streams, Methodennamen wie im Bestand
|
||||
> (`startCall`/`hangUp` statt `join`/`leave`), `CallSession`/LiveKit-`Room`
|
||||
> bleiben in den Schnittstellen sichtbar, weil die UI direkt rendert.
|
||||
> Echter Call-/Voice-Channel-Test steht aus (Gerät bzw. Klick-Test) –
|
||||
> siehe PROGRESS.md 2026-07-06. Schritte 2–5 unten sind weiter offen.
|
||||
>
|
||||
> **Nachtrag 2026-07-06 (zweite PC-Session):** `call_ui` ist jetzt ein
|
||||
> eigenes Modul (`lib/features/call_ui/`, reine Umbenennung von
|
||||
> `features/call/`; konsumiert weiterhin nur die zwei Service-Interfaces).
|
||||
> Außerdem ist der storage-Teilschritt für die Call-Module erledigt: die
|
||||
> voice_*-Prefs (Geräte + Ausgabelautstärke) laufen über die neue
|
||||
> `VoicePrefs`-Kapsel in `core/settings_prefs.dart` (Punkt 6 unten) –
|
||||
> kein direkter SharedPreferences-Zugriff mehr in `voip_manager.dart`/
|
||||
> `livekit_call_manager.dart`/`settings_voice.dart`, Keys unverändert.
|
||||
> „UI-State aus den Managern herauslösen" wartet bewusst auf Bernds
|
||||
> Klick-Test des Piloten (heiliger Call-Pfad).
|
||||
|
||||
Stand: 2026-07-03. Reine Schnittstellen-Planung, noch kein Umbau. Grundlage:
|
||||
Ist-Analyse in `PROGRESS.md` (Eintrag „M2: Ist-Analyse der Architektur
|
||||
geschrieben"). Leitprinzip (Bernd, `CLAUDE.md`): jedes Modul muss sich in
|
||||
|
||||
@@ -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
|
||||
Engine-Bootstrap (~1,7 s) + Client-Init/Entschlüsselung (~2,3 s).
|
||||
|
||||
## Darstellung: Chat-Verlauf statt Einzelnachricht (seit 2026-10-07)
|
||||
|
||||
Auf Bernds Beta-Feedback („nur eine Nachricht, wenig Text, kein Bild“):
|
||||
|
||||
- **Pro Raum ein Chat-Verlauf** (Android `MessagingStyle`) mit den letzten 8
|
||||
Nachrichten, Absendername + Avatar je Nachricht, Gruppenname als Titel.
|
||||
Der Verlauf liegt in SharedPreferences (`pyramid_notif_history`), weil bei
|
||||
gekillter App jeder Push in einem neuen Prozess ankommt. Er wird nur
|
||||
fortgeführt, solange die Benachrichtigung noch sichtbar ist (weggewischt
|
||||
oder Raum geöffnet → nächster Push beginnt neu).
|
||||
- **Gleiche eventId = Aktualisierung**, nicht neue Nachricht: der
|
||||
„Neue Nachricht“-Platzhalter wird durch den Klartext ersetzt, später kommen
|
||||
lautlos Avatar/Bild dazu. Nur wirklich neue Nachrichten klingeln
|
||||
(`setOnlyAlertOnce(!isNew)`).
|
||||
- **Text:** nicht mehr auf 100 Zeichen gekürzt (Deckel 1000), Antwort-Zitat
|
||||
entfernt, Medien als „📷 Foto“/„🎤 Sprachnachricht“/„📎 datei.pdf“ usw.
|
||||
Logik in `lib/core/notification_content.dart` (Tests:
|
||||
`test/notification_content_test.dart`). Einstellung „Nachrichtenvorschau“
|
||||
gilt jetzt auch bei gekillter App.
|
||||
- **Bilder/Avatare:** Dart legt sie unter `<cache>/downloads/notif/` ab
|
||||
(Vorschaubild, bei E2EE entschlüsselt; Avatar aus dem MediaCache), native
|
||||
reicht sie per FileProvider-URI an die System-UI weiter
|
||||
(`grantUriPermission("com.android.systemui")`). Dateien älter als 2 Tage
|
||||
werden aufgeräumt. Scheitert das Anzeigen mit Medien, fällt
|
||||
`NotificationHelper` auf reinen Text zurück.
|
||||
- **„Gelesen“** setzt jetzt eine echte Lesebestätigung bis zur neuesten
|
||||
Nachricht (Haupt-Engine `markReadFromNotification` bzw. BgEngine
|
||||
`markRead`), statt nur zu schließen.
|
||||
- **Testen ohne Login (nur Debug-Build):** `DebugNotifyReceiver`
|
||||
(`android/app/src/debug/`), z. B.
|
||||
`adb shell "am broadcast -n chat.pyramid.pyramid/.DebugNotifyReceiver --es roomName Familie --es sender Uta --es event e1 --ez avatar true --es body 'Hallo'"`
|
||||
(`--ez image true` für ein Testbild). Nicht im Release-APK.
|
||||
|
||||
## Verschlüsselt antworten (gekillt, vollständig still)
|
||||
|
||||
- Die Reply-Action ist ein **BroadcastReceiver** (`getBroadcast` → `ReplyReceiver`),
|
||||
@@ -103,7 +136,8 @@ Engine-Bootstrap (~1,7 s) + Client-Init/Entschlüsselung (~2,3 s).
|
||||
| `android/.../PushService.kt` | FCM-Empfang, Heartbeat-Check, decrypt-first-Routing |
|
||||
| `android/.../BgEngine.kt` | Headless-Engine-Bootstrap, Fallback-Platzhalter |
|
||||
| `android/.../ReplyReceiver.kt` | Stilles verschlüsseltes Antworten (Broadcast) |
|
||||
| `android/.../NotificationHelper.kt` | Native Benachrichtigung (Reply/Tap/Dismiss, setOnlyAlertOnce) |
|
||||
| `android/.../NotificationHelper.kt` | Native Benachrichtigung: Chat-Verlauf (MessagingStyle), Bilder, Antworten/Gelesen/Tippen |
|
||||
| `lib/core/notification_content.dart` | Inhalt einer Benachrichtigung (Text, Medien-Labels, Bild/Avatar-Dateien) |
|
||||
| `android/.../MainActivity.kt` | MethodChannel `install`, Cold-Start-Reply-Fallback |
|
||||
| `lib/core/background_push.dart` | Dart-Hintergrund-Isolate: decrypt + reply |
|
||||
| `lib/core/fcm_push_service.dart` | Pusher-Registrierung bei Sygnal |
|
||||
|
||||
+81
-6
@@ -1,6 +1,7 @@
|
||||
# PC-Testplan – alle aufgelaufenen UNGETESTET(Pi)-Punkte
|
||||
|
||||
Stand: 2026-07-03. Alle Pi-Sessions seit dem 2026-07-03 konnten nur
|
||||
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
|
||||
@@ -8,18 +9,92 @@ 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
|
||||
- [ ] `auth_log.txt` (App-Support-Verzeichnis) prüfen: LoginState-Wechsel
|
||||
werden mitgeschrieben, keine unerwarteten Warnungen
|
||||
- [ ] Logout (Warnung bei fehlendem Key-Backup erscheint? b5762ea)
|
||||
- [ ] Erneuter Login → **alte verschlüsselte Nachrichten noch lesbar?**
|
||||
- [ ] Mehrere Stunden laufen lassen: kein Random-Logout, kein
|
||||
(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
|
||||
|
||||
@@ -1,8 +1,30 @@
|
||||
# SQLCipher-Migration der `pyramid.sqlite` – Umsetzungsplan (PC-Termin)
|
||||
|
||||
Stand: 2026-07-03, geschrieben auf dem Pi (Plan gegen den echten Code verifiziert,
|
||||
Umsetzung braucht PC + Android-Testgerät). Gehört zum offenen M1-Punkt
|
||||
„Sichere Speicherung von Access-Token & Krypto-DB".
|
||||
> **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
|
||||
|
||||
|
||||
@@ -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,114 @@
|
||||
# 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. Offene Richtungsentscheidungen (Bernd)
|
||||
|
||||
Navigation Handy (Tabs vs. Seitenmenü), Chats + Gruppen zusammen, Stil der
|
||||
Anrufansicht, Anruf-Pille – siehe Fragen im Chat.
|
||||
@@ -70,7 +70,7 @@ Continuwuity hat einen speziellen Admin-Raum. Als Server-Admin kannst du dort Be
|
||||
# Als curl (Admin-Token erforderlich):
|
||||
# Admin-Room-ID herausfinden:
|
||||
curl -s "https://steggi-matrix.work/_matrix/client/v3/directory/room/%23admins%3Asteggi-matrix.work" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
-H "Authorization: Bearer <ADMIN_TOKEN>"
|
||||
# → {"room_id": "!adminRoomId:steggi-matrix.work", ...}
|
||||
```
|
||||
|
||||
@@ -84,22 +84,22 @@ curl -s "https://steggi-matrix.work/_matrix/client/v3/directory/room/%23admins%3
|
||||
|
||||
# User zu Server-Admin machen
|
||||
curl -X POST "https://steggi-matrix.work/_conduwuit/admin/v1/make_user_admin" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
|
||||
-H "Authorization: Bearer <ADMIN_TOKEN>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"user_id": "@todesneutron:steggi-matrix.work"}'
|
||||
|
||||
# Server-Status abfragen
|
||||
curl "https://steggi-matrix.work/_conduwuit/server/version" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
-H "Authorization: Bearer <ADMIN_TOKEN>"
|
||||
|
||||
# Alternativ — Standard Matrix-Whoami (prüft ob Token gültig):
|
||||
curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
-H "Authorization: Bearer <ADMIN_TOKEN>"
|
||||
# → {"user_id": "@todesneutron:steggi-matrix.work", "is_guest": false}
|
||||
|
||||
# Prüfen ob User Server-Admin ist (Conduwuit-spezifisch):
|
||||
curl "https://steggi-matrix.work/_conduwuit/admin/v1/users/@todesneutron:steggi-matrix.work" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
-H "Authorization: Bearer <ADMIN_TOKEN>"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -112,7 +112,7 @@ Als Server-Admin kannst du via Matrix Client API Power Levels in jedem Raum setz
|
||||
```bash
|
||||
# Schritt 1: Aktuelles Power-Level-Event lesen
|
||||
ROOM_ID="!raumId:steggi-matrix.work"
|
||||
TOKEN="J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
TOKEN="<ADMIN_TOKEN>"
|
||||
SERVER="https://steggi-matrix.work"
|
||||
|
||||
curl "${SERVER}/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.power_levels/" \
|
||||
@@ -172,7 +172,7 @@ await client.setRoomStateWithKey(
|
||||
```bash
|
||||
# Testen ob restricted join rules funktionieren:
|
||||
ROOM_ID="!testRaum:steggi-matrix.work"
|
||||
TOKEN="J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
TOKEN="<ADMIN_TOKEN>"
|
||||
SPACE_ID="!spaceId:steggi-matrix.work"
|
||||
|
||||
curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.join_rules/" \
|
||||
@@ -230,7 +230,7 @@ try {
|
||||
```bash
|
||||
# 1. Prüfen: Welchen Power Level hat mein User in diesem Raum?
|
||||
ROOM_ID="!raumId:steggi-matrix.work"
|
||||
TOKEN="J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
TOKEN="<ADMIN_TOKEN>"
|
||||
|
||||
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.power_levels/" \
|
||||
-H "Authorization: Bearer ${TOKEN}"
|
||||
@@ -269,7 +269,7 @@ curl "https://steggi-matrix.work/_matrix/client/v3/capabilities" \
|
||||
```bash
|
||||
# Prüfen ob User Server-Admin ist:
|
||||
curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
-H "Authorization: Bearer <ADMIN_TOKEN>"
|
||||
|
||||
# Auf Pi (SSH): User-Datenbank direkt abfragen (Conduwuit/Continuwuity nutzt RocksDB)
|
||||
# Alternativ: Über Admin-Room-Befehl
|
||||
@@ -278,7 +278,7 @@ curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \
|
||||
|
||||
# Über HTTP (Conduwuit Admin-Endpoint):
|
||||
curl -X POST "https://steggi-matrix.work/_conduwuit/admin/v1/make_user_admin" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
|
||||
-H "Authorization: Bearer <ADMIN_TOKEN>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"user_id": "@todesneutron:steggi-matrix.work"}'
|
||||
```
|
||||
@@ -292,19 +292,19 @@ Da kein Synapse-Admin-API existiert, nutze die Standard-Matrix-Client-API mit Ad
|
||||
```bash
|
||||
# Raum beitreten (als Admin, auch wenn invite-only):
|
||||
curl -X POST "https://steggi-matrix.work/_matrix/client/v3/join/!roomId%3Asteggi-matrix.work" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
|
||||
-H "Authorization: Bearer <ADMIN_TOKEN>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{}'
|
||||
|
||||
# User in Raum einladen:
|
||||
curl -X POST "https://steggi-matrix.work/_matrix/client/v3/rooms/!roomId%3Asteggi-matrix.work/invite" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
|
||||
-H "Authorization: Bearer <ADMIN_TOKEN>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"user_id": "@user:steggi-matrix.work"}'
|
||||
|
||||
# Space-Child-Relation setzen (Channel einem Space hinzufügen):
|
||||
curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/!spaceId%3Asteggi-matrix.work/state/m.space.child/!channelId%3Asteggi-matrix.work/" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
|
||||
-H "Authorization: Bearer <ADMIN_TOKEN>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"via": ["steggi-matrix.work"],
|
||||
@@ -313,7 +313,7 @@ curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/!spaceId%3Astegg
|
||||
|
||||
# Space-Parent-Relation setzen (im Channel-Raum):
|
||||
curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/!channelId%3Asteggi-matrix.work/state/m.space.parent/!spaceId%3Asteggi-matrix.work/" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
|
||||
-H "Authorization: Bearer <ADMIN_TOKEN>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"via": ["steggi-matrix.work"],
|
||||
@@ -322,11 +322,11 @@ curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/!channelId%3Aste
|
||||
|
||||
# Alle Mitglieder eines Raums auflisten:
|
||||
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/!roomId%3Asteggi-matrix.work/members" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
-H "Authorization: Bearer <ADMIN_TOKEN>"
|
||||
|
||||
# Raum verlassen (als User, nicht löschen):
|
||||
curl -X POST "https://steggi-matrix.work/_matrix/client/v3/rooms/!roomId%3Asteggi-matrix.work/leave" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
|
||||
-H "Authorization: Bearer <ADMIN_TOKEN>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{}'
|
||||
```
|
||||
@@ -419,15 +419,15 @@ Wenn du M_FORBIDDEN bekommst:
|
||||
```bash
|
||||
# 1. Token gültig?
|
||||
curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
-H "Authorization: Bearer <ADMIN_TOKEN>"
|
||||
|
||||
# 2. PL im betroffenen Raum prüfen:
|
||||
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/!ROOM_ID/state/m.room.power_levels/" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
-H "Authorization: Bearer <ADMIN_TOKEN>"
|
||||
|
||||
# 3. Mitglied des Raums?
|
||||
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/!ROOM_ID/state/m.room.member/%40todesneutron%3Asteggi-matrix.work/" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
-H "Authorization: Bearer <ADMIN_TOKEN>"
|
||||
|
||||
# 4. Was genau schlägt fehl? (Antwort-Body lesen!)
|
||||
# M_FORBIDDEN + "Power level too low" → PL erhöhen
|
||||
|
||||
@@ -0,0 +1,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,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_riverpod/flutter_riverpod.dart';
|
||||
import 'package:pyramid/core/app_state.dart';
|
||||
import 'package:pyramid/core/appearance.dart';
|
||||
import 'package:pyramid/core/notification_service.dart' show checkPendingReply, setAppForeground;
|
||||
import 'package:pyramid/core/router.dart';
|
||||
import 'package:pyramid/core/settings_prefs.dart';
|
||||
@@ -52,7 +53,9 @@ class _PyramidAppState extends ConsumerState<PyramidApp>
|
||||
Future<void> _loadPersistedPrefs() async {
|
||||
final saved = await loadThemePrefs();
|
||||
if (!mounted) return;
|
||||
ref.read(themeModeProvider.notifier).state = saved.isDark;
|
||||
ref.read(themeVariantProvider.notifier).state = saved.variant;
|
||||
ref.read(textScaleProvider.notifier).state = saved.textScale;
|
||||
ref.read(messageStyleProvider.notifier).state = saved.messageStyle;
|
||||
ref.read(accentProvider.notifier).state = saved.accentIdx;
|
||||
ref.read(radiusProvider.notifier).state = saved.radius;
|
||||
ref.read(motionProvider.notifier).state = saved.motion;
|
||||
@@ -75,7 +78,8 @@ class _PyramidAppState extends ConsumerState<PyramidApp>
|
||||
}
|
||||
|
||||
final router = ref.watch(routerProvider);
|
||||
final isDark = ref.watch(themeModeProvider);
|
||||
final variant = ref.watch(themeVariantProvider);
|
||||
final textScale = ref.watch(textScaleProvider);
|
||||
final accentIdx = ref.watch(accentProvider);
|
||||
final radius = ref.watch(radiusProvider);
|
||||
final density = ref.watch(densityProvider);
|
||||
@@ -83,17 +87,31 @@ class _PyramidAppState extends ConsumerState<PyramidApp>
|
||||
final accent = accentPresets[accentIdx].color;
|
||||
|
||||
return PyramidTheme(
|
||||
isDark: isDark,
|
||||
variant: variant,
|
||||
accent: accent,
|
||||
radius: radius,
|
||||
density: density,
|
||||
motion: motion,
|
||||
child: MaterialApp.router(
|
||||
title: 'Pyramid',
|
||||
theme: buildPyramidThemeData(false),
|
||||
darkTheme: buildPyramidThemeData(true),
|
||||
themeMode: isDark ? ThemeMode.dark : ThemeMode.light,
|
||||
theme: buildPyramidThemeData(ThemeVariant.light),
|
||||
darkTheme: buildPyramidThemeData(
|
||||
variant.isDark ? variant : ThemeVariant.soft),
|
||||
themeMode: variant.isDark ? ThemeMode.dark : ThemeMode.light,
|
||||
routerConfig: router,
|
||||
// Schriftgröße: Pyramid-Regler × Handy-/Systemeinstellung, für alle
|
||||
// Seiten und Dialoge (liegt über dem Navigator).
|
||||
builder: (context, child) {
|
||||
final mq = MediaQuery.of(context);
|
||||
final factor = textScale ??
|
||||
defaultTextScale(mobile: isMobileLayout(mq.size));
|
||||
return MediaQuery(
|
||||
data: mq.copyWith(
|
||||
textScaler: PyramidTextScaler(mq.textScaler, factor),
|
||||
),
|
||||
child: child!,
|
||||
);
|
||||
},
|
||||
localizationsDelegates: const [
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
|
||||
@@ -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:matrix/matrix.dart';
|
||||
import 'package:pyramid/core/appearance.dart';
|
||||
import 'package:pyramid/core/settings_prefs.dart';
|
||||
import 'package:pyramid/core/theme.dart';
|
||||
import 'package:pyramid/core/matrix_client.dart';
|
||||
import 'package:pyramid/core/livekit_call_manager.dart';
|
||||
import 'package:pyramid/core/voip_manager.dart';
|
||||
import 'package:pyramid/features/rooms/rooms_provider.dart';
|
||||
|
||||
// ─── Theme state ──────────────────────────────────────────────────────────────
|
||||
|
||||
final themeModeProvider = StateProvider<bool>((ref) => true); // true = dark
|
||||
final themeVariantProvider =
|
||||
StateProvider<ThemeVariant>((ref) => ThemeVariant.soft);
|
||||
|
||||
/// Pyramid-Schriftfaktor; `null` = automatisch (Handy größer, PC 1,0).
|
||||
final textScaleProvider = StateProvider<double?>((ref) => null);
|
||||
|
||||
final messageStyleProvider =
|
||||
StateProvider<MessageStyle>((ref) => MessageStyle.auto);
|
||||
final accentProvider = StateProvider<int>((ref) => 0); // index into accent presets
|
||||
|
||||
const accentPresets = [
|
||||
@@ -63,17 +69,14 @@ enum ModalKind { none, settings, search }
|
||||
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
final callStateProvider = ChangeNotifierProvider<LiveKitCallManager>((ref) {
|
||||
return LiveKitCallManager.instance;
|
||||
});
|
||||
|
||||
final voipStateProvider = ChangeNotifierProvider<PyramidVoipManager>((ref) {
|
||||
return PyramidVoipManager.instance;
|
||||
});
|
||||
|
||||
final incomingCallProvider = StateProvider<CallSession?>((ref) => null);
|
||||
// Die Call-Provider leben seit dem M2-Call-Pilot in ihren Modulen:
|
||||
// `callSignalingProvider` (features/call_signaling/call_signaling_service.dart)
|
||||
// und `voiceChannelProvider` (features/voice_channel/voice_channel_service.dart).
|
||||
|
||||
// ─── View mode (chat / voice) ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -0,0 +1,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 '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
|
||||
/// App-Neustart übersteht. Zweck: den Uta-Random-Logout-Bug nachvollziehen
|
||||
@@ -15,7 +15,7 @@ class AuthLog {
|
||||
static Future<File> _ensureFile() async {
|
||||
final existing = _file;
|
||||
if (existing != null) return existing;
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
final dir = await appSupportDir();
|
||||
final file = File(p.join(dir.path, 'auth_log.txt'));
|
||||
return _file = file;
|
||||
}
|
||||
|
||||
@@ -7,11 +7,11 @@ import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod;
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:pyramid/core/app_database.dart';
|
||||
import 'package:pyramid/core/notification_content.dart';
|
||||
import 'package:pyramid/core/read_markers.dart';
|
||||
import 'package:pyramid/core/soft_logout_guard.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:sqflite/sqflite.dart' as sqflite_native;
|
||||
|
||||
/// Port name used to locate the main isolate from background isolates.
|
||||
/// Registered by notification_service.dart when the app starts.
|
||||
@@ -66,6 +66,11 @@ void notificationEngineMain() {
|
||||
args['room_id'] as String?,
|
||||
args['text'] as String?,
|
||||
);
|
||||
case 'markRead':
|
||||
return await _bgMarkRead(
|
||||
args['room_id'] as String?,
|
||||
args['event_id'] as String?,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[NOTIF-BGENGINE] handler error: $e');
|
||||
@@ -90,26 +95,27 @@ Future<bool> _bgDecryptAndShow(String? roomId, String? eventId, int? notifId) as
|
||||
)
|
||||
.timeout(const Duration(seconds: 20));
|
||||
if (event == null) return false;
|
||||
// Bad-encrypted body still starts with "**" — keep the native placeholder.
|
||||
if (event.body.startsWith('**')) return false;
|
||||
|
||||
final room = client.getRoomById(roomId);
|
||||
final sender = room?.unsafeGetUserFromMemoryOrFallback(event.senderId).displayName ??
|
||||
event.senderId.split(':').first.replaceFirst('@', '');
|
||||
final title = (room?.isDirectChat ?? true)
|
||||
? sender
|
||||
: '$sender · ${room?.getLocalizedDisplayname() ?? ''}';
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final content = MessageNotificationContent.fromEvent(
|
||||
event,
|
||||
showPreview: prefs.getBool('notif_preview') ?? true,
|
||||
);
|
||||
// Nicht entschlüsselt / nichts Anzeigbares → nativer Platzhalter bleibt.
|
||||
if (content == null) return false;
|
||||
|
||||
// Hand the decrypted content back to native so it re-shows the SAME
|
||||
// notification via NotificationHelper — that keeps the reliable
|
||||
// MainActivity-based reply action (the flutter_local_notifications reply
|
||||
// path doesn't fire dependably when the app is killed).
|
||||
await const MethodChannel(_kBgEngineChannel).invokeMethod('showDecrypted', {
|
||||
'title': title,
|
||||
'body': _buildBody(event),
|
||||
'room_id': roomId,
|
||||
'notif_id': notifId ?? _stableId(roomId),
|
||||
});
|
||||
// ReplyReceiver-based reply action (the flutter_local_notifications reply
|
||||
// path doesn't fire dependably when the app is killed). Erst der Text,
|
||||
// dann lautlos Avatar/Bild (deliverMessageNotification).
|
||||
await deliverMessageNotification(
|
||||
event,
|
||||
content,
|
||||
notifId: notifId ?? _stableId(roomId),
|
||||
send: (a) => const MethodChannel(_kBgEngineChannel)
|
||||
.invokeMethod('showDecrypted', a),
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('[NOTIF-BGENGINE] decryptAndShow error: $e');
|
||||
@@ -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
|
||||
/// bootstrap the background engine. Call once at app startup.
|
||||
Future<void> registerBgEngineHandle() async {
|
||||
@@ -182,18 +211,13 @@ Future<Client?> _buildClient() async {
|
||||
await vod.init();
|
||||
} catch (_) {}
|
||||
|
||||
final appDir = await getApplicationSupportDirectory();
|
||||
final dbPath = p.join(appDir.path, 'pyramid.sqlite');
|
||||
|
||||
final db = await sqflite_native.databaseFactory
|
||||
.openDatabase(dbPath)
|
||||
.timeout(const Duration(seconds: 5));
|
||||
|
||||
// Prevent "database is locked" if the main process still has the DB open.
|
||||
try {
|
||||
await db.execute('PRAGMA busy_timeout = 5000');
|
||||
await db.execute('PRAGMA journal_mode = WAL');
|
||||
} catch (_) {}
|
||||
// Gemeinsame Storage-Fassade: erkennt Klartext vs. SQLCipher am
|
||||
// Datei-Header, migriert aber NIE selbst (docs/SQLCIPHER_MIGRATION.md).
|
||||
// null = gerade kein sicherer Zugriff (Migration läuft / DB oder
|
||||
// Schlüssel fehlt) → Notification behält den nativen Platzhalter.
|
||||
final db =
|
||||
await AppDatabase.openForPush().timeout(const Duration(seconds: 10));
|
||||
if (db == null) return null;
|
||||
|
||||
final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db);
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────
|
||||
// Called in a fresh Dart isolate when the user presses Reply or Gelesen.
|
||||
// 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 {
|
||||
// Your LiveKit Credentials
|
||||
static const String apiKey = 'LKMatrixPi';
|
||||
static const String apiSecret = 'rYUT2PRaKLedp5VLQCQE83eZG7fjuBWtFPXGiveBmIE';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
static String generate({
|
||||
required String roomName,
|
||||
required String identity,
|
||||
String? displayName,
|
||||
int ttlSeconds = 3600, // Valid for 1 hour by default
|
||||
}) {
|
||||
final jwt = JWT(
|
||||
{
|
||||
'video': {
|
||||
'roomJoin': true,
|
||||
'room': roomName,
|
||||
'canPublish': true,
|
||||
'canSubscribe': true,
|
||||
'canPublishData': true,
|
||||
},
|
||||
'metadata': displayName ?? identity,
|
||||
},
|
||||
issuer: apiKey,
|
||||
subject: identity,
|
||||
);
|
||||
/// LiveKit-Beitritts-Token vom Pyramid-Server holen.
|
||||
///
|
||||
/// Früher hat die App die Tokens selbst signiert – dafür steckte das
|
||||
/// LiveKit-Secret im App-Code, und wer die App hatte, konnte sich für jeden
|
||||
/// 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
|
||||
/// Matrix-Login per whoami und setzt die LiveKit-Identität selbst auf die
|
||||
/// geprüfte Matrix-ID. Siehe docs/LIVEKIT_TOKEN_MIGRATION.md.
|
||||
class LiveKitTokenService {
|
||||
static const endpoint =
|
||||
'https://dashboard.steggi-matrix.work/api/livekit-token';
|
||||
static const fallbackUrl = 'wss://livekit.steggi-matrix.work';
|
||||
static const _timeout = Duration(seconds: 15);
|
||||
|
||||
// Sign the token with your secret
|
||||
final token = jwt.sign(
|
||||
SecretKey(apiSecret),
|
||||
expiresIn: Duration(seconds: ttlSeconds),
|
||||
);
|
||||
|
||||
return token;
|
||||
/// Liefert Token und LiveKit-Adresse für [room]. Wirft
|
||||
/// [LiveKitTokenException] mit einer verständlichen Meldung.
|
||||
static Future<({String token, String url})> fetch({
|
||||
required String room,
|
||||
required String matrixToken,
|
||||
http.Client? httpClient,
|
||||
}) 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:io';
|
||||
|
||||
import 'package:flutter/foundation.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:matrix/encryption/utils/key_verification.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqflite/sqflite.dart' as sqflite_native;
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import 'package:pyramid/core/app_database.dart';
|
||||
import 'package:pyramid/core/auth_log.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 DatabaseFactory factory;
|
||||
|
||||
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
|
||||
// Use the native sqflite plugin — Android/iOS have system SQLite built-in,
|
||||
// no .so loading required.
|
||||
factory = sqflite_native.databaseFactory;
|
||||
} else if (!kIsWeb) {
|
||||
sqfliteFfiInit();
|
||||
factory = databaseFactoryFfi;
|
||||
} else {
|
||||
throw UnsupportedError('Web not supported');
|
||||
}
|
||||
|
||||
final appDir = await getApplicationSupportDirectory();
|
||||
final dbPath = p.join(appDir.path, 'pyramid.sqlite');
|
||||
|
||||
final db = await factory.openDatabase(dbPath);
|
||||
|
||||
// Optimization for Windows/FFI to prevent "database is locked" errors
|
||||
try {
|
||||
await db.execute('PRAGMA journal_mode=WAL');
|
||||
await db.execute('PRAGMA busy_timeout = 5000');
|
||||
} catch (e) {
|
||||
if (kDebugMode) debugPrint('Could not set PRAGMAs: $e');
|
||||
}
|
||||
// Factory-Wahl, Verschlüsselung (SQLCipher), Migration und PRAGMAs stecken
|
||||
// komplett in der Storage-Fassade — siehe docs/SQLCIPHER_MIGRATION.md.
|
||||
final db = await AppDatabase.open();
|
||||
|
||||
final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db);
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:pyramid/core/app_dirs.dart';
|
||||
|
||||
/// Shared, bounded LRU cache for media bytes (chat images, document previews,
|
||||
/// avatars). Eviction is driven by a total byte budget plus a hard entry cap,
|
||||
@@ -89,7 +89,7 @@ class MediaCache {
|
||||
}
|
||||
|
||||
Future<Directory> _initDiskDir() async {
|
||||
final support = await getApplicationSupportDirectory();
|
||||
final support = await appSupportDir();
|
||||
final dir = Directory(
|
||||
'${support.path}${Platform.pathSeparator}media_cache');
|
||||
await dir.create(recursive: true);
|
||||
|
||||
@@ -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/fcm_push_service.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:shared_preferences/shared_preferences.dart';
|
||||
import 'package:windows_taskbar/windows_taskbar.dart';
|
||||
@@ -197,6 +199,26 @@ Future<void> _initAndroid(WidgetRef ref) async {
|
||||
if (!share.isEmpty) {
|
||||
ref.read(pendingShareProvider.notifier).state = share;
|
||||
}
|
||||
} else if (call.method == 'markReadFromNotification') {
|
||||
// „Gelesen“ in der Benachrichtigung (ReplyReceiver, App lebt).
|
||||
final roomId = (args is Map ? args['room_id'] : null) as String?;
|
||||
final eventId = (args is Map ? args['event_id'] : null) as String?;
|
||||
if (roomId != null && eventId != null) {
|
||||
try {
|
||||
final client = await ref.read(matrixClientProvider.future);
|
||||
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') {
|
||||
// Called by PushService.kt after showing a "Neue Nachricht" placeholder.
|
||||
// We decrypt the event here (the Matrix client has the keys) and overwrite
|
||||
@@ -458,57 +480,37 @@ Future<void> _decryptAndUpdateNotification(
|
||||
final client = await ref
|
||||
.read(matrixClientProvider.future)
|
||||
.timeout(const Duration(seconds: 5));
|
||||
var room = client.getRoomById(roomId);
|
||||
if (room == null) {
|
||||
debugPrint('[NOTIF] decryptAndUpdate: room $roomId not found');
|
||||
if (eventId == null) return;
|
||||
|
||||
// Genau das gepushte Event holen (lokal oder vom Server, entschlüsselt) –
|
||||
// nicht room.lastEvent, das kann inzwischen eine Reaktion o. Ä. sein.
|
||||
final event = await client
|
||||
.getEventByPushNotification(
|
||||
PushNotification(roomId: roomId, eventId: eventId, devices: const []),
|
||||
storeInDatabase: false,
|
||||
returnNullIfSeen: false,
|
||||
)
|
||||
.timeout(const Duration(seconds: 20));
|
||||
if (event == null || event.senderId == client.userID) return;
|
||||
|
||||
final content = MessageNotificationContent.fromEvent(
|
||||
event,
|
||||
showPreview: ref.read(notifPreviewProvider),
|
||||
);
|
||||
if (content == null) {
|
||||
debugPrint('[NOTIF] decryptAndUpdate: nichts Anzeigbares, Platzhalter bleibt');
|
||||
return;
|
||||
}
|
||||
|
||||
// The event might not have synced yet — wait for the next sync (max 12 s).
|
||||
// We use lastEvent as a proxy: after sync it will be the event we want.
|
||||
Event? event = room.lastEvent;
|
||||
final bool alreadyHave =
|
||||
eventId == null || event?.eventId == eventId;
|
||||
if (!alreadyHave || event == null) {
|
||||
debugPrint('[NOTIF] decryptAndUpdate: waiting for sync to deliver $eventId');
|
||||
await client.onSync.stream.first.timeout(const Duration(seconds: 12));
|
||||
room = client.getRoomById(roomId);
|
||||
if (room == null) return;
|
||||
event = room.lastEvent;
|
||||
}
|
||||
|
||||
if (event == null) {
|
||||
debugPrint('[NOTIF] decryptAndUpdate: event still null after sync');
|
||||
return;
|
||||
}
|
||||
if (event.type != EventTypes.Message) return;
|
||||
if (event.senderId == client.userID) return;
|
||||
|
||||
final rawBody = event.body;
|
||||
// If decryption failed the body starts with "** Unable to decrypt"
|
||||
if (rawBody.isEmpty || rawBody.startsWith('**')) {
|
||||
debugPrint('[NOTIF] decryptAndUpdate: decryption failed, keeping placeholder');
|
||||
return;
|
||||
}
|
||||
|
||||
final notifBody =
|
||||
rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody;
|
||||
final sender = room.unsafeGetUserFromMemoryOrFallback(event.senderId);
|
||||
final displayName = sender.displayName ??
|
||||
event.senderId.split(':').first.replaceFirst('@', '');
|
||||
final title = room.isDirectChat
|
||||
? displayName
|
||||
: '$displayName · ${room.getLocalizedDisplayname()}';
|
||||
|
||||
// Overwrite the "Neue Nachricht" placeholder — same notifId so Android
|
||||
// replaces the existing notification rather than stacking a new one.
|
||||
await _kInstallChannel.invokeMethod('showNativeNotification', {
|
||||
'title': title,
|
||||
'body': notifBody,
|
||||
'roomId': roomId,
|
||||
'notifId': notifId ?? _stableRoomId(roomId),
|
||||
});
|
||||
debugPrint('[NOTIF] decryptAndUpdate: updated notification → "$title: $notifBody"');
|
||||
// Same eventId → NotificationHelper ersetzt den „Neue Nachricht“-
|
||||
// Platzhalter im Verlauf, statt eine zweite Nachricht anzuhängen.
|
||||
await deliverMessageNotification(
|
||||
event,
|
||||
content,
|
||||
notifId: notifId ?? _stableRoomId(roomId),
|
||||
send: (a) => _kInstallChannel.invokeMethod('showNativeNotification', a),
|
||||
);
|
||||
debugPrint('[NOTIF] decryptAndUpdate: updated notification for $eventId');
|
||||
} catch (e) {
|
||||
debugPrint('[NOTIF] decryptAndUpdate error: $e');
|
||||
}
|
||||
@@ -516,27 +518,30 @@ Future<void> _decryptAndUpdateNotification(
|
||||
|
||||
// ─── Show notification ────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> showMessageNotification({
|
||||
required String roomId,
|
||||
String? roomName,
|
||||
required String senderName,
|
||||
required String body,
|
||||
}) async {
|
||||
final title = roomName != null ? '$senderName · $roomName' : senderName;
|
||||
Future<void> showMessageNotification(
|
||||
Event event,
|
||||
MessageNotificationContent content,
|
||||
) async {
|
||||
final roomId = content.roomId;
|
||||
if (Platform.isAndroid && _androidInitialized) {
|
||||
// Use native Kotlin notification so the reply PendingIntent targets
|
||||
// ReplyReceiver directly — the app never opens when the user replies.
|
||||
try {
|
||||
await _kInstallChannel.invokeMethod('showNativeNotification', {
|
||||
'title': title,
|
||||
'body': body,
|
||||
'roomId': roomId,
|
||||
'notifId': _stableRoomId(roomId),
|
||||
});
|
||||
await deliverMessageNotification(
|
||||
event,
|
||||
content,
|
||||
notifId: _stableRoomId(roomId),
|
||||
send: (a) => _kInstallChannel.invokeMethod('showNativeNotification', a),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[NOTIF] showNativeNotification failed: $e');
|
||||
}
|
||||
} else if (Platform.isWindows) {
|
||||
final title = content.title;
|
||||
// Windows-Toasts zeigen ohnehin nur wenige Zeilen.
|
||||
final body = content.body.length > 300
|
||||
? '${content.body.substring(0, 300)}…'
|
||||
: content.body;
|
||||
// Close the previous notification for this room before showing a new one.
|
||||
_windowsNotifs.remove(roomId)?.close();
|
||||
|
||||
@@ -624,23 +629,13 @@ void _watchEvents(Ref ref, Client client) {
|
||||
if (!mentionedIds.contains(myId) && !bodyMentionsUs) return;
|
||||
}
|
||||
|
||||
// ── Build notification body ───────────────────────────────────────────
|
||||
final rawBody = event.body;
|
||||
final showPreview = ref.read(notifPreviewProvider);
|
||||
final notifBody = showPreview
|
||||
? (rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody)
|
||||
: 'Neue Nachricht';
|
||||
|
||||
final sender = room.unsafeGetUserFromMemoryOrFallback(event.senderId);
|
||||
final displayName = sender.displayName ??
|
||||
event.senderId.split(':').first.replaceFirst('@', '');
|
||||
|
||||
showMessageNotification(
|
||||
roomId: roomId,
|
||||
roomName: room.isDirectChat ? null : room.getLocalizedDisplayname(),
|
||||
senderName: displayName,
|
||||
body: notifBody,
|
||||
// ── Build notification content ────────────────────────────────────────
|
||||
final content = MessageNotificationContent.fromEvent(
|
||||
event,
|
||||
showPreview: ref.read(notifPreviewProvider),
|
||||
);
|
||||
if (content == null) return;
|
||||
showMessageNotification(event, content);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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: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 ──────────────────────────
|
||||
|
||||
class BoolPref extends StateNotifier<bool> {
|
||||
final String _key;
|
||||
// Guards against the load race: if set()/toggle() ran before _load's read
|
||||
// lands, the stale persisted value must not overwrite the user's choice.
|
||||
bool _userSet = false;
|
||||
|
||||
BoolPref(this._key, bool defaultValue) : super(defaultValue) {
|
||||
_load(defaultValue);
|
||||
@@ -14,10 +20,11 @@ class BoolPref extends StateNotifier<bool> {
|
||||
|
||||
Future<void> _load(bool def) async {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
if (mounted) state = p.getBool(_key) ?? def;
|
||||
if (mounted && !_userSet) state = p.getBool(_key) ?? def;
|
||||
}
|
||||
|
||||
Future<void> set(bool v) async {
|
||||
_userSet = true;
|
||||
state = v;
|
||||
final p = await SharedPreferences.getInstance();
|
||||
await p.setBool(_key, v);
|
||||
@@ -30,9 +37,10 @@ class BoolPref extends StateNotifier<bool> {
|
||||
|
||||
class StringSetPref extends StateNotifier<Set<String>> {
|
||||
final String _key;
|
||||
late final Future<void> _loaded;
|
||||
|
||||
StringSetPref(this._key) : super({}) {
|
||||
_load();
|
||||
_loaded = _load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
@@ -42,20 +50,31 @@ class StringSetPref extends StateNotifier<Set<String>> {
|
||||
}
|
||||
|
||||
Future<void> add(String v) async {
|
||||
if (state.contains(v)) return;
|
||||
// Read-modify-write: ohne abgeschlossenes Laden würde hier der halbleere
|
||||
// Start-State zurückgeschrieben und persistierte Einträge gingen verloren.
|
||||
await _loaded;
|
||||
if (!mounted || state.contains(v)) return;
|
||||
state = {...state, v};
|
||||
final p = await SharedPreferences.getInstance();
|
||||
await p.setStringList(_key, state.toList());
|
||||
}
|
||||
|
||||
Future<void> remove(String v) async {
|
||||
if (!state.contains(v)) return;
|
||||
await _loaded;
|
||||
if (!mounted || !state.contains(v)) return;
|
||||
state = {...state}..remove(v);
|
||||
final p = await SharedPreferences.getInstance();
|
||||
await p.setStringList(_key, state.toList());
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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 ─────────────────────────────────────────────────
|
||||
|
||||
final notifDesktopProvider =
|
||||
@@ -76,34 +95,116 @@ final privacyPresenceProvider =
|
||||
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('privacy_presence', true));
|
||||
|
||||
final privacyReadReceiptsProvider =
|
||||
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('privacy_read_receipts', true));
|
||||
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref(readReceiptsPrefKey, true));
|
||||
|
||||
final privacyTypingProvider =
|
||||
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('privacy_typing', true));
|
||||
|
||||
// ── Voice preferences ────────────────────────────────────────────────────────
|
||||
// Geschrieben von der Settings-Voice-Sektion, gelesen von den Call-Modulen
|
||||
// (call_signaling, voice_channel). Die historischen voice_*-Keys bleiben
|
||||
// unverändert, damit bestehende Nutzer-Einstellungen erhalten bleiben.
|
||||
|
||||
class VoicePrefs {
|
||||
VoicePrefs._();
|
||||
|
||||
static const _kMic = 'voice_mic_device';
|
||||
static const _kSpeaker = 'voice_speaker_device';
|
||||
static const _kCamera = 'voice_camera_device';
|
||||
static const _kOutputVolume = 'voice_output_volume';
|
||||
|
||||
static Future<double> loadOutputVolume() async {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
return p.getDouble(_kOutputVolume) ?? 1.0;
|
||||
}
|
||||
|
||||
static Future<void> saveOutputVolume(double v) async {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
await p.setDouble(_kOutputVolume, v);
|
||||
}
|
||||
|
||||
/// Gewählte Geräte-IDs; null = kein Pref gesetzt (System-Standard nutzen).
|
||||
static Future<({String? mic, String? speaker, String? camera})>
|
||||
loadDevices() async {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
return (
|
||||
mic: p.getString(_kMic),
|
||||
speaker: p.getString(_kSpeaker),
|
||||
camera: p.getString(_kCamera),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> saveMic(String? deviceId) => _saveDevice(_kMic, deviceId);
|
||||
static Future<void> saveSpeaker(String? deviceId) =>
|
||||
_saveDevice(_kSpeaker, deviceId);
|
||||
static Future<void> saveCamera(String? deviceId) =>
|
||||
_saveDevice(_kCamera, deviceId);
|
||||
|
||||
static Future<void> _saveDevice(String key, String? deviceId) async {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
if (deviceId == null || deviceId.isEmpty) {
|
||||
await p.remove(key);
|
||||
} else {
|
||||
await p.setString(key, deviceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Theme persistence helpers ────────────────────────────────────────────────
|
||||
|
||||
Future<void> saveThemePrefs({
|
||||
bool? isDark,
|
||||
ThemeVariant? variant,
|
||||
int? accentIdx,
|
||||
double? radius,
|
||||
double? motion,
|
||||
double? textScale,
|
||||
bool resetTextScale = false,
|
||||
MessageStyle? messageStyle,
|
||||
}) async {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
if (isDark != null) await p.setBool('theme_dark', isDark);
|
||||
if (variant != null) {
|
||||
await p.setString('theme_variant', variant.name);
|
||||
// Altes Feld mitpflegen (falls jemand auf eine ältere Version zurückgeht).
|
||||
await p.setBool('theme_dark', variant.isDark);
|
||||
}
|
||||
if (accentIdx != null) await p.setInt('theme_accent', accentIdx);
|
||||
if (radius != null) await p.setDouble('theme_radius', radius);
|
||||
if (motion != null) await p.setDouble('theme_motion', motion);
|
||||
if (resetTextScale) {
|
||||
await p.remove('ui_text_scale');
|
||||
} else if (textScale != null) {
|
||||
await p.setDouble('ui_text_scale', textScale);
|
||||
}
|
||||
if (messageStyle != null) {
|
||||
await p.setString('chat_message_style', messageStyle.name);
|
||||
}
|
||||
}
|
||||
|
||||
Future<({bool isDark, int accentIdx, double radius, double motion})>
|
||||
loadThemePrefs() async {
|
||||
Future<
|
||||
({
|
||||
ThemeVariant variant,
|
||||
int accentIdx,
|
||||
double radius,
|
||||
double motion,
|
||||
double? textScale,
|
||||
MessageStyle messageStyle,
|
||||
})> loadThemePrefs() async {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
// Vor 2026-10-07 gab es nur hell/dunkel ('theme_dark'). Wer dunkel hatte,
|
||||
// bekommt jetzt das neue „Weiche Dunkel“ (Bernds Entscheidung).
|
||||
final storedVariant = p.getString('theme_variant');
|
||||
final variant = storedVariant != null
|
||||
? ThemeVariant.fromName(storedVariant)
|
||||
: (p.getBool('theme_dark') ?? true)
|
||||
? ThemeVariant.soft
|
||||
: ThemeVariant.light;
|
||||
return (
|
||||
isDark: p.getBool('theme_dark') ?? true,
|
||||
variant: variant,
|
||||
accentIdx: p.getInt('theme_accent') ?? 0,
|
||||
radius: p.getDouble('theme_radius') ?? 12.0,
|
||||
motion: p.getDouble('theme_motion') ?? 1.0,
|
||||
textScale: p.getDouble('ui_text_scale'),
|
||||
messageStyle: MessageStyle.fromName(p.getString('chat_message_style')),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+123
-21
@@ -1,6 +1,103 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
// Pyramid design tokens — matching mockup CSS variables exactly
|
||||
/// Farbschema der App (Einstellungen → Erscheinungsbild → Farbmodus).
|
||||
enum ThemeVariant {
|
||||
/// „Weiches Dunkel“ – Dunkelgrau statt Fast-Schwarz, mehr Kontrast bei
|
||||
/// Text. Standard seit 2026-10-07 (Bernds Entscheidung).
|
||||
soft,
|
||||
|
||||
/// Das bisherige Fast-Schwarz (spart auf OLED-Displays etwas Akku).
|
||||
black,
|
||||
|
||||
/// Hell.
|
||||
light;
|
||||
|
||||
bool get isDark => this != ThemeVariant.light;
|
||||
|
||||
String get label => switch (this) {
|
||||
ThemeVariant.soft => 'Dunkel',
|
||||
ThemeVariant.black => 'Schwarz',
|
||||
ThemeVariant.light => 'Hell',
|
||||
};
|
||||
|
||||
static ThemeVariant fromName(String? name) =>
|
||||
ThemeVariant.values.firstWhere((v) => v.name == name,
|
||||
orElse: () => ThemeVariant.soft);
|
||||
}
|
||||
|
||||
/// Die Flächen- und Textfarben eines [ThemeVariant].
|
||||
class PyramidPalette {
|
||||
const PyramidPalette({
|
||||
required this.bg0,
|
||||
required this.bg1,
|
||||
required this.bg2,
|
||||
required this.bg3,
|
||||
required this.bgHover,
|
||||
required this.bgActive,
|
||||
required this.border,
|
||||
required this.borderStrong,
|
||||
required this.fg,
|
||||
required this.fgMuted,
|
||||
required this.fgDim,
|
||||
});
|
||||
|
||||
final Color bg0, bg1, bg2, bg3, bgHover, bgActive, border, borderStrong;
|
||||
final Color fg, fgMuted, fgDim;
|
||||
|
||||
/// Weiches Dunkel: alle Flächen ~10 % heller als „Schwarz“, Nebentexte
|
||||
/// deutlich kontrastreicher (fgDim auf bg1 ≈ 5:1 statt ≈ 3,8:1).
|
||||
static const soft = PyramidPalette(
|
||||
bg0: Color(0xFF15161A),
|
||||
bg1: Color(0xFF1B1C21),
|
||||
bg2: Color(0xFF212228),
|
||||
bg3: Color(0xFF282A31),
|
||||
bgHover: Color(0xFF2F3139),
|
||||
bgActive: Color(0xFF373945),
|
||||
border: Color(0xFF2C2E36),
|
||||
borderStrong: Color(0xFF3A3C46),
|
||||
fg: Color(0xFFF2F2F5),
|
||||
fgMuted: Color(0xFFB7B7C2),
|
||||
fgDim: Color(0xFF8B8B99),
|
||||
);
|
||||
|
||||
static const black = PyramidPalette(
|
||||
bg0: PyramidColors.bg0,
|
||||
bg1: PyramidColors.bg1,
|
||||
bg2: PyramidColors.bg2,
|
||||
bg3: PyramidColors.bg3,
|
||||
bgHover: PyramidColors.bgHover,
|
||||
bgActive: PyramidColors.bgActive,
|
||||
border: PyramidColors.border,
|
||||
borderStrong: PyramidColors.borderStrong,
|
||||
fg: PyramidColors.fg,
|
||||
fgMuted: PyramidColors.fgMuted,
|
||||
fgDim: PyramidColors.fgDim,
|
||||
);
|
||||
|
||||
static const light = PyramidPalette(
|
||||
bg0: PyramidColors.bg0Light,
|
||||
bg1: PyramidColors.bg1Light,
|
||||
bg2: PyramidColors.bg2Light,
|
||||
bg3: PyramidColors.bg3Light,
|
||||
bgHover: PyramidColors.bgHoverLight,
|
||||
bgActive: PyramidColors.bgActiveLight,
|
||||
border: PyramidColors.borderLight,
|
||||
borderStrong: PyramidColors.borderStrongLight,
|
||||
fg: PyramidColors.fgLight,
|
||||
fgMuted: PyramidColors.fgMutedLight,
|
||||
fgDim: PyramidColors.fgDimLight,
|
||||
);
|
||||
|
||||
static PyramidPalette of(ThemeVariant variant) => switch (variant) {
|
||||
ThemeVariant.soft => soft,
|
||||
ThemeVariant.black => black,
|
||||
ThemeVariant.light => light,
|
||||
};
|
||||
}
|
||||
|
||||
// Pyramid design tokens — matching mockup CSS variables exactly.
|
||||
// Die dunklen Werte hier sind die Variante „Schwarz“; Widgets sollten die
|
||||
// Farben über PyramidTheme.of(context) holen (folgt dem gewählten Schema).
|
||||
class PyramidColors {
|
||||
const PyramidColors._();
|
||||
|
||||
@@ -49,7 +146,7 @@ class PyramidColors {
|
||||
}
|
||||
|
||||
class PyramidTheme extends InheritedWidget {
|
||||
final bool isDark;
|
||||
final ThemeVariant variant;
|
||||
final Color accent;
|
||||
final double radius;
|
||||
final double density;
|
||||
@@ -58,7 +155,7 @@ class PyramidTheme extends InheritedWidget {
|
||||
const PyramidTheme({
|
||||
super.key,
|
||||
required super.child,
|
||||
this.isDark = true,
|
||||
this.variant = ThemeVariant.soft,
|
||||
this.accent = PyramidColors.accentAmber,
|
||||
this.radius = 12,
|
||||
this.density = 1,
|
||||
@@ -69,17 +166,20 @@ class PyramidTheme extends InheritedWidget {
|
||||
return context.dependOnInheritedWidgetOfExactType<PyramidTheme>()!;
|
||||
}
|
||||
|
||||
Color get bg0 => isDark ? PyramidColors.bg0 : PyramidColors.bg0Light;
|
||||
Color get bg1 => isDark ? PyramidColors.bg1 : PyramidColors.bg1Light;
|
||||
Color get bg2 => isDark ? PyramidColors.bg2 : PyramidColors.bg2Light;
|
||||
Color get bg3 => isDark ? PyramidColors.bg3 : PyramidColors.bg3Light;
|
||||
Color get bgHover => isDark ? PyramidColors.bgHover : PyramidColors.bgHoverLight;
|
||||
Color get bgActive => isDark ? PyramidColors.bgActive : PyramidColors.bgActiveLight;
|
||||
Color get border => isDark ? PyramidColors.border : PyramidColors.borderLight;
|
||||
Color get borderStrong => isDark ? PyramidColors.borderStrong : PyramidColors.borderStrongLight;
|
||||
Color get fg => isDark ? PyramidColors.fg : PyramidColors.fgLight;
|
||||
Color get fgMuted => isDark ? PyramidColors.fgMuted : PyramidColors.fgMutedLight;
|
||||
Color get fgDim => isDark ? PyramidColors.fgDim : PyramidColors.fgDimLight;
|
||||
bool get isDark => variant.isDark;
|
||||
PyramidPalette get palette => PyramidPalette.of(variant);
|
||||
|
||||
Color get bg0 => palette.bg0;
|
||||
Color get bg1 => palette.bg1;
|
||||
Color get bg2 => palette.bg2;
|
||||
Color get bg3 => palette.bg3;
|
||||
Color get bgHover => palette.bgHover;
|
||||
Color get bgActive => palette.bgActive;
|
||||
Color get border => palette.border;
|
||||
Color get borderStrong => palette.borderStrong;
|
||||
Color get fg => palette.fg;
|
||||
Color get fgMuted => palette.fgMuted;
|
||||
Color get fgDim => palette.fgDim;
|
||||
Color get danger => PyramidColors.danger;
|
||||
Color get online => PyramidColors.online;
|
||||
Color get away => PyramidColors.away;
|
||||
@@ -104,7 +204,7 @@ class PyramidTheme extends InheritedWidget {
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(PyramidTheme oldWidget) =>
|
||||
isDark != oldWidget.isDark ||
|
||||
variant != oldWidget.variant ||
|
||||
accent != oldWidget.accent ||
|
||||
radius != oldWidget.radius ||
|
||||
density != oldWidget.density ||
|
||||
@@ -114,9 +214,11 @@ class PyramidTheme extends InheritedWidget {
|
||||
// Inter ist als Asset gebündelt (pubspec `fonts:`) — kein Laufzeit-Download.
|
||||
TextStyle _base(Color color) => TextStyle(fontFamily: 'Inter', color: color);
|
||||
|
||||
ThemeData buildPyramidThemeData(bool isDark) {
|
||||
final fg = isDark ? PyramidColors.fg : PyramidColors.fgLight;
|
||||
final bg1 = isDark ? PyramidColors.bg1 : PyramidColors.bg1Light;
|
||||
ThemeData buildPyramidThemeData(ThemeVariant variant) {
|
||||
final palette = PyramidPalette.of(variant);
|
||||
final isDark = variant.isDark;
|
||||
final fg = palette.fg;
|
||||
final bg1 = palette.bg1;
|
||||
final accent = PyramidColors.accentAmber;
|
||||
|
||||
return ThemeData(
|
||||
@@ -137,11 +239,11 @@ ThemeData buildPyramidThemeData(bool isDark) {
|
||||
),
|
||||
textTheme: TextTheme(
|
||||
bodyMedium: _base(fg).copyWith(fontSize: 14),
|
||||
bodySmall: _base(fg).copyWith(fontSize: 12, color: isDark ? PyramidColors.fgMuted : PyramidColors.fgMutedLight),
|
||||
labelSmall: _base(fg).copyWith(fontSize: 11, color: isDark ? PyramidColors.fgDim : PyramidColors.fgDimLight),
|
||||
bodySmall: _base(fg).copyWith(fontSize: 12, color: palette.fgMuted),
|
||||
labelSmall: _base(fg).copyWith(fontSize: 11, color: palette.fgDim),
|
||||
),
|
||||
dividerTheme: DividerThemeData(
|
||||
color: isDark ? PyramidColors.border : PyramidColors.borderLight,
|
||||
color: palette.border,
|
||||
thickness: 1,
|
||||
space: 1,
|
||||
),
|
||||
|
||||
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_webrtc/flutter_webrtc.dart' as rtc;
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:webrtc_interface/webrtc_interface.dart';
|
||||
import 'package:pyramid/core/call_foreground.dart';
|
||||
import 'package:pyramid/core/ice_servers.dart';
|
||||
import 'package:pyramid/core/settings_prefs.dart';
|
||||
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
||||
import 'package:pyramid/widgets/screen_share_picker.dart';
|
||||
|
||||
class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
||||
/// Implementierung von [CallSignalingService] (Modul `call_signaling`).
|
||||
/// Wird ausschließlich vom Kompositions-Punkt (`matrix_client.dart`)
|
||||
/// konstruiert; alle anderen gehen über `callSignalingProvider`.
|
||||
class PyramidVoipManager extends CallSignalingService
|
||||
implements WebRTCDelegate {
|
||||
static PyramidVoipManager? _instance;
|
||||
static PyramidVoipManager get instance => _instance!;
|
||||
|
||||
final Client client;
|
||||
late final VoIP voip;
|
||||
late final Zone _sdkZone;
|
||||
@override
|
||||
CallSession? currentCall;
|
||||
|
||||
|
||||
@override
|
||||
final rtc.RTCVideoRenderer localRenderer = rtc.RTCVideoRenderer();
|
||||
@override
|
||||
final rtc.RTCVideoRenderer remoteRenderer = rtc.RTCVideoRenderer();
|
||||
bool _renderersInitialized = false;
|
||||
|
||||
@@ -27,18 +37,66 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
||||
bool _isDeafened = false;
|
||||
bool _isUpdating = false;
|
||||
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';
|
||||
|
||||
@override
|
||||
bool get isMicMuted => _isMicMuted;
|
||||
@override
|
||||
bool get isCameraMuted => _isCameraMuted;
|
||||
@override
|
||||
bool get isScreensharing => _isScreensharing;
|
||||
@override
|
||||
bool get isRemoteScreensharing => _isRemoteScreensharing;
|
||||
@override
|
||||
bool get isDeafened => _isDeafened;
|
||||
@override
|
||||
String get currentQualityKey => _currentQualityKey;
|
||||
|
||||
PyramidVoipManager(this.client) {
|
||||
_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
|
||||
try {
|
||||
(voip as dynamic).onConfigSpeakerphone = (bool _) async {};
|
||||
@@ -46,6 +104,29 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
||||
_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 {
|
||||
if (_renderersInitialized) return;
|
||||
try {
|
||||
@@ -90,13 +171,88 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
||||
await _ensureRenderers();
|
||||
currentCall = call;
|
||||
_isCameraMuted = call.type == CallType.kVoice;
|
||||
_speakerOn = true;
|
||||
_isDeafened = false;
|
||||
_isMicMuted = false;
|
||||
_setupCallListeners(call);
|
||||
_updateInternalStates();
|
||||
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;
|
||||
String? _listenedCallId;
|
||||
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) {
|
||||
debugPrint('[VOIP] State: $state');
|
||||
_updateInternalStates();
|
||||
@@ -106,14 +262,36 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
||||
debugPrint('[VOIP] Ensuring camera is unmuted on connect');
|
||||
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();
|
||||
});
|
||||
// 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 = Timer.periodic(
|
||||
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();
|
||||
@@ -130,99 +308,68 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
||||
}
|
||||
|
||||
Future<void> _updateRenderers() async {
|
||||
if (currentCall == null || _isUpdating) return;
|
||||
final call = currentCall;
|
||||
if (call == null || _isUpdating) return;
|
||||
_isUpdating = true;
|
||||
|
||||
try {
|
||||
await _ensureRenderers();
|
||||
final dynamic c = currentCall;
|
||||
rtc.MediaStream? remote;
|
||||
rtc.MediaStream? local;
|
||||
|
||||
rtc.MediaStream? extract(dynamic obj) {
|
||||
if (obj == null) return null;
|
||||
if (obj is rtc.MediaStream) return obj;
|
||||
try { return obj.stream as rtc.MediaStream?; } catch (_) {}
|
||||
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;
|
||||
}
|
||||
// Typisierte SDK-Zugriffe. Früher lief das über ein dynamisches
|
||||
// `getFeeds()` – das gibt es im SDK gar nicht; der Fehler wurde
|
||||
// geschluckt, und Bildschirmübertragungen wurden weder beim Empfänger
|
||||
// angezeigt noch beim Sender als aktiv erkannt (Bernd 2026-10-08).
|
||||
rtc.MediaStream? video(WrappedMediaStream? w, {bool respectMute = false}) {
|
||||
final s = w?.stream;
|
||||
// stopped: Gegenseite hat die Übertragung beendet (das SDK lässt den
|
||||
// Strom stehen und markiert ihn nur).
|
||||
if (w == null || w.stopped || s == null || s.getVideoTracks().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
if (respectMute && w.videoMuted) return null;
|
||||
return s;
|
||||
}
|
||||
|
||||
_isScreensharing = localScreenFound;
|
||||
_isRemoteScreensharing = remoteScreenFound;
|
||||
final remoteScreen = video(call.remoteScreenSharingStream);
|
||||
final localScreen = video(call.localScreenSharingStream);
|
||||
final remoteUser = call.remoteUserMediaStream?.stream;
|
||||
|
||||
if (remote == null) {
|
||||
try { remote = extract(c.remoteUserMediaStream); } catch (_) {}
|
||||
}
|
||||
if (remote == null) {
|
||||
try { remote = extract(c.remoteStream); } catch (_) {}
|
||||
}
|
||||
if (local == null) {
|
||||
try { local = extract(c.localUserMediaStream); } catch (_) {}
|
||||
var changed = false;
|
||||
final remoteSharing = remoteScreen != null;
|
||||
final localSharing = call.localScreenSharingStream != null;
|
||||
if (remoteSharing != _isRemoteScreensharing ||
|
||||
localSharing != _isScreensharing) {
|
||||
changed = true;
|
||||
}
|
||||
_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.
|
||||
if (remote != null && remote.id != _volumeAppliedStreamId) {
|
||||
_volumeAppliedStreamId = remote.id;
|
||||
_remoteAudioStream = remote;
|
||||
if (remoteUser != null && remoteUser.id != _volumeAppliedStreamId) {
|
||||
_volumeAppliedStreamId = remoteUser.id;
|
||||
_remoteAudioStream = remoteUser;
|
||||
applyOutputVolume();
|
||||
}
|
||||
|
||||
// Final check for tracks before assigning
|
||||
bool changed = false;
|
||||
if (remote != null && remote.getVideoTracks().isNotEmpty) {
|
||||
if (remoteRenderer.srcObject?.id != remote.id) {
|
||||
debugPrint('[VOIP] Assigning Remote Renderer: ${remote.id} (${remote.getVideoTracks().length} video tracks)');
|
||||
remoteRenderer.srcObject = remote;
|
||||
changed = true;
|
||||
}
|
||||
} else if (remoteRenderer.srcObject != null) {
|
||||
remoteRenderer.srcObject = null;
|
||||
// Bildschirm hat Vorrang vor der Kamera.
|
||||
final remoteVideo =
|
||||
remoteScreen ?? video(call.remoteUserMediaStream, respectMute: true);
|
||||
final localVideo =
|
||||
localScreen ?? video(call.localUserMediaStream, respectMute: true);
|
||||
|
||||
if (remoteRenderer.srcObject?.id != remoteVideo?.id) {
|
||||
debugPrint('[VOIP] Remote-Video: ${remoteVideo?.id} (Bildschirm: $remoteSharing)');
|
||||
remoteRenderer.srcObject = remoteVideo;
|
||||
changed = true;
|
||||
}
|
||||
if (localRenderer.srcObject?.id != localVideo?.id) {
|
||||
debugPrint('[VOIP] Lokales Video: ${localVideo?.id} (Bildschirm: $localSharing)');
|
||||
localRenderer.srcObject = localVideo;
|
||||
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();
|
||||
} catch (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
|
||||
Future<void> handleCallEnded(CallSession session) async {
|
||||
if (currentCall?.callId == session.callId) {
|
||||
_renderRefreshTimer?.cancel();
|
||||
_renderRefreshTimer = null;
|
||||
_stopLevelPolling();
|
||||
localRenderer.srcObject = null;
|
||||
remoteRenderer.srcObject = null;
|
||||
_remoteAudioStream = null;
|
||||
@@ -245,6 +445,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
||||
_isRemoteScreensharing = false;
|
||||
notifyListeners();
|
||||
}
|
||||
CallForeground.callEnded('voip');
|
||||
}
|
||||
|
||||
// ── 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
|
||||
/// Remote-Audio-Stream an. Ohne [value] wird der Pref-Wert gelesen.
|
||||
@override
|
||||
Future<void> applyOutputVolume([double? value]) async {
|
||||
var v = value;
|
||||
if (v == null) {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
v = prefs.getDouble('voice_output_volume') ?? 1.0;
|
||||
v = await VoicePrefs.loadOutputVolume();
|
||||
} catch (_) {
|
||||
v = 1.0;
|
||||
}
|
||||
@@ -278,6 +479,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
||||
currentCall = null;
|
||||
notifyListeners();
|
||||
}
|
||||
CallForeground.callEnded('voip');
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -295,9 +497,10 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
||||
@override
|
||||
Future<void> registerListeners(CallSession session) async {}
|
||||
|
||||
@override
|
||||
Future<void> safeAction(Future<dynamic> Function() action) async {
|
||||
try {
|
||||
await action();
|
||||
await _inSdkZone(action);
|
||||
await Future.delayed(const Duration(milliseconds: 1000));
|
||||
} catch (e) {
|
||||
debugPrint('[VOIP] Action error: $e');
|
||||
@@ -306,24 +509,35 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
||||
_updateRenderers();
|
||||
}
|
||||
|
||||
Future<void> toggleMic() async {
|
||||
if (currentCall == null) return;
|
||||
_isMicMuted = !_isMicMuted;
|
||||
@override
|
||||
Future<void> toggleMic() => _setMicMuted(!_isMicMuted);
|
||||
|
||||
Future<void> _setMicMuted(bool muted) async {
|
||||
final c = currentCall;
|
||||
if (c == null) return;
|
||||
_isMicMuted = muted;
|
||||
notifyListeners();
|
||||
try {
|
||||
await currentCall!.setMicrophoneMuted(_isMicMuted);
|
||||
await _inSdkZone(() => c.setMicrophoneMuted(muted));
|
||||
} catch (e) {
|
||||
_isMicMuted = !_isMicMuted;
|
||||
notifyListeners();
|
||||
// Das SDK schaltet die Spur VOR dem Senden der Status-Info um. Schlägt
|
||||
// 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 {
|
||||
if (currentCall == null) return;
|
||||
_isCameraMuted = !_isCameraMuted;
|
||||
notifyListeners();
|
||||
try {
|
||||
await currentCall!.setLocalVideoMuted(_isCameraMuted);
|
||||
final c = currentCall!;
|
||||
await _inSdkZone(() => c.setLocalVideoMuted(_isCameraMuted));
|
||||
for (var i = 0; i < 5; i++) {
|
||||
await Future.delayed(Duration(milliseconds: 300 + (i * 200)));
|
||||
await _updateRenderers();
|
||||
@@ -337,12 +551,14 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
||||
await _updateRenderers();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> toggleScreenSharing([BuildContext? context]) async {
|
||||
if (currentCall == null) return;
|
||||
final call = currentCall!;
|
||||
|
||||
if (_isScreensharing) {
|
||||
await safeAction(() => call.setScreensharingEnabled(false));
|
||||
await CallForeground.screenCaptureStopped('voip');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -359,34 +575,63 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
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;
|
||||
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 {
|
||||
if (currentCall == null) return;
|
||||
await safeAction(() async {
|
||||
await currentCall!.hangup(reason: CallErrorCode.userHangup);
|
||||
currentCall = null;
|
||||
});
|
||||
await CallForeground.callEnded('voip');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> startCall(String roomId, {bool video = false}) async {
|
||||
final room = client.getRoomById(roomId);
|
||||
if (room == null) return;
|
||||
await _ensureRenderers();
|
||||
_isCameraMuted = !video;
|
||||
|
||||
_speakerOn = true;
|
||||
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;
|
||||
|
||||
_setupCallListeners(call);
|
||||
|
||||
if (video) {
|
||||
await Future.delayed(const Duration(milliseconds: 800));
|
||||
await call.setLocalVideoMuted(false);
|
||||
await _inSdkZone(() => call.setLocalVideoMuted(false));
|
||||
await _updateRenderers();
|
||||
}
|
||||
|
||||
@@ -394,6 +639,7 @@ class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> changeQuality(String key) async {
|
||||
_currentQualityKey = key;
|
||||
if (currentCall == null) {
|
||||
@@ -444,10 +690,10 @@ class MediaDevicesWrapper extends MediaDevices {
|
||||
// In den Einstellungen gewählte Geräte (voice_*_device Prefs) anwenden —
|
||||
// nur wenn kein Screenshare-Source gesetzt ist (sonst nicht anfassen).
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final mic = prefs.getString('voice_mic_device');
|
||||
final cam = prefs.getString('voice_camera_device');
|
||||
final speaker = prefs.getString('voice_speaker_device');
|
||||
final devicePrefs = await VoicePrefs.loadDevices();
|
||||
final mic = devicePrefs.mic;
|
||||
final cam = devicePrefs.camera;
|
||||
final speaker = devicePrefs.speaker;
|
||||
if (mic != null &&
|
||||
improvedConstraints['audio'] != null &&
|
||||
improvedConstraints['audio'] != false) {
|
||||
@@ -503,6 +749,13 @@ class MediaDevicesWrapper extends MediaDevices {
|
||||
|
||||
@override
|
||||
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;
|
||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.windows && sourceId != null) {
|
||||
final desktopConstraints = {
|
||||
@@ -0,0 +1,201 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui';
|
||||
|
||||
/// Physik der Bubble-Anrufansicht (Bernd 2026-10-09: „animierte dynamische
|
||||
/// Bubbles, die sich aneinander anpassen und aussehen, als würden sie
|
||||
/// aneinander gedrückt“).
|
||||
///
|
||||
/// Jede Person ist ein Kreis. Eine Feder zieht alle Kreise zur Mitte, Kreise
|
||||
/// stoßen sich ab, sobald sie sich mehr als [overlap] überlappen, und die
|
||||
/// Ränder halten sie im Bild. Im Gleichgewicht drücken sie also leicht
|
||||
/// gegeneinander; [clipPath] schneidet jeden Kreis an der Berührungslinie
|
||||
/// (Potenzgerade) gerade ab – wie Seifenblasen in Schaum.
|
||||
///
|
||||
/// Wer spricht, wird größer und schiebt die anderen sanft beiseite; neue
|
||||
/// Teilnehmer wachsen aus dem Nichts heraus, Gehende schrumpfen weg.
|
||||
class BubbleBody {
|
||||
final String id;
|
||||
Offset pos;
|
||||
Offset vel = Offset.zero;
|
||||
double r;
|
||||
double rVel = 0;
|
||||
double targetR = 0;
|
||||
bool leaving = false;
|
||||
|
||||
BubbleBody(this.id, this.pos, {this.r = 0});
|
||||
}
|
||||
|
||||
class BubbleSim {
|
||||
/// Anteil der Kreisdurchmesser, um den sich Nachbarn überlappen dürfen –
|
||||
/// daraus entstehen die flachen Berührungsflächen.
|
||||
static const overlap = 0.10;
|
||||
|
||||
/// Wie viel größer ein sprechender Kreis wird.
|
||||
static const speakingScale = 1.22;
|
||||
|
||||
final Map<String, BubbleBody> bodies = {};
|
||||
Size _size = Size.zero;
|
||||
Size get size => _size;
|
||||
Offset get _center => Offset(_size.width / 2, _size.height / 2);
|
||||
|
||||
/// Gleicht die Kreise mit den aktuellen Teilnehmern ab: neue kommen dazu,
|
||||
/// fehlende schrumpfen weg, die Zielgrößen werden neu verteilt.
|
||||
/// [speaking] = IDs, die gerade sprechen.
|
||||
void sync(List<String> ids, Set<String> speaking, Size size) {
|
||||
_size = size;
|
||||
final present = ids.toSet();
|
||||
for (final b in bodies.values) {
|
||||
b.leaving = !present.contains(b.id);
|
||||
}
|
||||
for (final id in ids) {
|
||||
if (bodies.containsKey(id)) continue;
|
||||
// Neu: nahe der Mitte (leicht versetzt, damit es die anderen
|
||||
// auseinanderschiebt statt exakt auf ihnen zu liegen).
|
||||
final h = id.hashCode;
|
||||
final angle = (h % 360) * math.pi / 180;
|
||||
final spread = math.min(size.width, size.height) * 0.08;
|
||||
bodies[id] = BubbleBody(
|
||||
id,
|
||||
_center + Offset(math.cos(angle), math.sin(angle)) * spread,
|
||||
);
|
||||
}
|
||||
_assignTargets(ids, speaking);
|
||||
}
|
||||
|
||||
void _assignTargets(List<String> ids, Set<String> speaking) {
|
||||
final w = _size.width, h = _size.height;
|
||||
if (w <= 0 || h <= 0) return;
|
||||
final shortSide = math.min(w, h);
|
||||
double weight(String id) => speaking.contains(id) ? speakingScale : 1.0;
|
||||
final active = ids.where((id) => bodies.containsKey(id)).toList();
|
||||
double base;
|
||||
if (active.length <= 1) {
|
||||
base = shortSide * 0.27;
|
||||
} else {
|
||||
// Kreise sollen zusammen gut ein Drittel der Fläche bedecken.
|
||||
final sumW2 = active.fold<double>(0, (s, id) => s + weight(id) * weight(id));
|
||||
base = math.sqrt(0.36 * w * h / (math.pi * sumW2));
|
||||
}
|
||||
final maxR = shortSide * (active.length <= 2 ? 0.27 : 0.36);
|
||||
for (final b in bodies.values) {
|
||||
if (b.leaving) {
|
||||
b.targetR = 0;
|
||||
} else {
|
||||
b.targetR = (base * weight(b.id)).clamp(28.0, maxR);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Zeitschritt in Sekunden. Liefert `true`, solange sich noch etwas
|
||||
/// bewegt (sonst kann die Animation pausieren).
|
||||
bool step(double dt) {
|
||||
if (_size.isEmpty) return false;
|
||||
dt = dt.clamp(0.0, 1 / 30);
|
||||
// Zwei Unterschritte: stabil auch bei großen Kräften.
|
||||
var moving = false;
|
||||
for (var i = 0; i < 2; i++) {
|
||||
moving = _substep(dt / 2) || moving;
|
||||
}
|
||||
bodies.removeWhere((_, b) => b.leaving && b.r < 1);
|
||||
return moving;
|
||||
}
|
||||
|
||||
bool _substep(double dt) {
|
||||
const centerK = 18.0; // Zug zur Mitte
|
||||
const pushK = 260.0; // Abstoßung bei zu starker Überlappung
|
||||
const wallK = 160.0;
|
||||
const damping = 9.0;
|
||||
const radiusK = 110.0;
|
||||
const radiusDamping = 13.0;
|
||||
const margin = 10.0;
|
||||
|
||||
final list = bodies.values.toList();
|
||||
final forces = List<Offset>.filled(list.length, Offset.zero);
|
||||
final c = _center;
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
final a = list[i];
|
||||
// Die Mitte zieht, aber gestaucht in der langen Richtung, damit sich
|
||||
// die Kreise im Hochformat übereinander, im Querformat nebeneinander
|
||||
// anordnen.
|
||||
final d = a.pos - c;
|
||||
final ax = _size.width >= _size.height ? 0.55 : 1.0;
|
||||
final ay = _size.height > _size.width ? 0.55 : 1.0;
|
||||
forces[i] += Offset(-d.dx * centerK * ax, -d.dy * centerK * ay);
|
||||
|
||||
// Ränder
|
||||
final left = a.pos.dx - a.r - margin;
|
||||
final right = _size.width - margin - (a.pos.dx + a.r);
|
||||
final top = a.pos.dy - a.r - margin;
|
||||
final bottom = _size.height - margin - (a.pos.dy + a.r);
|
||||
if (left < 0) forces[i] += Offset(-left * wallK, 0);
|
||||
if (right < 0) forces[i] += Offset(right * wallK, 0);
|
||||
if (top < 0) forces[i] += Offset(0, -top * wallK);
|
||||
if (bottom < 0) forces[i] += Offset(0, bottom * wallK);
|
||||
|
||||
for (var j = i + 1; j < list.length; j++) {
|
||||
final b = list[j];
|
||||
var delta = b.pos - a.pos;
|
||||
var dist = delta.distance;
|
||||
if (dist < 0.01) {
|
||||
// exakt übereinander: in eine feste Richtung auseinander
|
||||
delta = Offset(math.cos(i + j * 1.7), math.sin(i + j * 1.7));
|
||||
dist = 0.01;
|
||||
}
|
||||
final rest = (a.r + b.r) * (1 - overlap);
|
||||
if (dist < rest) {
|
||||
final u = delta / dist;
|
||||
final f = u * ((rest - dist) * pushK);
|
||||
forces[i] -= f;
|
||||
forces[j] += f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var moving = false;
|
||||
final decay = math.exp(-damping * dt);
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
final b = list[i];
|
||||
b.vel = (b.vel + forces[i] * dt) * decay;
|
||||
b.pos += b.vel * dt;
|
||||
// Größe federt zum Ziel (leichtes Überschwingen beim Erscheinen).
|
||||
b.rVel += (radiusK * (b.targetR - b.r) - radiusDamping * b.rVel) * dt;
|
||||
b.r = math.max(0, b.r + b.rVel * dt);
|
||||
if (b.vel.distance > 2 ||
|
||||
b.rVel.abs() > 2 ||
|
||||
(b.targetR - b.r).abs() > 0.5) {
|
||||
moving = true;
|
||||
}
|
||||
}
|
||||
return moving;
|
||||
}
|
||||
|
||||
/// Umriss von [id] in Bühnen-Koordinaten: der Kreis, an jeder Berührung
|
||||
/// mit einem Nachbarn gerade abgeschnitten (mit [seam] Pixel Fuge).
|
||||
Path clipPath(String id, {double seam = 3}) {
|
||||
final a = bodies[id]!;
|
||||
final circle = Rect.fromCircle(center: a.pos, radius: math.max(a.r, 0.01));
|
||||
var path = Path()..addOval(circle);
|
||||
for (final b in bodies.values) {
|
||||
if (identical(a, b) || b.r < 1) continue;
|
||||
final delta = b.pos - a.pos;
|
||||
final d = delta.distance;
|
||||
if (d < 0.01 || d >= a.r + b.r) continue;
|
||||
final u = delta / d;
|
||||
// Abstand von a zur Potenzgerade der beiden Kreise
|
||||
final cut = (d * d + a.r * a.r - b.r * b.r) / (2 * d) - seam / 2;
|
||||
if (cut >= a.r) continue;
|
||||
final n = Offset(-u.dy, u.dx);
|
||||
final big = a.r * 4 + 10;
|
||||
final base = a.pos + u * cut;
|
||||
final half = Path()
|
||||
..addPolygon([
|
||||
base + n * big,
|
||||
base - n * big,
|
||||
base - n * big - u * big,
|
||||
base + n * big - u * big,
|
||||
], true);
|
||||
path = Path.combine(PathOperation.intersect, path, half);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
}
|
||||
+10
-10
@@ -3,14 +3,14 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:pyramid/core/app_state.dart';
|
||||
import 'package:pyramid/core/theme.dart';
|
||||
import 'package:pyramid/core/livekit_call_manager.dart';
|
||||
import 'package:pyramid/core/voip_manager.dart';
|
||||
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
||||
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
|
||||
import 'package:pyramid/widgets/screen_share_picker.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
||||
import 'package:livekit_client/livekit_client.dart';
|
||||
|
||||
class MiniCallWidget extends ConsumerStatefulWidget {
|
||||
final dynamic call; // Can be LiveKitCallManager or PyramidVoipManager
|
||||
final dynamic call; // CallSignalingService (1:1) oder VoiceChannelService
|
||||
const MiniCallWidget({super.key, required this.call});
|
||||
|
||||
@override
|
||||
@@ -45,11 +45,11 @@ class _MiniCallWidgetState extends ConsumerState<MiniCallWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = PyramidTheme.of(context);
|
||||
final isVoip = widget.call is PyramidVoipManager;
|
||||
final isVoip = widget.call is CallSignalingService;
|
||||
final call = widget.call;
|
||||
|
||||
final String displayName = isVoip
|
||||
? (call.currentCall?.room.getLocalizedDisplayname() ?? 'Unknown')
|
||||
? (call.currentCall?.room.getLocalizedDisplayname() ?? 'Unbekannt')
|
||||
: call.displayName;
|
||||
|
||||
final bool isConnecting = isVoip ? false : call.isConnecting;
|
||||
@@ -95,7 +95,7 @@ class _MiniCallWidgetState extends ConsumerState<MiniCallWidget> {
|
||||
isStreaming ? call.localRenderer : call.remoteRenderer,
|
||||
objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
|
||||
)
|
||||
: _LiveKitPreview(manager: call as LiveKitCallManager),
|
||||
: _LiveKitPreview(manager: call as VoiceChannelService),
|
||||
),
|
||||
|
||||
// Top bar of preview
|
||||
@@ -160,7 +160,7 @@ class _MiniCallWidgetState extends ConsumerState<MiniCallWidget> {
|
||||
const _LiveDot(),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
isConnecting ? 'CONNECTING...' : 'VOICE CONNECTED',
|
||||
isConnecting ? 'CONNECTING...' : (isVoip ? 'ANRUF LÄUFT' : (call.isVoiceChannel ? 'SPRACHKANAL' : 'GRUPPENANRUF')),
|
||||
style: TextStyle(
|
||||
color: pt.online,
|
||||
fontSize: 10,
|
||||
@@ -259,9 +259,9 @@ class _MiniCallWidgetState extends ConsumerState<MiniCallWidget> {
|
||||
pt: pt,
|
||||
onTap: () async {
|
||||
if (isVoip) {
|
||||
(call as PyramidVoipManager).toggleScreenSharing(context);
|
||||
(call as CallSignalingService).toggleScreenSharing(context);
|
||||
} else {
|
||||
final lk = call as LiveKitCallManager;
|
||||
final lk = call as VoiceChannelService;
|
||||
if (lk.isScreenSharing) {
|
||||
await lk.stopScreenShare();
|
||||
} else {
|
||||
@@ -351,7 +351,7 @@ class _ControlBtnState extends State<_ControlBtn> {
|
||||
}
|
||||
|
||||
class _LiveKitPreview extends StatelessWidget {
|
||||
final LiveKitCallManager manager;
|
||||
final VoiceChannelService manager;
|
||||
const _LiveKitPreview({required this.manager});
|
||||
|
||||
@override
|
||||
@@ -0,0 +1,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,362 @@
|
||||
part of '../voice_channel.dart';
|
||||
|
||||
/// Anrufansicht als aneinandergedrückte Bubbles (Physik: bubble_physics.dart).
|
||||
/// Jede Person ist eine Blase mit Profilbild (oder Kamera-Bild), wer spricht,
|
||||
/// wächst und leuchtet; Blasen weichen sich federnd aus.
|
||||
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 _sim = BubbleSim();
|
||||
// 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 = _sim.step(dt);
|
||||
_data.removeWhere((id, _) => !_sim.bodies.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;
|
||||
}
|
||||
_sim.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 != _sim.size) {
|
||||
_needsSync = false;
|
||||
_sync(size);
|
||||
}
|
||||
final glow = 0.5 + 0.5 * math.sin(_time * 6);
|
||||
final children = <Widget>[];
|
||||
for (final b in _sim.bodies.values) {
|
||||
final d = _data[b.id];
|
||||
if (d == null || b.r < 2) continue;
|
||||
final rect = Rect.fromCircle(center: b.pos, radius: b.r);
|
||||
children.add(Positioned.fromRect(
|
||||
key: ValueKey('bubble:${b.id}'),
|
||||
rect: rect,
|
||||
child: _Bubble(
|
||||
data: d,
|
||||
path: _sim.clipPath(b.id).shift(-rect.topLeft),
|
||||
radius: b.r,
|
||||
client: widget.client,
|
||||
pt: pt,
|
||||
glow: glow,
|
||||
onTap: () => widget.onSelect(d),
|
||||
),
|
||||
));
|
||||
}
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: RadialGradient(
|
||||
colors: [pt.bg2, pt.bg1],
|
||||
radius: 0.9,
|
||||
),
|
||||
),
|
||||
child: Stack(clipBehavior: Clip.hardEdge, children: children),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _Bubble extends StatelessWidget {
|
||||
final _ParticipantData data;
|
||||
final Path path;
|
||||
final double radius;
|
||||
final Client? client;
|
||||
final PyramidTheme pt;
|
||||
final double glow;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _Bubble({
|
||||
required this.data,
|
||||
required this.path,
|
||||
required this.radius,
|
||||
required this.client,
|
||||
required this.pt,
|
||||
required this.glow,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final showName = radius > 38;
|
||||
return Semantics(
|
||||
label: data.isMuted ? '${data.name}, stumm' : data.name,
|
||||
button: data.hasVideo,
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: CustomPaint(
|
||||
foregroundPainter: _BubbleRingPainter(
|
||||
path: path,
|
||||
speaking: data.isSpeaking,
|
||||
glow: glow,
|
||||
color: pt.online,
|
||||
),
|
||||
child: ClipPath(
|
||||
clipper: _PathClipper(path),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
_face(),
|
||||
if (data.hasVideo && data.coverVideoWidget != null)
|
||||
_MirrorWrapper(
|
||||
isLocal: data.isLocal,
|
||||
isScreenShare: data.isScreenShare,
|
||||
child: data.coverVideoWidget!,
|
||||
),
|
||||
const IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: RadialGradient(
|
||||
center: Alignment(-0.45, -0.55),
|
||||
radius: 0.95,
|
||||
colors: [Color(0x2EFFFFFF), Color(0x00FFFFFF)],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showName)
|
||||
Align(
|
||||
alignment: const Alignment(0, 0.62),
|
||||
child: _nameTag(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Profilbild oder Farbverlauf mit Anfangsbuchstaben – fest in 256 px
|
||||
/// gebaut und skaliert, damit das Bild beim Wachsen nicht neu lädt.
|
||||
Widget _face() {
|
||||
Widget initial(BuildContext _) => Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [data.avatarColor, data.avatarColor.withAlpha(150)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
alignment: const Alignment(0, -0.12),
|
||||
child: Text(data.initial,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 104,
|
||||
fontWeight: FontWeight.w700)),
|
||||
);
|
||||
final c = client;
|
||||
final face = (c != null && data.avatarUrl != null)
|
||||
? MxcAvatar(
|
||||
mxcUri: data.avatarUrl,
|
||||
client: c,
|
||||
size: 256,
|
||||
borderRadius: BorderRadius.zero,
|
||||
placeholder: initial,
|
||||
)
|
||||
: Builder(builder: initial);
|
||||
return FittedBox(
|
||||
fit: BoxFit.cover,
|
||||
child: SizedBox(width: 256, height: 256, child: face),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _nameTag() {
|
||||
final fontSize = (radius * 0.16).clamp(10.0, 15.0);
|
||||
return Container(
|
||||
constraints: BoxConstraints(maxWidth: radius * 1.5),
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: fontSize * 0.7, vertical: fontSize * 0.25),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.45),
|
||||
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),
|
||||
],
|
||||
Flexible(
|
||||
child: Text(
|
||||
data.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 _BubbleRingPainter extends CustomPainter {
|
||||
final Path path;
|
||||
final bool speaking;
|
||||
final double glow;
|
||||
final Color color;
|
||||
|
||||
_BubbleRingPainter({
|
||||
required this.path,
|
||||
required this.speaking,
|
||||
required this.glow,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
if (speaking) {
|
||||
canvas.drawPath(
|
||||
path,
|
||||
Paint()
|
||||
..color = color.withValues(alpha: 0.30 + 0.30 * glow)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 7
|
||||
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 6),
|
||||
);
|
||||
canvas.drawPath(
|
||||
path,
|
||||
Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 3
|
||||
..strokeJoin = StrokeJoin.round,
|
||||
);
|
||||
} else {
|
||||
canvas.drawPath(
|
||||
path,
|
||||
Paint()
|
||||
..color = Colors.white.withValues(alpha: 0.10)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.2,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_BubbleRingPainter old) => true;
|
||||
}
|
||||
|
||||
/// Vorführ-Ansicht der Bubbles 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;
|
||||
const CallBubblesDemo({super.key, this.count = 5});
|
||||
|
||||
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 == 2,
|
||||
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/bubble_physics.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:io';
|
||||
import 'dart:ui' as ui;
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -7,7 +8,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:pyramid/core/settings_prefs.dart';
|
||||
import 'package:pyramid/core/appearance.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/emoji_picker.dart';
|
||||
import 'package:pyramid/features/chat/gif_sticker_picker.dart';
|
||||
@@ -32,6 +37,10 @@ class ChatComposer extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
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();
|
||||
late final FocusNode _focus;
|
||||
bool _canSend = false;
|
||||
@@ -44,6 +53,12 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_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);
|
||||
}
|
||||
|
||||
@@ -118,6 +133,8 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
final text = _ctrl.text;
|
||||
if (text.trim().isNotEmpty) _drafts[widget.room.id] = text;
|
||||
_typingTimer?.cancel();
|
||||
if (_isTyping) widget.room.setTyping(false).catchError((_) {});
|
||||
_gifOverlay?.remove();
|
||||
@@ -300,22 +317,43 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
|
||||
final pt = PyramidTheme.of(context);
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
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();
|
||||
// „#“ nur bei Server-Kanälen; Gruppen heißen wie die Gruppe.
|
||||
final placeholder = widget.room.isDirectChat
|
||||
? 'Message…'
|
||||
: 'Message #$roomName';
|
||||
? 'Nachricht…'
|
||||
: ref.watch(spaceChannelIdsProvider).contains(widget.room.id)
|
||||
? 'Nachricht an #$roomName'
|
||||
: 'Nachricht an $roomName';
|
||||
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.replyTo != null)
|
||||
_ReplyBar(
|
||||
event: widget.replyTo!,
|
||||
onClear: widget.onClearReply ?? () {},
|
||||
pt: pt,
|
||||
// Antwort-Leiste federt auf und klappt beim Schließen weich zu.
|
||||
AnimatedSwitcher(
|
||||
duration: motionDuration(context, 300),
|
||||
reverseDuration: motionDuration(context, 160),
|
||||
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(
|
||||
// Bottom inset matches the profile block in the rooms list (8px) so
|
||||
// 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
|
||||
// multiline.
|
||||
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(
|
||||
color: pt.bg2,
|
||||
borderRadius: BorderRadius.circular(pt.rBase + 4),
|
||||
border: Border.all(
|
||||
color: _focus.hasFocus ? pt.accent : pt.border,
|
||||
@@ -337,7 +377,13 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
|
||||
? [BoxShadow(color: pt.accentSoft, blurRadius: 0, spreadRadius: 3)]
|
||||
: 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(() {}),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
@@ -347,7 +393,7 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
|
||||
icon: Icons.add_rounded,
|
||||
tooltip: 'Datei anhängen',
|
||||
pt: pt,
|
||||
size: size.width < 600 ? 24 : 30,
|
||||
size: mobile ? 42 : 30,
|
||||
onTap: _pickAttachment,
|
||||
),
|
||||
// Text input
|
||||
@@ -397,14 +443,14 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
|
||||
icon: Icons.gif_box_outlined,
|
||||
tooltip: 'GIF',
|
||||
pt: pt,
|
||||
size: size.width < 600 ? 22 : 26,
|
||||
size: mobile ? 40 : 26,
|
||||
onTap: _toggleGifPicker,
|
||||
),
|
||||
_ComposerIconBtn(
|
||||
icon: Icons.tag_faces_rounded,
|
||||
tooltip: 'Emoji',
|
||||
pt: pt,
|
||||
size: size.width < 600 ? 22 : 26,
|
||||
size: mobile ? 40 : 26,
|
||||
onTap: _toggleEmojiPicker,
|
||||
),
|
||||
if (size.width > 600)
|
||||
@@ -424,10 +470,14 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
|
||||
canSend: _canSend,
|
||||
pt: pt,
|
||||
onTap: _send,
|
||||
size: mobile ? 42 : 36,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -461,9 +511,7 @@ class _ComposerIconBtnState extends State<_ComposerIconBtn> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final platform = Theme.of(context).platform;
|
||||
final isMobile =
|
||||
platform == TargetPlatform.android || platform == TargetPlatform.iOS;
|
||||
final isMobile = isMobileLayout(MediaQuery.sizeOf(context));
|
||||
final touchSize = isMobile ? 44.0 : widget.size;
|
||||
|
||||
return MouseRegion(
|
||||
@@ -475,7 +523,9 @@ class _ComposerIconBtnState extends State<_ComposerIconBtn> {
|
||||
child: Tooltip(
|
||||
message: widget.tooltip,
|
||||
waitDuration: const Duration(milliseconds: 500),
|
||||
child: SizedBox(
|
||||
child: SpringPress(
|
||||
enabled: widget.onTap != null,
|
||||
child: SizedBox(
|
||||
width: touchSize,
|
||||
height: touchSize,
|
||||
child: Center(
|
||||
@@ -497,6 +547,7 @@ class _ComposerIconBtnState extends State<_ComposerIconBtn> {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -507,8 +558,14 @@ class _SendButton extends StatefulWidget {
|
||||
final bool canSend;
|
||||
final PyramidTheme pt;
|
||||
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
|
||||
State<_SendButton> createState() => _SendButtonState();
|
||||
@@ -528,11 +585,14 @@ class _SendButtonState extends State<_SendButton> {
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: active ? widget.onTap : null,
|
||||
child: AnimatedContainer(
|
||||
child: SpringPress(
|
||||
enabled: active,
|
||||
pressedScale: 0.82,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOutBack,
|
||||
width: 36,
|
||||
height: 36,
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
decoration: BoxDecoration(
|
||||
color: active ? pt.accent : pt.bg3,
|
||||
borderRadius: BorderRadius.circular(pt.rSm),
|
||||
@@ -547,13 +607,22 @@ class _SendButtonState extends State<_SendButton> {
|
||||
: Matrix4.identity(),
|
||||
transformAlignment: Alignment.center,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.send_rounded,
|
||||
size: 16,
|
||||
color: active ? pt.accentFg : pt.fgDim,
|
||||
// Sobald gesendet werden kann, ploppt der Pfeil federnd auf.
|
||||
child: AnimatedScale(
|
||||
scale: active ? 1 : 0.8,
|
||||
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(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
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> {
|
||||
Timeline? _timeline;
|
||||
StreamSubscription<String>? _roomUpdateSub;
|
||||
StreamSubscription<SyncUpdate>? _syncSub;
|
||||
Timer? _redecryptTimer;
|
||||
String _receiptSig = '';
|
||||
|
||||
@override
|
||||
Future<Timeline> build(String arg) async {
|
||||
@@ -21,6 +23,7 @@ class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
|
||||
|
||||
_timeline?.cancelSubscriptions();
|
||||
_roomUpdateSub?.cancel();
|
||||
_syncSub?.cancel();
|
||||
_redecryptTimer?.cancel();
|
||||
|
||||
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
|
||||
// Megolm session key sharing works on the first send.
|
||||
if (room.encrypted && client.encryptionEnabled) {
|
||||
@@ -57,6 +82,7 @@ class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
|
||||
|
||||
ref.onDispose(() {
|
||||
_roomUpdateSub?.cancel();
|
||||
_syncSub?.cancel();
|
||||
_redecryptTimer?.cancel();
|
||||
_timeline?.cancelSubscriptions();
|
||||
_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 =
|
||||
AsyncNotifierProvider.family<TimelineNotifier, Timeline, String>(
|
||||
TimelineNotifier.new,
|
||||
|
||||
@@ -10,6 +10,7 @@ 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/appearance.dart' show isMobileLayout;
|
||||
import 'package:pyramid/core/matrix_client.dart';
|
||||
import 'package:pyramid/core/settings_prefs.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/message_group.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/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/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';
|
||||
|
||||
@@ -6,7 +6,7 @@ import 'package:archive/archive.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.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:path_provider/path_provider.dart';
|
||||
import 'package:pdfx/pdfx.dart';
|
||||
|
||||
@@ -628,7 +628,7 @@ class _SectionLabel extends StatelessWidget {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
|
||||
child: Text(title,
|
||||
style: TextStyle(color: PyramidColors.fgDim,
|
||||
style: TextStyle(color: PyramidTheme.of(context).fgDim,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.6)),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
@@ -419,6 +420,15 @@ class _ChunkyBarsPainter extends CustomPainter {
|
||||
//
|
||||
// Variant: minimal — Controls immer sichtbar (overlay auf Video, kein Auto-Hide)
|
||||
|
||||
/// Maximale Höhe für Bilder/Videos im Chat. Auf dem Handy bis zur halben
|
||||
/// Bildschirmhöhe – vorher galten überall 320 px, wodurch Hochkant-Fotos auf
|
||||
/// dem Handy nur ~180 px schmal waren (Bernds Feedback 2026-10-07).
|
||||
double inlineMediaMaxHeight(BuildContext context) {
|
||||
final screen = MediaQuery.sizeOf(context);
|
||||
final isMobile = screen.width < 900 || screen.height < 500;
|
||||
return isMobile ? (screen.height * 0.5).clamp(240.0, 560.0) : 320.0;
|
||||
}
|
||||
|
||||
class PyramidVideoPlayer extends StatefulWidget {
|
||||
final Uint8List bytes;
|
||||
final String filename;
|
||||
@@ -441,6 +451,10 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
|
||||
late final Player _player;
|
||||
late final VideoController _controller;
|
||||
bool _loading = true;
|
||||
// Echtes Seitenverhältnis, sobald das Video seine Größe kennt (vorher fest
|
||||
// 16:9 → Hochkant-Handyvideos klein zwischen schwarzen Balken).
|
||||
double? _aspect;
|
||||
final _subs = <StreamSubscription<int?>>[];
|
||||
double _speed = 1.0;
|
||||
String _quality = 'Auto';
|
||||
|
||||
@@ -452,9 +466,19 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
|
||||
super.initState();
|
||||
_player = Player();
|
||||
_controller = VideoController(_player);
|
||||
_subs.add(_player.stream.width.listen((_) => _updateAspect()));
|
||||
_subs.add(_player.stream.height.listen((_) => _updateAspect()));
|
||||
_init();
|
||||
}
|
||||
|
||||
void _updateAspect() {
|
||||
final w = _player.state.width;
|
||||
final h = _player.state.height;
|
||||
if (w == null || h == null || w <= 0 || h <= 0) return;
|
||||
final aspect = w / h;
|
||||
if (mounted && aspect != _aspect) setState(() => _aspect = aspect);
|
||||
}
|
||||
|
||||
Future<void> _init() async {
|
||||
final ext = widget.filename.contains('.') ? widget.filename.split('.').last : 'mp4';
|
||||
final dir = await getTemporaryDirectory();
|
||||
@@ -466,6 +490,9 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final s in _subs) {
|
||||
s.cancel();
|
||||
}
|
||||
_player.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -506,7 +533,10 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(top: 4, bottom: 4),
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 420,
|
||||
maxHeight: inlineMediaMaxHeight(context),
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black,
|
||||
borderRadius: BorderRadius.circular(pt.rLg),
|
||||
@@ -514,7 +544,7 @@ class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
|
||||
),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
aspectRatio: _aspect ?? 16 / 9,
|
||||
child: StreamBuilder<bool>(
|
||||
stream: _player.stream.playing,
|
||||
builder: (_, playSnap) => StreamBuilder<Duration>(
|
||||
|
||||
@@ -6,9 +6,17 @@ import 'package:path_provider/path_provider.dart';
|
||||
import 'package:pyramid/core/theme.dart';
|
||||
import 'package:pyramid/features/chat/media_player.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.
|
||||
/// Uses a semi-transparent backdrop; tapping outside the content closes it.
|
||||
///
|
||||
/// Bedienung (Bernds Feedback 2026-10-07, wie man es von WhatsApp & Co. kennt):
|
||||
/// * Handy: randlos auf schwarzem Grund (vorher 24-px-Rand, halbdurchsichtig).
|
||||
/// * Tippen NEBEN das Bild schließt, Tippen AUFS Bild blendet die Leiste ein/aus.
|
||||
/// * Nach oben/unten wischen schließt (nur ohne Zoom), der Hintergrund blendet
|
||||
/// dabei aus.
|
||||
/// * Doppeltipp zoomt an der Stelle hinein bzw. wieder heraus; mit zwei
|
||||
/// Fingern aufziehen geht weiterhin.
|
||||
class MediaViewer extends StatefulWidget {
|
||||
final String senderName;
|
||||
final Uint8List? bytes;
|
||||
@@ -40,10 +48,22 @@ class MediaViewer extends StatefulWidget {
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
barrierLabel: 'Schließen',
|
||||
barrierColor: Colors.black.withAlpha(178),
|
||||
transitionDuration: const Duration(milliseconds: 180),
|
||||
// Den Hintergrund zeichnet der Viewer selbst (damit er beim Wischen
|
||||
// ausblenden kann).
|
||||
barrierColor: Colors.transparent,
|
||||
transitionDuration: motionDuration(context, 300),
|
||||
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(
|
||||
senderName: senderName,
|
||||
filename: filename,
|
||||
@@ -59,21 +79,127 @@ class MediaViewer extends StatefulWidget {
|
||||
State<MediaViewer> createState() => _MediaViewerState();
|
||||
}
|
||||
|
||||
class _MediaViewerState extends State<MediaViewer> {
|
||||
class _MediaViewerState extends State<MediaViewer>
|
||||
with SingleTickerProviderStateMixin {
|
||||
/// Ab so viel Pixel Wischweg (oder entsprechend schnell) wird geschlossen.
|
||||
static const _dismissDistance = 120.0;
|
||||
static const _dismissVelocity = 1000.0;
|
||||
static const _doubleTapScale = 2.5;
|
||||
|
||||
final _tc = TransformationController();
|
||||
final _contentKey = GlobalKey();
|
||||
late final AnimationController _zoomAnim;
|
||||
Animation<Matrix4>? _zoomTween;
|
||||
|
||||
bool _controlsVisible = true;
|
||||
bool _saving = false;
|
||||
bool _savingSticker = false;
|
||||
bool _zoomed = false;
|
||||
bool _dragging = false;
|
||||
double _dragOffset = 0;
|
||||
Offset? _doubleTapGlobal;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_zoomAnim = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 220),
|
||||
)..addListener(() {
|
||||
final tween = _zoomTween;
|
||||
if (tween != null) _tc.value = tween.value;
|
||||
});
|
||||
_tc.addListener(_onTransform);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_zoomAnim.dispose();
|
||||
_tc.removeListener(_onTransform);
|
||||
_tc.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onTransform() {
|
||||
final zoomed = _tc.value.getMaxScaleOnAxis() > 1.01;
|
||||
if (zoomed != _zoomed) setState(() => _zoomed = zoomed);
|
||||
}
|
||||
|
||||
void _close() => Navigator.of(context).maybePop();
|
||||
|
||||
void _toggle() => setState(() => _controlsVisible = !_controlsVisible);
|
||||
|
||||
void _resetZoom() => _tc.value = Matrix4.identity();
|
||||
void _animateTo(Matrix4 target) {
|
||||
_zoomTween = Matrix4Tween(begin: _tc.value, end: target).animate(
|
||||
CurvedAnimation(parent: _zoomAnim, curve: Curves.easeOutCubic),
|
||||
);
|
||||
_zoomAnim.forward(from: 0);
|
||||
}
|
||||
|
||||
void _resetZoom() => _animateTo(Matrix4.identity());
|
||||
|
||||
/// Doppeltipp: an der getippten Stelle hineinzoomen bzw. ganz heraus.
|
||||
void _toggleZoom() {
|
||||
final global = _doubleTapGlobal;
|
||||
final box = _contentKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (_zoomed || global == null || box == null) {
|
||||
_resetZoom();
|
||||
return;
|
||||
}
|
||||
final p = box.globalToLocal(global);
|
||||
_animateTo(Matrix4.identity()
|
||||
..translateByDouble(
|
||||
-p.dx * (_doubleTapScale - 1),
|
||||
-p.dy * (_doubleTapScale - 1),
|
||||
0,
|
||||
1,
|
||||
)
|
||||
..scaleByDouble(_doubleTapScale, _doubleTapScale, 1, 1));
|
||||
}
|
||||
|
||||
// ── Wischen zum Schließen (über die InteractiveViewer-Callbacks, damit es
|
||||
// nicht mit dem Zwei-Finger-Zoom um die Gesten konkurriert) ─────────────
|
||||
|
||||
void _onInteractionStart(ScaleStartDetails d) {
|
||||
_dragging = !_zoomed && d.pointerCount == 1;
|
||||
}
|
||||
|
||||
void _onInteractionUpdate(ScaleUpdateDetails d) {
|
||||
if (!_dragging) return;
|
||||
if (d.pointerCount > 1) {
|
||||
// Zweiter Finger dazu → das ist ein Zoom, kein Wischen.
|
||||
setState(() {
|
||||
_dragging = false;
|
||||
_dragOffset = 0;
|
||||
});
|
||||
return;
|
||||
}
|
||||
setState(() => _dragOffset += d.focalPointDelta.dy);
|
||||
}
|
||||
|
||||
void _onInteractionEnd(ScaleEndDetails d) {
|
||||
if (!_dragging) return;
|
||||
_dragging = false;
|
||||
final fast = d.velocity.pixelsPerSecond.dy.abs() > _dismissVelocity;
|
||||
if (_dragOffset.abs() > _dismissDistance || fast) {
|
||||
_close();
|
||||
} else {
|
||||
setState(() => _dragOffset = 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Video: kein InteractiveViewer, also normales vertikales Wischen.
|
||||
void _onVideoDragUpdate(DragUpdateDetails d) =>
|
||||
setState(() => _dragOffset += d.delta.dy);
|
||||
|
||||
void _onVideoDragEnd(DragEndDetails d) {
|
||||
final fast = d.velocity.pixelsPerSecond.dy.abs() > _dismissVelocity;
|
||||
if (_dragOffset.abs() > _dismissDistance || fast) {
|
||||
_close();
|
||||
} else {
|
||||
setState(() => _dragOffset = 0);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveAsSticker() async {
|
||||
final event = widget.event;
|
||||
@@ -127,9 +253,16 @@ class _MediaViewerState extends State<MediaViewer> {
|
||||
final pt = PyramidTheme.of(context);
|
||||
final isVideo = widget.isVideo && widget.bytes != null;
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
final isMobile = size.width < 900 || size.height < 500;
|
||||
|
||||
// Beim Wischen blendet der Hintergrund aus und zeigt, dass gleich zu ist.
|
||||
final dismissProgress = (_dragOffset.abs() / (_dismissDistance * 2.5)).clamp(0.0, 1.0);
|
||||
final bgAlpha = (isMobile ? 1.0 : 0.82) * (1 - dismissProgress);
|
||||
// Handy: randlos; Desktop: etwas Luft, damit das Fenster dahinter erkennbar bleibt.
|
||||
final margin = isMobile ? 0.0 : 24.0;
|
||||
|
||||
final header = AnimatedOpacity(
|
||||
opacity: _controlsVisible ? 1 : 0,
|
||||
opacity: _controlsVisible && _dragOffset == 0 ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 180),
|
||||
child: IgnorePointer(
|
||||
ignoring: !_controlsVisible,
|
||||
@@ -146,7 +279,7 @@ class _MediaViewerState extends State<MediaViewer> {
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
onPressed: _close,
|
||||
icon: const Icon(Icons.close_rounded, color: Colors.white),
|
||||
tooltip: 'Schließen',
|
||||
),
|
||||
@@ -181,7 +314,7 @@ class _MediaViewerState extends State<MediaViewer> {
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||||
: const Icon(Icons.download_rounded, color: Colors.white),
|
||||
),
|
||||
if (!isVideo)
|
||||
if (!isVideo && _zoomed)
|
||||
IconButton(
|
||||
onPressed: _resetZoom,
|
||||
tooltip: 'Zoom zurücksetzen',
|
||||
@@ -194,61 +327,71 @@ class _MediaViewerState extends State<MediaViewer> {
|
||||
),
|
||||
);
|
||||
|
||||
// Content area — constrained so the transparent Material outside it lets
|
||||
// pointer events through to the barrier (which dismisses on tap).
|
||||
final contentMaxW = (size.width - 48).clamp(200.0, 1200.0);
|
||||
final contentMaxH = (size.height - 48).clamp(200.0, 900.0);
|
||||
|
||||
final Widget content;
|
||||
if (isVideo) {
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
content = GestureDetector(
|
||||
onVerticalDragUpdate: _onVideoDragUpdate,
|
||||
onVerticalDragEnd: _onVideoDragEnd,
|
||||
child: FullscreenVideoPlayer(bytes: widget.bytes!, filename: widget.filename),
|
||||
);
|
||||
} else {
|
||||
Widget img;
|
||||
if (widget.bytes != null) {
|
||||
img = Image.memory(widget.bytes!, fit: BoxFit.contain, gaplessPlayback: true);
|
||||
} else if (widget.networkUrl != null) {
|
||||
img = Image.network(widget.networkUrl!, fit: BoxFit.contain);
|
||||
} else {
|
||||
img = Icon(Icons.broken_image_outlined, size: 64, color: pt.fgDim);
|
||||
}
|
||||
content = InteractiveViewer(
|
||||
transformationController: _tc,
|
||||
minScale: 1.0,
|
||||
maxScale: 10.0,
|
||||
// Ohne Zoom gibt es nichts zu verschieben – die Ein-Finger-Bewegung
|
||||
// gehört dann dem Wischen zum Schließen.
|
||||
panEnabled: _zoomed,
|
||||
onInteractionStart: _onInteractionStart,
|
||||
onInteractionUpdate: _onInteractionUpdate,
|
||||
onInteractionEnd: _onInteractionEnd,
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: contentMaxW,
|
||||
height: contentMaxH,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
FullscreenVideoPlayer(bytes: widget.bytes!, filename: widget.filename),
|
||||
Positioned(top: 0, left: 0, right: 0, child: header),
|
||||
],
|
||||
),
|
||||
key: _contentKey,
|
||||
// Nur das Bild selbst fängt Tipps ab; daneben greift das Schließen.
|
||||
child: GestureDetector(
|
||||
onTap: _toggle,
|
||||
onDoubleTapDown: (d) => _doubleTapGlobal = d.globalPosition,
|
||||
onDoubleTap: _toggleZoom,
|
||||
child: img,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget img;
|
||||
if (widget.bytes != null) {
|
||||
img = Image.memory(widget.bytes!, fit: BoxFit.contain, gaplessPlayback: true);
|
||||
} else if (widget.networkUrl != null) {
|
||||
img = Image.network(widget.networkUrl!, fit: BoxFit.contain);
|
||||
} else {
|
||||
img = Icon(Icons.broken_image_outlined, size: 64, color: pt.fgDim);
|
||||
}
|
||||
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: contentMaxW,
|
||||
height: contentMaxH,
|
||||
child: GestureDetector(
|
||||
onTap: _toggle,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
InteractiveViewer(
|
||||
transformationController: _tc,
|
||||
minScale: 0.5,
|
||||
maxScale: 10.0,
|
||||
child: Center(child: img),
|
||||
),
|
||||
Positioned(top: 0, left: 0, right: 0, child: header),
|
||||
],
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// Hintergrund + „daneben tippen schließt“.
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: _close,
|
||||
child: ColoredBox(color: Colors.black.withValues(alpha: bgAlpha)),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.all(margin),
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, _dragOffset),
|
||||
child: GestureDetector(
|
||||
// Tipps auf die leere Fläche um das Bild (innerhalb des
|
||||
// Inhaltsbereichs) schließen ebenfalls.
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: _close,
|
||||
child: content,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(top: 0, left: 0, right: 0, child: header),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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';
|
||||
|
||||
/// 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) {
|
||||
final h = t.hour.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
|
||||
/// "Neue Nachrichten" divider is rendered after that event's row.
|
||||
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
|
||||
/// [jumpKey] attached (for ensureVisible) and [highlightEventId] is tinted.
|
||||
final String? jumpEventId;
|
||||
final GlobalKey? jumpKey;
|
||||
final String? highlightEventId;
|
||||
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({
|
||||
super.key,
|
||||
@@ -41,10 +58,13 @@ class MessageGroup extends StatefulWidget {
|
||||
this.showStatusIndicator = false,
|
||||
this.readEventIds = const {},
|
||||
this.fullyReadId,
|
||||
this.unreadFading = false,
|
||||
this.unreadDividerKey,
|
||||
this.jumpEventId,
|
||||
this.jumpKey,
|
||||
this.highlightEventId,
|
||||
this.highlightOn = false,
|
||||
this.popInAfter,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -64,7 +84,23 @@ class _MessageGroupState extends State<MessageGroup> {
|
||||
|
||||
@override
|
||||
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 sender = firstEvent.senderFromMemoryOrFallback;
|
||||
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
|
||||
// marker and there are more events in this group.
|
||||
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) ──
|
||||
@@ -143,7 +179,7 @@ class _MessageGroupState extends State<MessageGroup> {
|
||||
if (widget.readEventIds.contains(event.eventId)) rows.add(_ReadMarker(pt: pt));
|
||||
// Inline divider after this event if it's the fullyRead and not the last.
|
||||
if (widget.fullyReadId == event.eventId && j < widget.events.length - 1) {
|
||||
rows.add(_InlineUnreadDivider(pt: pt));
|
||||
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
|
||||
// group is just the column of rows.
|
||||
Widget content = Column(
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: rows,
|
||||
);
|
||||
}
|
||||
|
||||
// Handy: nach links wischen = antworten (beide Darstellungen).
|
||||
Widget _withSwipeToReply(PyramidTheme pt, Widget content) {
|
||||
if (!_isMobile) return content;
|
||||
|
||||
return GestureDetector(
|
||||
@@ -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.
|
||||
return MouseRegion(
|
||||
return _popIn(event, MouseRegion(
|
||||
onEnter: (_) => setState(() => _hoveredEventId = event.eventId),
|
||||
onExit: (_) => setState(() {
|
||||
if (_hoveredEventId == event.eventId) _hoveredEventId = null;
|
||||
}),
|
||||
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.
|
||||
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),
|
||||
child: IntrinsicHeight(
|
||||
child: ClipRRect(
|
||||
@@ -204,6 +215,9 @@ class _ReplyPreview extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ class _ImageContentState extends ConsumerState<_ImageContent> {
|
||||
final maxW = constraints.maxWidth.isFinite
|
||||
? constraints.maxWidth.clamp(80.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,
|
||||
// so the layout doesn't shift (and yank the scroll position) when the image
|
||||
// finishes loading. Null when dimensions are absent.
|
||||
@@ -378,7 +378,7 @@ class _MediaContentState extends ConsumerState<_MediaContent> {
|
||||
final pt = widget.pt;
|
||||
final isSticker = widget.event.type == EventTypes.Sticker;
|
||||
final maxW = isSticker ? 160.0 : 420.0;
|
||||
final maxH = isSticker ? 160.0 : 320.0;
|
||||
final maxH = isSticker ? 160.0 : inlineMediaMaxHeight(context);
|
||||
|
||||
return FutureBuilder<_MediaResult>(
|
||||
future: _future,
|
||||
|
||||
@@ -22,11 +22,15 @@ class _ReactionsRow extends StatelessWidget {
|
||||
|
||||
// Group by emoji key → list of senders
|
||||
final Map<String, List<String>> grouped = {};
|
||||
final newest = <String, DateTime>{};
|
||||
for (final r in reactionEvents) {
|
||||
final key = r.content.tryGetMap<String, dynamic>('m.relates_to')?['key'] as String?;
|
||||
if (key == null || key.isEmpty) continue;
|
||||
(grouped[key] ??= []).add(r.senderId);
|
||||
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();
|
||||
|
||||
return Padding(
|
||||
@@ -37,6 +41,9 @@ class _ReactionsRow extends StatelessWidget {
|
||||
children: grouped.entries.map((e) {
|
||||
final myReaction = e.value.contains(currentUserId);
|
||||
return _ReactionChip(
|
||||
key: ValueKey(e.key),
|
||||
// Gerade erst vergeben → federnd aufploppen.
|
||||
fresh: now.difference(newest[e.key]!) < const Duration(seconds: 4),
|
||||
emoji: e.key,
|
||||
count: e.value.length,
|
||||
mine: myReaction,
|
||||
@@ -69,8 +76,11 @@ class _ReactionChip extends StatefulWidget {
|
||||
final bool mine;
|
||||
final PyramidTheme pt;
|
||||
final VoidCallback onTap;
|
||||
final bool fresh;
|
||||
|
||||
const _ReactionChip({
|
||||
super.key,
|
||||
this.fresh = false,
|
||||
required this.emoji,
|
||||
required this.count,
|
||||
required this.mine,
|
||||
@@ -88,12 +98,22 @@ class _ReactionChipState extends State<_ReactionChip> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: SpringPress(
|
||||
pressedScale: 0.85,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
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).
|
||||
class _InlineUnreadDivider extends StatelessWidget {
|
||||
final PyramidTheme pt;
|
||||
const _InlineUnreadDivider({required this.pt});
|
||||
final bool fading;
|
||||
const _InlineUnreadDivider({super.key, required this.pt, this.fading = false});
|
||||
|
||||
@override
|
||||
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),
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -112,6 +120,7 @@ class _InlineUnreadDivider extends StatelessWidget {
|
||||
Expanded(child: Divider(color: pt.accent.withAlpha(160), height: 1)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,10 @@ class _MessageContent extends StatelessWidget {
|
||||
final displayEvent = _displayEvent;
|
||||
final isEdited = displayEvent.eventId != event.eventId;
|
||||
|
||||
if (GroupCallNotice.matches(displayEvent)) {
|
||||
return GroupCallNotice(event: displayEvent);
|
||||
}
|
||||
|
||||
// Still encrypted (decryption pending or failed)
|
||||
if (displayEvent.type == EventTypes.Encrypted) {
|
||||
return Padding(
|
||||
@@ -213,7 +217,7 @@ class _MatrixHtmlText extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final spans = _parseHtml(html, baseStyle, linkColor);
|
||||
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(
|
||||
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_riverpod/flutter_riverpod.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/media_cache.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/media_viewer.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/widgets/mxc_image.dart';
|
||||
import 'package:pyramid/widgets/spring_press.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
part 'message/message_group_core.dart';
|
||||
part 'message/message_bubbles.dart';
|
||||
part 'message/message_text.dart';
|
||||
part 'message/message_link_preview.dart';
|
||||
part 'message/message_media.dart';
|
||||
|
||||
@@ -26,10 +26,19 @@ class _ChatHeader extends ConsumerWidget {
|
||||
final name = room?.getLocalizedDisplayname() ?? roomId;
|
||||
final isDm = room?.isDirectChat ?? 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 size = MediaQuery.sizeOf(context);
|
||||
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(
|
||||
height: 52,
|
||||
@@ -42,15 +51,19 @@ class _ChatHeader extends ConsumerWidget {
|
||||
children: [
|
||||
// Back button on mobile
|
||||
if (isMobile) ...[
|
||||
GestureDetector(
|
||||
onTap: () => ref.read(activeRoomIdProvider.notifier).state = null,
|
||||
child: Icon(Icons.arrow_back_rounded, size: 22, color: pt.fg),
|
||||
PyrIconBtn(
|
||||
icon: Icon(Icons.arrow_back_rounded, color: pt.fg),
|
||||
tooltip: 'Zurück',
|
||||
onPressed: () =>
|
||||
ref.read(activeRoomIdProvider.notifier).state = null,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
// Room icon / avatar
|
||||
if (isDm && client != null) ...[
|
||||
_DmHeaderAvatar(room: room, name: name, client: client),
|
||||
] else if (isGroup && client != null) ...[
|
||||
_GroupHeaderAvatar(room: room!, client: client, pt: pt),
|
||||
] else if (!isDm) ...[
|
||||
Icon(
|
||||
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
|
||||
Text(
|
||||
name,
|
||||
@@ -90,7 +125,9 @@ class _ChatHeader extends ConsumerWidget {
|
||||
color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
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),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
@@ -105,18 +142,25 @@ class _ChatHeader extends ConsumerWidget {
|
||||
// Right actions
|
||||
Row(
|
||||
children: [
|
||||
if (isDm) ...[
|
||||
if (isDm || isGroup) ...[
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.phone_rounded),
|
||||
tooltip: 'Voice call',
|
||||
onPressed: () => _startCall(ref, room, cam: false),
|
||||
icon: Icon(Icons.phone_rounded,
|
||||
color: groupCallRunning ? pt.online : null),
|
||||
tooltip: groupCallRunning
|
||||
? 'Gruppenanruf beitreten'
|
||||
: (isGroup ? 'Gruppenanruf' : 'Anrufen'),
|
||||
onPressed: () => isGroup
|
||||
? startOrJoinGroupCall(context, ref, room!)
|
||||
: _startCall(ref, room, cam: false),
|
||||
),
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.videocam_rounded),
|
||||
tooltip: 'Video call',
|
||||
onPressed: () => _startCall(ref, room, cam: true),
|
||||
tooltip: 'Videoanruf',
|
||||
onPressed: () => isGroup
|
||||
? startOrJoinGroupCall(context, ref, room!, video: true)
|
||||
: _startCall(ref, room, cam: true),
|
||||
),
|
||||
if (size.width > 700)
|
||||
Container(
|
||||
@@ -127,12 +171,6 @@ class _ChatHeader extends ConsumerWidget {
|
||||
),
|
||||
],
|
||||
if (size.width > 800) ...[
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.inbox_rounded),
|
||||
tooltip: 'Threads',
|
||||
onPressed: () {},
|
||||
),
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.push_pin_rounded),
|
||||
@@ -141,19 +179,27 @@ class _ChatHeader extends ConsumerWidget {
|
||||
onPressed: onTogglePinned,
|
||||
),
|
||||
],
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.search_rounded),
|
||||
tooltip: 'Search',
|
||||
onPressed: onOpenSearch,
|
||||
),
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.group_rounded),
|
||||
tooltip: 'Members',
|
||||
active: membersOpen,
|
||||
onPressed: onToggleMembers,
|
||||
),
|
||||
if (isMobile)
|
||||
_ChatOverflowMenu(
|
||||
pt: pt,
|
||||
onSearch: onOpenSearch,
|
||||
onMembers: onToggleMembers,
|
||||
)
|
||||
else ...[
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.search_rounded),
|
||||
tooltip: 'Suchen',
|
||||
onPressed: onOpenSearch,
|
||||
),
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.group_rounded),
|
||||
tooltip: 'Mitglieder',
|
||||
active: membersOpen,
|
||||
onPressed: onToggleMembers,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -167,17 +213,19 @@ class _ChatHeader extends ConsumerWidget {
|
||||
if (room.isDirectChat) {
|
||||
// Use native Matrix VoIP for 1:1 DMs
|
||||
ref.read(viewModeProvider.notifier).state = ViewMode.voice;
|
||||
await PyramidVoipManager.instance.startCall(room.id, video: cam);
|
||||
await ref.read(callSignalingProvider).startCall(room.id, video: cam);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use LiveKit for group voice rooms
|
||||
ref.read(viewModeProvider.notifier).state = ViewMode.voice;
|
||||
await ref.read(callStateProvider).startCall(
|
||||
await ref.read(voiceChannelProvider).startCall(
|
||||
roomName: roomId,
|
||||
roomDisplayName: room.getLocalizedDisplayname(),
|
||||
identity: 'user_${DateTime.now().millisecondsSinceEpoch}',
|
||||
identity: room.client.userID ?? '',
|
||||
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;
|
||||
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;
|
||||
bool _online = false;
|
||||
|
||||
@@ -291,6 +353,7 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _measureActions());
|
||||
final pt = widget.pt;
|
||||
final expanded = widget.expanded;
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
@@ -361,198 +424,222 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
if (isMobile) ...[
|
||||
GestureDetector(
|
||||
onTap: () =>
|
||||
ref.read(activeRoomIdProvider.notifier).state = null,
|
||||
child:
|
||||
Icon(Icons.arrow_back_rounded, size: 22, color: pt.fg),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
// Klickbarer Profilbereich: Avatar + Name + Details
|
||||
Expanded(
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.onToggle,
|
||||
child: Row(
|
||||
children: [
|
||||
// Avatar — skaliert mit (rund, wie im Mockup)
|
||||
AnimatedContainer(
|
||||
duration: _animDuration,
|
||||
curve: _animCurve,
|
||||
width: avatarSize,
|
||||
height: avatarSize,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: pt.bg3,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: avatarUrl != null && client != null
|
||||
? MxcImage(
|
||||
mxcUri: avatarUrl.toString(),
|
||||
client: client,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: _initialBox(initial, pt),
|
||||
)
|
||||
: _initialBox(initial, pt),
|
||||
),
|
||||
AnimatedContainer(
|
||||
duration: _animDuration,
|
||||
curve: _animCurve,
|
||||
width: expanded ? 20 : 10,
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
// Profilbereich: gleitet beim Aufklappen unter die oberste Zeile und
|
||||
// nutzt dann die volle Breite (vorher teilte er sie mit den Symbolen –
|
||||
// aufgeklappt wurde der Name zu „p…“ gekürzt).
|
||||
AnimatedPositioned(
|
||||
duration: _animDuration,
|
||||
curve: _animCurve,
|
||||
left: expanded ? 16 : (isMobile ? 54 : 16),
|
||||
right: expanded ? 16 : _actionsWidth + (isMobile ? 14 : 24),
|
||||
top: expanded ? 44 : 0,
|
||||
bottom: 0,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.onToggle,
|
||||
child: Row(
|
||||
children: [
|
||||
// Avatar — skaliert mit (rund, wie im Mockup)
|
||||
AnimatedContainer(
|
||||
duration: _animDuration,
|
||||
curve: _animCurve,
|
||||
width: avatarSize,
|
||||
height: avatarSize,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: pt.bg3,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: avatarUrl != null && client != null
|
||||
? MxcImage(
|
||||
mxcUri: avatarUrl.toString(),
|
||||
client: client,
|
||||
fit: BoxFit.cover,
|
||||
placeholder:
|
||||
_initialBox(initial, pt, avatarSize),
|
||||
)
|
||||
: _initialBox(initial, pt, avatarSize),
|
||||
),
|
||||
AnimatedContainer(
|
||||
duration: _animDuration,
|
||||
curve: _animCurve,
|
||||
width: expanded ? 20 : 10,
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Name skaliert mit; Status blendet daneben ein
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Name skaliert mit; Status blendet daneben ein
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: AnimatedDefaultTextStyle(
|
||||
duration: _animDuration,
|
||||
curve: _animCurve,
|
||||
style: TextStyle(
|
||||
color: pt.fg,
|
||||
fontSize: expanded ? 24 : 15,
|
||||
fontWeight: expanded
|
||||
? FontWeight.w700
|
||||
: FontWeight.w600,
|
||||
),
|
||||
child: Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: AnimatedDefaultTextStyle(
|
||||
duration: _animDuration,
|
||||
curve: _animCurve,
|
||||
style: TextStyle(
|
||||
color: pt.fg,
|
||||
fontSize: expanded ? 24 : 15,
|
||||
fontWeight: expanded
|
||||
? FontWeight.w700
|
||||
: FontWeight.w600,
|
||||
),
|
||||
AnimatedOpacity(
|
||||
opacity: expanded ? 1 : 0,
|
||||
duration: _animDuration,
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.only(left: 8),
|
||||
child: Text(
|
||||
_online ? 'online' : 'offline',
|
||||
style: TextStyle(
|
||||
color: _online
|
||||
? pt.online
|
||||
: pt.fgMuted,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Eingeklappt: "zuletzt online vor…",
|
||||
// aufgeklappt: Handle + Biographie.
|
||||
AnimatedCrossFade(
|
||||
AnimatedOpacity(
|
||||
opacity: expanded ? 1 : 0,
|
||||
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,
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.only(left: 8),
|
||||
child: Text(
|
||||
_online ? 'online' : 'offline',
|
||||
style: TextStyle(
|
||||
color: _online
|
||||
? pt.online
|
||||
: pt.fgMuted,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
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: [
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.phone_rounded),
|
||||
tooltip: 'Voice call',
|
||||
onPressed: () => _startCall(cam: false),
|
||||
),
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.videocam_rounded),
|
||||
tooltip: 'Video call',
|
||||
onPressed: () => _startCall(cam: true),
|
||||
),
|
||||
if (size.width > 700)
|
||||
Container(
|
||||
width: 1,
|
||||
height: 20,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
color: pt.border,
|
||||
),
|
||||
if (size.width > 800)
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.push_pin_rounded),
|
||||
tooltip: 'Angepinnte Nachrichten',
|
||||
active: widget.pinnedOpen,
|
||||
onPressed: widget.onTogglePinned,
|
||||
),
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.search_rounded),
|
||||
tooltip: 'Search',
|
||||
onPressed: widget.onOpenSearch,
|
||||
),
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.group_rounded),
|
||||
tooltip: 'Members',
|
||||
active: widget.membersOpen,
|
||||
onPressed: widget.onToggleMembers,
|
||||
),
|
||||
],
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.phone_rounded),
|
||||
tooltip: 'Anrufen',
|
||||
onPressed: () => _startCall(cam: false),
|
||||
),
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.videocam_rounded),
|
||||
tooltip: 'Videoanruf',
|
||||
onPressed: () => _startCall(cam: true),
|
||||
),
|
||||
if (size.width > 700)
|
||||
Container(
|
||||
width: 1,
|
||||
height: 20,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
color: pt.border,
|
||||
),
|
||||
if (size.width > 800)
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.push_pin_rounded),
|
||||
tooltip: 'Angepinnte Nachrichten',
|
||||
active: widget.pinnedOpen,
|
||||
onPressed: widget.onTogglePinned,
|
||||
),
|
||||
if (isMobile)
|
||||
_ChatOverflowMenu(
|
||||
pt: pt,
|
||||
onSearch: widget.onOpenSearch,
|
||||
onMembers: widget.onToggleMembers,
|
||||
)
|
||||
else ...[
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.search_rounded),
|
||||
tooltip: 'Suchen',
|
||||
onPressed: widget.onOpenSearch,
|
||||
),
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.group_rounded),
|
||||
tooltip: 'Mitglieder',
|
||||
active: widget.membersOpen,
|
||||
onPressed: widget.onToggleMembers,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -560,15 +647,25 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _initialBox(String initial, PyramidTheme pt) => Center(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Text(
|
||||
initial,
|
||||
style: TextStyle(
|
||||
color: pt.fg, fontSize: 28, fontWeight: FontWeight.w700),
|
||||
// Kürzel exakt mittig: Schrift wächst mit dem Avatar mit (vorher feste
|
||||
// 28 px in FittedBox mit Innenabstand – wirkte aufgeklappt verschoben).
|
||||
Widget _initialBox(String initial, PyramidTheme pt, double avatarSize) =>
|
||||
Center(
|
||||
child: AnimatedDefaultTextStyle(
|
||||
duration: _animDuration,
|
||||
curve: _animCurve,
|
||||
textAlign: TextAlign.center,
|
||||
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 {
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 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({
|
||||
required this.timeline,
|
||||
required this.scrollCtrl,
|
||||
@@ -35,6 +42,8 @@ class _MessageList extends StatelessWidget {
|
||||
this.jumpKey,
|
||||
this.highlightEventId,
|
||||
this.highlightOn = false,
|
||||
this.bottomInset = 0,
|
||||
this.popInAfter,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -56,7 +65,7 @@ class _MessageList extends StatelessWidget {
|
||||
Icon(Icons.forum_outlined, size: 48, color: pt.fgDim),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No messages yet',
|
||||
'Noch keine Nachrichten – schreib die erste!',
|
||||
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
|
||||
// (not the last event) so MessageGroup can render an inline divider.
|
||||
// groupContainsFullyRead already guarantees fullyReadId != null.
|
||||
final passedFullyReadId =
|
||||
(groupContainsFullyRead && group.last.eventId != fullyReadId)
|
||||
? fullyReadId
|
||||
: null;
|
||||
final passedFullyReadId = (hasUnread &&
|
||||
groupContainsFullyRead &&
|
||||
group.last.eventId != fullyReadId)
|
||||
? fullyReadId
|
||||
: null;
|
||||
itemFullyReadIds.add(passedFullyReadId);
|
||||
|
||||
if (isLastOfDay) {
|
||||
@@ -241,7 +251,7 @@ class _MessageList extends StatelessWidget {
|
||||
controller: scrollCtrl,
|
||||
reverse: true,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
padding: EdgeInsets.only(bottom: 8 + bottomInset),
|
||||
scrollCacheExtent: const ScrollCacheExtent.pixels(2500),
|
||||
itemCount: itemKeys.length,
|
||||
findChildIndexCallback: (Key key) {
|
||||
@@ -283,10 +293,14 @@ class _MessageList extends StatelessWidget {
|
||||
showStatusIndicator: showStatus,
|
||||
readEventIds: readEventIds,
|
||||
fullyReadId: itemFullyReadIds[i],
|
||||
unreadFading: unreadFading,
|
||||
unreadDividerKey:
|
||||
itemFullyReadIds[i] != null ? unreadDividerKey : null,
|
||||
jumpEventId: jumpEventId,
|
||||
jumpKey: jumpKey,
|
||||
highlightEventId: highlightEventId,
|
||||
highlightOn: highlightOn,
|
||||
popInAfter: popInAfter,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -115,12 +115,12 @@ class NoChatSelected extends StatelessWidget {
|
||||
Icon(Icons.forum_outlined, size: 64, color: pt.fgDim),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Select a room to start chatting',
|
||||
'Wähle einen Chat aus',
|
||||
style: TextStyle(color: pt.fgMuted, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Choose from the sidebar on the left',
|
||||
'Links findest du alle Chats und Räume.',
|
||||
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.
|
||||
bool _unreadFading = false;
|
||||
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.
|
||||
final _jumpKey = GlobalKey();
|
||||
|
||||
// Höhe der schwebenden Eingabeleiste (gemessen, wächst mit Antwort-Leiste
|
||||
// oder mehrzeiligem Text).
|
||||
double _composerHeight = 72;
|
||||
String? _jumpEventId;
|
||||
String? _highlightEventId;
|
||||
bool _highlightOn = false;
|
||||
@@ -77,11 +93,25 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
||||
setState(() { _jumpInProgress = false; _highlightEventId = null; _jumpEventId = null; _highlightOn = false; });
|
||||
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;
|
||||
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((_) {});
|
||||
}
|
||||
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((_) {
|
||||
Future.delayed(const Duration(milliseconds: 120), () => _attemptJump(attempt + 1));
|
||||
});
|
||||
@@ -93,6 +123,9 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
||||
|
||||
bool get _inSelectionMode => _selectedIds.isNotEmpty;
|
||||
|
||||
double get _scrollBtnSize =>
|
||||
isMobileLayout(MediaQuery.sizeOf(context)) ? 44 : 36;
|
||||
|
||||
void _toggleSelect(String eventId) {
|
||||
setState(() {
|
||||
if (_selectedIds.contains(eventId)) {
|
||||
@@ -109,12 +142,22 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_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
|
||||
void didUpdateWidget(ChatView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
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;
|
||||
_sessionFullyReadId = null;
|
||||
_sessionHasUnread = false;
|
||||
@@ -124,6 +167,9 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
||||
_historyExhausted = false;
|
||||
_unreadFading = false;
|
||||
_unreadFadeTimer?.cancel();
|
||||
_unreadDwellTimer?.cancel();
|
||||
_lastMarkedEventId = null;
|
||||
_popInAfter = null;
|
||||
_jumpEventId = null;
|
||||
_highlightEventId = null;
|
||||
_highlightOn = false;
|
||||
@@ -137,6 +183,7 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
||||
@override
|
||||
void dispose() {
|
||||
_unreadFadeTimer?.cancel();
|
||||
_unreadDwellTimer?.cancel();
|
||||
_highlightTimer?.cancel();
|
||||
_scrollCtrl.dispose();
|
||||
super.dispose();
|
||||
@@ -174,6 +221,7 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
||||
setState(() {
|
||||
_sessionFullyReadId = newFullyReadId;
|
||||
_sessionHasUnread = newHasUnread;
|
||||
_popInAfter = events.firstOrNull?.originServerTs ?? DateTime.now();
|
||||
});
|
||||
if (newHasUnread) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToUnread(timeline));
|
||||
@@ -206,10 +254,12 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
_scheduleUnreadFadeIfAtNewest();
|
||||
return;
|
||||
}
|
||||
if (attempt >= 24) {
|
||||
_didScrollToUnread = true; // give up — stay at the newest messages
|
||||
_scheduleUnreadFadeIfAtNewest();
|
||||
return;
|
||||
}
|
||||
if (attempt > 0 && attempt % 4 == 0 && _scrollCtrl.hasClients) {
|
||||
@@ -224,21 +274,100 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
||||
}
|
||||
|
||||
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).
|
||||
cancelNotificationForRoom(widget.roomId);
|
||||
|
||||
final sendReceipts = ref.read(privacyReadReceiptsProvider);
|
||||
if (!sendReceipts) return;
|
||||
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 latest = timeline?.events.firstOrNull;
|
||||
if (latest != null && latest.status.isSynced) {
|
||||
room.setReadMarker(latest.eventId, mRead: latest.eventId)
|
||||
.catchError((_) {});
|
||||
} else {
|
||||
room.markUnread(false).catchError((_) {});
|
||||
final latest = timeline?.events
|
||||
.where((e) => e.status.isSynced)
|
||||
.firstOrNull;
|
||||
if (latest == null || latest.eventId == _lastMarkedEventId) return;
|
||||
_lastMarkedEventId = latest.eventId;
|
||||
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 {
|
||||
@@ -246,18 +375,10 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
||||
final pixels = _scrollCtrl.position.pixels;
|
||||
final maxExtent = _scrollCtrl.position.maxScrollExtent;
|
||||
|
||||
if (pixels < 60) {
|
||||
_markRead(null);
|
||||
// Reached the newest messages → the "Neue Nachrichten" divider is no
|
||||
// longer useful. Fade it out, then remove it.
|
||||
if (_sessionHasUnread && !_unreadFading) {
|
||||
setState(() => _unreadFading = true);
|
||||
_unreadFadeTimer?.cancel();
|
||||
_unreadFadeTimer = Timer(const Duration(milliseconds: 900), () {
|
||||
if (mounted) setState(() { _sessionHasUnread = false; _unreadFading = false; });
|
||||
});
|
||||
}
|
||||
}
|
||||
if (pixels < 60) _markRead(null);
|
||||
// Bei den neuesten Nachrichten angekommen → Trenner nach einer
|
||||
// Verweildauer ausblenden.
|
||||
if (_atNewest) _scheduleUnreadFade();
|
||||
final shouldShow = pixels > 400;
|
||||
if (shouldShow != _showScrollDown) {
|
||||
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
|
||||
// DIFFERENT room — listen would miss a value set before registration.
|
||||
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((_) {
|
||||
if (mounted &&
|
||||
!_jumpInProgress &&
|
||||
@@ -324,6 +447,9 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
||||
}
|
||||
|
||||
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(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
@@ -394,8 +520,13 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
||||
setState(() => _pinnedOpen = !_pinnedOpen),
|
||||
),
|
||||
const _ConnectionBanner(),
|
||||
const _ActiveCallBar(),
|
||||
if (room != null && !isInvite)
|
||||
GroupCallBanner(room: room),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
child: isInvite
|
||||
? _InvitePanel(room: room!, pt: pt)
|
||||
: Stack(
|
||||
children: [
|
||||
timelineAsync.when(
|
||||
loading: () => Center(
|
||||
@@ -406,7 +537,7 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
||||
),
|
||||
error: (e, _) => Center(
|
||||
child: Text(
|
||||
'Could not load messages',
|
||||
'Nachrichten konnten nicht geladen werden.',
|
||||
style: TextStyle(color: pt.fgDim, fontSize: 14),
|
||||
),
|
||||
),
|
||||
@@ -429,28 +560,36 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
||||
jumpKey: _jumpKey,
|
||||
highlightEventId: _highlightEventId,
|
||||
highlightOn: _highlightOn,
|
||||
bottomInset: room != null ? _composerHeight : 0,
|
||||
popInAfter: _popInAfter,
|
||||
);
|
||||
},
|
||||
),
|
||||
if (_showScrollDown)
|
||||
Positioned(
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: 12,
|
||||
child: AnimatedOpacity(
|
||||
opacity: _showScrollDown ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
bottom: (room != null ? _composerHeight : 0) + 12,
|
||||
child: IgnorePointer(
|
||||
ignoring: !_showScrollDown,
|
||||
child: AnimatedScale(
|
||||
scale: _showScrollDown ? 1 : 0,
|
||||
duration: motionDuration(
|
||||
context, _showScrollDown ? 520 : 180),
|
||||
curve: _showScrollDown
|
||||
? Curves.elasticOut
|
||||
: Curves.easeInBack,
|
||||
child: GestureDetector(
|
||||
onTap: () => _scrollCtrl.animateTo(
|
||||
0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
),
|
||||
child: SpringPress(
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
width: _scrollBtnSize,
|
||||
height: _scrollBtnSize,
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg0,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
borderRadius: BorderRadius.circular(_scrollBtnSize / 2),
|
||||
border: Border.all(color: pt.border),
|
||||
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:pyramid/core/matrix_client.dart';
|
||||
import 'package:pyramid/core/theme.dart';
|
||||
import 'package:pyramid/widgets/mxc_image.dart';
|
||||
import 'package:pyramid/widgets/pyramid_logo.dart';
|
||||
|
||||
class MembersPanel extends ConsumerWidget {
|
||||
@@ -43,7 +44,7 @@ class MembersPanel extends ConsumerWidget {
|
||||
Icon(Icons.group_rounded, size: 16, color: pt.fg),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Members',
|
||||
'Mitglieder',
|
||||
style: TextStyle(
|
||||
color: pt.fg,
|
||||
fontSize: 14,
|
||||
@@ -94,7 +95,7 @@ class MembersPanel extends ConsumerWidget {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 24),
|
||||
child: Text(
|
||||
'No members loaded',
|
||||
'Keine Mitglieder geladen',
|
||||
style: TextStyle(color: pt.fgDim, fontSize: 12),
|
||||
),
|
||||
),
|
||||
@@ -177,19 +178,22 @@ class _MemberItemState extends State<_MemberItem> {
|
||||
opacity: widget.isOffline ? 0.5 : 1,
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(pt.rSm),
|
||||
),
|
||||
child: Center(
|
||||
// Rund wie überall sonst, mit echtem Profilbild.
|
||||
MxcAvatar(
|
||||
mxcUri: member.avatarUrl,
|
||||
client: member.room.client,
|
||||
size: 32,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
placeholder: (_) => Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
initial,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
@@ -214,7 +218,7 @@ class _MemberItemState extends State<_MemberItem> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
member.id == member.room.client.userID ? '$name (Du)' : name,
|
||||
style: TextStyle(
|
||||
color: widget.isOffline ? pt.fgMuted : pt.fg,
|
||||
fontSize: 13,
|
||||
|
||||
@@ -15,6 +15,7 @@ class _Header extends ConsumerWidget {
|
||||
final isRealSpace = spaceId != null && spaceId != 'dms' && spaceId != 'rooms';
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
final isMobile = size.width < 900 || size.height < 500;
|
||||
final bottomNav = ref.watch(bottomNavProvider);
|
||||
|
||||
final spaces = ref.watch(spacesProvider).valueOrNull ?? [];
|
||||
Room? activeSpace;
|
||||
@@ -29,7 +30,7 @@ class _Header extends ConsumerWidget {
|
||||
|
||||
final name = isRealSpace
|
||||
? (activeSpace?.getLocalizedDisplayname() ?? 'Space')
|
||||
: (spaceId == 'dms' ? 'Chats' : 'Rooms');
|
||||
: 'Chats';
|
||||
|
||||
void handleMenu(_SpaceMenuItem item) {
|
||||
switch (item) {
|
||||
@@ -101,7 +102,7 @@ class _Header extends ConsumerWidget {
|
||||
).then((confirmed) {
|
||||
if (confirmed == true) {
|
||||
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() {
|
||||
if (spaceId == 'dms' || spaceId == null) {
|
||||
if (!isRealSpace) {
|
||||
return [
|
||||
PopupMenuItem(
|
||||
value: _SpaceMenuItem.newDm,
|
||||
@@ -119,15 +120,10 @@ class _Header extends ConsumerWidget {
|
||||
PopupMenuItem(
|
||||
value: _SpaceMenuItem.createGroup,
|
||||
child: _MenuRow(Icons.group_add_rounded, 'Gruppe erstellen', pt)),
|
||||
];
|
||||
} else if (spaceId == 'rooms') {
|
||||
return [
|
||||
PopupMenuItem(
|
||||
value: _SpaceMenuItem.createRoom,
|
||||
child: _MenuRow(Icons.add_rounded, 'Raum erstellen', pt)),
|
||||
const PopupMenuDivider(),
|
||||
PopupMenuItem(
|
||||
value: _SpaceMenuItem.joinRoom,
|
||||
child: _MenuRow(Icons.login_rounded, 'Beitreten', pt)),
|
||||
child: _MenuRow(Icons.login_rounded, 'Raum beitreten', pt)),
|
||||
PopupMenuItem(
|
||||
value: _SpaceMenuItem.discoverRooms,
|
||||
child: _MenuRow(Icons.explore_rounded, 'Entdecken', pt)),
|
||||
@@ -167,7 +163,18 @@ class _Header extends ConsumerWidget {
|
||||
|
||||
Widget controlsRow = Row(
|
||||
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(
|
||||
icon: const Icon(Icons.menu_rounded),
|
||||
tooltip: 'Spaces',
|
||||
@@ -176,7 +183,7 @@ class _Header extends ConsumerWidget {
|
||||
else ...[
|
||||
PyrIconBtn(
|
||||
icon: const Icon(Icons.menu_rounded),
|
||||
tooltip: 'Toggle sidebar',
|
||||
tooltip: 'Seitenleiste ein/aus',
|
||||
onPressed: () =>
|
||||
ref.read(railCollapsedProvider.notifier).update((s) => !s),
|
||||
),
|
||||
@@ -316,7 +323,7 @@ class _Header extends ConsumerWidget {
|
||||
height: isRealSpace ? 56 : 52,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: isRealSpace ? pt.bg0 : pt.bg1,
|
||||
color: isRealSpace && !isMobile ? pt.bg0 : pt.bg1,
|
||||
border: Border(bottom: BorderSide(color: pt.border)),
|
||||
),
|
||||
child: controlsRow,
|
||||
@@ -458,9 +465,13 @@ class _NotificationsButton extends ConsumerWidget {
|
||||
class _FilterInput extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final PyramidTheme pt;
|
||||
final String hint;
|
||||
final ValueChanged<String> onChanged;
|
||||
const _FilterInput(
|
||||
{required this.controller, required this.pt, required this.onChanged});
|
||||
{required this.controller,
|
||||
required this.pt,
|
||||
required this.hint,
|
||||
required this.onChanged});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -469,18 +480,17 @@ class _FilterInput extends StatelessWidget {
|
||||
child: Stack(
|
||||
alignment: Alignment.centerLeft,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: Icon(Icons.search_rounded, size: 14, color: pt.fgDim),
|
||||
),
|
||||
TextField(
|
||||
controller: controller,
|
||||
onChanged: onChanged,
|
||||
style: TextStyle(color: pt.fg, fontSize: 13),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Filter rooms',
|
||||
hintText: hint,
|
||||
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,
|
||||
fillColor: pt.bg2,
|
||||
border: OutlineInputBorder(
|
||||
|
||||
@@ -16,7 +16,7 @@ class _InviteItemState extends ConsumerState<_InviteItem> {
|
||||
Future<void> _accept() async {
|
||||
setState(() => _accepting = true);
|
||||
try {
|
||||
await widget.room.join();
|
||||
await acceptInvite(ref, widget.room);
|
||||
if (mounted) {
|
||||
ref.read(activeRoomIdProvider.notifier).state = widget.room.id;
|
||||
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 {
|
||||
final List<Room> rooms;
|
||||
final String filter;
|
||||
final String? spaceId;
|
||||
final Map<String, bool> collapsed;
|
||||
final PyramidTheme pt;
|
||||
final ValueChanged<String> onToggleSection;
|
||||
|
||||
const _RoomsList({
|
||||
required this.rooms,
|
||||
required this.filter,
|
||||
required this.spaceId,
|
||||
required this.collapsed,
|
||||
required this.pt,
|
||||
required this.onToggleSection,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final activeRoomId = ref.watch(activeRoomIdProvider);
|
||||
final isDms = spaceId == 'dms';
|
||||
final channels = ref.watch(spaceChannelIdsProvider);
|
||||
final chatFilter = ref.watch(_chatFilterProvider);
|
||||
final query = filter.toLowerCase();
|
||||
|
||||
final invites = rooms
|
||||
.where((r) =>
|
||||
r.membership == Membership.invite &&
|
||||
isDms == r.isDirectChat)
|
||||
.toList();
|
||||
final invites =
|
||||
rooms.where((r) => r.membership == Membership.invite).toList();
|
||||
|
||||
final filtered = rooms.where((r) {
|
||||
if (r.membership == Membership.invite) return false;
|
||||
if (isDms != r.isDirectChat) return false;
|
||||
if (filter.isEmpty) return true;
|
||||
return r.getLocalizedDisplayname()
|
||||
.toLowerCase()
|
||||
.contains(filter.toLowerCase());
|
||||
}).toList();
|
||||
|
||||
if (filtered.isEmpty && invites.isEmpty) {
|
||||
return Center(
|
||||
child:
|
||||
Text('No rooms', style: TextStyle(color: pt.fgDim, fontSize: 13)));
|
||||
bool matches(Room r) {
|
||||
switch (chatFilter) {
|
||||
case _ChatFilter.all:
|
||||
break;
|
||||
case _ChatFilter.unread:
|
||||
if (!r.isUnread) return false;
|
||||
case _ChatFilter.people:
|
||||
if (!r.isDirectChat) return false;
|
||||
case _ChatFilter.groups:
|
||||
if (r.isDirectChat) return false;
|
||||
}
|
||||
if (query.isEmpty) return true;
|
||||
return r.getLocalizedDisplayname().toLowerCase().contains(query);
|
||||
}
|
||||
|
||||
final sectionTitle = isDms ? 'Direct Messages' : 'Rooms';
|
||||
final sectionId = isDms ? 'dms' : 'rooms';
|
||||
final isCollapsed = collapsed[sectionId] ?? false;
|
||||
final chats = rooms
|
||||
.where((r) =>
|
||||
r.membership == Membership.join && isChatListRoom(r, channels))
|
||||
.toList();
|
||||
final filtered = chats.where(matches).toList();
|
||||
final unreadCount = chats.where((r) => r.isUnread).length;
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(8, 4, 8, 12),
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 12),
|
||||
children: [
|
||||
_ChatFilterChips(
|
||||
value: chatFilter,
|
||||
unreadCount: unreadCount,
|
||||
pt: pt,
|
||||
onChanged: (f) => ref.read(_chatFilterProvider.notifier).state = f,
|
||||
),
|
||||
if (invites.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 2),
|
||||
@@ -124,111 +134,204 @@ class _RoomsList extends ConsumerWidget {
|
||||
child: _InviteItem(room: r, pt: pt))),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
_SectionHeader(
|
||||
title: sectionTitle,
|
||||
count: filtered.length,
|
||||
isCollapsed: isCollapsed,
|
||||
pt: pt,
|
||||
onToggle: () => onToggleSection(sectionId),
|
||||
onAddTap: isDms
|
||||
? () => showDialog(
|
||||
context: context, builder: (_) => const NewDmDialog())
|
||||
: () => showDialog(
|
||||
context: context, builder: (_) => const CreateJoinDialog()),
|
||||
),
|
||||
if (!isCollapsed)
|
||||
...filtered.map((room) => _RoomItem(
|
||||
room: room,
|
||||
isActive: activeRoomId == room.id,
|
||||
pt: pt,
|
||||
onTap: () {
|
||||
if (filtered.isEmpty)
|
||||
_ChatListEmpty(
|
||||
filter: chatFilter,
|
||||
searching: query.isNotEmpty,
|
||||
hasInvites: invites.isNotEmpty,
|
||||
pt: pt,
|
||||
)
|
||||
else
|
||||
...filtered.map((room) {
|
||||
final isVoice = _isVoiceRoom(room);
|
||||
return _RoomItem(
|
||||
key: ValueKey('chat:${room.id}'),
|
||||
room: room,
|
||||
isActive: isVoice ? false : activeRoomId == room.id,
|
||||
isInVoice:
|
||||
isVoice && ref.read(activeVoiceRoomIdProvider) == room.id,
|
||||
pt: pt,
|
||||
// Voice-Räume treten dem Kanal bei (wie in der Space-Liste) –
|
||||
// vorher öffnete der Klick hier nur die Chat-Ansicht, ein
|
||||
// Standalone-Voice-Channel war per Klick gar nicht beitretbar.
|
||||
onTap: () {
|
||||
if (isVoice) {
|
||||
_toggleVoiceChannel(ref, room);
|
||||
} else {
|
||||
ref.read(activeRoomIdProvider.notifier).state = room.id;
|
||||
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||
},
|
||||
)),
|
||||
}
|
||||
},
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatefulWidget {
|
||||
final String title;
|
||||
final int count;
|
||||
final bool isCollapsed;
|
||||
class _ChatFilterChips extends StatelessWidget {
|
||||
final _ChatFilter value;
|
||||
final int unreadCount;
|
||||
final PyramidTheme pt;
|
||||
final VoidCallback onToggle;
|
||||
final VoidCallback? onAddTap;
|
||||
final ValueChanged<_ChatFilter> onChanged;
|
||||
|
||||
const _SectionHeader({
|
||||
required this.title,
|
||||
required this.count,
|
||||
required this.isCollapsed,
|
||||
const _ChatFilterChips({
|
||||
required this.value,
|
||||
required this.unreadCount,
|
||||
required this.pt,
|
||||
required this.onToggle,
|
||||
this.onAddTap,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_SectionHeader> createState() => _SectionHeaderState();
|
||||
}
|
||||
|
||||
class _SectionHeaderState extends State<_SectionHeader> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 4),
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: widget.onToggle,
|
||||
child: Row(children: [
|
||||
AnimatedRotation(
|
||||
turns: widget.isCollapsed ? -0.25 : 0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOutBack,
|
||||
child: Icon(Icons.keyboard_arrow_down_rounded,
|
||||
size: 14,
|
||||
color: _hovered ? pt.fgMuted : pt.fgDim),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(widget.title.toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: _hovered ? pt.fgMuted : pt.fgDim,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.04 * 11)),
|
||||
const Spacer(),
|
||||
Text('${widget.count}',
|
||||
style: TextStyle(color: pt.fgDim, fontSize: 11)),
|
||||
]),
|
||||
Widget chip(_ChatFilter f, String label) {
|
||||
final selected = value == f;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 6),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
child: GestureDetector(
|
||||
onTap: () => onChanged(selected ? _ChatFilter.all : f),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOutCubic,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? pt.accentSoft : pt.bg2,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(
|
||||
color: selected ? pt.accent.withAlpha(120) : pt.border),
|
||||
),
|
||||
child: Text(label,
|
||||
style: TextStyle(
|
||||
color: selected ? pt.accent : pt.fgMuted,
|
||||
fontSize: 12.5,
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w500)),
|
||||
),
|
||||
),
|
||||
if (_hovered && widget.onAddTap != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.onAddTap,
|
||||
child: SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: Center(
|
||||
child: Icon(Icons.add, size: 12, color: pt.fgMuted)),
|
||||
),
|
||||
),
|
||||
] else
|
||||
const SizedBox(width: 22),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final chips = [
|
||||
chip(_ChatFilter.all, 'Alle'),
|
||||
chip(_ChatFilter.unread,
|
||||
unreadCount > 0 ? 'Ungelesen · $unreadCount' : 'Ungelesen'),
|
||||
chip(_ChatFilter.people, 'Personen'),
|
||||
chip(_ChatFilter.groups, 'Gruppen'),
|
||||
];
|
||||
// Handy: wischbare Zeile; PC (schmale Spalte): umbrechen statt
|
||||
// abschneiden – mit der Maus wischt niemand seitwärts.
|
||||
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 {
|
||||
final Room room;
|
||||
final bool isActive;
|
||||
@@ -477,6 +580,31 @@ class _RoomItemState extends ConsumerState<_RoomItem> {
|
||||
final voiceParticipants = isVoice ? ref.watch(voiceParticipantsProvider(room.id)) : <Map<String, dynamic>>[];
|
||||
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;
|
||||
|
||||
return MouseRegion(
|
||||
@@ -526,6 +654,11 @@ class _RoomItemState extends ConsumerState<_RoomItem> {
|
||||
children: [
|
||||
if (isDm) ...[
|
||||
_DmAvatar(room: room, pt: pt),
|
||||
] else if (!isVoice &&
|
||||
!ref
|
||||
.watch(spaceChannelIdsProvider)
|
||||
.contains(room.id)) ...[
|
||||
_RoomTileAvatar(room: room, pt: pt, size: 22),
|
||||
] else ...[
|
||||
SizedBox(
|
||||
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,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
|
||||
final _filterCtrl = TextEditingController();
|
||||
String _filter = '';
|
||||
final Map<String, bool> _collapsed = {};
|
||||
String? _lastShownCallError;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -37,10 +38,34 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = PyramidTheme.of(context);
|
||||
|
||||
// Voice-Zustand mit der UI synchron halten: Bei unerwartetem Abbruch
|
||||
// oder fehlgeschlagenem Beitritt wird der Service inaktiv, aber
|
||||
// activeVoiceRoomIdProvider zeigte vorher weiter auf den Kanal – das
|
||||
// Kanal-Highlight und der eigene (optimistische) Teilnehmer-Eintrag
|
||||
// blieben hängen. Außerdem den Fehler sichtbar machen (kein stiller
|
||||
// Fehlerpfad, CLAUDE.md).
|
||||
ref.listen<VoiceChannelService>(voiceChannelProvider, (_, call) {
|
||||
if (!call.isActive && ref.read(activeVoiceRoomIdProvider) != null) {
|
||||
ref.read(activeVoiceRoomIdProvider.notifier).state = null;
|
||||
}
|
||||
final err = call.error;
|
||||
if (err == null) {
|
||||
_lastShownCallError = null;
|
||||
} else if (err != _lastShownCallError) {
|
||||
_lastShownCallError = err;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
|
||||
SnackBar(content: Text('Voice: $err')),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
final roomsAsync = ref.watch(roomListProvider);
|
||||
final activeSpaceId = ref.watch(activeSpaceIdProvider);
|
||||
final call = ref.watch(callStateProvider);
|
||||
final voip = ref.watch(voipStateProvider);
|
||||
final bottomNav = ref.watch(bottomNavProvider) &&
|
||||
isMobileLayout(MediaQuery.sizeOf(context));
|
||||
|
||||
final isRealSpace = activeSpaceId != null &&
|
||||
activeSpaceId != 'dms' &&
|
||||
@@ -62,6 +87,7 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
|
||||
_FilterInput(
|
||||
controller: _filterCtrl,
|
||||
pt: pt,
|
||||
hint: isRealSpace ? 'Kanäle durchsuchen' : 'Chats durchsuchen',
|
||||
onChanged: (v) => setState(() => _filter = v),
|
||||
),
|
||||
Expanded(
|
||||
@@ -87,60 +113,90 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
|
||||
),
|
||||
),
|
||||
error: (e, _) => Center(
|
||||
child: Text('Error',
|
||||
child: Text('Fehler',
|
||||
style: TextStyle(color: pt.fgDim, fontSize: 12)),
|
||||
),
|
||||
data: (rooms) => _RoomsList(
|
||||
rooms: rooms,
|
||||
filter: _filter,
|
||||
spaceId: activeSpaceId,
|
||||
collapsed: _collapsed,
|
||||
pt: pt,
|
||||
onToggleSection: (id) => setState(() {
|
||||
_collapsed[id] = !(_collapsed[id] ?? false);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Call-Dock: immer ÜBER dem Konto-Balken, animiert ein-/ausblenden.
|
||||
// Im Mobil-Querformat (Höhe knapp) liegen Call-Balken und
|
||||
// Konto-Balken nebeneinander statt übereinander.
|
||||
Builder(builder: (context) {
|
||||
final hasVoipCall = voip.currentCall != null &&
|
||||
voip.currentCall!.state != CallState.kEnded;
|
||||
final hasCallDock = call.isActive || hasVoipCall;
|
||||
final isLandscapeCompact =
|
||||
MediaQuery.sizeOf(context).height < 500;
|
||||
|
||||
final callDock = AnimatedSize(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (call.isActive) MiniCallWidget(call: call),
|
||||
if (hasVoipCall) MiniCallWidget(call: voip),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (isLandscapeCompact && hasCallDock) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(child: callDock),
|
||||
const Expanded(child: UserPanel()),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [callDock, const UserPanel()],
|
||||
);
|
||||
}),
|
||||
// Call-Dock + Profilbalken. Mit der unteren Leiste (Handy) liegen
|
||||
// beide dort (layout/mobile_tabs.dart).
|
||||
if (!bottomNav) const _PanelFooter(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Laufender Anruf als kompakte Leiste, animiert ein-/ausgeblendet.
|
||||
class CallDock extends ConsumerWidget {
|
||||
const CallDock({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final call = ref.watch(voiceChannelProvider);
|
||||
final voip = ref.watch(callSignalingProvider);
|
||||
// Klingeln (noch nicht angenommen) zählt nicht – dafür gibt es das
|
||||
// Annehmen-Fenster (Bernd 2026-10-08).
|
||||
final hasVoipCall = voip.currentCall != null &&
|
||||
voip.currentCall!.state != CallState.kEnded &&
|
||||
voip.currentCall!.state != CallState.kRinging;
|
||||
// Am PC bei offener Anrufansicht nicht doppelt steuern (UI-Audit S6); am
|
||||
// Handy liegt die Anrufansicht ohnehin darüber.
|
||||
final callViewOpen = ref.watch(viewModeProvider) == ViewMode.voice &&
|
||||
!isMobileLayout(MediaQuery.sizeOf(context));
|
||||
return AnimatedSize(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (call.isActive && !callViewOpen) MiniCallWidget(call: call),
|
||||
if (hasVoipCall && !callViewOpen) MiniCallWidget(call: voip),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Ist gerade ein Anruf im Dock sichtbar?
|
||||
static bool visible(WidgetRef ref, BuildContext context) {
|
||||
final call = ref.watch(voiceChannelProvider);
|
||||
final voip = ref.watch(callSignalingProvider);
|
||||
final hasVoipCall = voip.currentCall != null &&
|
||||
voip.currentCall!.state != CallState.kEnded &&
|
||||
voip.currentCall!.state != CallState.kRinging;
|
||||
final callViewOpen = ref.watch(viewModeProvider) == ViewMode.voice &&
|
||||
!isMobileLayout(MediaQuery.sizeOf(context));
|
||||
return !callViewOpen && (call.isActive || hasVoipCall);
|
||||
}
|
||||
}
|
||||
|
||||
/// Call-Dock immer ÜBER dem Konto-Balken. Im Mobil-Querformat (Höhe knapp)
|
||||
/// liegen beide nebeneinander statt übereinander.
|
||||
class _PanelFooter extends ConsumerWidget {
|
||||
const _PanelFooter();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final hasCallDock = CallDock.visible(ref, context);
|
||||
final isLandscapeCompact = MediaQuery.sizeOf(context).height < 500;
|
||||
if (isLandscapeCompact && hasCallDock) {
|
||||
return const Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(child: CallDock()),
|
||||
Expanded(child: UserPanel()),
|
||||
],
|
||||
);
|
||||
}
|
||||
return const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [CallDock(), UserPanel()],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ part of '../rooms_panel.dart';
|
||||
class _DmAvatar extends ConsumerStatefulWidget {
|
||||
final Room room;
|
||||
final PyramidTheme pt;
|
||||
const _DmAvatar({required this.room, required this.pt});
|
||||
final double size;
|
||||
const _DmAvatar({required this.room, required this.pt, this.size = 22});
|
||||
|
||||
@override
|
||||
ConsumerState<_DmAvatar> createState() => _DmAvatarState();
|
||||
@@ -69,13 +70,13 @@ class _DmAvatarState extends ConsumerState<_DmAvatar> {
|
||||
avatar = MxcAvatar(
|
||||
mxcUri: widget.room.avatar,
|
||||
client: client,
|
||||
size: 22,
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
size: widget.size,
|
||||
borderRadius: BorderRadius.circular(widget.size / 2),
|
||||
placeholder: (_) =>
|
||||
_InitialCircle(initial: initial, color: color, size: 22),
|
||||
_InitialCircle(initial: initial, color: color, size: widget.size),
|
||||
);
|
||||
} else {
|
||||
avatar = _InitialCircle(initial: initial, color: color, size: 22);
|
||||
avatar = _InitialCircle(initial: initial, color: color, size: widget.size);
|
||||
}
|
||||
|
||||
final dot = _dotColor();
|
||||
@@ -83,11 +84,11 @@ class _DmAvatarState extends ConsumerState<_DmAvatar> {
|
||||
return Stack(clipBehavior: Clip.none, children: [
|
||||
avatar,
|
||||
Positioned(
|
||||
right: -2,
|
||||
bottom: -2,
|
||||
right: widget.size > 30 ? 0 : -2,
|
||||
bottom: widget.size > 30 ? 0 : -2,
|
||||
child: Container(
|
||||
width: 9,
|
||||
height: 9,
|
||||
width: widget.size > 30 ? 13 : 9,
|
||||
height: widget.size > 30 ? 13 : 9,
|
||||
decoration: BoxDecoration(
|
||||
color: dot,
|
||||
shape: BoxShape.circle,
|
||||
@@ -123,9 +124,9 @@ class _InitialCircle extends StatelessWidget {
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
child: Center(
|
||||
child: Text(initial,
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontSize: size * 0.45,
|
||||
fontWeight: FontWeight.w600))),
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user