Compare commits
+214
@@ -13,6 +13,220 @@ Schritt (und beim Abbruch mitten im Schritt den Zwischenstand). Format:
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-07 (Teil 4) – Handy-UI-Feinschliff, Animationen, Lesebestätigung, LiveKit ohne Secret → Test-Version 0.1.38
|
||||
|
||||
**Erledigt (je ein Commit, alles gepusht):**
|
||||
- **Handy-UI** (382747c): größere Symbole (Anruf, Video, Anhang, GIF, Emoji),
|
||||
Suche/Mitglieder im ⋮-Menü, aufgeklapptes Kontakt-Banner (Kürzel mittig,
|
||||
Symbole bleiben an ihrem Platz), Schrift-Standard 110 %, federnde Knöpfe.
|
||||
- **Antwort-Zitat antippen** springt zur Nachricht (7063ebf).
|
||||
- **Schwebende Eingabeleiste** (223c944): Chat läuft darunter weiter,
|
||||
Milchglas nur im Eingabefeld, Fläche fängt Berührungen ab.
|
||||
- **Lesebestätigung** (1d0ccfd): nur bei Fokus/Vordergrund, je Event einmal
|
||||
(vorher bei jedem Neuaufbau, auch im Hintergrund – die gerade gezeigte
|
||||
Benachrichtigung verschwand sofort); „Neue Nachrichten“-Trenner nach
|
||||
Rückkehr in die App; „Lesebestätigungen senden“ aus = private Bestätigung
|
||||
statt gar keiner (Ungelesen-Zähler stimmen). Haken-Logik geprüft: Uhr =
|
||||
wird gesendet, ✓ = beim Server, goldenes ✓✓ = gelesen. Matrix kennt kein
|
||||
„auf dem Gerät angekommen“ – daher keine zweistufigen grauen Haken.
|
||||
- **Animationen** (ae6f651): Chat gleitet auf dem Handy federnd über die
|
||||
Liste (Liste bleibt aufgebaut), neue Nachrichten/Reaktionen ploppen auf,
|
||||
Zähler hüpfen, Scroll-Knopf/Antwort-Leiste/Senden-Pfeil federn, Vollansicht
|
||||
zoomt auf; alles mit Einstellung „Animationen“ skaliert. Fix dabei: beim
|
||||
Raumwechsel blieben Antwort-Ziel und getippter Text am neuen Raum hängen –
|
||||
jetzt Entwurf je Raum.
|
||||
- **LiveKit-Secret aus der App** (756486d): Token kommt von
|
||||
`/api/livekit-token`; echter Beitritt mit Testkonto grün.
|
||||
- **flutter_markdown_plus** statt eingestelltem flutter_markdown (0ad06e3).
|
||||
- **Version 0.1.38+39** (df377d7).
|
||||
|
||||
**Offen/Nächster Schritt:** Bernd testet 0.1.38 auf dem Handy (Push, neue
|
||||
Animationen, schwebende Leiste, Anruf/Sprachkanal wegen LiveKit-Umbau).
|
||||
Danach: LiveKit-Secret rotieren (Bernd, wenn alle Geräte aktualisiert),
|
||||
SQLCipher-Nachfolger (sqlite3 3.x), matrix-SDK-Sprung.
|
||||
|
||||
**Stolperfallen:** (1) Wischen-zum-Antworten gibt es nur auf echten Handys
|
||||
(Platform-Check) – im Windows-UI-Test über Lange-Drücken → Antworten, und
|
||||
NEBEN die Sprechblase drücken (im Text greift am PC die Textauswahl und
|
||||
der Test bricht undurchsichtig ab). (2) `Matrix4.getMaxScaleOnAxis` liefert
|
||||
bei 2D-Skalierung < 1 immer 1 (z-Achse) – `storage[0]` lesen.
|
||||
(3) LiveKit protokolliert auf dem Pi nur Warnungen – Beitritte sind dort
|
||||
nicht sichtbar; Nachweis über die Identität aus der Beitrittsantwort.
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-07 (Teil 3) – Beta-Feedback umgesetzt: Push-Verlauf, Handy-UI, neues Aussehen → Test-Version 0.1.37
|
||||
|
||||
**Erledigt (je ein Commit, alles gepusht):**
|
||||
- **Push als Chat-Verlauf** (ef1d881): MessagingStyle mit den letzten 8
|
||||
Nachrichten pro Raum, voller Text (Deckel 1000 statt 100), Antwort-Zitat
|
||||
entfernt, Medien-Labels, Bildvorschau (E2EE entschlüsselt, FileProvider),
|
||||
Avatare, „Gelesen“ setzt echte Lesebestätigung. Emulator: Verlauf,
|
||||
Platzhalter-Ersetzen, Bild, Avatar, Gelesen geprüft (DebugNotifyReceiver,
|
||||
nur Debug-Build).
|
||||
- **Handy** (a1d64ec): edge-to-edge statt immersiveSticky + SafeArea
|
||||
(Statusleiste überdeckt nichts mehr); Bild-Vollansicht randlos, Tippen
|
||||
daneben/Wischen schließt, Doppeltipp zoomt; Bilder im Chat bis halbe
|
||||
Bildschirmhöhe, Videos im echten Seitenverhältnis.
|
||||
- **UI-Umbau** (c5f1090): Farbmodus Dunkel (neues weiches Dunkelgrau,
|
||||
Standard) / Schwarz (alt) / Hell; Schriftgrößen-Regler (Handy-Standard
|
||||
115 % × Systemgröße); Nachrichten Automatisch/Sprechblasen/Liste;
|
||||
Einstellungen auf dem Handy als Vollbild; formatierte Nachrichten
|
||||
(Antworten!) ignorierten die Schriftgröße (RichText → Text.rich).
|
||||
- **Chatliste** (c38e9c1): Handy-Kachel mit Avatar, Uhrzeit, Vorschau der
|
||||
letzten Nachricht, Ungelesen-Zähler; sichtbare englische Texte eingedeutscht.
|
||||
- **Werkzeuge:** `integration_test/mobile_ui_screens_test.dart` (echte App im
|
||||
Handy-Format mit Testprofil, prüft Vollansicht-Gesten, Screenshots nach
|
||||
`build/ui_screens/`), `test/live_ui_fixture_test.dart` (Testinhalte),
|
||||
`scripts/test.ps1 -Ui`. 60 Unit-Tests grün.
|
||||
- **Version 0.1.37+38**, release-signierte APKs lokal gebaut
|
||||
(`build/app/outputs/flutter-apk/`), Signatur-Zertifikat identisch mit
|
||||
v0.1.36 (SHA-256 c3de66be…), Release-APK startet auf dem Emulator ohne
|
||||
Fehler. NICHT auf Gitea veröffentlicht (Bernd testet zuerst).
|
||||
|
||||
- **SDK-Logout-Wettlauf** geschlossen: `lib/core/session_logout.dart`
|
||||
(`logoutWithoutStraySync`) an allen drei Abmelde-Stellen; Live-Test
|
||||
`test/live_logout_race_test.dart` (Leerlauf + direkt nach Login, je 45 s
|
||||
beobachtet). Ehrliche Einordnung: verspätete 401 tritt real auf, kam aber
|
||||
stets vor Abschluss der Neuanmeldung – Risiko war klein. E2E-Live-Test nutzt
|
||||
jetzt denselben App-Baustein.
|
||||
|
||||
- **Android-Backup aus** (allowBackup=false, fullBackupContent=false,
|
||||
dataExtractionRules schließt Cloud-Backup UND Geräteumzug aus): verhindert
|
||||
den Aussperr-Fall „verschlüsselte DB ohne Schlüssel auf neuem Handy“ und
|
||||
den Push-Token im Cloud-Backup. Gradle 8.13 → 8.14.3.
|
||||
|
||||
**Offen/Nächster Schritt:** Bernd testet 0.1.37 auf dem Handy (Push mit
|
||||
mehreren Nachrichten + Bild, „Gelesen“, Statusleiste, Vollansicht-Gesten,
|
||||
Sprechblasen, Schriftgröße). Danach ROADMAP: SDK-Logout-Wettlauf im
|
||||
App-Pfad, Abhängigkeiten (sqlcipher_flutter_libs EOL, Gradle 8.13→8.14,
|
||||
Kotlin-Plugin-Migration laut Flutter-Warnung).
|
||||
|
||||
**Stolperfallen:** (1) Simulierte Fensterklicks (PostMessage) erreichen
|
||||
Flutter unter Windows nicht → UI-Prüfung über integration_test. (2) PowerShell
|
||||
ohne DPI-Awareness bekommt bei 125 % Skalierung verkleinerte Fenstermaße →
|
||||
abgeschnittene Screenshots (SetProcessDPIAware). (3) SharedPreferences sind
|
||||
unter Windows NICHT pro PYRAMID_PROFILE_DIR getrennt – Testläufe teilen Bernds
|
||||
Einstellungen (nur lesen, im UI-Test nichts speichern). (4) RichText
|
||||
skaliert nicht mit MediaQuery.textScaler – immer Text.rich verwenden.
|
||||
(5) apksigner braucht JAVA_HOME = Android-Studio-JBR (aus `flutter doctor -v`).
|
||||
(6) Kein `shrinkImageMaxDimension: 0` an sendFileEvent (SDK verkleinert auf 0).
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-07 (Teil 2) – Bernds Beta-Feedback analysiert, Plan steht (NOCH KEIN CODE)
|
||||
|
||||
**Bernds Feedback (Beta v0.1.36, Android):** Push kommt an, aber (a) beim
|
||||
Aufklappen viel weniger Text als bei Element/WhatsApp, (b) immer nur EINE
|
||||
Nachricht statt mehrerer, (c) keine Bildvorschau. Handy-UI gefällt nicht:
|
||||
dunkle Farbe, Schrift zu klein, oben kein Platz (Statusleiste überlagert
|
||||
Text), Bild-/Video-Vollansicht unnötig klein (durchsichtiger Rahmen), lässt
|
||||
sich nicht durch Tippen ins Leere schließen. Wunsch: genereller UI-Overhaul;
|
||||
danach Liste weiter, Fokus Stabilität + Flexibilität. Härtetest (PC_TESTPLAN 1)
|
||||
und Call-Tests (3) kann er noch nicht machen.
|
||||
|
||||
**Bernds Entscheidungen (AskUserQuestion, 2026-10-07):** Farben = „Weiches
|
||||
Dunkel“ (Dunkelgrau statt #0A0A0C, Hell bleibt Option); Nachrichten-
|
||||
Darstellung = UMSCHALTBAR (Sprechblasen / Liste, Einstellung); Schrift =
|
||||
größerer Standard (≈16 statt 14 px) + Regler unter Erscheinungsbild, System-
|
||||
Schriftgröße weiter beachten.
|
||||
|
||||
**Ursachen (im Code verifiziert):**
|
||||
- Push (b): `NotificationHelper.show` nutzt EINE notifId pro Raum
|
||||
(`stableId(roomId)`) und ersetzt die alte Benachrichtigung komplett.
|
||||
- Push (a): kein Style (nur `setContentText`) UND Dart kürzt hart auf 100
|
||||
Zeichen (`_buildBody` in background_push.dart, `_watchEvents` +
|
||||
`_decryptAndUpdateNotification` in notification_service.dart).
|
||||
- Push (c): kein Bild-Pfad vorhanden.
|
||||
- Statusleiste: `main.dart` setzt `SystemUiMode.immersiveSticky`, und
|
||||
`_MobileShell` (app_shell.dart) hat kein SafeArea → Leiste liegt über
|
||||
Kopfzeile (auch auf April-Screenshots sichtbar).
|
||||
- Vollansicht: `media_viewer.dart` Inhalt = Bildschirm minus 24 px,
|
||||
Barriere 70 % transparent; leere Fläche neben dem Bild gehört zum
|
||||
GestureDetector (`_toggle`) → Tippen blendet nur Knöpfe aus.
|
||||
- Zusatzbefunde: Knopf „Gelesen“ schließt nur, sendet KEINE Lesebestätigung;
|
||||
`_decryptAndUpdateNotification` nimmt `room.lastEvent` statt des gepushten
|
||||
Events; Access-Token liegt für PushService.kt im Klartext in
|
||||
FlutterSharedPreferences (`notif_access_token`) – Sicherheitsnotiz.
|
||||
|
||||
**Plan / nächster konkreter Handgriff (je ein Commit):**
|
||||
1. Push: Dart-Modul für Benachrichtigungs-Inhalt (Text ohne 100er-Kürzung,
|
||||
Antwort-Zitat entfernt via `calcUnlocalizedBody(hideReply: true)`,
|
||||
Medien-Labels, `notif_preview` auch im Hintergrund beachten) + Tests;
|
||||
native `NotificationHelper` auf `MessagingStyle` (Verlauf pro Raum in
|
||||
SharedPreferences, nur fortführen solange Benachrichtigung aktiv ist;
|
||||
Ersetzen per eventId statt Anhängen beim Platzhalter→Klartext; neue
|
||||
Nachricht = Alarm, Update = still); Bild: Dart lädt Thumbnail
|
||||
(`downloadAndDecryptAttachment(getThumbnail: true)`) nach
|
||||
`cacheDir/downloads/notif/`, native per FileProvider (`.fileprovider`,
|
||||
Pfad `downloads/` existiert) + `grantUriPermission("com.android.systemui")`;
|
||||
„Gelesen“ → echte Lesebestätigung (Haupt-Engine bzw. BgEngine `markRead`).
|
||||
Test: Debug-only Receiver zum Auslösen auf dem Emulator (kein Login nötig),
|
||||
dann Bernd auf echtem Gerät.
|
||||
2. Handy: `edgeToEdge` statt immersiveSticky + SafeArea im MobileShell;
|
||||
Vollansicht randlos/deckend schwarz, Tippen außerhalb des Bildes schließt,
|
||||
Wischen nach unten schließt, Doppeltipp-Zoom.
|
||||
3. UI-Overhaul nach Bernds Entscheidungen (Palette in theme.dart, globaler
|
||||
TextScaler × Nutzer-Regler, Sprechblasen-Variante in chat/message/).
|
||||
4. Danach ROADMAP-Liste (SDK-Logout-Wettlauf, Abhängigkeiten, …).
|
||||
|
||||
**Stolperfallen:** Android-Toolchain + Emulator `Medium_Phone_API_36.1`
|
||||
laufen auf diesem PC (flutter doctor sauber). In die App auf dem Emulator
|
||||
KEINE Zugangsdaten eintippen (echter Server) – UI im eingeloggten Zustand
|
||||
per Windows-Build mit `PYRAMID_PROFILE_DIR=…\.pyramid-autopilot\profile1`
|
||||
und schmalem Fenster (<900 px = Handy-Layout) prüfen.
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-07 – Wiederaufnahme nach Pause: Bestandsaufnahme, Test-Runner, Benchmarks, Live-Test stabilisiert
|
||||
|
||||
**Erledigt:**
|
||||
- **Bestandsaufnahme:** Repo synchron mit Gitea (0 vor/0 hinter), Arbeitsbaum
|
||||
war sauber, Toolchain unverändert (Flutter 3.44.4 / Dart 3.12.2).
|
||||
`flutter analyze` sauber (nur bekannter onUpdate-Hinweis), 36 Unit-Tests
|
||||
grün. Autopilot (PC + Pi) weiterhin pausiert.
|
||||
- **Lokale Suche als Modul:** `_localMessageSearch` aus `search_modal.dart`
|
||||
nach `lib/features/search/local_message_search.dart` verschoben – gleiche
|
||||
Deckel (1500/Raum, 60 Treffer, 500er-Blöcke), gleiche Schleife, nur der
|
||||
Medien-Filter kommt jetzt als Parameter. 5 Vertragstests
|
||||
(`test/local_message_search_test.dart`) auf der echten SDK-DB-Schicht.
|
||||
- **Benchmarks** (`benchmark/`, `docs/TESTS_UND_BENCHMARKS.md`): Klartext vs.
|
||||
SQLCipher (Sync-Schreiben, App-Start, Chat öffnen, Suche) + Migrationsdauer;
|
||||
JSON-Berichte, Baseline in `benchmark/baseline/`, Regressionswarnung >25 %.
|
||||
Ergebnis: SQLCipher-Aufschlag im Rauschen (±5–10 %), Migration ≈ 42 ms/MB,
|
||||
Suche (Worst Case) 64 ms für 10 000 Nachrichten.
|
||||
- **`scripts/test.ps1`:** Analyse + Unit-Tests (+ `-Live`, `-Bench`, `-All`,
|
||||
`-UpdateBaseline`) mit Zusammenfassung; baut die SQLCipher-DLL bei Bedarf.
|
||||
- **Live-Test sporadisch rot → Ursache gefunden, Test stabilisiert:** siehe
|
||||
Befund unten. `live_encryption_e2e_test.dart` meldet sich jetzt über
|
||||
`_logoutWithoutStraySync` ab (Sync anhalten, laufenden Long-Poll
|
||||
auslaufen lassen, dann Logout). Danach 3 Läufe in Folge grün (Ergebnis
|
||||
siehe unten).
|
||||
|
||||
**Befund (neu in ROADMAP, M1-Bereich):** matrix 6.2 `_innerSync` wirft den
|
||||
Fehler einer Sync-Antwort, bevor geprüft wird, ob die Anfrage noch aktuell
|
||||
ist. Die 401 eines alten Long-Polls nach einem Logout löst ein erneutes
|
||||
`clear()` aus – nach einem schnellen Re-Login auf demselben Client würde das
|
||||
die neue Session löschen. Im Test trat es als „databaseFactory not
|
||||
initialized" nach `dispose()` auf. Upstream `main` unverändert (geprüft).
|
||||
App-Logout-Pfad noch NICHT angefasst (heilig, braucht Gerätetest).
|
||||
|
||||
**Offen/Nächster Schritt:** Bernds Rückmeldungen aus der Beta-Testphase
|
||||
(v0.1.36) einsammeln, dann Prioritäten setzen: Klick-Tests aus
|
||||
`docs/PC_TESTPLAN.md`, App-Logout-Pfad gegen den SDK-Wettlauf prüfen,
|
||||
Abhängigkeits-Updates (ROADMAP M2, Achtung `sqlcipher_flutter_libs` EOL).
|
||||
|
||||
**Stolperfallen:** (1) sqflite liefert für denselben Pfad dieselbe
|
||||
Verbindung zurück – ein zweites Öffnen+Schließen kappt die erste
|
||||
(Benchmark-„database_closed"). (2) Benchmark-Reihenfolge: der Dart-JIT wird
|
||||
während des Laufs schneller, der zuerst gemessene Modus verlor ±20 % →
|
||||
großer Aufwärm-Durchlauf + A-B-B-A. (3) `dart format` NUR auf neue Dateien
|
||||
anwenden – Bestandsdateien sind nicht formatiert, sonst riesiger Diff.
|
||||
(4) Vor einer Aussage nachmessen: Schreibkosten hängen NICHT nennenswert von
|
||||
der Raumgröße ab (250 vs. 4000 Nachrichten/Raum ≈ +15 %).
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-06 – Nacht-Session (C+B): 60-FPS-Streaming + Call mit ZWEI echten Test-Accounts real durchgeklickt
|
||||
|
||||
**Erledigt:** Fokus (C) Streaming und (B) Calls – beides mit zwei echten
|
||||
|
||||
@@ -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
|
||||
|
||||
+70
-3
@@ -129,9 +129,11 @@ Punkte abhaken (`[x]`), wenn erledigt UND in `PROGRESS.md` protokolliert.
|
||||
PROGRESS.md 2026-07-04). `POST /api/livekit-token` läuft live in `server.py`
|
||||
(whoami-Auth, stdlib-HS256-Minting aus `livekit.yaml`), headless geprüft
|
||||
(401/400/413/200, JWT-Signatur unabhängig gegen `livekit.yaml` gültig, Identität =
|
||||
geprüfte user_id). **Offen:** Client-Umstellung (`livekit_token.dart`/
|
||||
`livekit_call_manager.dart`) + Secret-Rotation – beides PC/Gerät (heiliger
|
||||
Call-Pfad), siehe `docs/LIVEKIT_TOKEN_MIGRATION.md`.
|
||||
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:
|
||||
@@ -140,6 +142,7 @@ Punkte abhaken (`[x]`), wenn erledigt UND in `PROGRESS.md` protokolliert.
|
||||
`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
|
||||
@@ -160,6 +163,13 @@ Punkte abhaken (`[x]`), wenn erledigt UND in `PROGRESS.md` protokolliert.
|
||||
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
|
||||
@@ -169,6 +179,28 @@ Punkte abhaken (`[x]`), wenn erledigt UND in `PROGRESS.md` protokolliert.
|
||||
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!)
|
||||
|
||||
@@ -253,6 +285,35 @@ 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)
|
||||
|
||||
@@ -316,6 +377,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
|
||||
}
|
||||
}
|
||||
@@ -17,10 +17,15 @@
|
||||
<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:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:allowBackup="false"
|
||||
android:fullBackupContent="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules">
|
||||
<service
|
||||
android:name="com.cloudwebrtc.webrtc.FlutterForegroundService"
|
||||
android:enabled="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)
|
||||
|
||||
@@ -206,12 +206,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
-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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,9 +1,32 @@
|
||||
# LiveKit-Token server-seitig minten (apiSecret aus dem Client entfernen)
|
||||
|
||||
**Status:** geplant, NICHT umgesetzt (braucht echten Call-Test auf einem Gerät –
|
||||
Calls sind laut CLAUDE.md „heilig"). Dieser Plan ist gegen den echten Code und die
|
||||
echte Pi-Konfiguration geschrieben (2026-07-04), damit ein PC-Termin direkt starten
|
||||
kann. Analog zu `docs/SQLCIPHER_MIGRATION.md`.
|
||||
**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
|
||||
|
||||
|
||||
+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 |
|
||||
|
||||
@@ -21,6 +21,10 @@ Schnellster Weg: `flutter run -d windows`.
|
||||
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).
|
||||
|
||||
@@ -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,67 @@
|
||||
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();
|
||||
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));
|
||||
}
|
||||
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,312 @@
|
||||
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();
|
||||
await _pumpUntil(
|
||||
tester,
|
||||
find.text(_otherUser),
|
||||
timeout: const Duration(seconds: 90),
|
||||
);
|
||||
final container = ProviderScope.containerOf(
|
||||
tester.element(find.byType(MaterialApp).first),
|
||||
);
|
||||
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,
|
||||
);
|
||||
await tester.scrollUntilVisible(
|
||||
quote.first,
|
||||
300,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
maxScrolls: 40,
|
||||
);
|
||||
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 ownMsg = tester.getCenter(find.text('Danke, sieht gut aus!').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 = 'rooms';
|
||||
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}');
|
||||
}
|
||||
+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,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
@@ -7,7 +8,14 @@ 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 = [
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -8,6 +8,8 @@ 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: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';
|
||||
|
||||
@@ -64,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');
|
||||
@@ -88,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');
|
||||
@@ -152,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 {
|
||||
@@ -217,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
|
||||
|
||||
+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;
|
||||
}
|
||||
|
||||
@@ -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,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();
|
||||
}
|
||||
@@ -2,6 +2,9 @@ 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 ──────────────────────────
|
||||
|
||||
@@ -85,7 +88,7 @@ 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));
|
||||
@@ -143,26 +146,58 @@ class VoicePrefs {
|
||||
// ── 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,
|
||||
),
|
||||
|
||||
@@ -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,9 @@ 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/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 +35,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 +51,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 +131,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 +315,40 @@ 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();
|
||||
final placeholder = widget.room.isDirectChat
|
||||
? 'Message…'
|
||||
: 'Message #$roomName';
|
||||
? 'Nachricht…'
|
||||
: '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 +360,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 +372,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 +388,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 +438,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 +465,14 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
|
||||
canSend: _canSend,
|
||||
pt: pt,
|
||||
onTap: _send,
|
||||
size: mobile ? 42 : 36,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -461,9 +506,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 +518,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 +542,7 @@ class _ComposerIconBtnState extends State<_ComposerIconBtn> {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -507,8 +553,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 +580,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 +602,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 +663,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,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';
|
||||
@@ -19,6 +20,9 @@ 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/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/widgets/mxc_image.dart';
|
||||
|
||||
import 'package:pyramid/core/notification_service.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(pt: pt));
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
@@ -28,6 +38,10 @@ class MessageGroup extends StatefulWidget {
|
||||
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,
|
||||
@@ -45,6 +59,7 @@ class MessageGroup extends StatefulWidget {
|
||||
this.jumpKey,
|
||||
this.highlightEventId,
|
||||
this.highlightOn = false,
|
||||
this.popInAfter,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -64,7 +79,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();
|
||||
@@ -168,11 +199,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 +316,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> {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -213,7 +213,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 +388,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';
|
||||
@@ -19,9 +22,11 @@ import 'package:pyramid/features/chat/media_viewer.dart';
|
||||
import 'package:pyramid/features/chat/reaction_service.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';
|
||||
|
||||
@@ -42,11 +42,13 @@ 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) ...[
|
||||
@@ -141,19 +143,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,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -176,8 +186,10 @@ class _ChatHeader extends ConsumerWidget {
|
||||
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 +234,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 +317,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 +388,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: '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,
|
||||
),
|
||||
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 +611,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,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -733,3 +794,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
|
||||
@@ -241,7 +250,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) {
|
||||
@@ -287,6 +296,7 @@ class _MessageList extends StatelessWidget {
|
||||
jumpKey: jumpKey,
|
||||
highlightEventId: highlightEventId,
|
||||
highlightOn: highlightOn,
|
||||
popInAfter: popInAfter,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -115,7 +115,7 @@ 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),
|
||||
@@ -129,3 +129,25 @@ 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,9 +28,20 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
||||
// Fade-out of the "Neue Nachrichten" divider once the user reaches the bottom.
|
||||
bool _unreadFading = false;
|
||||
Timer? _unreadFadeTimer;
|
||||
// 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 +88,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 +118,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 +137,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 +162,8 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
||||
_historyExhausted = false;
|
||||
_unreadFading = false;
|
||||
_unreadFadeTimer?.cancel();
|
||||
_lastMarkedEventId = null;
|
||||
_popInAfter = null;
|
||||
_jumpEventId = null;
|
||||
_highlightEventId = null;
|
||||
_highlightOn = false;
|
||||
@@ -174,6 +214,7 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
||||
setState(() {
|
||||
_sessionFullyReadId = newFullyReadId;
|
||||
_sessionHasUnread = newHasUnread;
|
||||
_popInAfter = events.firstOrNull?.originServerTs ?? DateTime.now();
|
||||
});
|
||||
if (newHasUnread) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToUnread(timeline));
|
||||
@@ -224,21 +265,60 @@ 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.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();
|
||||
setState(() {
|
||||
_sessionFullyReadId = since;
|
||||
_sessionHasUnread = true;
|
||||
_unreadFading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
_markRead(timeline);
|
||||
}
|
||||
|
||||
Future<void> _onScroll() async {
|
||||
@@ -313,7 +393,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 &&
|
||||
@@ -429,28 +511,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 +550,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 +600,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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -43,7 +43,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,
|
||||
|
||||
@@ -29,7 +29,7 @@ class _Header extends ConsumerWidget {
|
||||
|
||||
final name = isRealSpace
|
||||
? (activeSpace?.getLocalizedDisplayname() ?? 'Space')
|
||||
: (spaceId == 'dms' ? 'Chats' : 'Rooms');
|
||||
: (spaceId == 'dms' ? 'Chats' : 'Räume');
|
||||
|
||||
void handleMenu(_SpaceMenuItem item) {
|
||||
switch (item) {
|
||||
@@ -478,7 +478,7 @@ class _FilterInput extends StatelessWidget {
|
||||
onChanged: onChanged,
|
||||
style: TextStyle(color: pt.fg, fontSize: 13),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Filter rooms',
|
||||
hintText: 'Chats durchsuchen',
|
||||
hintStyle: TextStyle(color: pt.fgDim, fontSize: 13),
|
||||
contentPadding: const EdgeInsets.fromLTRB(28, 7, 10, 7),
|
||||
filled: true,
|
||||
|
||||
@@ -99,10 +99,10 @@ class _RoomsList extends ConsumerWidget {
|
||||
if (filtered.isEmpty && invites.isEmpty) {
|
||||
return Center(
|
||||
child:
|
||||
Text('No rooms', style: TextStyle(color: pt.fgDim, fontSize: 13)));
|
||||
Text(isDms ? 'Noch keine Chats' : 'Keine Räume', style: TextStyle(color: pt.fgDim, fontSize: 13)));
|
||||
}
|
||||
|
||||
final sectionTitle = isDms ? 'Direct Messages' : 'Rooms';
|
||||
final sectionTitle = isDms ? 'Direktnachrichten' : 'Räume';
|
||||
final sectionId = isDms ? 'dms' : 'rooms';
|
||||
final isCollapsed = collapsed[sectionId] ?? false;
|
||||
|
||||
@@ -513,6 +513,18 @@ class _RoomItemState extends ConsumerState<_RoomItem> {
|
||||
final voiceParticipants = isVoice ? ref.watch(voiceParticipantsProvider(room.id)) : <Map<String, dynamic>>[];
|
||||
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||
|
||||
// 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(
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
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 abgerundetes Symbol.
|
||||
class _RoomTileAvatar extends ConsumerWidget {
|
||||
final Room room;
|
||||
final PyramidTheme pt;
|
||||
const _RoomTileAvatar({required this.room, required this.pt});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||
final fallback = Container(
|
||||
width: _kTileAvatar,
|
||||
height: _kTileAvatar,
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg3,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Icon(
|
||||
room.encrypted ? Icons.lock_outline_rounded : Icons.tag_rounded,
|
||||
size: 22,
|
||||
color: pt.fgMuted,
|
||||
),
|
||||
);
|
||||
if (client == null || room.avatar == null) return fallback;
|
||||
return MxcAvatar(
|
||||
mxcUri: room.avatar,
|
||||
client: client,
|
||||
size: _kTileAvatar,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
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';
|
||||
}
|
||||
@@ -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))),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ import 'package:matrix/matrix.dart';
|
||||
// ignore: implementation_imports
|
||||
import 'package:matrix/src/utils/space_child.dart';
|
||||
import 'package:pyramid/core/app_state.dart';
|
||||
import 'package:pyramid/core/appearance.dart';
|
||||
import 'package:pyramid/core/matrix_client.dart';
|
||||
import 'package:pyramid/core/notification_content.dart' show notificationBody;
|
||||
import 'package:pyramid/core/theme.dart';
|
||||
import 'package:pyramid/features/rooms/rooms_provider.dart';
|
||||
import 'package:pyramid/features/call_ui/mini_call_widget.dart';
|
||||
@@ -15,10 +17,12 @@ import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
||||
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
|
||||
import 'package:pyramid/features/rooms/user_panel.dart';
|
||||
import 'package:pyramid/features/spaces/space_admin_dialog.dart';
|
||||
import 'package:pyramid/utils/date_labels.dart';
|
||||
import 'package:pyramid/widgets/create_join_dialog.dart';
|
||||
import 'package:pyramid/widgets/hover_region.dart';
|
||||
import 'package:pyramid/widgets/mxc_image.dart';
|
||||
import 'package:pyramid/widgets/new_dm_dialog.dart';
|
||||
import 'package:pyramid/widgets/spring_press.dart' show SpringBump;
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -31,3 +35,4 @@ part 'panel/rooms_list.dart';
|
||||
part 'panel/rooms_voice.dart';
|
||||
part 'panel/rooms_invite_item.dart';
|
||||
part 'panel/rooms_shared.dart';
|
||||
part 'panel/rooms_mobile_tile.dart';
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
/// Lokale Nachrichtensuche über die entschlüsselten Events in der Client-DB.
|
||||
///
|
||||
/// Die server-seitige `/search`-API sieht bei E2EE nur Chiffretext und liefert
|
||||
/// für verschlüsselte Räume nichts – deshalb wird (wie bei Element) die lokale
|
||||
/// Datenbank durchsucht. Aus `search_modal.dart` herausgelöst, damit Tests und
|
||||
/// `benchmark/` genau diesen Codepfad prüfen/messen können.
|
||||
///
|
||||
/// Bei [mediaOnly] werden nur Medien-Nachrichten geliefert, deren
|
||||
/// Dateiname/Beschriftung passt. Ergebnis: neueste zuerst, höchstens
|
||||
/// [searchTotalCap] Treffer; pro Raum werden höchstens
|
||||
/// [searchPerRoomScanCap] Events gescannt.
|
||||
Future<List<Event>> localMessageSearch(
|
||||
Client client,
|
||||
String query, {
|
||||
bool mediaOnly = false,
|
||||
}) async {
|
||||
final db = client.database;
|
||||
final lower = query.toLowerCase();
|
||||
const mediaTypes = {'m.image', 'm.video', 'm.file', 'm.audio'};
|
||||
|
||||
final results = <Event>[];
|
||||
for (final room in client.rooms.where((r) => !r.isSpace)) {
|
||||
var start = 0;
|
||||
var scanned = 0;
|
||||
try {
|
||||
while (scanned < searchPerRoomScanCap) {
|
||||
final events = await db.getEventList(
|
||||
room,
|
||||
start: start,
|
||||
limit: searchScanChunk,
|
||||
);
|
||||
if (events.isEmpty) break;
|
||||
for (final e in events) {
|
||||
if (e.type != EventTypes.Message || e.redacted) continue;
|
||||
final msgtype = e.content.tryGet<String>('msgtype') ?? '';
|
||||
if (mediaOnly && !mediaTypes.contains(msgtype)) continue;
|
||||
if (!e.body.toLowerCase().contains(lower)) continue;
|
||||
results.add(e);
|
||||
}
|
||||
start += searchScanChunk;
|
||||
scanned += events.length;
|
||||
final roomExhausted = events.length < searchScanChunk;
|
||||
if (roomExhausted || results.length >= searchTotalCap) break;
|
||||
}
|
||||
} catch (_) {}
|
||||
if (results.length >= searchTotalCap) break;
|
||||
}
|
||||
|
||||
results.sort((a, b) => b.originServerTs.compareTo(a.originServerTs));
|
||||
return results.take(searchTotalCap).toList();
|
||||
}
|
||||
|
||||
/// Höchstens so viele Events werden pro Raum gescannt.
|
||||
const searchPerRoomScanCap = 1500;
|
||||
|
||||
/// Höchstens so viele Treffer liefert die Suche insgesamt.
|
||||
const searchTotalCap = 60;
|
||||
|
||||
/// So viele Events werden pro DB-Abfrage geladen.
|
||||
const searchScanChunk = 500;
|
||||
@@ -71,7 +71,7 @@ class SpacesRail extends ConsumerWidget {
|
||||
id: 'rooms',
|
||||
label: 'R',
|
||||
color: const Color(0xFFF59E0B),
|
||||
name: 'Rooms',
|
||||
name: 'Räume',
|
||||
active: activeSpaceId == 'rooms',
|
||||
unread: 0,
|
||||
pt: pt,
|
||||
@@ -179,7 +179,7 @@ class _HomeButtonState extends State<_HomeButton> {
|
||||
? (Matrix4.translationValues(0, -2, 0)..rotateZ(-0.05))
|
||||
: Matrix4.identity(),
|
||||
child: Tooltip(
|
||||
message: 'Direct Messages',
|
||||
message: 'Direktnachrichten',
|
||||
waitDuration: const Duration(milliseconds: 400),
|
||||
preferBelow: false,
|
||||
child: Stack(
|
||||
|
||||
@@ -173,8 +173,6 @@ class LiveKitCallManager extends VoiceChannelService {
|
||||
if (isActive) return;
|
||||
final gen = ++_callGeneration;
|
||||
|
||||
final url = 'wss://livekit.steggi-matrix.work';
|
||||
|
||||
isConnecting = true;
|
||||
isVoiceChannel = voiceChannel;
|
||||
error = null;
|
||||
@@ -191,12 +189,21 @@ class LiveKitCallManager extends VoiceChannelService {
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final token = LiveKitTokenGenerator.generate(
|
||||
roomName: roomName,
|
||||
identity: identity,
|
||||
displayName: identity,
|
||||
ttlSeconds: 21600,
|
||||
// Token holt der Server (prüft den Matrix-Login, Identität = eigene
|
||||
// Matrix-ID); das LiveKit-Secret liegt nicht mehr in der App.
|
||||
// [identity] wird dafür nicht mehr gebraucht.
|
||||
final accessToken = matrixClient?.accessToken;
|
||||
if (accessToken == null || accessToken.isEmpty) {
|
||||
throw const LiveKitTokenException(
|
||||
'Nicht angemeldet – Beitritt nicht möglich.');
|
||||
}
|
||||
final lk = await LiveKitTokenService.fetch(
|
||||
room: roomName,
|
||||
matrixToken: accessToken,
|
||||
);
|
||||
if (gen != _callGeneration) return; // inzwischen aufgelegt/gewechselt
|
||||
final token = lk.token;
|
||||
final url = lk.url;
|
||||
|
||||
final room = Room(
|
||||
roomOptions: const RoomOptions(
|
||||
|
||||
+56
-19
@@ -17,6 +17,8 @@ import 'package:pyramid/features/chat/chat_view.dart';
|
||||
import 'package:pyramid/features/members/members_panel.dart';
|
||||
import 'package:pyramid/features/rooms/rooms_panel.dart';
|
||||
import 'package:pyramid/features/spaces/spaces_rail.dart';
|
||||
import 'package:pyramid/layout/mobile_page_stack.dart';
|
||||
import 'package:pyramid/widgets/spring_press.dart' show motionDuration;
|
||||
import 'package:pyramid/widgets/search_modal.dart';
|
||||
import 'package:pyramid/widgets/settings_modal.dart';
|
||||
import 'package:pyramid/widgets/share_target_dialog.dart';
|
||||
@@ -335,16 +337,31 @@ class _MobileShell extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final voip = ref.watch(callSignalingProvider);
|
||||
|
||||
Widget body;
|
||||
if (viewMode == ViewMode.voice) {
|
||||
body = const VoiceChannelView();
|
||||
} else if (activeRoomId != null) {
|
||||
body = ChatView(roomId: activeRoomId!);
|
||||
} else {
|
||||
body = const RoomsPanel();
|
||||
}
|
||||
// Die Chatliste bleibt aufgebaut; Chat bzw. Sprachkanal gleiten federnd
|
||||
// darüber (Bernds Wunsch 2026-10-07: mehr Übergänge/Elastizität).
|
||||
final Widget? front = viewMode == ViewMode.voice
|
||||
? const VoiceChannelView()
|
||||
: activeRoomId != null
|
||||
? ChatView(roomId: activeRoomId!)
|
||||
: null;
|
||||
final body = MobilePageStack(
|
||||
base: const RoomsPanel(),
|
||||
front: front,
|
||||
background: pt.bg1,
|
||||
);
|
||||
|
||||
return PopScope(
|
||||
// Symbole der Statusleiste passend zum Theme (hell auf dunkel und umgekehrt).
|
||||
final overlayStyle = (pt.isDark
|
||||
? SystemUiOverlayStyle.light
|
||||
: SystemUiOverlayStyle.dark)
|
||||
.copyWith(
|
||||
statusBarColor: Colors.transparent,
|
||||
systemNavigationBarColor: Colors.transparent,
|
||||
);
|
||||
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: overlayStyle,
|
||||
child: PopScope(
|
||||
canPop: activeRoomId == null && viewMode == ViewMode.chat && !membersOpen,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop) {
|
||||
@@ -358,13 +375,17 @@ class _MobileShell extends ConsumerWidget {
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: pt.bg0,
|
||||
// bg1 = Farbe der Kopfzeilen → der Streifen hinter der Statusleiste
|
||||
// geht nahtlos in sie über.
|
||||
backgroundColor: pt.bg1,
|
||||
drawer: Drawer(
|
||||
width: 80,
|
||||
backgroundColor: pt.bg0,
|
||||
child: const SpacesRail(),
|
||||
child: const SafeArea(child: SpacesRail()),
|
||||
),
|
||||
body: Stack(
|
||||
// SafeArea: Abstand zu Statusleiste/Kamera-Aussparung oben und zur
|
||||
// Gesten-/Navigationsleiste unten (edge-to-edge, siehe main.dart).
|
||||
body: SafeArea(child: Stack(
|
||||
children: [
|
||||
body,
|
||||
if (membersOpen && activeRoomId != null && viewMode != ViewMode.voice) ...[
|
||||
@@ -372,7 +393,12 @@ class _MobileShell extends ConsumerWidget {
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onTap: () => ref.read(membersPanelOpenProvider.notifier).state = false,
|
||||
child: Container(color: Colors.black.withAlpha(110)),
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0, end: 1),
|
||||
duration: motionDuration(context, 220),
|
||||
builder: (_, v, _) =>
|
||||
ColoredBox(color: Colors.black.withAlpha((110 * v).round())),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
@@ -381,10 +407,20 @@ class _MobileShell extends ConsumerWidget {
|
||||
bottom: 0,
|
||||
child: Builder(builder: (ctx) {
|
||||
final w = MediaQuery.sizeOf(ctx).width;
|
||||
return MembersPanel(
|
||||
roomId: activeRoomId!,
|
||||
width: (w * 0.82).clamp(240.0, 320.0),
|
||||
onClose: () => ref.read(membersPanelOpenProvider.notifier).state = false,
|
||||
// Gleitet von rechts herein.
|
||||
return TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 1, end: 0),
|
||||
duration: motionDuration(ctx, 340),
|
||||
curve: Curves.easeOutCubic,
|
||||
builder: (_, v, child) => FractionalTranslation(
|
||||
translation: Offset(v, 0),
|
||||
child: child,
|
||||
),
|
||||
child: MembersPanel(
|
||||
roomId: activeRoomId!,
|
||||
width: (w * 0.82).clamp(240.0, 320.0),
|
||||
onClose: () => ref.read(membersPanelOpenProvider.notifier).state = false,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
@@ -400,7 +436,8 @@ class _MobileShell extends ConsumerWidget {
|
||||
child: _NotificationBlockedBanner(),
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -576,7 +613,7 @@ class NoChatSelected extends StatelessWidget {
|
||||
Icon(Icons.chat_bubble_outline_rounded, size: 64, color: pt.fgDim.withAlpha(50)),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Select a chat to start messaging',
|
||||
'Wähle einen Chat aus',
|
||||
style: TextStyle(color: pt.fgMuted, fontSize: 15),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import 'package:flutter/physics.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:pyramid/core/theme.dart';
|
||||
|
||||
/// Handy-Navigation zwischen Chatliste und geöffnetem Chat.
|
||||
///
|
||||
/// Die Chatliste ([base]) bleibt immer aufgebaut – Scrollposition und Zustand
|
||||
/// bleiben beim Zurückkehren erhalten. Der Vordergrund ([front], z. B. der
|
||||
/// Chat) gleitet federnd von rechts darüber, die Liste weicht dabei leicht
|
||||
/// nach links aus und dunkelt ab; beim Zurück läuft es umgekehrt. Wechselt
|
||||
/// der Vordergrund direkt (anderer Chat), wird ohne Schieben getauscht.
|
||||
/// Mit „Animationen: 0 %“ wird sofort umgeschaltet.
|
||||
class MobilePageStack extends StatefulWidget {
|
||||
const MobilePageStack({
|
||||
super.key,
|
||||
required this.base,
|
||||
required this.front,
|
||||
required this.background,
|
||||
});
|
||||
|
||||
final Widget base;
|
||||
final Widget? front;
|
||||
|
||||
/// Hintergrund hinter dem Vordergrund, damit die Liste beim Schieben nicht
|
||||
/// durch durchsichtige Stellen scheint.
|
||||
final Color background;
|
||||
|
||||
@override
|
||||
State<MobilePageStack> createState() => _MobilePageStackState();
|
||||
}
|
||||
|
||||
class _MobilePageStackState extends State<MobilePageStack>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _ctrl = AnimationController.unbounded(
|
||||
vsync: this,
|
||||
value: widget.front != null ? 1.0 : 0.0,
|
||||
);
|
||||
|
||||
/// Angezeigter Vordergrund – bleibt beim Hinausgleiten noch stehen.
|
||||
late Widget? _front = widget.front;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(MobilePageStack oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.front != null) {
|
||||
_front = widget.front;
|
||||
if (oldWidget.front == null) _animateTo(1);
|
||||
} else if (oldWidget.front != null) {
|
||||
_animateTo(0);
|
||||
}
|
||||
}
|
||||
|
||||
void _animateTo(double target) {
|
||||
final motion = PyramidTheme.of(context).motion;
|
||||
if (motion < 0.05) {
|
||||
_ctrl.value = target;
|
||||
_settle();
|
||||
return;
|
||||
}
|
||||
// Feder knapp unter kritischer Dämpfung: zügig, weich auslaufend.
|
||||
// Kleinere „Animationen“-Werte = steifere Feder = schneller.
|
||||
final spring = SpringDescription.withDampingRatio(
|
||||
mass: 1,
|
||||
stiffness: 520 / (motion * motion),
|
||||
ratio: 0.86,
|
||||
);
|
||||
_ctrl
|
||||
.animateWith(
|
||||
SpringSimulation(spring, _ctrl.value, target, _ctrl.velocity),
|
||||
)
|
||||
.then((_) {
|
||||
_ctrl.value = target;
|
||||
_settle();
|
||||
});
|
||||
}
|
||||
|
||||
void _settle() {
|
||||
if (mounted && widget.front == null && _front != null) {
|
||||
setState(() => _front = null);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
return AnimatedBuilder(
|
||||
animation: _ctrl,
|
||||
builder: (context, _) {
|
||||
final front = _front;
|
||||
final shown = _ctrl.value.clamp(0.0, 1.0);
|
||||
// Vollständig verdeckt: Liste nicht mehr zeichnen/animieren
|
||||
// (bleibt aber aufgebaut).
|
||||
final covered = front != null && shown >= 0.999;
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
KeyedSubtree(
|
||||
key: const ValueKey('base'),
|
||||
child: IgnorePointer(
|
||||
// Nur gesperrt, solange der Vordergrund offen ist bzw.
|
||||
// hereinkommt – beim Hinausgleiten ist die Liste schon
|
||||
// wieder bedienbar (schneller Tipp auf den nächsten Chat).
|
||||
ignoring: widget.front != null,
|
||||
child: TickerMode(
|
||||
enabled: !covered,
|
||||
child: Offstage(
|
||||
offstage: covered,
|
||||
child: Transform.translate(
|
||||
offset: Offset(-0.25 * width * shown, 0),
|
||||
child: widget.base,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (front != null && !covered && shown > 0)
|
||||
Positioned.fill(
|
||||
key: const ValueKey('scrim'),
|
||||
child: IgnorePointer(
|
||||
child: ColoredBox(
|
||||
color: const Color(0xFF000000)
|
||||
.withValues(alpha: 0.30 * shown),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (front != null)
|
||||
KeyedSubtree(
|
||||
key: const ValueKey('front'),
|
||||
child: Transform.translate(
|
||||
offset: Offset(width * (1 - shown), 0),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: widget.background,
|
||||
boxShadow: covered
|
||||
? null
|
||||
: const [
|
||||
BoxShadow(
|
||||
color: Color(0x40000000),
|
||||
blurRadius: 18,
|
||||
),
|
||||
],
|
||||
),
|
||||
// Beim Hinausgleiten nicht mehr bedienbar.
|
||||
child: IgnorePointer(
|
||||
ignoring: widget.front == null,
|
||||
child: front,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -33,8 +33,16 @@ void main() async {
|
||||
|
||||
await vod.init();
|
||||
|
||||
// Hide status bar and navigation bar for immersive feel
|
||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||||
// Status- und Navigationsleiste bleiben sichtbar (edge-to-edge): die App
|
||||
// zeichnet darunter, hält per SafeArea (Handy-Layout in app_shell.dart)
|
||||
// aber Abstand. Vorher immersiveSticky – da schob sich die Statusleiste
|
||||
// beim Herunterwischen über Kopfzeile und Text (Bernds Feedback 2026-10-07).
|
||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
systemNavigationBarColor: Colors.transparent,
|
||||
systemNavigationBarContrastEnforced: false,
|
||||
));
|
||||
|
||||
runApp(const ProviderScope(child: PyramidBootstrap()));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/// Kurze Datums-/Zeitangaben für Listen (deutsch).
|
||||
library;
|
||||
|
||||
/// Zeit in der Chatliste: heute „14:05“, gestern „Gestern“, diese Woche der
|
||||
/// Wochentag, sonst das Datum.
|
||||
String chatListTime(DateTime t, {DateTime? now}) {
|
||||
final n = now ?? DateTime.now();
|
||||
final today = DateTime(n.year, n.month, n.day);
|
||||
final day = DateTime(t.year, t.month, t.day);
|
||||
// Über Stunden runden: an Zeitumstellungs-Tagen hat ein Tag 23/25 Stunden,
|
||||
// inDays würde dann „gestern“ als „heute“ zählen.
|
||||
final diff = (today.difference(day).inHours / 24).round();
|
||||
if (diff <= 0) {
|
||||
return '${t.hour.toString().padLeft(2, '0')}:'
|
||||
'${t.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
if (diff == 1) return 'Gestern';
|
||||
if (diff < 7) {
|
||||
const days = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||
return days[t.weekday - 1];
|
||||
}
|
||||
return '${t.day.toString().padLeft(2, '0')}.'
|
||||
'${t.month.toString().padLeft(2, '0')}.'
|
||||
'${(t.year % 100).toString().padLeft(2, '0')}';
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pyramid/core/appearance.dart';
|
||||
import 'package:pyramid/core/theme.dart';
|
||||
import 'package:pyramid/widgets/spring_press.dart';
|
||||
|
||||
// Simple hover-aware widget for desktop hover effects
|
||||
class HoverRegion extends StatefulWidget {
|
||||
@@ -29,7 +34,10 @@ class _HoverRegionState extends State<HoverRegion> {
|
||||
}
|
||||
}
|
||||
|
||||
// Icon button styled to match Pyramid's .icon-btn
|
||||
// Icon button styled to match Pyramid's .icon-btn.
|
||||
// Handy-Layout (Bernds Feedback 2026-10-07: Symbole zu klein): Symbol 22 px
|
||||
// statt 16, Fläche mind. 40 px, Tippfläche mind. 44 px. Farben folgen dem
|
||||
// gewählten Farbmodus, Druck federt (SpringPress).
|
||||
class PyrIconBtn extends StatefulWidget {
|
||||
final Widget icon;
|
||||
final VoidCallback? onPressed;
|
||||
@@ -39,6 +47,9 @@ class PyrIconBtn extends StatefulWidget {
|
||||
final Color? dangerColor;
|
||||
final double size;
|
||||
|
||||
/// Symbolgröße; ohne Angabe 16 (PC) bzw. 22 (Handy-Layout).
|
||||
final double? iconSize;
|
||||
|
||||
const PyrIconBtn({
|
||||
super.key,
|
||||
required this.icon,
|
||||
@@ -48,6 +59,7 @@ class PyrIconBtn extends StatefulWidget {
|
||||
this.activeColor,
|
||||
this.dangerColor,
|
||||
this.size = 32,
|
||||
this.iconSize,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -59,20 +71,16 @@ class _PyrIconBtnState extends State<PyrIconBtn> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final fgMuted = isDark ? const Color(0xFFA4A4B0) : const Color(0xFF54545E);
|
||||
final fg = isDark ? const Color(0xFFECECF0) : const Color(0xFF1A1A1F);
|
||||
final bgHover = isDark ? const Color(0xFF232330) : const Color(0xFFE9E7E1);
|
||||
final pt = PyramidTheme.of(context);
|
||||
final mobile = isMobileLayout(MediaQuery.sizeOf(context));
|
||||
|
||||
final color = widget.active
|
||||
? (widget.activeColor ?? const Color(0xFFF5A614))
|
||||
: _hovered ? fg : fgMuted;
|
||||
? (widget.activeColor ?? pt.accent)
|
||||
: _hovered ? pt.fg : pt.fgMuted;
|
||||
|
||||
final platform = Theme.of(context).platform;
|
||||
final isMobile = platform == TargetPlatform.android || platform == TargetPlatform.iOS;
|
||||
// On mobile, use a slightly larger touch target, but respect the requested size
|
||||
final touchSize = isMobile ? (widget.size + 8).clamp(32.0, 48.0) : widget.size;
|
||||
final boxSize = mobile ? math.max(widget.size, 40.0) : widget.size;
|
||||
final touchSize = mobile ? math.max(boxSize, 44.0) : boxSize;
|
||||
final iconSize = widget.iconSize ?? (mobile ? 22.0 : 16.0);
|
||||
|
||||
final btn = MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
@@ -80,23 +88,26 @@ class _PyrIconBtnState extends State<PyrIconBtn> {
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onPressed,
|
||||
child: SizedBox(
|
||||
width: touchSize,
|
||||
height: touchSize,
|
||||
child: Center(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: Curves.easeOutCubic,
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
decoration: BoxDecoration(
|
||||
color: _hovered ? bgHover : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: Center(
|
||||
child: IconTheme(
|
||||
data: IconThemeData(color: color, size: 16),
|
||||
child: widget.icon,
|
||||
child: SpringPress(
|
||||
enabled: widget.onPressed != null,
|
||||
child: SizedBox(
|
||||
width: touchSize,
|
||||
height: touchSize,
|
||||
child: Center(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: Curves.easeOutCubic,
|
||||
width: boxSize,
|
||||
height: boxSize,
|
||||
decoration: BoxDecoration(
|
||||
color: _hovered ? pt.bgHover : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(mobile ? boxSize / 2 : 7),
|
||||
),
|
||||
child: Center(
|
||||
child: IconTheme(
|
||||
data: IconThemeData(color: color, size: iconSize),
|
||||
child: widget.icon,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Meldet die gezeichnete Größe des Kindes, sobald sie sich ändert (z. B.
|
||||
/// die Höhe der schwebenden Eingabeleiste, die mit Antwort-Leiste oder
|
||||
/// mehrzeiligem Text wächst). Die Meldung kommt nach dem Layout, damit der
|
||||
/// Empfänger gefahrlos setState aufrufen kann.
|
||||
class MeasureSize extends SingleChildRenderObjectWidget {
|
||||
const MeasureSize({super.key, required this.onChange, super.child});
|
||||
|
||||
final ValueChanged<Size> onChange;
|
||||
|
||||
@override
|
||||
RenderObject createRenderObject(BuildContext context) =>
|
||||
_MeasureSizeRender(onChange);
|
||||
|
||||
@override
|
||||
void updateRenderObject(BuildContext context, RenderObject renderObject) {
|
||||
(renderObject as _MeasureSizeRender).onChange = onChange;
|
||||
}
|
||||
}
|
||||
|
||||
class _MeasureSizeRender extends RenderProxyBox {
|
||||
_MeasureSizeRender(this.onChange);
|
||||
|
||||
ValueChanged<Size> onChange;
|
||||
Size? _last;
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
super.performLayout();
|
||||
final size = this.size;
|
||||
if (size == _last) return;
|
||||
_last = size;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => onChange(size));
|
||||
}
|
||||
}
|
||||
@@ -104,9 +104,9 @@ class _ProfilePopoverState extends State<ProfilePopover> {
|
||||
child: Container(
|
||||
width: popoverW,
|
||||
decoration: BoxDecoration(
|
||||
color: PyramidColors.bg0,
|
||||
color: PyramidTheme.of(context).bg0,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: PyramidColors.border),
|
||||
border: Border.all(color: PyramidTheme.of(context).border),
|
||||
boxShadow: const [
|
||||
BoxShadow(color: Color(0x60000000), blurRadius: 24, offset: Offset(0, 8))
|
||||
],
|
||||
@@ -172,7 +172,7 @@ class _ProfilePopoverState extends State<ProfilePopover> {
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: PyramidColors.bg0, width: 3),
|
||||
border: Border.all(color: PyramidTheme.of(context).bg0, width: 3),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: avatarUrl != null
|
||||
@@ -212,14 +212,14 @@ class _ProfilePopoverState extends State<ProfilePopover> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(displayName,
|
||||
style: const TextStyle(
|
||||
color: PyramidColors.fg,
|
||||
style: TextStyle(
|
||||
color: PyramidTheme.of(context).fg,
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 2),
|
||||
Text(handle,
|
||||
style: const TextStyle(
|
||||
color: PyramidColors.fgMuted, fontSize: 12)),
|
||||
style: TextStyle(
|
||||
color: PyramidTheme.of(context).fgMuted, fontSize: 12)),
|
||||
const SizedBox(height: 12),
|
||||
// Action buttons
|
||||
Row(
|
||||
@@ -248,9 +248,9 @@ class _ProfilePopoverState extends State<ProfilePopover> {
|
||||
),
|
||||
if (powerLevel > 0) ...[
|
||||
const SizedBox(height: 14),
|
||||
const Text('Rollen',
|
||||
Text('Rollen',
|
||||
style: TextStyle(
|
||||
color: PyramidColors.fgDim,
|
||||
color: PyramidTheme.of(context).fgDim,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.8)),
|
||||
@@ -270,16 +270,16 @@ class _ProfilePopoverState extends State<ProfilePopover> {
|
||||
],
|
||||
if (joinDate != null) ...[
|
||||
const SizedBox(height: 14),
|
||||
const Text('Mitglied seit',
|
||||
Text('Mitglied seit',
|
||||
style: TextStyle(
|
||||
color: PyramidColors.fgDim,
|
||||
color: PyramidTheme.of(context).fgDim,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.8)),
|
||||
const SizedBox(height: 4),
|
||||
Text(joinDate,
|
||||
style: const TextStyle(
|
||||
color: PyramidColors.fgMuted, fontSize: 12)),
|
||||
style: TextStyle(
|
||||
color: PyramidTheme.of(context).fgMuted, fontSize: 12)),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -337,7 +337,7 @@ class _PresenceDot extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: PyramidColors.bg0, width: 2),
|
||||
border: Border.all(color: PyramidTheme.of(context).bg0, width: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -380,10 +380,10 @@ class _ProfileBtnState extends State<_ProfileBtn> {
|
||||
? (_hover
|
||||
? PyramidTheme.of(context).accent.withAlpha(200)
|
||||
: PyramidTheme.of(context).accent)
|
||||
: (_hover ? PyramidColors.bgHover : PyramidColors.bg2),
|
||||
: (_hover ? PyramidTheme.of(context).bgHover : PyramidTheme.of(context).bg2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: widget.primary ? Colors.transparent : PyramidColors.border,
|
||||
color: widget.primary ? Colors.transparent : PyramidTheme.of(context).border,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
@@ -393,14 +393,14 @@ class _ProfileBtnState extends State<_ProfileBtn> {
|
||||
size: 14,
|
||||
color: widget.primary
|
||||
? PyramidColors.accentFg
|
||||
: PyramidColors.fg),
|
||||
: PyramidTheme.of(context).fg),
|
||||
if (hasLabel) ...[
|
||||
const SizedBox(width: 6),
|
||||
Text(widget.label!,
|
||||
style: TextStyle(
|
||||
color: widget.primary
|
||||
? PyramidColors.accentFg
|
||||
: PyramidColors.fg,
|
||||
: PyramidTheme.of(context).fg,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500)),
|
||||
],
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:matrix/matrix.dart';
|
||||
import 'package:pyramid/core/app_state.dart';
|
||||
import 'package:pyramid/core/matrix_client.dart';
|
||||
import 'package:pyramid/core/theme.dart';
|
||||
import 'package:pyramid/features/search/local_message_search.dart';
|
||||
import 'package:pyramid/widgets/mxc_image.dart';
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────────────────────
|
||||
@@ -110,7 +111,11 @@ class _SearchModalState extends ConsumerState<SearchModal> {
|
||||
List<Event> messageResults = [];
|
||||
if (_filter != _Filter.people) {
|
||||
try {
|
||||
messageResults = await _localMessageSearch(client, q);
|
||||
messageResults = await localMessageSearch(
|
||||
client,
|
||||
q,
|
||||
mediaOnly: _filter == _Filter.media,
|
||||
);
|
||||
} catch (e) {
|
||||
setState(() => _state = _state.copyWith(
|
||||
loading: false,
|
||||
@@ -129,45 +134,6 @@ class _SearchModalState extends ConsumerState<SearchModal> {
|
||||
}
|
||||
}
|
||||
|
||||
// Scans the local (decrypted) event database across all rooms for messages
|
||||
// whose body matches the query. For the media filter, only media messages
|
||||
// whose filename/caption matches are returned.
|
||||
Future<List<Event>> _localMessageSearch(Client client, String q) async {
|
||||
final db = client.database;
|
||||
final lower = q.toLowerCase();
|
||||
final media = _filter == _Filter.media;
|
||||
const mediaTypes = {'m.image', 'm.video', 'm.file', 'm.audio'};
|
||||
const perRoomScanCap = 1500;
|
||||
const totalCap = 60;
|
||||
const chunk = 500;
|
||||
|
||||
final results = <Event>[];
|
||||
for (final room in client.rooms.where((r) => !r.isSpace)) {
|
||||
var start = 0;
|
||||
var scanned = 0;
|
||||
try {
|
||||
while (scanned < perRoomScanCap) {
|
||||
final events = await db.getEventList(room, start: start, limit: chunk);
|
||||
if (events.isEmpty) break;
|
||||
for (final e in events) {
|
||||
if (e.type != EventTypes.Message || e.redacted) continue;
|
||||
final msgtype = e.content.tryGet<String>('msgtype') ?? '';
|
||||
if (media && !mediaTypes.contains(msgtype)) continue;
|
||||
if (!e.body.toLowerCase().contains(lower)) continue;
|
||||
results.add(e);
|
||||
}
|
||||
start += chunk;
|
||||
scanned += events.length;
|
||||
if (events.length < chunk || results.length >= totalCap) break;
|
||||
}
|
||||
} catch (_) {}
|
||||
if (results.length >= totalCap) break;
|
||||
}
|
||||
|
||||
results.sort((a, b) => b.originServerTs.compareTo(a.originServerTs));
|
||||
return results.take(totalCap).toList();
|
||||
}
|
||||
|
||||
void _openRoom(String roomId, {String? eventId}) {
|
||||
widget.onClose();
|
||||
ref.read(activeRoomIdProvider.notifier).state = roomId;
|
||||
@@ -657,6 +623,7 @@ class _MessageItemState extends State<_MessageItem> {
|
||||
]),
|
||||
const SizedBox(height: 1),
|
||||
RichText(
|
||||
textScaler: MediaQuery.textScalerOf(context),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
text: TextSpan(children: [
|
||||
|
||||
@@ -130,7 +130,7 @@ class _AboutSection extends ConsumerWidget {
|
||||
);
|
||||
if (confirm == true) {
|
||||
unawaited(AuthLog.write('User-initiated logout (Settings)'));
|
||||
await client.logout().catchError((_) {});
|
||||
await logoutWithoutStraySync(client).catchError((_) {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,7 +192,7 @@ class _DeleteAccountDialogState extends ConsumerState<_DeleteAccountDialog> {
|
||||
}
|
||||
// Konto wurde gelöscht — ausloggen
|
||||
unawaited(AuthLog.write('User-initiated logout (Konto gelöscht)'));
|
||||
try { await client.logout(); } catch (_) {}
|
||||
try { await logoutWithoutStraySync(client); } catch (_) {}
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -2,7 +2,9 @@ part of '../settings_modal.dart';
|
||||
|
||||
class _AppearanceSection extends StatelessWidget {
|
||||
final PyramidTheme pt;
|
||||
final bool isDark;
|
||||
final ThemeVariant variant;
|
||||
final double? textScale;
|
||||
final MessageStyle messageStyle;
|
||||
final int accentIdx;
|
||||
final double radius;
|
||||
final double density;
|
||||
@@ -11,7 +13,9 @@ class _AppearanceSection extends StatelessWidget {
|
||||
|
||||
const _AppearanceSection({
|
||||
required this.pt,
|
||||
required this.isDark,
|
||||
required this.variant,
|
||||
required this.textScale,
|
||||
required this.messageStyle,
|
||||
required this.accentIdx,
|
||||
required this.radius,
|
||||
required this.density,
|
||||
@@ -21,6 +25,12 @@ class _AppearanceSection extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final mobile = isMobileLayout(MediaQuery.sizeOf(context));
|
||||
final autoScale = defaultTextScale(mobile: mobile);
|
||||
final effectiveScale = textScale ?? autoScale;
|
||||
final variants = ThemeVariant.values;
|
||||
final styles = MessageStyle.values;
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -30,18 +40,18 @@ class _AppearanceSection extends StatelessWidget {
|
||||
subtitle: 'Gestalte Pyramid nach deinem Geschmack.',
|
||||
pt: pt),
|
||||
_SettingsGroup(title: 'Theme', pt: pt, children: [
|
||||
_SettingsRow(
|
||||
_AdaptiveSettingsRow(
|
||||
title: 'Farbmodus',
|
||||
desc: 'Hell oder dunkel',
|
||||
desc: 'Dunkel = weiches Dunkelgrau, Schwarz = wie früher (spart '
|
||||
'auf OLED-Displays Akku)',
|
||||
pt: pt,
|
||||
control: _SegControl(
|
||||
options: const ['Dunkel', 'Hell'],
|
||||
selected: isDark ? 0 : 1,
|
||||
options: [for (final v in variants) v.label],
|
||||
selected: variants.indexOf(variant),
|
||||
pt: pt,
|
||||
onSelect: (i) {
|
||||
final dark = i == 0;
|
||||
ref.read(themeModeProvider.notifier).state = dark;
|
||||
saveThemePrefs(isDark: dark);
|
||||
ref.read(themeVariantProvider.notifier).state = variants[i];
|
||||
saveThemePrefs(variant: variants[i]);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -80,6 +90,85 @@ class _AppearanceSection extends StatelessWidget {
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
_SettingsGroup(title: 'Text & Chat', pt: pt, children: [
|
||||
_AdaptiveSettingsRow(
|
||||
title: 'Schriftgröße',
|
||||
desc: textScale == null
|
||||
? 'Automatisch (${(autoScale * 100).round()} %) – zusätzlich '
|
||||
'zur Schriftgröße des Geräts'
|
||||
: '${(effectiveScale * 100).round()} %',
|
||||
pt: pt,
|
||||
control: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: mobile ? 250 : 170,
|
||||
child: SliderTheme(
|
||||
data: SliderThemeData(
|
||||
activeTrackColor: pt.accent,
|
||||
thumbColor: pt.accent,
|
||||
inactiveTrackColor: pt.border,
|
||||
trackHeight: 3,
|
||||
thumbShape:
|
||||
const RoundSliderThumbShape(enabledThumbRadius: 7),
|
||||
),
|
||||
child: Slider(
|
||||
value: effectiveScale.clamp(minTextScale, maxTextScale),
|
||||
min: minTextScale,
|
||||
max: maxTextScale,
|
||||
divisions: ((maxTextScale - minTextScale) / 0.05).round(),
|
||||
onChanged: (v) {
|
||||
final rounded = (v * 20).round() / 20;
|
||||
ref.read(textScaleProvider.notifier).state = rounded;
|
||||
saveThemePrefs(textScale: rounded);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
if (textScale != null)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref.read(textScaleProvider.notifier).state = null;
|
||||
saveThemePrefs(resetTextScale: true);
|
||||
},
|
||||
child: Text('Standard',
|
||||
style: TextStyle(color: pt.accent, fontSize: 12)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg3,
|
||||
borderRadius: BorderRadius.circular(pt.rBase),
|
||||
),
|
||||
child: Text(
|
||||
'Vorschau: So groß erscheinen Nachrichten im Chat.',
|
||||
style: TextStyle(color: pt.fg, fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
_Divider(pt: pt),
|
||||
_AdaptiveSettingsRow(
|
||||
title: 'Nachrichten',
|
||||
desc: 'Automatisch: Handy Sprechblasen, PC Liste',
|
||||
pt: pt,
|
||||
control: _SegControl(
|
||||
options: [for (final m in styles) m.label],
|
||||
selected: styles.indexOf(messageStyle),
|
||||
pt: pt,
|
||||
onSelect: (i) {
|
||||
ref.read(messageStyleProvider.notifier).state = styles[i];
|
||||
saveThemePrefs(messageStyle: styles[i]);
|
||||
},
|
||||
),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
_SettingsGroup(title: 'Form & Bewegung', pt: pt, children: [
|
||||
_SettingsRow(
|
||||
title: 'Eckenrundung',
|
||||
@@ -185,3 +274,51 @@ class _AppearanceSection extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Wie [_SettingsRow], aber auf schmalen Bildschirmen steht das Bedienelement
|
||||
/// UNTER dem Text statt daneben (sonst quetschen sich Schalter mit drei
|
||||
/// Optionen auf dem Handy).
|
||||
class _AdaptiveSettingsRow extends StatelessWidget {
|
||||
final String title;
|
||||
final String? desc;
|
||||
final PyramidTheme pt;
|
||||
final Widget control;
|
||||
|
||||
const _AdaptiveSettingsRow({
|
||||
required this.title,
|
||||
required this.pt,
|
||||
required this.control,
|
||||
this.desc,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(builder: (context, constraints) {
|
||||
if (constraints.maxWidth >= 520) {
|
||||
return _SettingsRow(title: title, desc: desc, pt: pt, control: control);
|
||||
}
|
||||
// Volle Breite: _SettingsGroup zentriert seine Kinder sonst.
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title,
|
||||
style: TextStyle(
|
||||
color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500)),
|
||||
if (desc != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(desc!, style: TextStyle(color: pt.fgDim, fontSize: 12)),
|
||||
],
|
||||
const SizedBox(height: 10),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: control,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,7 +218,7 @@ class _LogoutNavButtonState extends ConsumerState<_LogoutNavButton> {
|
||||
try {
|
||||
widget.onClose();
|
||||
unawaited(AuthLog.write('User-initiated logout (Settings-Sidebar)'));
|
||||
await client.logout();
|
||||
await logoutWithoutStraySync(client);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,12 +21,14 @@ import 'package:pretty_qr_code/pretty_qr_code.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:pointycastle/export.dart' as pc;
|
||||
import 'package:pyramid/core/app_state.dart';
|
||||
import 'package:pyramid/core/appearance.dart';
|
||||
import 'package:pyramid/core/auth_log.dart';
|
||||
import 'package:pyramid/core/e2ee_diagnostics.dart';
|
||||
import 'package:pyramid/features/auth/bootstrap_dialog.dart' show isKeyBackupMissing;
|
||||
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
||||
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
|
||||
import 'package:pyramid/core/matrix_client.dart';
|
||||
import 'package:pyramid/core/session_logout.dart';
|
||||
import 'package:pyramid/core/settings_prefs.dart';
|
||||
import 'package:pyramid/core/theme.dart';
|
||||
import 'package:pyramid/core/update_checker.dart';
|
||||
@@ -85,7 +87,7 @@ class _SettingsModalState extends ConsumerState<SettingsModal> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = PyramidTheme.of(context);
|
||||
final isDark = ref.watch(themeModeProvider);
|
||||
final isDark = ref.watch(themeVariantProvider).isDark;
|
||||
final accentIdx = ref.watch(accentProvider);
|
||||
final radius = ref.watch(radiusProvider);
|
||||
final density = ref.watch(densityProvider);
|
||||
@@ -101,24 +103,30 @@ class _SettingsModalState extends ConsumerState<SettingsModal> {
|
||||
child: Center(
|
||||
child: GestureDetector(
|
||||
onTap: () {},
|
||||
// Handy: ganze Seite (wie System-Einstellungen) statt schwebender
|
||||
// Karte mit abgedunkeltem Rand – mehr Platz, weniger Gequetsche.
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 820, maxHeight: 660),
|
||||
width: isMobile ? size.width * 0.97 : 740,
|
||||
height: isMobile ? size.height * 0.92 : 560,
|
||||
constraints: isMobile
|
||||
? null
|
||||
: const BoxConstraints(maxWidth: 820, maxHeight: 660),
|
||||
width: isMobile ? size.width : 740,
|
||||
height: isMobile ? size.height : 560,
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg1,
|
||||
borderRadius: BorderRadius.circular(pt.rXl),
|
||||
border: Border.all(color: pt.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withAlpha(130),
|
||||
blurRadius: 40,
|
||||
offset: const Offset(0, 12),
|
||||
),
|
||||
],
|
||||
borderRadius: BorderRadius.circular(isMobile ? 0 : pt.rXl),
|
||||
border: isMobile ? null : Border.all(color: pt.border),
|
||||
boxShadow: isMobile
|
||||
? null
|
||||
: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withAlpha(130),
|
||||
blurRadius: 40,
|
||||
offset: const Offset(0, 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(pt.rXl),
|
||||
borderRadius: BorderRadius.circular(isMobile ? 0 : pt.rXl),
|
||||
child: isMobile
|
||||
? _mobileLayout(pt, isDark, accentIdx, radius, density, motion)
|
||||
: _desktopLayout(pt, isDark, accentIdx, radius, density, motion),
|
||||
@@ -242,10 +250,15 @@ class _SettingsModalState extends ConsumerState<SettingsModal> {
|
||||
|
||||
Widget _buildContent(PyramidTheme pt, bool isDark, int accentIdx,
|
||||
double radius, double density, double motion, String section) {
|
||||
final variant = ref.watch(themeVariantProvider);
|
||||
final textScale = ref.watch(textScaleProvider);
|
||||
final messageStyle = ref.watch(messageStyleProvider);
|
||||
return switch (section) {
|
||||
'appearance' => _AppearanceSection(
|
||||
pt: pt,
|
||||
isDark: isDark,
|
||||
variant: variant,
|
||||
textScale: textScale,
|
||||
messageStyle: messageStyle,
|
||||
accentIdx: accentIdx,
|
||||
radius: radius,
|
||||
density: density,
|
||||
@@ -261,7 +274,9 @@ class _SettingsModalState extends ConsumerState<SettingsModal> {
|
||||
'about' => _AboutSection(pt: pt),
|
||||
_ => _AppearanceSection(
|
||||
pt: pt,
|
||||
isDark: isDark,
|
||||
variant: variant,
|
||||
textScale: textScale,
|
||||
messageStyle: messageStyle,
|
||||
accentIdx: accentIdx,
|
||||
radius: radius,
|
||||
density: density,
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:pyramid/core/theme.dart';
|
||||
|
||||
/// Federnde Druck-Rückmeldung: beim Drücken leicht kleiner, beim Loslassen
|
||||
/// elastisch zurück (Bernds Wunsch 2026-10-07: mehr „Springiness“).
|
||||
///
|
||||
/// Hört nur passiv mit (Listener) – Tippen, Halten und Scrollen bleiben beim
|
||||
/// Kind bzw. beim umgebenden Scrollbereich. Bewegt sich der Finger (Scrollen),
|
||||
/// wird die Rückmeldung sofort abgebrochen. Mit „Animationen: 0 %“ in den
|
||||
/// Einstellungen (PyramidTheme.motion) ist sie aus.
|
||||
class SpringPress extends StatefulWidget {
|
||||
const SpringPress({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.pressedScale = 0.88,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final double pressedScale;
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
State<SpringPress> createState() => _SpringPressState();
|
||||
}
|
||||
|
||||
class _SpringPressState extends State<SpringPress> {
|
||||
static const _moveSlop = 8.0;
|
||||
bool _pressed = false;
|
||||
Offset? _down;
|
||||
|
||||
void _set(bool pressed) {
|
||||
if (_pressed != pressed && mounted) setState(() => _pressed = pressed);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final motion = PyramidTheme.of(context).motion;
|
||||
if (!widget.enabled || motion < 0.05) return widget.child;
|
||||
return Listener(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onPointerDown: (e) {
|
||||
_down = e.position;
|
||||
_set(true);
|
||||
},
|
||||
onPointerMove: (e) {
|
||||
final d = _down;
|
||||
if (d != null && (e.position - d).distance > _moveSlop) _set(false);
|
||||
},
|
||||
onPointerUp: (_) => _set(false),
|
||||
onPointerCancel: (_) => _set(false),
|
||||
child: AnimatedScale(
|
||||
scale: _pressed ? widget.pressedScale : 1.0,
|
||||
duration: _pressed
|
||||
? const Duration(milliseconds: 90)
|
||||
: Duration(milliseconds: (420 * motion).round().clamp(120, 420)),
|
||||
curve: _pressed ? Curves.easeOut : Curves.elasticOut,
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Gemeinsame „federnde“ Kurve für Ein-/Ausblendungen (leichtes Überschwingen).
|
||||
const springyCurve = Cubic(0.18, 0.89, 0.32, 1.12);
|
||||
|
||||
/// Dauer, skaliert mit der Einstellung „Animationen“ (0 = sofort).
|
||||
Duration motionDuration(BuildContext context, int milliseconds) {
|
||||
final motion = PyramidTheme.of(context).motion;
|
||||
return Duration(milliseconds: (milliseconds * motion).round());
|
||||
}
|
||||
|
||||
/// Einmaliges federndes Einblenden (z. B. eine gerade angekommene Nachricht):
|
||||
/// kommt etwas kleiner und tiefer herein und schwingt leicht über.
|
||||
///
|
||||
/// Ob gespielt wird, entscheidet sich nur beim ersten Aufbau ([play]); danach
|
||||
/// bleibt das Kind unverändert, auch wenn [play] sich ändert. Mit
|
||||
/// „Animationen: 0 %“ wird nie gespielt.
|
||||
class PopIn extends StatefulWidget {
|
||||
const PopIn({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.play = true,
|
||||
this.alignment = Alignment.bottomCenter,
|
||||
this.offsetY = 18,
|
||||
this.fromScale = 0.86,
|
||||
this.milliseconds = 460,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final bool play;
|
||||
final Alignment alignment;
|
||||
final double offsetY;
|
||||
final double fromScale;
|
||||
final int milliseconds;
|
||||
|
||||
@override
|
||||
State<PopIn> createState() => _PopInState();
|
||||
}
|
||||
|
||||
class _PopInState extends State<PopIn> with SingleTickerProviderStateMixin {
|
||||
AnimationController? _ctrl;
|
||||
bool _decided = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_decided) return;
|
||||
_decided = true;
|
||||
final motion = PyramidTheme.of(context).motion;
|
||||
if (!widget.play || motion < 0.05) return;
|
||||
_ctrl = AnimationController(
|
||||
vsync: this,
|
||||
duration: Duration(milliseconds: (widget.milliseconds * motion).round()),
|
||||
)..forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ctrl = _ctrl;
|
||||
if (ctrl == null) return widget.child;
|
||||
return AnimatedBuilder(
|
||||
animation: ctrl,
|
||||
child: widget.child,
|
||||
builder: (context, child) {
|
||||
final t = ctrl.value;
|
||||
final move = Curves.easeOutBack.transform(t);
|
||||
final fade = Curves.easeOut.transform((t / 0.4).clamp(0.0, 1.0));
|
||||
return Opacity(
|
||||
opacity: fade,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, (1 - move) * widget.offsetY),
|
||||
child: Transform.scale(
|
||||
scale: widget.fromScale + (1 - widget.fromScale) * move,
|
||||
alignment: widget.alignment,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Federndes „Aufploppen“, sobald [value] steigt (z. B. Ungelesen-Zähler,
|
||||
/// Anzahl einer Reaktion). Beim ersten Aufbau passiert nichts – so ploppt
|
||||
/// beim Scrollen durch eine Liste nichts grundlos auf.
|
||||
class SpringBump extends StatefulWidget {
|
||||
const SpringBump({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.child,
|
||||
this.alignment = Alignment.center,
|
||||
});
|
||||
|
||||
final num value;
|
||||
final Widget child;
|
||||
final Alignment alignment;
|
||||
|
||||
@override
|
||||
State<SpringBump> createState() => _SpringBumpState();
|
||||
}
|
||||
|
||||
class _SpringBumpState extends State<SpringBump>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _ctrl =
|
||||
AnimationController(vsync: this, value: 1);
|
||||
double _from = 1;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(SpringBump oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.value <= oldWidget.value) return;
|
||||
final motion = PyramidTheme.of(context).motion;
|
||||
if (motion < 0.05) return;
|
||||
// Aus dem Nichts (0 → 1) größer aufploppen als beim Hochzählen.
|
||||
_from = oldWidget.value <= 0 ? 0.3 : 0.7;
|
||||
_ctrl.duration = Duration(milliseconds: (650 * motion).round());
|
||||
_ctrl.forward(from: 0);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _ctrl,
|
||||
child: widget.child,
|
||||
builder: (context, child) => Transform.scale(
|
||||
scale: _from + (1 - _from) * Curves.elasticOut.transform(_ctrl.value),
|
||||
alignment: widget.alignment,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+44
-5
@@ -202,7 +202,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
dart_jsonwebtoken:
|
||||
dependency: "direct main"
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dart_jsonwebtoken
|
||||
sha256: ad84e60181696513d04d5f2078e0bbc20365b911f46f647797317414bdc88fbe
|
||||
@@ -350,6 +350,11 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_driver:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_launcher_icons:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -395,14 +400,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_markdown:
|
||||
flutter_markdown_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_markdown
|
||||
sha256: "08fb8315236099ff8e90cb87bb2b935e0a724a3af1623000a9cec930468e0f27"
|
||||
name: flutter_markdown_plus
|
||||
sha256: fce641d6c2106cc495de1cd603f97a4f9d615a97a64011928ded380dbdade935
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.7+1"
|
||||
version: "1.0.12"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -517,6 +522,11 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
fuchsia_remote_debug_protocol:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -581,6 +591,11 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.8.0"
|
||||
integration_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
intl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1005,6 +1020,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.6.0"
|
||||
process:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: process
|
||||
sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.6"
|
||||
protobuf:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1274,6 +1297,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
sync_http:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sync_http
|
||||
sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.1"
|
||||
synchronized:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1515,6 +1546,14 @@ packages:
|
||||
url: "https://github.com/google/webcrypto.dart.git"
|
||||
source: git
|
||||
version: "0.6.0"
|
||||
webdriver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webdriver
|
||||
sha256: "28b82ec894fed45dd71c23ba62d1af973ed97dd59a4f5790a4d38b0b13e5657e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.0"
|
||||
webrtc_interface:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
+5
-3
@@ -1,7 +1,7 @@
|
||||
name: pyramid
|
||||
description: A flexible Matrix client with resizable panel layout.
|
||||
publish_to: none
|
||||
version: 0.1.36+37
|
||||
version: 0.1.38+39
|
||||
environment:
|
||||
sdk: ">=3.11.1 <4.0.0"
|
||||
|
||||
@@ -26,7 +26,6 @@ dependencies:
|
||||
livekit_client: ^2.4.0
|
||||
flutter_webrtc: ^1.4.1
|
||||
webrtc_interface: ^1.5.1
|
||||
dart_jsonwebtoken: ^3.3.1
|
||||
|
||||
# State management & routing
|
||||
flutter_riverpod: ^2.6.1
|
||||
@@ -68,7 +67,7 @@ dependencies:
|
||||
# Document viewer
|
||||
pdfx: ^2.7.0
|
||||
archive: ^4.0.0
|
||||
flutter_markdown: ^0.7.7
|
||||
flutter_markdown_plus: ^1.0.3
|
||||
|
||||
# Clipboard image paste (Windows FFI) & image decoding
|
||||
ffi: ^2.1.0
|
||||
@@ -82,6 +81,9 @@ dependencies:
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
# UI-Durchläufe mit der echten App (integration_test/), Teil des Flutter-SDKs.
|
||||
integration_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
flutter_launcher_icons: ^0.13.1
|
||||
# Nur für Tests: offizieller Weg, path_provider ohne echte Plattform zu
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
#Requires -Version 7.0
|
||||
# Pyramid – Tests & Benchmarks mit einem Befehl
|
||||
#
|
||||
# pwsh scripts/test.ps1 Analyse + Unit-Tests (Standard, ~2 min)
|
||||
# pwsh scripts/test.ps1 -Live + Live-Test gegen steggi-matrix.work
|
||||
# (Test-Accounts pyramidtest1/2, NICHT Bernd/Uta)
|
||||
# pwsh scripts/test.ps1 -Bench + Benchmarks (Datenbank/Verschlüsselung, Migration)
|
||||
# pwsh scripts/test.ps1 -Ui + UI-Durchlauf im Handy-Format mit Screenshots
|
||||
# (echte App, Testprofil pyramidtest1 → build/ui_screens/)
|
||||
# pwsh scripts/test.ps1 -All alles zusammen
|
||||
# pwsh scripts/test.ps1 -Bench -UpdateBaseline
|
||||
# Benchmark-Werte als neue Baseline speichern
|
||||
# (nach bewusster Änderung / auf neuer Hardware)
|
||||
# -SkipAnalyze flutter analyze überspringen
|
||||
#
|
||||
# Details: docs/TESTS_UND_BENCHMARKS.md
|
||||
|
||||
param(
|
||||
[switch]$Live,
|
||||
[switch]$Bench,
|
||||
[switch]$Ui,
|
||||
[switch]$All,
|
||||
[switch]$UpdateBaseline,
|
||||
[switch]$SkipAnalyze
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Immer aus dem Projektordner arbeiten, egal von wo das Skript aufgerufen wird.
|
||||
Set-Location (Split-Path $PSScriptRoot -Parent)
|
||||
|
||||
if ($All) { $Live = $true; $Bench = $true; $Ui = $true }
|
||||
|
||||
$results = [System.Collections.Generic.List[object]]::new()
|
||||
|
||||
function Invoke-Step([string]$Name, [scriptblock]$Action) {
|
||||
Write-Host ""
|
||||
Write-Host "━━ $Name" -ForegroundColor Cyan
|
||||
$watch = [Diagnostics.Stopwatch]::StartNew()
|
||||
$global:LASTEXITCODE = 0
|
||||
& $Action
|
||||
$ok = ($LASTEXITCODE -eq 0)
|
||||
$results.Add([pscustomobject]@{
|
||||
Schritt = $Name
|
||||
Ergebnis = if ($ok) { "OK" } else { "FEHLER" }
|
||||
Dauer = "{0:n0} s" -f $watch.Elapsed.TotalSeconds
|
||||
})
|
||||
if (-not $ok) { Write-Host "✗ $Name fehlgeschlagen (Exit-Code $LASTEXITCODE)" -ForegroundColor Red }
|
||||
}
|
||||
|
||||
function Add-Skipped([string]$Name, [string]$Reason) {
|
||||
Write-Host ""
|
||||
Write-Host "━━ $Name – übersprungen: $Reason" -ForegroundColor Yellow
|
||||
$results.Add([pscustomobject]@{ Schritt = $Name; Ergebnis = "ÜBERSPRUNGEN"; Dauer = "–" })
|
||||
}
|
||||
|
||||
# ── SQLCipher-Bibliothek ─────────────────────────────────────────────────────
|
||||
# Die Datenbank-Tests und Benchmarks benutzen dieselbe sqlite3.dll (SQLCipher)
|
||||
# wie die App – sie entsteht beim Windows-Build. Fehlt sie, einmal bauen.
|
||||
$cipherDll = Get-ChildItem "build/windows/x64/runner/*/sqlite3.dll" -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 1
|
||||
if (-not $cipherDll) {
|
||||
Invoke-Step "Windows-Build (einmalig, liefert die SQLCipher-DLL)" {
|
||||
flutter build windows --debug
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $SkipAnalyze) {
|
||||
# Infos (z. B. der bekannte onUpdate-Hinweis) werden angezeigt, lassen den
|
||||
# Lauf aber nicht scheitern – Warnungen und Fehler schon.
|
||||
Invoke-Step "Statische Analyse (flutter analyze)" { flutter analyze --no-fatal-infos }
|
||||
}
|
||||
|
||||
Invoke-Step "Unit-Tests (test/)" { flutter test }
|
||||
|
||||
if ($Live) {
|
||||
$accounts = if ($env:PYRAMID_TEST_ACCOUNTS) { $env:PYRAMID_TEST_ACCOUNTS }
|
||||
else { Join-Path $env:USERPROFILE ".pyramid-autopilot\test-accounts.txt" }
|
||||
if (-not (Test-Path $accounts)) {
|
||||
Add-Skipped "Live-Test (echter Homeserver)" "Zugangsdatei fehlt: $accounts"
|
||||
} else {
|
||||
$env:PYRAMID_LIVE_TEST = "1"
|
||||
try {
|
||||
Invoke-Step "Live-Test: SQLCipher mit echtem Login (pyramidtest1)" {
|
||||
flutter test test/live_encryption_e2e_test.dart --concurrency=1
|
||||
}
|
||||
Invoke-Step "Live-Test: Logout + sofortiger Re-Login (SDK-Wettlauf)" {
|
||||
flutter test test/live_logout_race_test.dart --concurrency=1
|
||||
}
|
||||
} finally {
|
||||
Remove-Item Env:PYRAMID_LIVE_TEST -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($Bench) {
|
||||
if ($UpdateBaseline) { $env:PYRAMID_BENCH_UPDATE_BASELINE = "1" }
|
||||
try {
|
||||
# concurrency=1: Benchmarks dürfen sich nicht gegenseitig die CPU klauen.
|
||||
Invoke-Step "Benchmarks (benchmark/)" { flutter test benchmark --concurrency=1 }
|
||||
} finally {
|
||||
Remove-Item Env:PYRAMID_BENCH_UPDATE_BASELINE -ErrorAction SilentlyContinue
|
||||
}
|
||||
Write-Host "Benchmark-Berichte: build/benchmarks/*.json (Baseline: benchmark/baseline/)"
|
||||
}
|
||||
|
||||
if ($Ui) {
|
||||
$uiProfile = Join-Path $env:USERPROFILE ".pyramid-autopilot\profile1"
|
||||
if (-not (Test-Path (Join-Path $uiProfile "pyramid.sqlite"))) {
|
||||
Add-Skipped "UI-Durchlauf (Handy-Format)" "Testprofil fehlt: $uiProfile"
|
||||
} elseif (Get-Process pyramid -ErrorAction SilentlyContinue) {
|
||||
# Nie zwei Matrix-Clients auf derselben DB – und Bernds Fenster nicht stören.
|
||||
Add-Skipped "UI-Durchlauf (Handy-Format)" "Pyramid läuft gerade – erst beenden"
|
||||
} else {
|
||||
$env:PYRAMID_PROFILE_DIR = $uiProfile
|
||||
try {
|
||||
Invoke-Step "UI-Durchlauf (Handy-Format, Testprofil)" {
|
||||
flutter test integration_test/mobile_ui_screens_test.dart -d windows
|
||||
}
|
||||
} finally {
|
||||
Remove-Item Env:PYRAMID_PROFILE_DIR -ErrorAction SilentlyContinue
|
||||
}
|
||||
Write-Host "Screenshots: build/ui_screens/ (Testinhalte ggf. mit test/live_ui_fixture_test.dart anlegen)"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "━━ Zusammenfassung" -ForegroundColor Cyan
|
||||
$results | Format-Table -AutoSize | Out-String | Write-Host
|
||||
|
||||
$failed = @($results | Where-Object { $_.Ergebnis -eq "FEHLER" })
|
||||
if ($failed.Count -gt 0) {
|
||||
Write-Host "✗ $($failed.Count) Schritt(e) fehlgeschlagen." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
Write-Host "✓ Alles grün." -ForegroundColor Green
|
||||
exit 0
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pyramid/utils/date_labels.dart';
|
||||
|
||||
void main() {
|
||||
// Mittwoch, 7. Oktober 2026, 15:00
|
||||
final now = DateTime(2026, 10, 7, 15);
|
||||
|
||||
test('heute: Uhrzeit', () {
|
||||
expect(chatListTime(DateTime(2026, 10, 7, 9, 5), now: now), '09:05');
|
||||
});
|
||||
|
||||
test('gestern: „Gestern“ (auch kurz nach Mitternacht)', () {
|
||||
expect(chatListTime(DateTime(2026, 10, 6, 23, 59), now: now), 'Gestern');
|
||||
expect(
|
||||
chatListTime(DateTime(2026, 10, 6, 22), now: DateTime(2026, 10, 7, 0, 5)),
|
||||
'Gestern',
|
||||
);
|
||||
});
|
||||
|
||||
test('diese Woche: Wochentag', () {
|
||||
expect(chatListTime(DateTime(2026, 10, 5, 12), now: now), 'Mo');
|
||||
expect(chatListTime(DateTime(2026, 10, 1, 12), now: now), 'Do');
|
||||
});
|
||||
|
||||
test('älter: Datum', () {
|
||||
expect(chatListTime(DateTime(2026, 9, 30, 12), now: now), '30.09.26');
|
||||
expect(chatListTime(DateTime(2025, 12, 24, 18), now: now), '24.12.25');
|
||||
});
|
||||
|
||||
test('Zeitumstellung (25.10.2026): gestern bleibt gestern', () {
|
||||
expect(
|
||||
chatListTime(DateTime(2026, 10, 24, 20), now: DateTime(2026, 10, 25, 10)),
|
||||
'Gestern',
|
||||
);
|
||||
expect(
|
||||
chatListTime(DateTime(2026, 3, 28, 20), now: DateTime(2026, 3, 29, 10)),
|
||||
'Gestern',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pyramid/core/theme.dart';
|
||||
import 'package:pyramid/features/chat/document_viewer.dart';
|
||||
|
||||
/// Markdown-Vorschau (seit 2026-10-07 mit flutter_markdown_plus statt dem
|
||||
/// eingestellten flutter_markdown): Überschrift, Liste, Code und Link werden
|
||||
/// gezeichnet.
|
||||
void main() {
|
||||
testWidgets('Markdown-Datei wird in der Vorschau dargestellt', (
|
||||
tester,
|
||||
) async {
|
||||
const md = '# Einkaufsliste\n\n'
|
||||
'- **Milch**\n- _Brot_\n\n'
|
||||
'`code` und [ein Link](https://example.org)\n\n'
|
||||
'> Zitat\n';
|
||||
await tester.pumpWidget(
|
||||
PyramidTheme(
|
||||
child: MaterialApp(
|
||||
home: DocumentViewer(
|
||||
filename: 'notizen.md',
|
||||
bytes: Uint8List.fromList(utf8.encode(md)),
|
||||
senderName: 'Test',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
// Auswählbarer Text (SelectableText → EditableText) oder RichText.
|
||||
bool shows(String text) =>
|
||||
find.textContaining(text, findRichText: true).evaluate().isNotEmpty;
|
||||
expect(shows('Einkaufsliste'), isTrue);
|
||||
expect(shows('Milch'), isTrue);
|
||||
expect(shows('ein Link'), isTrue);
|
||||
expect(shows('Zitat'), isTrue);
|
||||
});
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import 'package:matrix/matrix.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import 'package:sqlite3/open.dart' as sqlite_open;
|
||||
import 'package:pyramid/core/app_database.dart';
|
||||
import 'package:pyramid/core/session_logout.dart';
|
||||
|
||||
/// LIVE-End-to-End-Test der SQLCipher-Verschlüsselung mit einem ECHTEN
|
||||
/// Matrix-Login gegen steggi-matrix.work (Test-Account, NICHT Bernd/Uta).
|
||||
@@ -340,7 +341,7 @@ void main() {
|
||||
expect(client.userID?.toLowerCase(), acc.user1.toLowerCase());
|
||||
|
||||
// ── Phase 4: Logout → Re-Login → alte Nachricht wieder lesbar ──
|
||||
await client.logout();
|
||||
await logoutWithoutStraySync(client); // App-Baustein (session_logout.dart)
|
||||
expect(client.isLogged(), isFalse);
|
||||
await _login(client, acc,
|
||||
user: acc.user1, pass: acc.pass1, device: 'Autopilot Enc-Test 2');
|
||||
@@ -348,7 +349,7 @@ void main() {
|
||||
expect(await _timelineContains(roomAfter, marker), isTrue,
|
||||
reason: 'Alte Nachricht nach Logout+Re-Login nicht mehr lesbar');
|
||||
// Aufräumen: Session serverseitig beenden, DB wird dabei geleert.
|
||||
await client.logout();
|
||||
await logoutWithoutStraySync(client); // App-Baustein (session_logout.dart)
|
||||
await client.dispose(closeDatabase: true);
|
||||
},
|
||||
skip: skip,
|
||||
@@ -414,7 +415,7 @@ void main() {
|
||||
expect(roomAfter, isNotNull,
|
||||
reason: 'Testraum nach Migration nicht mehr in der DB');
|
||||
// Aufräumen (Session serverseitig beenden).
|
||||
await client.logout();
|
||||
await logoutWithoutStraySync(client); // App-Baustein (session_logout.dart)
|
||||
await client.dispose(closeDatabase: true);
|
||||
},
|
||||
skip: skip,
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
@TestOn('windows')
|
||||
library;
|
||||
|
||||
import 'dart:ffi';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:pyramid/core/session_logout.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import 'package:sqlite3/open.dart' as sqlite_open;
|
||||
|
||||
/// LIVE-Test des SDK-Logout-Wettlaufs (ROADMAP M1, gefunden 2026-10-07) mit
|
||||
/// dem Test-Account pyramidtest1 – gegated wie die anderen Live-Tests
|
||||
/// (`PYRAMID_LIVE_TEST=1`), läuft nie im normalen `flutter test`.
|
||||
///
|
||||
/// Szenario wie in der App: Abmelden und SOFORT auf demselben Client-Objekt
|
||||
/// wieder anmelden, dann 45 s beobachten. Kommt die 401 des alten Long-Polls
|
||||
/// verspätet zurück, löscht das SDK die NEUE Session (`clear()`).
|
||||
///
|
||||
/// * „Nachweis“ (nur mit zusätzlich `PYRAMID_LIVE_RACE_PROOF=1`): der alte
|
||||
/// Weg (direktes `client.logout()`) – zeigt, ob der Wettlauf auftritt.
|
||||
/// * Regressionstest: `logoutWithoutStraySync` – die neue Session MUSS
|
||||
/// eingeloggt bleiben.
|
||||
|
||||
Map<String, String>? _loadAccounts() {
|
||||
final override = Platform.environment['PYRAMID_TEST_ACCOUNTS'];
|
||||
final profile = Platform.environment['USERPROFILE'] ?? '';
|
||||
final file =
|
||||
File(override ?? '$profile\\.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;
|
||||
}
|
||||
|
||||
String? _findSqliteDll() {
|
||||
for (final config in ['Debug', 'Profile', 'Release']) {
|
||||
final file = File('build/windows/x64/runner/$config/sqlite3.dll');
|
||||
if (file.existsSync()) return file.absolute.path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<Client> _newClient(String name) async {
|
||||
final dll = _findSqliteDll()!;
|
||||
final factory = createDatabaseFactoryFfi(
|
||||
ffiInit: () {
|
||||
sqlite_open.open.overrideFor(
|
||||
sqlite_open.OperatingSystem.windows,
|
||||
() => DynamicLibrary.open(dll),
|
||||
);
|
||||
},
|
||||
noIsolate: true,
|
||||
);
|
||||
final db = await factory.openDatabase(inMemoryDatabasePath);
|
||||
final client =
|
||||
Client(name, database: await MatrixSdkDatabase.init(name, database: db));
|
||||
await client.init(
|
||||
waitForFirstSync: false, waitUntilLoadCompletedLoaded: false);
|
||||
return client;
|
||||
}
|
||||
|
||||
Future<void> _login(Client client, Map<String, String> acc, String device) async {
|
||||
await client.checkHomeserver(Uri.parse(acc['homeserver']!));
|
||||
await client.login(
|
||||
LoginType.mLoginPassword,
|
||||
identifier: AuthenticationUserIdentifier(user: acc['user1']!),
|
||||
password: acc['pass1']!,
|
||||
initialDeviceDisplayName: device,
|
||||
refreshToken: true,
|
||||
);
|
||||
}
|
||||
|
||||
/// Abmelden → sofort neu anmelden → [watch] lang beobachten. Liefert, ob die
|
||||
/// NEUE Session dabei verloren ging.
|
||||
Future<bool> _reloginLosesSession(
|
||||
Map<String, String> acc,
|
||||
Future<void> Function(Client) logout,
|
||||
String label, {
|
||||
Duration watch = const Duration(seconds: 45),
|
||||
bool logoutRightAfterLogin = false,
|
||||
}) async {
|
||||
final client = await _newClient('PyramidRace$label');
|
||||
try {
|
||||
await _login(client, acc, 'Autopilot Race $label 1');
|
||||
if (!logoutRightAfterLogin) {
|
||||
// Warten, bis die Sync-Schleife im Long-Poll hängt (erster Sync hat
|
||||
// timeout 0, danach 30-s-Long-Polls).
|
||||
await client.oneShotSync().timeout(const Duration(seconds: 60));
|
||||
await Future.delayed(const Duration(seconds: 3));
|
||||
}
|
||||
// Sonst: sofort abmelden, während die Schleife nach dem ersten (schnellen)
|
||||
// Sync gerade neue Anfragen mit dem alten Token losschickt.
|
||||
|
||||
await logout(client);
|
||||
expect(client.isLogged(), isFalse);
|
||||
|
||||
await _login(client, acc, 'Autopilot Race $label 2');
|
||||
final newToken = client.accessToken;
|
||||
var lost = false;
|
||||
final sub = client.onLoginStateChanged.stream.listen((s) {
|
||||
if (s == LoginState.loggedOut) lost = true;
|
||||
});
|
||||
final end = DateTime.now().add(watch);
|
||||
while (DateTime.now().isBefore(end) && !lost) {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
}
|
||||
await sub.cancel();
|
||||
lost = lost || !client.isLogged() || client.accessToken != newToken;
|
||||
// ignore: avoid_print
|
||||
print('[$label] neue Session nach ${watch.inSeconds}s '
|
||||
'${lost ? 'VERLOREN' : 'noch eingeloggt'}');
|
||||
return lost;
|
||||
} finally {
|
||||
if (client.isLogged()) {
|
||||
try {
|
||||
await logoutWithoutStraySync(client);
|
||||
} catch (_) {}
|
||||
}
|
||||
await client.dispose(closeDatabase: true);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
final acc = _loadAccounts();
|
||||
final skip = Platform.environment['PYRAMID_LIVE_TEST'] != '1'
|
||||
? 'Live-Test nur mit PYRAMID_LIVE_TEST=1 (echter Homeserver!)'
|
||||
: acc == null
|
||||
? 'Test-Account-Datei fehlt'
|
||||
: _findSqliteDll() == null
|
||||
? 'SQLite-DLL fehlt (erst flutter build windows)'
|
||||
: null;
|
||||
|
||||
for (final immediately in [false, true]) {
|
||||
final when = immediately ? 'direkt nach dem Login' : 'nach Leerlauf';
|
||||
final tag = immediately ? 'sofort' : 'leerlauf';
|
||||
|
||||
test(
|
||||
'Nachweis ($when): direktes logout() + sofortiger Re-Login (alter Weg)',
|
||||
() async {
|
||||
final lost = await _reloginLosesSession(
|
||||
acc!,
|
||||
(c) => c.logout(),
|
||||
'alt-$tag',
|
||||
logoutRightAfterLogin: immediately,
|
||||
);
|
||||
// Kein expect: dokumentiert nur, ob der Wettlauf auftritt.
|
||||
// ignore: avoid_print
|
||||
print('Wettlauf ($when) mit altem Weg: ${lost ? 'JA' : 'nein'}');
|
||||
},
|
||||
skip: skip ??
|
||||
(Platform.environment['PYRAMID_LIVE_RACE_PROOF'] != '1'
|
||||
? 'Nachweis nur mit PYRAMID_LIVE_RACE_PROOF=1'
|
||||
: null),
|
||||
timeout: const Timeout(Duration(minutes: 4)),
|
||||
);
|
||||
|
||||
test(
|
||||
'logoutWithoutStraySync ($when): neue Session bleibt nach sofortigem '
|
||||
'Re-Login',
|
||||
() async {
|
||||
final lost = await _reloginLosesSession(
|
||||
acc!,
|
||||
logoutWithoutStraySync,
|
||||
'neu-$tag',
|
||||
logoutRightAfterLogin: immediately,
|
||||
);
|
||||
expect(lost, isFalse,
|
||||
reason: 'verspätete 401 einer alten Sync-Anfrage hat die neue '
|
||||
'Session gelöscht');
|
||||
},
|
||||
skip: skip,
|
||||
timeout: const Timeout(Duration(minutes: 4)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
@TestOn('windows')
|
||||
library;
|
||||
|
||||
import 'dart:ffi';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import 'package:sqlite3/open.dart' as sqlite_open;
|
||||
|
||||
/// Setup-Helfer für UI-Prüfungen (gegated wie die anderen Live-Tests, läuft
|
||||
/// NIE im normalen `flutter test`): pyramidtest2 schickt pyramidtest1 eine
|
||||
/// Direktnachricht mit typischem Chat-Inhalt – langer Text,
|
||||
/// Antwort, Hochkant- und Querformat-Bild. Danach zeigt die GUI-Instanz mit
|
||||
/// dem Testprofil (PYRAMID_PROFILE_DIR=…\profile1) echte Inhalte, an denen
|
||||
/// sich Layout, Bildgrößen und Vollansicht prüfen lassen.
|
||||
///
|
||||
/// $env:PYRAMID_LIVE_TEST=1; flutter test test/live_ui_fixture_test.dart
|
||||
///
|
||||
/// Der Raum ist unverschlüsselt (der Host-Test hat kein vodozemac). Nur die
|
||||
/// Skript-Geräte melden sich am Ende ab, GUI-Sessions bleiben unberührt.
|
||||
|
||||
Map<String, String>? _loadAccounts() {
|
||||
final override = Platform.environment['PYRAMID_TEST_ACCOUNTS'];
|
||||
final profile = Platform.environment['USERPROFILE'] ?? '';
|
||||
final file = File(
|
||||
override ?? '$profile\\.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;
|
||||
}
|
||||
|
||||
String? _findSqliteDll() {
|
||||
for (final config in ['Debug', 'Profile', 'Release']) {
|
||||
final file = File('build/windows/x64/runner/$config/sqlite3.dll');
|
||||
if (file.existsSync()) return file.absolute.path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<Client> _login(String name, String user, String pass, Uri hs) async {
|
||||
final dll = _findSqliteDll()!;
|
||||
final factory = createDatabaseFactoryFfi(
|
||||
ffiInit: () {
|
||||
sqlite_open.open.overrideFor(
|
||||
sqlite_open.OperatingSystem.windows,
|
||||
() => DynamicLibrary.open(dll),
|
||||
);
|
||||
},
|
||||
noIsolate: true,
|
||||
);
|
||||
final db = await factory.openDatabase(inMemoryDatabasePath);
|
||||
final client = Client(
|
||||
name,
|
||||
database: await MatrixSdkDatabase.init(name, database: db),
|
||||
);
|
||||
await client.init(
|
||||
waitForFirstSync: false,
|
||||
waitUntilLoadCompletedLoaded: false,
|
||||
);
|
||||
await client.checkHomeserver(hs);
|
||||
await client.login(
|
||||
LoginType.mLoginPassword,
|
||||
identifier: AuthenticationUserIdentifier(user: user),
|
||||
password: pass,
|
||||
initialDeviceDisplayName: name,
|
||||
);
|
||||
return client;
|
||||
}
|
||||
|
||||
/// Logout ohne verspätete 401 des laufenden Long-Polls (siehe
|
||||
/// live_encryption_e2e_test.dart, `_logoutWithoutStraySync`).
|
||||
Future<void> _logout(Client c) async {
|
||||
try {
|
||||
c.backgroundSync = false;
|
||||
await c
|
||||
.oneShotSync(timeout: Duration.zero)
|
||||
.timeout(const Duration(seconds: 60));
|
||||
await c.logout();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// Einfaches, gut erkennbares Testbild (Farbfläche + Kreis + Rahmen).
|
||||
Uint8List _testImage(int w, int h, img.Color bg) {
|
||||
final image = img.Image(width: w, height: h);
|
||||
img.fill(image, color: bg);
|
||||
img.fillCircle(
|
||||
image,
|
||||
x: w ~/ 2,
|
||||
y: h ~/ 2,
|
||||
radius: (w < h ? w : h) ~/ 4,
|
||||
color: img.ColorRgb8(245, 166, 20),
|
||||
);
|
||||
img.drawRect(
|
||||
image,
|
||||
x1: 0,
|
||||
y1: 0,
|
||||
x2: w - 1,
|
||||
y2: h - 1,
|
||||
color: img.ColorRgb8(255, 255, 255),
|
||||
thickness: 6,
|
||||
);
|
||||
return Uint8List.fromList(img.encodeJpg(image, quality: 85));
|
||||
}
|
||||
|
||||
void main() {
|
||||
final acc = _loadAccounts();
|
||||
final skip = Platform.environment['PYRAMID_LIVE_TEST'] != '1'
|
||||
? 'Live-Setup nur mit PYRAMID_LIVE_TEST=1'
|
||||
: acc == null
|
||||
? 'Test-Account-Datei fehlt'
|
||||
: _findSqliteDll() == null
|
||||
? 'SQLite-DLL fehlt (erst flutter build windows)'
|
||||
: null;
|
||||
|
||||
test(
|
||||
'UI-Testinhalte: DM pyramidtest2 → pyramidtest1 mit Text und Bildern',
|
||||
() async {
|
||||
final hs = Uri.parse(acc!['homeserver']!);
|
||||
final c1 = await _login(
|
||||
'AutopilotUiFixture1',
|
||||
acc['user1']!,
|
||||
acc['pass1']!,
|
||||
hs,
|
||||
);
|
||||
final c2 = await _login(
|
||||
'AutopilotUiFixture2',
|
||||
acc['user2']!,
|
||||
acc['pass2']!,
|
||||
hs,
|
||||
);
|
||||
try {
|
||||
await c1.oneShotSync().timeout(const Duration(seconds: 60));
|
||||
await c2.oneShotSync().timeout(const Duration(seconds: 60));
|
||||
|
||||
final roomId = await c2.startDirectChat(
|
||||
c1.userID!,
|
||||
enableEncryption: false,
|
||||
preset: CreateRoomPreset.privateChat,
|
||||
);
|
||||
final room = c2.getRoomById(roomId)!;
|
||||
|
||||
// user1 nimmt die Einladung an (falls noch nicht drin) – über
|
||||
// room.join(), damit sie wie in der App als Direktnachricht zählt.
|
||||
await c1.oneShotSync().timeout(const Duration(seconds: 60));
|
||||
var r1 = c1.getRoomById(roomId);
|
||||
if (r1 == null || r1.membership != Membership.join) {
|
||||
if (r1 != null && r1.membership == Membership.invite) {
|
||||
await r1.join();
|
||||
} else {
|
||||
await c1.joinRoom(roomId);
|
||||
}
|
||||
await c1
|
||||
.waitForRoomInSync(roomId, join: true)
|
||||
.timeout(const Duration(seconds: 60));
|
||||
r1 = c1.getRoomById(roomId);
|
||||
}
|
||||
// „Chats“ in Pyramid zeigt nur Direktnachrichten (m.direct).
|
||||
if (r1 != null && !r1.isDirectChat) {
|
||||
await r1.addToDirectChat(c2.userID!);
|
||||
}
|
||||
|
||||
final first = await room.sendTextEvent(
|
||||
'Hallo! Das ist ein UI-Test mit einer etwas längeren Nachricht, damit '
|
||||
'man sieht, wie Zeilenumbrüche, Schriftgröße und Abstände auf dem '
|
||||
'Handy wirken. Umlaute: äöü ÄÖÜ ß.',
|
||||
);
|
||||
expect(first, isNotNull);
|
||||
await c2.oneShotSync().timeout(const Duration(seconds: 30));
|
||||
final firstEvent = await room.getEventById(first!);
|
||||
await room.sendTextEvent(
|
||||
'Kurze Antwort auf die erste Nachricht',
|
||||
inReplyTo: firstEvent,
|
||||
);
|
||||
|
||||
await room.sendFileEvent(
|
||||
MatrixImageFile(
|
||||
bytes: _testImage(720, 1280, img.ColorRgb8(59, 109, 17)),
|
||||
name: 'hochkant.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
width: 720,
|
||||
height: 1280,
|
||||
),
|
||||
);
|
||||
await room.sendFileEvent(
|
||||
MatrixImageFile(
|
||||
bytes: _testImage(1280, 720, img.ColorRgb8(24, 95, 165)),
|
||||
name: 'querformat.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
),
|
||||
);
|
||||
await room.sendTextEvent('Und noch eine letzte Nachricht 👋');
|
||||
|
||||
// Eigene Nachrichten (aus Sicht von pyramidtest1 = rechts).
|
||||
final myDm = c1.getRoomById(roomId)!;
|
||||
await myDm.sendTextEvent('Danke, sieht gut aus!');
|
||||
await myDm.sendTextEvent(
|
||||
'Und eine etwas längere eigene Antwort, damit man sieht, wie '
|
||||
'Sprechblasen umbrechen, wenn der Text über mehrere Zeilen geht.',
|
||||
);
|
||||
|
||||
// Gruppenraum (Name + Avatar je Absender in der Sprechblasen-Ansicht).
|
||||
const groupName = 'UI-Test Gruppe';
|
||||
Room? group;
|
||||
for (final r in c2.rooms) {
|
||||
if (r.membership == Membership.join && r.name == groupName) {
|
||||
group = r;
|
||||
}
|
||||
}
|
||||
if (group == null) {
|
||||
final groupId = await c2.createRoom(
|
||||
name: groupName,
|
||||
preset: CreateRoomPreset.privateChat,
|
||||
invite: [c1.userID!],
|
||||
);
|
||||
await c2
|
||||
.waitForRoomInSync(groupId, join: true)
|
||||
.timeout(const Duration(seconds: 60));
|
||||
group = c2.getRoomById(groupId)!;
|
||||
await c1.oneShotSync().timeout(const Duration(seconds: 60));
|
||||
await c1.getRoomById(groupId)?.join();
|
||||
await c1
|
||||
.waitForRoomInSync(groupId, join: true)
|
||||
.timeout(const Duration(seconds: 60));
|
||||
}
|
||||
await group.sendTextEvent('Hallo zusammen! Wer ist am Samstag dabei?');
|
||||
await group.sendTextEvent('Ich bringe Kuchen mit.');
|
||||
await c1.getRoomById(group.id)!.sendTextEvent('Ich komme gern!');
|
||||
|
||||
// ignore: avoid_print
|
||||
print('UI-Testraum bereit: $roomId, Gruppe: ${group.id}');
|
||||
} finally {
|
||||
await _logout(c1);
|
||||
await _logout(c2);
|
||||
await c1.dispose(closeDatabase: false);
|
||||
await c2.dispose(closeDatabase: false);
|
||||
}
|
||||
},
|
||||
skip: skip,
|
||||
timeout: const Timeout(Duration(minutes: 5)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:pyramid/core/livekit_token.dart';
|
||||
|
||||
/// Beitritts-Token kommt vom Server; das LiveKit-Secret steckt nicht mehr
|
||||
/// in der App (docs/LIVEKIT_TOKEN_MIGRATION.md).
|
||||
void main() {
|
||||
test('schickt Raum + Matrix-Token und liefert Token und Adresse', () async {
|
||||
late Map<String, dynamic> sent;
|
||||
final client = MockClient((req) async {
|
||||
expect(req.url.toString(), LiveKitTokenService.endpoint);
|
||||
sent = jsonDecode(req.body) as Map<String, dynamic>;
|
||||
return http.Response(
|
||||
jsonEncode({'token': 'abc.def.ghi', 'url': 'wss://lk.example'}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
final r = await LiveKitTokenService.fetch(
|
||||
room: '!raum:server',
|
||||
matrixToken: 'mt',
|
||||
httpClient: client,
|
||||
);
|
||||
expect(sent, {'room': '!raum:server', 'matrix_token': 'mt'});
|
||||
expect(r.token, 'abc.def.ghi');
|
||||
expect(r.url, 'wss://lk.example');
|
||||
});
|
||||
|
||||
test('ohne brauchbare Adresse: Standard-LiveKit', () async {
|
||||
final client = MockClient(
|
||||
(_) async => http.Response(jsonEncode({'token': 't'}), 200),
|
||||
);
|
||||
final r = await LiveKitTokenService.fetch(
|
||||
room: 'r',
|
||||
matrixToken: 'm',
|
||||
httpClient: client,
|
||||
);
|
||||
expect(r.url, LiveKitTokenService.fallbackUrl);
|
||||
});
|
||||
|
||||
Future<String> failureFor(MockClient client) async {
|
||||
try {
|
||||
await LiveKitTokenService.fetch(
|
||||
room: 'r',
|
||||
matrixToken: 'm',
|
||||
httpClient: client,
|
||||
);
|
||||
} on LiveKitTokenException catch (e) {
|
||||
return e.message;
|
||||
}
|
||||
fail('LiveKitTokenException erwartet');
|
||||
}
|
||||
|
||||
test('Fehler werden verständlich gemeldet statt still geschluckt', () async {
|
||||
expect(
|
||||
await failureFor(MockClient((_) async => http.Response('{}', 401))),
|
||||
contains('neu anmelden'),
|
||||
);
|
||||
expect(
|
||||
await failureFor(MockClient((_) async => http.Response('kaputt', 500))),
|
||||
contains('HTTP 500'),
|
||||
);
|
||||
expect(
|
||||
await failureFor(MockClient((_) async => http.Response('{}', 200))),
|
||||
contains('kein Zugangstoken'),
|
||||
);
|
||||
expect(
|
||||
await failureFor(MockClient((_) async => http.Response('<html>', 200))),
|
||||
contains('unlesbare'),
|
||||
);
|
||||
expect(
|
||||
await failureFor(
|
||||
MockClient((_) async => throw const SocketException('offline')),
|
||||
),
|
||||
contains('nicht erreichbar'),
|
||||
);
|
||||
});
|
||||
|
||||
test('App-Code enthält kein LiveKit-Secret und signiert nicht selbst', () {
|
||||
final src = File('lib/core/livekit_token.dart').readAsStringSync();
|
||||
expect(src, isNot(contains('apiSecret')));
|
||||
expect(src, isNot(contains('jsonwebtoken')));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:pyramid/features/search/local_message_search.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
|
||||
import 'support/synthetic_matrix_data.dart';
|
||||
|
||||
/// Vertragstests für die lokale Nachrichtensuche (Suchdialog, Strg+K).
|
||||
/// Laufen auf der echten SDK-Datenbankschicht mit normalem sqlite – die
|
||||
/// Suche selbst ist von der Verschlüsselung unabhängig (die misst
|
||||
/// `benchmark/db_benchmark_test.dart`).
|
||||
void main() {
|
||||
late Client client;
|
||||
|
||||
Future<void> setUpClient({
|
||||
required int rooms,
|
||||
required int eventsPerRoom,
|
||||
int? needleEvery,
|
||||
}) async {
|
||||
final factory = createDatabaseFactoryFfi(
|
||||
ffiInit: sqfliteFfiInit,
|
||||
noIsolate: true,
|
||||
);
|
||||
final db = await factory.openDatabase(inMemoryDatabasePath);
|
||||
client = await SyntheticMatrixData.openClient(
|
||||
db,
|
||||
name: 'PyramidSearchTest',
|
||||
);
|
||||
await SyntheticMatrixData.populate(
|
||||
client,
|
||||
rooms: rooms,
|
||||
eventsPerRoom: eventsPerRoom,
|
||||
needleEvery: needleEvery,
|
||||
);
|
||||
await SyntheticMatrixData.loadRooms(client);
|
||||
}
|
||||
|
||||
tearDown(() async {
|
||||
await client.dispose(closeDatabase: true);
|
||||
});
|
||||
|
||||
test(
|
||||
'findet genau die Nachrichten mit dem Suchwort, neueste zuerst',
|
||||
() async {
|
||||
// 3 Räume × 40 Nachrichten, jede 7. Text-Nachricht trägt das Suchwort.
|
||||
// Jede 10. Nachricht ist ein Bild (ohne Suchwort im Dateinamen).
|
||||
await setUpClient(rooms: 3, eventsPerRoom: 40, needleEvery: 7);
|
||||
final expectedPerRoom = [
|
||||
for (var i = 0; i < 40; i += 7) i,
|
||||
].where((i) => i % 10 != 9).length;
|
||||
|
||||
final results = await localMessageSearch(client, 'nadelwort');
|
||||
|
||||
expect(results, hasLength(3 * expectedPerRoom));
|
||||
expect(results.every((e) => e.body.contains('nadelwort')), isTrue);
|
||||
for (var i = 1; i < results.length; i++) {
|
||||
expect(
|
||||
results[i - 1].originServerTs.isAfter(results[i].originServerTs) ||
|
||||
results[i - 1].originServerTs == results[i].originServerTs,
|
||||
isTrue,
|
||||
reason: 'Ergebnisse müssen absteigend nach Zeit sortiert sein',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('Groß-/Kleinschreibung egal, auch bei Umlauten', () async {
|
||||
await setUpClient(rooms: 1, eventsPerRoom: 30, needleEvery: 3);
|
||||
for (final (lowerQuery, upperQuery) in [
|
||||
('nadelwort', 'NADELWORT'),
|
||||
('übermorgen', 'ÜBERMORGEN'),
|
||||
]) {
|
||||
final lower = await localMessageSearch(client, lowerQuery);
|
||||
final upper = await localMessageSearch(client, upperQuery);
|
||||
expect(lower, isNotEmpty, reason: '"$lowerQuery" kommt in den Daten vor');
|
||||
expect(upper.map((e) => e.eventId), lower.map((e) => e.eventId));
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
'Medien-Filter liefert nur Medien-Nachrichten (Dateiname passt)',
|
||||
() async {
|
||||
await setUpClient(rooms: 2, eventsPerRoom: 30);
|
||||
// "g" steckt in "IMG_…jpg" UND in vielen Textwörtern ("morgen", …).
|
||||
final all = await localMessageSearch(client, 'g');
|
||||
final media = await localMessageSearch(client, 'g', mediaOnly: true);
|
||||
expect(
|
||||
all.any((e) => e.content['msgtype'] == 'm.text'),
|
||||
isTrue,
|
||||
reason: 'ohne Filter müssen auch Text-Treffer kommen',
|
||||
);
|
||||
// Jede 10. Nachricht ist ein Bild: i = 9, 19, 29 → 3 pro Raum.
|
||||
expect(media, hasLength(6));
|
||||
expect(media.every((e) => e.content['msgtype'] == 'm.image'), isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test('Trefferzahl ist auf searchTotalCap gedeckelt', () async {
|
||||
// Jede Nachricht trägt das Suchwort → weit mehr Treffer als der Deckel.
|
||||
await setUpClient(rooms: 2, eventsPerRoom: 80, needleEvery: 1);
|
||||
final results = await localMessageSearch(client, 'nadelwort');
|
||||
expect(results, hasLength(searchTotalCap));
|
||||
});
|
||||
|
||||
test('kein Treffer → leere Liste, kein Fehler', () async {
|
||||
await setUpClient(rooms: 2, eventsPerRoom: 20);
|
||||
expect(await localMessageSearch(client, 'gibtsnicht'), isEmpty);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:pyramid/core/notification_content.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
|
||||
import 'support/synthetic_matrix_data.dart';
|
||||
|
||||
/// Vertragstests für den Inhalt der Push-Benachrichtigungen (Bernds
|
||||
/// Beta-Feedback 2026-10-07: „beim Aufklappen viel weniger Text“, „keine
|
||||
/// Bildvorschau“). Native Darstellung (Verlauf, Bild) prüft das Gerät.
|
||||
void main() {
|
||||
late Client client;
|
||||
late Room room;
|
||||
|
||||
setUp(() async {
|
||||
final factory = createDatabaseFactoryFfi(
|
||||
ffiInit: sqfliteFfiInit,
|
||||
noIsolate: true,
|
||||
);
|
||||
final db = await factory.openDatabase(inMemoryDatabasePath);
|
||||
client = await SyntheticMatrixData.openClient(db, name: 'PyramidNotifTest');
|
||||
room = Room(id: '!raum:bench.invalid', client: client);
|
||||
room.setState(
|
||||
Event(
|
||||
type: EventTypes.RoomName,
|
||||
content: {'name': 'Familie'},
|
||||
eventId: r'$name',
|
||||
senderId: '@uta:bench.invalid',
|
||||
stateKey: '',
|
||||
originServerTs: DateTime(2026),
|
||||
room: room,
|
||||
),
|
||||
);
|
||||
room.setState(
|
||||
Event(
|
||||
type: EventTypes.RoomMember,
|
||||
content: {'membership': 'join', 'displayname': 'Uta'},
|
||||
eventId: r'$member',
|
||||
senderId: '@uta:bench.invalid',
|
||||
stateKey: '@uta:bench.invalid',
|
||||
originServerTs: DateTime(2026),
|
||||
room: room,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await client.dispose(closeDatabase: true);
|
||||
});
|
||||
|
||||
Event message(
|
||||
Map<String, Object?> content, {
|
||||
String type = EventTypes.Message,
|
||||
String sender = '@uta:bench.invalid',
|
||||
}) => Event(
|
||||
type: type,
|
||||
content: content,
|
||||
eventId: r'$ev1',
|
||||
senderId: sender,
|
||||
originServerTs: DateTime(2026, 10, 7, 12, 30),
|
||||
room: room,
|
||||
);
|
||||
|
||||
test('Text wird NICHT mehr auf 100 Zeichen gekürzt', () {
|
||||
final text = 'Wort ' * 60; // 300 Zeichen
|
||||
final body = notificationBody(message({'msgtype': 'm.text', 'body': text}));
|
||||
expect(body, text.trim());
|
||||
});
|
||||
|
||||
test('sehr lange Texte werden bei 1000 Zeichen gekappt', () {
|
||||
final body = notificationBody(
|
||||
message({'msgtype': 'm.text', 'body': 'x' * 5000}),
|
||||
)!;
|
||||
expect(body.length, notificationBodyMaxChars + 1);
|
||||
expect(body.endsWith('…'), isTrue);
|
||||
});
|
||||
|
||||
test('Antwort-Zitat (Reply-Fallback) wird entfernt', () {
|
||||
final body = notificationBody(
|
||||
message({
|
||||
'msgtype': 'm.text',
|
||||
'body': '> <@bernd:bench.invalid> Kommst du heute?\n\nJa, um acht!',
|
||||
}),
|
||||
);
|
||||
expect(body, 'Ja, um acht!');
|
||||
});
|
||||
|
||||
test('Medien bekommen verständliche Bezeichnungen', () {
|
||||
expect(
|
||||
notificationBody(message({'msgtype': 'm.image', 'body': 'IMG_1.jpg'})),
|
||||
'📷 Foto',
|
||||
);
|
||||
expect(
|
||||
notificationBody(
|
||||
message({
|
||||
'msgtype': 'm.image',
|
||||
'body': 'Unser Garten',
|
||||
'filename': 'IMG_2.jpg',
|
||||
}),
|
||||
),
|
||||
'📷 Unser Garten',
|
||||
reason: 'MSC2530: abweichender body neben filename = Bildunterschrift',
|
||||
);
|
||||
expect(
|
||||
notificationBody(message({'msgtype': 'm.video', 'body': 'v.mp4'})),
|
||||
'🎥 Video',
|
||||
);
|
||||
expect(
|
||||
notificationBody(
|
||||
message({
|
||||
'msgtype': 'm.audio',
|
||||
'body': 'Sprachnachricht.ogg',
|
||||
'org.matrix.msc3245.voice': <String, Object?>{},
|
||||
}),
|
||||
),
|
||||
'🎤 Sprachnachricht',
|
||||
);
|
||||
expect(
|
||||
notificationBody(message({'msgtype': 'm.audio', 'body': 'lied.mp3'})),
|
||||
'🎵 Audio',
|
||||
);
|
||||
expect(
|
||||
notificationBody(
|
||||
message({
|
||||
'msgtype': 'm.file',
|
||||
'body': 'rechnung.pdf',
|
||||
'filename': 'rechnung.pdf',
|
||||
}),
|
||||
),
|
||||
'📎 rechnung.pdf',
|
||||
);
|
||||
expect(
|
||||
notificationBody(message({'body': 'x'}, type: EventTypes.Sticker)),
|
||||
'🏷️ Sticker',
|
||||
);
|
||||
});
|
||||
|
||||
test('nicht entschlüsselte Nachricht → null (Platzhalter bleibt)', () {
|
||||
expect(
|
||||
notificationBody(
|
||||
message({
|
||||
'algorithm': 'm.megolm.v1.aes-sha2',
|
||||
'ciphertext': 'abc',
|
||||
}, type: EventTypes.Encrypted),
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
notificationBody(
|
||||
message({
|
||||
'msgtype': MessageTypes.BadEncrypted,
|
||||
'body': '** Unable to decrypt: …',
|
||||
}),
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('Markdown-Fett am Anfang ist KEIN Entschlüsselungsfehler', () {
|
||||
// Früher galt jeder Text mit "**" am Anfang als nicht entschlüsselt.
|
||||
expect(
|
||||
notificationBody(
|
||||
message({'msgtype': 'm.text', 'body': '**Wichtig** heute'}),
|
||||
),
|
||||
'**Wichtig** heute',
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'Inhalt: Absender-Anzeigename, Raumname, Bild-Flag, Native-Argumente',
|
||||
() {
|
||||
final content = MessageNotificationContent.fromEvent(
|
||||
message({'msgtype': 'm.image', 'body': 'IMG_1.jpg'}),
|
||||
)!;
|
||||
expect(content.senderName, 'Uta');
|
||||
expect(content.roomName, 'Familie');
|
||||
expect(content.isGroup, isTrue);
|
||||
expect(content.isImage, isTrue);
|
||||
expect(content.title, 'Uta · Familie');
|
||||
|
||||
final args = content.toNativeArgs(notifId: 42, imagePath: '/x/img');
|
||||
expect(args['notifId'], 42);
|
||||
expect(args['eventId'], r'$ev1');
|
||||
expect(args['senderId'], '@uta:bench.invalid');
|
||||
expect(args['imagePath'], '/x/img');
|
||||
expect(
|
||||
args['timestamp'],
|
||||
DateTime(2026, 10, 7, 12, 30).millisecondsSinceEpoch,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('unbekannter Absender → Localpart statt leerem Namen', () {
|
||||
final content = MessageNotificationContent.fromEvent(
|
||||
message({
|
||||
'msgtype': 'm.text',
|
||||
'body': 'Hi',
|
||||
}, sender: '@jochen:bench.invalid'),
|
||||
)!;
|
||||
expect(content.senderName, 'jochen');
|
||||
});
|
||||
|
||||
test('Vorschau aus → kein Inhalt, kein Bild', () {
|
||||
final content = MessageNotificationContent.fromEvent(
|
||||
message({'msgtype': 'm.image', 'body': 'IMG_1.jpg'}),
|
||||
showPreview: false,
|
||||
)!;
|
||||
expect(content.body, notificationPlaceholderBody);
|
||||
expect(content.isImage, isFalse);
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pyramid/core/appearance.dart';
|
||||
import 'package:pyramid/core/settings_prefs.dart';
|
||||
import 'package:pyramid/core/theme.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Tests für die Storage-Fassade, die laut M2-Modulschnitt zur Pflicht-
|
||||
@@ -94,22 +97,117 @@ void main() {
|
||||
group('Theme-Prefs', () {
|
||||
test('leerer Store liefert die dokumentierten Defaults', () async {
|
||||
final t = await loadThemePrefs();
|
||||
expect(t.isDark, true);
|
||||
expect(
|
||||
t.variant,
|
||||
ThemeVariant.soft,
|
||||
reason: 'Standard seit 2026-10-07: weiches Dunkel',
|
||||
);
|
||||
expect(t.accentIdx, 0);
|
||||
expect(t.radius, 12.0);
|
||||
expect(t.motion, 1.0);
|
||||
expect(t.textScale, isNull, reason: 'null = automatisch');
|
||||
expect(t.messageStyle, MessageStyle.auto);
|
||||
});
|
||||
|
||||
test('Roundtrip: gespeicherte Werte kommen zurück, Teil-Save lässt '
|
||||
'den Rest unangetastet', () async {
|
||||
await saveThemePrefs(isDark: false, accentIdx: 3, radius: 8, motion: 0.5);
|
||||
await saveThemePrefs(
|
||||
variant: ThemeVariant.light,
|
||||
accentIdx: 3,
|
||||
radius: 8,
|
||||
motion: 0.5,
|
||||
textScale: 1.3,
|
||||
messageStyle: MessageStyle.list,
|
||||
);
|
||||
var t = await loadThemePrefs();
|
||||
expect((t.isDark, t.accentIdx, t.radius, t.motion), (false, 3, 8.0, 0.5));
|
||||
expect(
|
||||
(
|
||||
t.variant,
|
||||
t.accentIdx,
|
||||
t.radius,
|
||||
t.motion,
|
||||
t.textScale,
|
||||
t.messageStyle,
|
||||
),
|
||||
(ThemeVariant.light, 3, 8.0, 0.5, 1.3, MessageStyle.list),
|
||||
);
|
||||
|
||||
// Nur ein Feld ändern – die anderen dürfen nicht zurückfallen.
|
||||
await saveThemePrefs(accentIdx: 1);
|
||||
t = await loadThemePrefs();
|
||||
expect((t.isDark, t.accentIdx, t.radius, t.motion), (false, 1, 8.0, 0.5));
|
||||
expect(
|
||||
(
|
||||
t.variant,
|
||||
t.accentIdx,
|
||||
t.radius,
|
||||
t.motion,
|
||||
t.textScale,
|
||||
t.messageStyle,
|
||||
),
|
||||
(ThemeVariant.light, 1, 8.0, 0.5, 1.3, MessageStyle.list),
|
||||
);
|
||||
|
||||
// Schriftgröße zurück auf automatisch.
|
||||
await saveThemePrefs(resetTextScale: true);
|
||||
t = await loadThemePrefs();
|
||||
expect(t.textScale, isNull);
|
||||
});
|
||||
|
||||
test(
|
||||
'Übernahme alter Einstellungen (vor 2026-10-07 nur theme_dark)',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({'theme_dark': true});
|
||||
expect(
|
||||
(await loadThemePrefs()).variant,
|
||||
ThemeVariant.soft,
|
||||
reason: 'wer dunkel hatte, bekommt das neue weiche Dunkel',
|
||||
);
|
||||
|
||||
SharedPreferences.setMockInitialValues({'theme_dark': false});
|
||||
expect((await loadThemePrefs()).variant, ThemeVariant.light);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'Variante „Schwarz“ wird gespeichert und theme_dark mitgepflegt',
|
||||
() async {
|
||||
await saveThemePrefs(variant: ThemeVariant.black);
|
||||
final p = await SharedPreferences.getInstance();
|
||||
expect(p.getString('theme_variant'), 'black');
|
||||
expect(
|
||||
p.getBool('theme_dark'),
|
||||
true,
|
||||
reason: 'ältere App-Versionen lesen nur theme_dark',
|
||||
);
|
||||
expect((await loadThemePrefs()).variant, ThemeVariant.black);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('Darstellung', () {
|
||||
test('Sprechblasen: Automatisch = Handy ja, PC nein', () {
|
||||
expect(MessageStyle.auto.useBubbles(mobile: true), isTrue);
|
||||
expect(MessageStyle.auto.useBubbles(mobile: false), isFalse);
|
||||
expect(MessageStyle.bubbles.useBubbles(mobile: false), isTrue);
|
||||
expect(MessageStyle.list.useBubbles(mobile: true), isFalse);
|
||||
});
|
||||
|
||||
test('Schriftfaktor wirkt zusätzlich zur Systemeinstellung', () {
|
||||
const system = TextScaler.linear(1.2);
|
||||
const scaler = PyramidTextScaler(system, 1.15);
|
||||
expect(scaler.scale(14), closeTo(14 * 1.15 * 1.2, 0.001));
|
||||
expect(defaultTextScale(mobile: true), greaterThan(1.0));
|
||||
expect(defaultTextScale(mobile: false), 1.0);
|
||||
});
|
||||
|
||||
test('Handy-Layout-Grenze', () {
|
||||
expect(isMobileLayout(const Size(411, 891)), isTrue);
|
||||
expect(isMobileLayout(const Size(1280, 720)), isFalse);
|
||||
expect(
|
||||
isMobileLayout(const Size(891, 411)),
|
||||
isTrue,
|
||||
reason: 'Handy quer (flach) bleibt Handy-Layout',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pyramid/core/theme.dart';
|
||||
import 'package:pyramid/layout/mobile_page_stack.dart';
|
||||
import 'package:pyramid/widgets/spring_press.dart';
|
||||
|
||||
/// Handy-Übergang Chatliste ↔ Chat und die federnden Einblendungen.
|
||||
void main() {
|
||||
Widget host({required Widget? front, double motion = 1}) {
|
||||
return PyramidTheme(
|
||||
motion: motion,
|
||||
child: Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: MobilePageStack(
|
||||
base: const _Counter(key: ValueKey('liste')),
|
||||
front: front,
|
||||
background: const Color(0xFF101010),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('Chatliste behält ihren Zustand, Chat gleitet herein und hinaus',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(host(front: null));
|
||||
await tester.tap(find.byKey(const ValueKey('liste')));
|
||||
await tester.pump();
|
||||
expect(find.text('Zähler 1'), findsOneWidget);
|
||||
|
||||
// Chat öffnen: gleitet von rechts herein.
|
||||
await tester.pumpWidget(host(front: const Text('Chat')));
|
||||
await tester.pump(const Duration(milliseconds: 40));
|
||||
final midX = tester.getTopLeft(find.text('Chat')).dx;
|
||||
expect(midX, greaterThan(0), reason: 'Chat sollte noch hereingleiten');
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.getTopLeft(find.text('Chat')).dx, 0);
|
||||
// Verdeckte Liste ist nicht mehr bedienbar, aber noch aufgebaut.
|
||||
expect(find.text('Zähler 1', skipOffstage: false), findsOneWidget);
|
||||
|
||||
// Zurück: Chat gleitet hinaus und wird danach entfernt.
|
||||
await tester.pumpWidget(host(front: null));
|
||||
await tester.pump(const Duration(milliseconds: 40));
|
||||
expect(find.text('Chat'), findsOneWidget, reason: 'noch beim Hinausgleiten');
|
||||
// Schon während des Hinausgleitens ist die Liste wieder bedienbar.
|
||||
await tester.tapAt(const Offset(20, 300));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Zähler 2'), findsOneWidget);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Chat'), findsNothing);
|
||||
// Zustand der Liste ist erhalten geblieben.
|
||||
await tester.tap(find.byKey(const ValueKey('liste')));
|
||||
await tester.pump();
|
||||
expect(find.text('Zähler 3'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Animationen 0 %: sofortiger Wechsel ohne Gleiten', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(host(front: null, motion: 0));
|
||||
await tester.pumpWidget(host(front: const Text('Chat'), motion: 0));
|
||||
await tester.pump();
|
||||
expect(tester.getTopLeft(find.text('Chat')).dx, 0);
|
||||
await tester.pumpWidget(host(front: null, motion: 0));
|
||||
await tester.pump();
|
||||
expect(find.text('Chat'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('PopIn spielt nur, wenn verlangt, und endet unverändert', (
|
||||
tester,
|
||||
) async {
|
||||
Widget pop(bool play) => PyramidTheme(
|
||||
child: Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: Center(
|
||||
child: PopIn(play: play, child: const Text('Neu')),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(pop(true));
|
||||
await tester.pump(const Duration(milliseconds: 30));
|
||||
final opacity = tester.widget<Opacity>(
|
||||
find.ancestor(of: find.text('Neu'), matching: find.byType(Opacity)),
|
||||
);
|
||||
expect(opacity.opacity, lessThan(1));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester
|
||||
.widget<Opacity>(
|
||||
find.ancestor(of: find.text('Neu'), matching: find.byType(Opacity)),
|
||||
)
|
||||
.opacity,
|
||||
1,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await tester.pumpWidget(pop(false));
|
||||
expect(
|
||||
find.ancestor(of: find.text('Neu'), matching: find.byType(Opacity)),
|
||||
findsNothing,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('SpringBump hüpft nur beim Hochzählen', (tester) async {
|
||||
Widget bump(int v) => PyramidTheme(
|
||||
child: Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: Center(child: SpringBump(value: v, child: Text('$v'))),
|
||||
),
|
||||
);
|
||||
double scale() => tester
|
||||
.widget<Transform>(
|
||||
find.ancestor(
|
||||
of: find.byType(Text),
|
||||
matching: find.byType(Transform),
|
||||
),
|
||||
)
|
||||
.transform
|
||||
.storage[0]; // x-Skalierung (z bleibt bei Transform.scale immer 1)
|
||||
|
||||
await tester.pumpWidget(bump(1));
|
||||
expect(scale(), 1);
|
||||
await tester.pumpWidget(bump(2));
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(scale(), lessThan(1), reason: 'Hochzählen soll hüpfen');
|
||||
await tester.pumpAndSettle();
|
||||
expect(scale(), closeTo(1, 0.001));
|
||||
await tester.pumpWidget(bump(0));
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(scale(), closeTo(1, 0.001), reason: 'Herunterzählen hüpft nicht');
|
||||
});
|
||||
}
|
||||
|
||||
class _Counter extends StatefulWidget {
|
||||
const _Counter({super.key});
|
||||
|
||||
@override
|
||||
State<_Counter> createState() => _CounterState();
|
||||
}
|
||||
|
||||
class _CounterState extends State<_Counter> {
|
||||
int _n = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => setState(() => _n++),
|
||||
child: Center(child: Text('Zähler $_n')),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
|
||||
/// Synthetische Matrix-Daten für Tests und `benchmark/` – komplett offline:
|
||||
/// kein Homeserver, keine echten Konten, keine Secrets.
|
||||
///
|
||||
/// Die Daten gehen über die ECHTE SDK-Datenbankschicht (`MatrixSdkDatabase`,
|
||||
/// `storeRoomUpdate`/`storeEventUpdate` in Sync-artigen Transaktionen) – also
|
||||
/// denselben Weg wie beim echten Sync. Dadurch messen/prüfen Tests und
|
||||
/// Benchmarks die Speicherstruktur, die die App wirklich auf der Platte hat.
|
||||
class SyntheticMatrixData {
|
||||
SyntheticMatrixData._();
|
||||
|
||||
static const homeserverDomain = 'bench.invalid';
|
||||
static const _senders = 5;
|
||||
|
||||
/// Wörter für die Nachrichtentexte (bewusst mit Umlauten – die Suche
|
||||
/// arbeitet mit `toLowerCase`).
|
||||
static const _words = [
|
||||
'hallo',
|
||||
'morgen',
|
||||
'treffen',
|
||||
'kaffee',
|
||||
'bahnhof',
|
||||
'urlaub',
|
||||
'foto',
|
||||
'danke',
|
||||
'später',
|
||||
'gleich',
|
||||
'abend',
|
||||
'essen',
|
||||
'kino',
|
||||
'wetter',
|
||||
'regen',
|
||||
'sonne',
|
||||
'arbeit',
|
||||
'pause',
|
||||
'auto',
|
||||
'zug',
|
||||
'buch',
|
||||
'musik',
|
||||
'spiel',
|
||||
'garten',
|
||||
'hund',
|
||||
'katze',
|
||||
'einkaufen',
|
||||
'termin',
|
||||
'grüße',
|
||||
'übermorgen',
|
||||
];
|
||||
|
||||
static String roomId(int r) => '!room$r:$homeserverDomain';
|
||||
static String eventId(int r, int i) => '\$r${r}e$i';
|
||||
static String sender(int i) => '@user${i % _senders}:$homeserverDomain';
|
||||
|
||||
/// Öffnet die SDK-Datenbank auf [db] und baut einen Offline-Client darauf.
|
||||
static Future<Client> openClient(
|
||||
Database db, {
|
||||
String name = 'PyramidBench',
|
||||
}) async {
|
||||
final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db);
|
||||
return Client(name, database: sdkDb);
|
||||
}
|
||||
|
||||
/// Schreibt [rooms] Räume mit je [eventsPerRoom] Nachrichten in die DB.
|
||||
///
|
||||
/// Wie beim echten Sync landen die Events in Transaktionen à [batchSize]
|
||||
/// (≈ eine Sync-Antwort) und in zeitlicher Reihenfolge (älteste zuerst).
|
||||
/// Jede 10. Nachricht ist ein Bild (`m.image`). Ist [needleEvery] gesetzt,
|
||||
/// enthält jede n-te Nachricht zusätzlich das Wort [needle] – so kennen
|
||||
/// Tests die exakte Trefferzahl der Suche.
|
||||
static Future<void> populate(
|
||||
Client client, {
|
||||
required int rooms,
|
||||
required int eventsPerRoom,
|
||||
int batchSize = 50,
|
||||
String needle = 'nadelwort',
|
||||
int? needleEvery,
|
||||
int seed = 42,
|
||||
}) async {
|
||||
final db = client.database;
|
||||
final rnd = Random(seed);
|
||||
final baseTs = DateTime.utc(2026, 1, 1).millisecondsSinceEpoch;
|
||||
|
||||
for (var r = 0; r < rooms; r++) {
|
||||
final id = roomId(r);
|
||||
await db.transaction(() async {
|
||||
await db.storeRoomUpdate(
|
||||
id,
|
||||
JoinedRoomUpdate(
|
||||
summary: RoomSummary.fromJson({
|
||||
'm.joined_member_count': _senders,
|
||||
'm.invited_member_count': 0,
|
||||
}),
|
||||
),
|
||||
null,
|
||||
client,
|
||||
);
|
||||
for (final state in _roomState(r)) {
|
||||
await db.storeEventUpdate(id, state, EventUpdateType.state, client);
|
||||
}
|
||||
});
|
||||
|
||||
for (var start = 0; start < eventsPerRoom; start += batchSize) {
|
||||
final end = min(start + batchSize, eventsPerRoom);
|
||||
await db.transaction(() async {
|
||||
for (var i = start; i < end; i++) {
|
||||
final hasNeedle = needleEvery != null && i % needleEvery == 0;
|
||||
await db.storeEventUpdate(
|
||||
id,
|
||||
_message(r, i, baseTs, rnd, hasNeedle ? needle : null),
|
||||
EventUpdateType.timeline,
|
||||
client,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lädt die Raumliste aus der DB in `client.rooms` – das, was beim
|
||||
/// App-Start `client.init()` macht, ohne Netzwerk/Login.
|
||||
static Future<List<Room>> loadRooms(Client client) async {
|
||||
final rooms = await client.database.getRoomList(client);
|
||||
client.rooms
|
||||
..clear()
|
||||
..addAll(rooms);
|
||||
return rooms;
|
||||
}
|
||||
|
||||
static List<MatrixEvent> _roomState(int r) {
|
||||
final ts = DateTime.utc(2025, 12, 1).millisecondsSinceEpoch;
|
||||
MatrixEvent state(
|
||||
String type,
|
||||
String stateKey,
|
||||
Map<String, Object?> content,
|
||||
int n,
|
||||
) => MatrixEvent(
|
||||
type: type,
|
||||
eventId: '\$state${r}_$n',
|
||||
senderId: sender(0),
|
||||
originServerTs: DateTime.fromMillisecondsSinceEpoch(ts + n),
|
||||
stateKey: stateKey,
|
||||
content: content,
|
||||
roomId: roomId(r),
|
||||
);
|
||||
return [
|
||||
state(EventTypes.RoomCreate, '', {'creator': sender(0)}, 0),
|
||||
state(EventTypes.RoomName, '', {'name': 'Raum $r'}, 1),
|
||||
for (var u = 0; u < _senders; u++)
|
||||
state(EventTypes.RoomMember, sender(u), {
|
||||
'membership': 'join',
|
||||
'displayname': 'Nutzer $u',
|
||||
}, 2 + u),
|
||||
];
|
||||
}
|
||||
|
||||
static MatrixEvent _message(
|
||||
int r,
|
||||
int i,
|
||||
int baseTs,
|
||||
Random rnd,
|
||||
String? needle,
|
||||
) {
|
||||
final isImage = i % 10 == 9;
|
||||
final Map<String, Object?> content;
|
||||
if (isImage) {
|
||||
content = {
|
||||
'msgtype': 'm.image',
|
||||
'body': 'IMG_${r}_$i.jpg',
|
||||
'url': 'mxc://$homeserverDomain/img$r$i',
|
||||
'info': {
|
||||
'w': 1920,
|
||||
'h': 1080,
|
||||
'mimetype': 'image/jpeg',
|
||||
'size': 250000,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
final words = List.generate(
|
||||
4 + rnd.nextInt(20),
|
||||
(_) => _words[rnd.nextInt(_words.length)],
|
||||
);
|
||||
if (needle != null) words.insert(rnd.nextInt(words.length), needle);
|
||||
content = {'msgtype': 'm.text', 'body': words.join(' ')};
|
||||
}
|
||||
return MatrixEvent(
|
||||
type: EventTypes.Message,
|
||||
eventId: eventId(r, i),
|
||||
senderId: sender(i),
|
||||
originServerTs: DateTime.fromMillisecondsSinceEpoch(
|
||||
baseTs + r * 1000 + i * 60000,
|
||||
),
|
||||
content: content,
|
||||
roomId: roomId(r),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user