Compare commits

...
13 Commits
Author SHA1 Message Date
Bernd SteckmeisterandClaude Opus 5.5 775cf71b77 chore: Testversion 0.1.39
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 00:01:46 +02:00
Bernd SteckmeisterandClaude Opus 5.5 441cc6ef54 fix(calls): Bildschirmübertragung ohne Absturz, Lautsprecher, Mikrofon im Hintergrund, zurück in den Anruf
Bernds Anruftest Handy↔PC (2026-10-07):
- Teilen am Handy: App "verkleinerte sich", Anruf danach weg. Ursache:
  Android 14+ verlangt vor MediaProjection einen Vordergrunddienst vom Typ
  mediaProjection; flutter_webrtc startet keinen, der Manifest-Eintrag
  verwies auf eine nicht mehr existierende Klasse. Neu: CallService
  (microphone|mediaProjection) + CallForeground: Zustimmung → Dienst
  (wartet auf echten startForeground) → Mitschnitt. Emulator-Test (Android
  16): mit Fix 1080x2400@30 läuft; Gegenprobe ohne Dienst wirft genau
  'Media projections require a foreground service ...'.
- Dienst läuft während jedes Anrufs: Mikrofon bleibt im Hintergrund aktiv,
  Benachrichtigung "Anruf läuft" führt zurück. Mikrofon-Typ nur mit
  Berechtigung, sonst schmalere Variante statt Dienst-Abbruch.
- Handy hörte nichts: SDK schaltet Sprachanrufe auf die Hörmuschel. Jetzt
  Lautsprecher als Standard (wie Discord) + Umschalter in der Anrufleiste.
- LiveKit-Teilen am Handy öffnete den PC-Quellen-Auswähler → System-Dialog.
- Im offenen Chat (Handy) Leiste "Anruf läuft · Zurück".
- Einstellungen → Sprache & Video: Mikrofontest mit Pegel (PC-Diagnose:
  das Standardgerät lieferte nur Stille – Voicemeeter/virtuelle Geräte).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 00:01:29 +02:00
Bernd SteckmeisterandClaude Opus 5.5 c817de924a fix: Haken live, Einladung im Chat annehmen, Trenner verschwindet zuverlässig
Bernds Test mit der PC-Testinstanz:
- ✓✓ erschien erst nach erneutem Öffnen: eine Lesebestätigung ohne neue
  Nachricht zeichnete nichts neu (Raum-Update kommt im SDK vor der
  Bestätigung). Jetzt nach jedem Sync Fingerabdruck der Lesepositionen
  prüfen und bei Änderung neu zeichnen.
- Einladung: Chat zeigte leeren Verlauf, Annehmen ging nur in der Liste, und
  danach blieb der Chat leer bis zum App-Neustart. Jetzt Annehmen/Ablehnen
  direkt im Chat; Annehmen wartet auf den Beitritts-Sync, der Verlauf baut
  sich bei invite→join selbst neu auf.
- Trenner blieb stehen, wenn nach dem Öffnen/Zurückkommen nicht gescrollt
  wurde. Jetzt: neueste Nachrichten sichtbar (≤ halbe Bildschirmhöhe
  hochgescrollt) → nach 6 s weich ausblenden, auch ohne Scrollen.
- Live-Test mit beiden Testkonten (Gegenprobe: ohne Fix rot) in
  scripts/test.ps1 -Ui; Testlauf-Sperre erkennt nur noch eigene Testläufe.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 23:33:59 +02:00
Bernd SteckmeisterandClaude Opus 5.5 a6461fe23c feat: Trenner bleibt 6 s stehen, Testprofil im Fenstertitel
- "Neue Nachrichten"-Trenner verschwand beim Öffnen aus der Benachrichtigung
  sofort (Bernd). Jetzt: bei den neuesten Nachrichten angekommen noch 6 s
  stehen, dann weich ausblenden; wer hochscrollt, behält ihn.
- Windows: mit PYRAMID_PROFILE_DIR heißt das Fenster "Pyramid - TESTINSTANZ"
  (Testinstanz mit Testkonto nicht mit der echten Pyramid verwechseln).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 23:06:20 +02:00
Bernd SteckmeisterandClaude Opus 5.5 a30d57ba69 docs: PROGRESS/ROADMAP – Runde 4 (UI, Animationen, Lesebestätigung, LiveKit, Markdown)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 22:15:00 +02:00
Bernd SteckmeisterandClaude Opus 5.5 df377d7c4b chore: Testversion 0.1.38
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 22:13:57 +02:00
Bernd SteckmeisterandClaude Opus 5.5 0ad06e3238 chore: flutter_markdown (eingestellt) -> flutter_markdown_plus
Gleiche API, gepflegter Nachfolger. Rendertest für die Markdown-Vorschau
(test/document_markdown_test.dart).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 22:13:27 +02:00
Bernd SteckmeisterandClaude Opus 5.5 756486d1fe security: LiveKit-Token vom Server statt Secret in der App
Die App signierte LiveKit-Tokens selbst – dafür steckte das SFU-Secret im
App-Code (jeder mit der APK konnte sich Tokens für beliebige Räume und
Identitäten ausstellen). Jetzt holt die App das Token von der bereits
vorhandenen Server-Route /api/livekit-token (whoami-Prüfung, Identität =
geprüfte Matrix-ID). Secret und JWT-Signieren sind aus dem Client entfernt.

- Lesbare Fehlermeldungen (offline, 401, Serverfehler) statt stiller Fehler.
- Gruppenanruf aus dem Chat-Kopf übergibt jetzt den Matrix-Client.
- Tests: Unit-Test mit Mock-HTTP + echter LiveKit-Beitritt (Testkonto).
- Offen: Handy-Anruftest; danach Secret-Rotation durch Bernd (Doku).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 22:12:15 +02:00
Bernd SteckmeisterandClaude Opus 5.5 ae6f651f4c feat: federnde Übergänge und Animationen, Entwürfe je Raum
- Handy: Chat gleitet federnd über die Chatliste (Liste bleibt aufgebaut,
  Scrollposition erhalten), beim Zurück wieder hinaus; Liste schon während
  des Hinausgleitens bedienbar. Mitgliederleiste gleitet herein.
- Neue/gesendete Nachrichten ploppen weich auf (nur live, nicht beim Öffnen
  oder Nachladen), Reaktionen ploppen auf und hüpfen beim Hochzählen,
  Ungelesen-Zähler hüpft, Scroll-nach-unten-Knopf federt (Handy größer),
  Antwort-Leiste klappt federnd auf/zu, Senden-Pfeil ploppt, Vollansicht
  zoomt auf. Alles skaliert mit Einstellung „Animationen“ (0 % = aus).
- Fix: Beim Raumwechsel blieben Antwort-Ziel, Auswahl und getippter Text am
  neuen Raum hängen (Antwort auf Raum A konnte in Raum B rausgehen). Jetzt
  eigenes Eingabefeld je Raum mit Entwurf im Speicher.
- Antwort-Leiste: größeres Schließen-X auf dem Handy.
- Tests: test/spring_motion_test.dart, UI-Durchlauf mit Übergängen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 22:07:17 +02:00
Bernd SteckmeisterandClaude Opus 5.5 1d0ccfde8b fix: Lesebestätigung nur im Vordergrund, ohne Dauerfeuer, privat statt gar nicht
- Offener Chat meldete bei jedem Neuaufbau erneut "gelesen" – auch mit
  App im Hintergrund; die gerade gezeigte Benachrichtigung verschwand dann
  sofort wieder. Jetzt nur bei Fokus, je Event nur einmal.
- Zurück in der App: "Neue Nachrichten"-Trenner hinter dem zuletzt
  gelesenen Stand, danach gelesen melden.
- "Lesebestätigungen senden" aus: nur noch private Bestätigung
  (m.read.private) statt gar keiner – Ungelesen-Zähler stimmen wieder.
  Gilt auch für "Gelesen" aus der Benachrichtigung.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 21:32:36 +02:00
Bernd SteckmeisterandClaude Opus 5.5 223c9448f6 feat: schwebende Eingabeleiste - Chat laeuft darunter weiter (Milchglas), Flaeche faengt Beruehrungen ab
Bernds Wunsch 2026-10-07: Die Nachrichtenliste reicht jetzt bis ganz unten;
Tipp-Anzeige und Eingabefeld schweben darueber. Um das Feld herum ist die
Flaeche klar (Chat sichtbar), aber beruehrungsdicht - dort reagiert nur die
Eingabe. Hinter dem Feld selbst Milchglas (Unschaerfe + 78 % Flaeche), Rahmen
und Fokus-Ring aussen. Die Liste bekommt unten genau die gemessene Hoehe der
Leiste als Abstand (MeasureSize; waechst mit Antwort-Leiste/mehrzeiligem
Text), damit die neueste Nachricht ganz ueber die Leiste scrollt; der
Nach-unten-Knopf sitzt darueber.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 21:26:33 +02:00
Bernd SteckmeisterandClaude Opus 5.5 7063ebfceb feat: Antwort-Zitat antippen springt zur zitierten Nachricht (inkl. Aufleuchten)
_ReplyPreview nutzt den Sprung-Mechanismus der Suche (pendingJumpEventProvider).
Der Sprung scrollt jetzt schrittweise Richtung aeltere Nachrichten, wenn das
Ziel zwar geladen, aber in der lazy ListView noch nicht gezeichnet ist (vorher
gab er dann nach 40 Versuchen auf); dasselbe Zitat darf erneut angetippt
werden. UI-Durchlauf prueft den Sprung per Screenshot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 18:17:35 +02:00
Bernd SteckmeisterandClaude Opus 5.5 382747cdb1 feat: Handy - groessere Symbole, Suche/Mitglieder im Menue, Kontakt-Banner repariert, Schrift 110 %, federnde Knoepfe
Bernds Feedback 2026-10-07:
- Schrift-Standard Handy 110 % (statt 115 %).
- PyrIconBtn: im Handy-Layout Symbol 22 px (vorher fest 16), Flaeche 40,
  Tippflaeche 44; Farben aus dem Farbmodus statt fest verdrahtet.
- Eingabeleiste: Anhaengen/GIF/Emoji/Senden auf dem Handy 22-23 px statt
  12-13 px.
- Chat-Kopf (Handy): Anrufen + Videoanruf sichtbar, Suchen + Mitglieder im
  Drei-Punkte-Menue.
- Aufklappbarer DM-Kopf als Ebenen: Zurueck + Symbole bleiben oben,
  Profilbereich gleitet darunter und nutzt die volle Breite (vorher Name zu
  "p..." gekuerzt, Symbole rutschten in die Mitte); Kuerzel im Avatar
  mittig, skaliert mit.
- SpringPress (widgets/spring_press.dart): federnde Druck-Rueckmeldung,
  aus bei "Animationen 0 %".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 18:13:32 +02:00
56 changed files with 3103 additions and 399 deletions
+42
View File
@@ -13,6 +13,48 @@ 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 ## 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):** **Erledigt (je ein Commit, alles gepusht):**
+14 -8
View File
@@ -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` 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 (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 = (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`/ geprüfte user_id). **Client umgestellt 2026-10-07 (756486d):** kein Secret
`livekit_call_manager.dart`) + Secret-Rotation – beides PC/Gerät (heiliger mehr in der App, echter LiveKit-Beitritt mit Testkonto grün
Call-Pfad), siehe `docs/LIVEKIT_TOKEN_MIGRATION.md`. (`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), - [ ] **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 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: 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. `apiSecret`, Giphy-Key. Alte jeweils widerrufen.
- **LiveKit-Token server-seitig minten:** kleiner Token-Endpoint auf dem Pi, damit - **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). `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, **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. 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 - **E2EE-Diagnose-Upload: ERLEDIGT (2026-07-04).** Der Dashboard-Server auf dem Pi
@@ -303,11 +306,14 @@ lassen, ohne dass andere Module angefasst werden müssen.
(libwebrtc #142) ist auch in 1.6.2+hotfix.4 NICHT enthalten (libwebrtc #142) ist auch in 1.6.2+hotfix.4 NICHT enthalten
(Changelog geprüft 2026-10-07) → Dummy-PeerConnection-Workaround (Changelog geprüft 2026-10-07) → Dummy-PeerConnection-Workaround
bleibt nötig. bleibt nötig.
- Android-Build: Gradle 8.13 → 8.14.3 ERLEDIGT (2026-10-07). Offen: Umstieg auf - Android-Build: Gradle 8.13 → 8.14.3 ERLEDIGT (2026-10-07). „Built-in Kotlin“
„Built-in Kotlin“ (Flutter kündigt Build-Fehler für das Kotlin-Plugin an; **bewusst noch nicht** (Entscheidung 2026-10-07): AGP bleibt 8.11; die vom
betrifft auch Plugins wie flutter_webrtc/livekit_client). Flutter-Migrator gesetzten Flags `android.builtInKotlin=false` /
- `flutter_markdown` ist eingestellt (pub: „replaced by `android.newDsl=false` halten den Build stabil. Umstieg erst zusammen mit
flutter_markdown_plus“) → umstellen (Markdown-Vorschau im Dokument-Viewer). AGP 9 und wenn flutter_webrtc, livekit_client, media_kit & Co. ihn
unterstützen – vorher Release-APK + Anruftest.
- ERLEDIGT 2026-10-07 (0ad06e3): `flutter_markdown` → `flutter_markdown_plus`
(Rendertest `test/document_markdown_test.dart`).
## M3 – Calls: Logik, UI & Audio (Discord als Referenz) ## M3 – Calls: Logik, UI & Audio (Discord als Referenz)
+7 -2
View File
@@ -9,6 +9,7 @@
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.VIBRATE"/> <uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
@@ -26,11 +27,15 @@
android:allowBackup="false" android:allowBackup="false"
android:fullBackupContent="false" android:fullBackupContent="false"
android:dataExtractionRules="@xml/data_extraction_rules"> android:dataExtractionRules="@xml/data_extraction_rules">
<!-- Laufender Anruf: hält das Mikrofon im Hintergrund am Leben und
ist Pflicht für Bildschirmübertragung ab Android 14 (CallService.kt).
Der frühere Eintrag com.cloudwebrtc...FlutterForegroundService
verwies auf eine Klasse, die flutter_webrtc 1.x nicht mehr hat. -->
<service <service
android:name="com.cloudwebrtc.webrtc.FlutterForegroundService" android:name=".CallService"
android:enabled="true" android:enabled="true"
android:exported="false" android:exported="false"
android:foregroundServiceType="mediaProjection" /> android:foregroundServiceType="microphone|mediaProjection" />
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
@@ -0,0 +1,143 @@
package chat.pyramid.pyramid
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.Manifest
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
/**
* Vordergrunddienst für laufende Anrufe (1:1 und Sprachkanäle).
*
* Warum: Android schaltet das Mikrofon einer App im Hintergrund stumm, und
* seit Android 14 stürzt jede Bildschirmübertragung ab, wenn vorher kein
* Dienst vom Typ „mediaProjection“ läuft (flutter_webrtc startet keinen).
* Genau das war Bernds Fehlerbild 2026-10-07: Teilen gestartet → App weg →
* beim Öffnen kein Anruf mehr.
*
* Typen: immer „microphone“ (ab Android 11), bei laufender Übertragung
* zusätzlich „mediaProjection“. Der Dienst darf mit mediaProjection erst
* NACH der Zustimmung des Nutzers gestartet werden – das stellt die
* Dart-Seite sicher (lib/core/call_foreground.dart).
*/
class CallService : Service() {
companion object {
private const val TAG = "PYRAMID-CALL"
private const val CHANNEL_ID = "pyramid_call"
private const val NOTIF_ID = 7301
private const val EXTRA_TITLE = "title"
private const val EXTRA_SCREEN = "screen"
// Wer auf den tatsächlichen startForeground wartet (Hauptthread).
// Die Bildschirmübertragung darf erst danach beginnen.
private val waiters = mutableListOf<(Boolean) -> Unit>()
fun whenStarted(callback: (Boolean) -> Unit) {
waiters.add(callback)
}
private fun notifyWaiters(ok: Boolean) {
val list = waiters.toList()
waiters.clear()
list.forEach { it(ok) }
}
fun start(ctx: Context, title: String, screen: Boolean) {
val intent = Intent(ctx, CallService::class.java)
.putExtra(EXTRA_TITLE, title)
.putExtra(EXTRA_SCREEN, screen)
ContextCompat.startForegroundService(ctx, intent)
}
fun stop(ctx: Context) {
ctx.stopService(Intent(ctx, CallService::class.java))
}
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val title = intent?.getStringExtra(EXTRA_TITLE) ?: "Pyramid"
val screen = intent?.getBooleanExtra(EXTRA_SCREEN, false) ?: false
ensureChannel()
val launch = packageManager.getLaunchIntentForPackage(packageName)?.apply {
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
}
val open = launch?.let {
PendingIntent.getActivity(
this, 0, it,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
}
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(if (screen) "Bildschirm wird übertragen" else "Anruf läuft")
.setContentText("$title – tippen, um zurückzukehren")
.setOngoing(true)
.setOnlyAlertOnce(true)
.setSilent(true)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setContentIntent(open)
.build()
// Typ „microphone“ nur mit Mikrofon-Berechtigung (sonst verweigert
// Android den GANZEN Dienst – auch den für die Übertragung).
val micAllowed = ContextCompat.checkSelfPermission(
this, Manifest.permission.RECORD_AUDIO
) == PackageManager.PERMISSION_GRANTED
val candidates = mutableListOf<Int>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val mic = ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
val proj = ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
if (screen) {
if (micAllowed) candidates.add(mic or proj)
candidates.add(proj)
} else if (micAllowed) {
candidates.add(mic)
}
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
candidates.add(if (screen) ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION else 0)
} else {
candidates.add(-1) // ohne Typ
}
var ok = false
for (type in candidates) {
try {
if (type == -1) startForeground(NOTIF_ID, notification)
else startForeground(NOTIF_ID, notification, type)
ok = true
break
} catch (e: Exception) {
// Nächste, schmalere Variante versuchen – lieber ohne Dienst
// weiter als die App abstürzen zu lassen.
Log.e(TAG, "startForeground fehlgeschlagen (screen=$screen, type=$type): ${e.message}")
}
}
if (!ok) stopSelf()
notifyWaiters(ok)
return START_NOT_STICKY
}
private fun ensureChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val nm = getSystemService(NotificationManager::class.java)
if (nm.getNotificationChannel(CHANNEL_ID) != null) return
nm.createNotificationChannel(
NotificationChannel(CHANNEL_ID, "Laufende Anrufe", NotificationManager.IMPORTANCE_LOW).apply {
description = "Zeigt an, dass ein Anruf oder eine Bildschirmübertragung läuft."
setShowBadge(false)
}
)
}
}
@@ -97,6 +97,39 @@ class MainActivity : FlutterActivity() {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
// Cache so ReplyReceiver can call back into Dart without starting a new engine. // Cache so ReplyReceiver can call back into Dart without starting a new engine.
FlutterEngineCache.getInstance().put("main", flutterEngine) FlutterEngineCache.getInstance().put("main", flutterEngine)
// Anruf-Vordergrunddienst (lib/core/call_foreground.dart ↔ CallService.kt).
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "chat.pyramid.pyramid/call")
.setMethodCallHandler { call, result ->
when (call.method) {
"start" -> {
// Erst antworten, wenn der Dienst wirklich im Vordergrund
// läuft (oder nach 3 s aufgeben) – eine Übertragung vor
// dem startForeground würde die App abstürzen lassen.
val done = java.util.concurrent.atomic.AtomicBoolean(false)
val reply = { ok: Boolean ->
if (done.compareAndSet(false, true)) result.success(ok)
}
try {
CallService.whenStarted(reply)
CallService.start(
this,
call.argument<String>("title") ?: "Pyramid",
call.argument<Boolean>("screen") ?: false,
)
android.os.Handler(mainLooper).postDelayed({ reply(false) }, 3000)
} catch (e: Exception) {
// z. B. Start aus dem Hintergrund verboten (Android 12+).
Log.e("PYRAMID-CALL", "CallService-Start abgelehnt: ${e.message}")
reply(false)
}
}
"stop" -> {
CallService.stop(this)
result.success(true)
}
else -> result.notImplemented()
}
}
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channel) MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channel)
.setMethodCallHandler { call, result -> .setMethodCallHandler { call, result ->
when (call.method) { when (call.method) {
+27 -4
View File
@@ -1,9 +1,32 @@
# LiveKit-Token server-seitig minten (apiSecret aus dem Client entfernen) # LiveKit-Token server-seitig minten (apiSecret aus dem Client entfernen)
**Status:** geplant, NICHT umgesetzt (braucht echten Call-Test auf einem Gerät – **Status (2026-10-07):** Server-Route UND Client umgestellt.
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 - Server: `POST /api/livekit-token` im Dashboard-Server auf dem Pi
kann. Analog zu `docs/SQLCIPHER_MIGRATION.md`. (`/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 ## Problem
@@ -0,0 +1,52 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:integration_test/integration_test.dart';
import 'package:pyramid/core/call_foreground.dart';
/// Android 14+: Bildschirmübertragung darf die App nicht abstürzen lassen
/// (Bernds Test 2026-10-07: „Teilen“ → App weg → Anruf verloren).
///
/// Ablauf wie im Anruf: Zustimmung → Anruf-Dienst mit mediaProjection →
/// Mitschnitt. Der System-Dialog muss bestätigt werden (auf dem Emulator per
/// adb, siehe docs/TESTS_UND_BENCHMARKS.md).
///
/// flutter test integration_test/android_screen_capture_test.dart -d emulator-5554
///
/// PYRAMID_SCREEN_NEGATIVE=1 (per --dart-define) überspringt den Dienst –
/// Gegenprobe: dann stürzt die App wie früher ab.
const _negative = bool.fromEnvironment('PYRAMID_SCREEN_NEGATIVE');
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets(
'Bildschirmübertragung startet ohne Absturz',
(tester) async {
await tester.pumpWidget(const MaterialApp(home: Scaffold()));
if (_negative) {
expect(await Helper.requestCapturePermission(), isTrue);
} else {
expect(
await CallForeground.prepareScreenCapture('test'),
isTrue,
reason: 'Zustimmung oder Dienststart fehlgeschlagen',
);
}
final stream = await navigator.mediaDevices
.getDisplayMedia({'video': true, 'audio': false});
expect(stream.getVideoTracks(), isNotEmpty);
await Future.delayed(const Duration(seconds: 3));
for (final t in stream.getTracks()) {
await t.stop();
}
await stream.dispose();
await CallForeground.screenCaptureStopped('test');
await CallForeground.callEnded('test');
},
skip: !Platform.isAndroid,
timeout: const Timeout(Duration(minutes: 3)),
);
}
@@ -0,0 +1,243 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:integration_test/integration_test.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/main.dart' as app;
/// Live mit beiden Testkonten (Bernds Fehlerbericht 2026-10-07):
/// 1. ✓✓ erscheint LIVE, sobald der Partner liest – ohne den Chat neu zu
/// öffnen (vorher zeichnete eine reine Lesebestätigung nichts neu).
/// 2. Einladung lässt sich direkt im Chat annehmen, danach ist der Verlauf
/// sofort da (vorher leerer Chat bis zum App-Neustart).
///
/// Die App läuft mit dem Testprofil (PYRAMID_PROFILE_DIR, pyramidtest1).
/// pyramidtest2 wird per Client-Server-API mit einem kurzlebigen Gerät
/// gesteuert, das am Ende abgemeldet wird – andere Sitzungen (z. B. Bernds
/// Testinstanz mit profile2) bleiben unberührt.
///
/// $env:PYRAMID_PROFILE_DIR="$env:USERPROFILE\.pyramid-autopilot\profile1"
/// flutter test integration_test/live_receipts_invite_test.dart -d windows
const _hs = 'https://steggi-matrix.work';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final profile = Platform.environment['PYRAMID_PROFILE_DIR'];
final accounts = _loadAccounts();
testWidgets(
'Lesebestätigung live + Einladung im Chat annehmen',
(tester) async {
tester.view
..physicalSize = const Size(1080, 2340)
..devicePixelRatio = 2.625;
addTearDown(tester.view.reset);
final partner = await tester.runAsync(
() => _RestUser.login(accounts!['user2']!, accounts['pass2']!),
);
addTearDown(() => tester.runAsync(() => partner!.logout()));
app.main();
await _pumpUntil(tester, find.byType(MaterialApp));
final container = ProviderScope.containerOf(
tester.element(find.byType(MaterialApp).first),
);
final client = (await tester.runAsync(
() => container.read(matrixClientProvider.future),
))!;
expect(client.userID, '@pyramidtest1:steggi-matrix.work');
await _pumpFor(tester, const Duration(seconds: 4));
// ── 1. Lesebestätigung live ──
final dm = client.rooms.firstWhere(
(r) =>
r.membership == Membership.join &&
r.directChatMatrixID == '@pyramidtest2:steggi-matrix.work',
);
container.read(activeRoomIdProvider.notifier).state = dm.id;
await _pumpFor(tester, const Duration(seconds: 4));
final text = 'Lesetest ${DateTime.now().millisecondsSinceEpoch}';
final eventId = await tester.runAsync(() => dm.sendTextEvent(text));
await _pumpUntil(tester, find.textContaining(text));
await _pumpFor(tester, const Duration(seconds: 3));
expect(
find.byIcon(Icons.done_rounded),
findsOneWidget,
reason: 'eigene neueste Nachricht sollte noch ✓ (ungelesen) zeigen',
);
await tester.runAsync(() => partner!.readReceipt(dm.id, eventId!));
// Ohne den Chat neu zu öffnen: ✓ wird zu ✓✓ direkt unter der Nachricht.
await _pumpUntil(
tester,
find.byIcon(Icons.done_rounded),
gone: true,
timeout: const Duration(seconds: 30),
);
final msgY = tester.getCenter(find.textContaining(text).first).dy;
final readIcons = find.byIcon(Icons.done_all_rounded).evaluate();
expect(
readIcons.any((e) {
final box = e.renderObject as RenderBox;
final y = box.localToGlobal(box.size.center(Offset.zero)).dy;
return y > msgY && y - msgY < 120;
}),
isTrue,
reason: '✓✓ erscheint nicht live unter der gelesenen Nachricht',
);
// ── 2. Einladung im Chat annehmen ──
final welcome = 'Willkommen im Einladungstest ${DateTime.now().millisecondsSinceEpoch}';
final inviteRoomId = await tester.runAsync(() async {
final id = await partner!.createRoom('Einladungstest');
await partner.sendText(id, welcome);
await partner.invite(id, client.userID!);
return id;
});
addTearDown(() => tester.runAsync(() => partner!.leave(inviteRoomId!)));
// Einladung kommt per Sync an → Chat öffnen (wie aus der Benachrichtigung).
final end = DateTime.now().add(const Duration(seconds: 30));
while (client.getRoomById(inviteRoomId!)?.membership != Membership.invite &&
DateTime.now().isBefore(end)) {
await _pumpFor(tester, const Duration(milliseconds: 500));
}
container.read(activeRoomIdProvider.notifier).state = inviteRoomId;
await _pumpUntil(tester, find.text('Annehmen'));
await tester.tap(find.text('Annehmen'));
// Ohne App-Neustart: Verlauf mit der Begrüßung erscheint.
await _pumpUntil(
tester,
find.textContaining(welcome),
timeout: const Duration(seconds: 40),
);
expect(find.text('Annehmen'), findsNothing);
// Aufräumen: Testraum verlassen + vergessen.
await tester.runAsync(() async {
final r = client.getRoomById(inviteRoomId);
await r?.leave();
await r?.forget().catchError((_) {});
});
container.read(activeRoomIdProvider.notifier).state = null;
await _pumpFor(tester, const Duration(seconds: 1));
},
skip: profile == null || accounts == null,
timeout: const Timeout(Duration(minutes: 4)),
);
}
Map<String, String>? _loadAccounts() {
final home = Platform.environment['USERPROFILE'] ?? '';
final file = File('$home\\.pyramid-autopilot\\test-accounts.txt');
if (!file.existsSync()) return null;
final map = <String, String>{};
for (final line in file.readAsLinesSync()) {
final t = line.trim();
if (t.isEmpty || t.startsWith('#')) continue;
final i = t.indexOf('=');
if (i > 0) map[t.substring(0, i)] = t.substring(i + 1);
}
return map;
}
/// Minimaler Matrix-Zugang per Client-Server-API (kurzlebiges Gerät).
class _RestUser {
_RestUser(this._token);
final String _token;
int _txn = 0;
static Future<_RestUser> login(String user, String pass) async {
final res = await http.post(
Uri.parse('$_hs/_matrix/client/v3/login'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'type': 'm.login.password',
'identifier': {'type': 'm.id.user', 'user': user},
'password': pass,
'initial_device_display_name': 'Pyramid-Livetest (kurzlebig)',
}),
);
if (res.statusCode != 200) throw StateError('Login ${res.statusCode}');
return _RestUser(jsonDecode(res.body)['access_token'] as String);
}
Future<Map<String, dynamic>> _call(String method, String path,
[Object? body]) async {
final req = http.Request(method, Uri.parse('$_hs/_matrix/client/v3$path'))
..headers['Authorization'] = 'Bearer $_token'
..headers['Content-Type'] = 'application/json'
..body = jsonEncode(body ?? {});
final res = await http.Response.fromStream(await req.send());
if (res.statusCode != 200) {
throw StateError('$method $path → ${res.statusCode}');
}
return jsonDecode(res.body) as Map<String, dynamic>;
}
String _enc(String s) => Uri.encodeComponent(s);
Future<void> readReceipt(String roomId, String eventId) => _call(
'POST',
'/rooms/${_enc(roomId)}/read_markers',
{'m.fully_read': eventId, 'm.read': eventId},
);
Future<String> createRoom(String name) async =>
(await _call('POST', '/createRoom', {
'name': name,
'preset': 'private_chat',
}))['room_id'] as String;
Future<void> sendText(String roomId, String text) => _call(
'PUT',
'/rooms/${_enc(roomId)}/send/m.room.message/t${DateTime.now().microsecondsSinceEpoch}${_txn++}',
{'msgtype': 'm.text', 'body': text},
);
Future<void> invite(String roomId, String userId) =>
_call('POST', '/rooms/${_enc(roomId)}/invite', {'user_id': userId});
Future<void> leave(String roomId) async {
try {
await _call('POST', '/rooms/${_enc(roomId)}/leave');
await _call('POST', '/rooms/${_enc(roomId)}/forget');
} catch (_) {}
}
Future<void> logout() async {
try {
await _call('POST', '/logout');
} catch (_) {}
}
}
Future<void> _pumpFor(WidgetTester tester, Duration d) async {
final end = DateTime.now().add(d);
while (DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 100));
}
}
Future<void> _pumpUntil(
WidgetTester tester,
Finder finder, {
bool gone = false,
Duration timeout = const Duration(seconds: 60),
}) async {
final end = DateTime.now().add(timeout);
while (DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 200));
final found = finder.evaluate().isNotEmpty;
if (found != gone) return;
}
fail('Zeitüberschreitung: $finder ${gone ? "verschwindet nicht" : "nicht gefunden"}');
}
@@ -0,0 +1,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)),
);
}
@@ -10,6 +10,7 @@ import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/appearance.dart'; import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/theme.dart'; import 'package:pyramid/core/theme.dart';
import 'package:pyramid/main.dart' as app; 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 /// UI-Durchlauf im HANDY-Format mit der echten App (Windows-Build) und einem
/// TEST-Profil – erzeugt Screenshots unter build/ui_screens/. /// TEST-Profil – erzeugt Screenshots unter build/ui_screens/.
@@ -59,10 +60,63 @@ void main() {
// ── Direktnachricht: Sprechblasen (Handy-Standard) und Liste ── // ── Direktnachricht: Sprechblasen (Handy-Standard) und Liste ──
await tester.tap(find.text(_otherUser).first); 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 _pumpFor(tester, const Duration(seconds: 6));
await _scrollToNewest(tester); await _scrollToNewest(tester);
await _shot(tester, '02_chat'); 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; container.read(messageStyleProvider.notifier).state = MessageStyle.list;
await _pumpFor(tester, const Duration(seconds: 2)); await _pumpFor(tester, const Duration(seconds: 2));
await _shot(tester, '02b_chat_liste'); await _shot(tester, '02b_chat_liste');
@@ -177,6 +231,14 @@ void main() {
await _shot(tester, '10_einstellungen_text_chat'); await _shot(tester, '10_einstellungen_text_chat');
container.read(activeModalProvider.notifier).state = ModalKind.none; container.read(activeModalProvider.notifier).state = ModalKind.none;
await _pumpFor(tester, const Duration(seconds: 1)); 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, skip: profile == null,
timeout: const Timeout(Duration(minutes: 6)), timeout: const Timeout(Duration(minutes: 6)),
+3 -2
View File
@@ -10,8 +10,9 @@ bool isMobileLayout(Size size) => size.width < 900 || size.height < 500;
// ── Schriftgröße ─────────────────────────────────────────────────────────── // ── Schriftgröße ───────────────────────────────────────────────────────────
/// Standard-Schriftfaktor, solange der Nutzer nichts eingestellt hat: auf dem /// Standard-Schriftfaktor, solange der Nutzer nichts eingestellt hat: auf dem
/// Handy größer (Bernds Wunsch 2026-10-07: ≈ 16 statt 14 px), am PC wie bisher. /// Handy größer (Bernd 2026-10-07: 115 % ausprobiert, 110 % als Standard
double defaultTextScale({required bool mobile}) => mobile ? 1.15 : 1.0; /// gewünscht), am PC wie bisher.
double defaultTextScale({required bool mobile}) => mobile ? 1.10 : 1.0;
const minTextScale = 0.85; const minTextScale = 0.85;
const maxTextScale = 1.5; const maxTextScale = 1.5;
+6 -3
View File
@@ -9,6 +9,7 @@ import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod;
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:pyramid/core/app_database.dart'; import 'package:pyramid/core/app_database.dart';
import 'package:pyramid/core/notification_content.dart'; import 'package:pyramid/core/notification_content.dart';
import 'package:pyramid/core/read_markers.dart';
import 'package:pyramid/core/soft_logout_guard.dart'; import 'package:pyramid/core/soft_logout_guard.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -167,9 +168,11 @@ Future<bool> _bgMarkRead(String? roomId, String? eventId) async {
if (client == null) return false; if (client == null) return false;
try { try {
final room = client.getRoomById(roomId) ?? Room(id: roomId, client: client); final room = client.getRoomById(roomId) ?? Room(id: roomId, client: client);
await room await markRoomReadUpTo(
.setReadMarker(eventId, mRead: eventId) room,
.timeout(const Duration(seconds: 15)); eventId,
publicReceipt: await loadReadReceiptsPublic(),
).timeout(const Duration(seconds: 15));
debugPrint('[NOTIF-BGENGINE] read marker set for $roomId'); debugPrint('[NOTIF-BGENGINE] read marker set for $roomId');
return true; return true;
} catch (e) { } catch (e) {
+84
View File
@@ -0,0 +1,84 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
/// Anruf-Vordergrunddienst auf Android (android/.../CallService.kt).
///
/// * Solange ein Anruf läuft, hält der Dienst das Mikrofon auch im
/// Hintergrund aktiv (Android schaltet es sonst stumm) und zeigt eine
/// „Anruf läuft“-Benachrichtigung, über die man zurück in die App kommt.
/// * Für eine Bildschirmübertragung MUSS ab Android 14 vorher ein Dienst vom
/// Typ mediaProjection laufen – und der darf erst NACH der Zustimmung des
/// Nutzers starten. [prepareScreenCapture] erledigt beides in der richtigen
/// Reihenfolge; flutter_webrtc benutzt danach die gespeicherte Zustimmung.
///
/// Beide Anrufwege (1:1 über Matrix, Sprachkanäle über LiveKit) melden sich
/// mit eigenem Namen an; der Dienst endet erst, wenn keiner mehr läuft.
/// Auf anderen Plattformen passiert nichts.
class CallForeground {
CallForeground._();
static const _channel = MethodChannel('chat.pyramid.pyramid/call');
static final _owners = <String>{};
static final _screenOwners = <String>{};
static String _title = 'Pyramid';
static bool get _android => !kIsWeb && Platform.isAndroid;
/// true = Dienst läuft (bzw. wurde beendet) wie gewünscht.
static Future<bool> _apply() async {
if (!_android) return true;
try {
if (_owners.isEmpty) {
await _channel.invokeMethod('stop');
return true;
}
final ok = await _channel.invokeMethod<bool>('start', {
'title': _title,
'screen': _screenOwners.isNotEmpty,
});
return ok ?? false;
} catch (e) {
debugPrint('[CallForeground] $e');
return false;
}
}
/// Anruf von [owner] läuft (z. B. 'voip', 'livekit').
static Future<void> callStarted(String owner, {required String title}) async {
_owners.add(owner);
_title = title;
await _apply();
}
/// Anruf von [owner] ist beendet.
static Future<void> callEnded(String owner) async {
final changed = _owners.remove(owner) | _screenOwners.remove(owner);
if (changed) await _apply();
}
/// Vor einer Bildschirmübertragung auf Android: Zustimmung einholen, dann
/// den Dienst um „mediaProjection“ erweitern. false = Nutzer hat abgelehnt.
static Future<bool> prepareScreenCapture(String owner) async {
if (!_android) return true;
final granted = await rtc.Helper.requestCapturePermission();
if (!granted) return false;
_owners.add(owner);
_screenOwners.add(owner);
if (!await _apply()) {
// Ohne laufenden mediaProjection-Dienst würde der Mitschnitt die App
// abstürzen lassen – dann lieber gar nicht übertragen.
_screenOwners.remove(owner);
await _apply();
return false;
}
return true;
}
/// Übertragung von [owner] beendet – Dienst wieder ohne mediaProjection.
static Future<void> screenCaptureStopped(String owner) async {
if (_screenOwners.remove(owner)) await _apply();
}
}
+84 -32
View File
@@ -1,37 +1,89 @@
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; import 'dart:async';
import 'dart:convert';
import 'dart:io';
class LiveKitTokenGenerator { import 'package:http/http.dart' as http;
// Your LiveKit Credentials
static const String apiKey = 'LKMatrixPi';
static const String apiSecret = 'rYUT2PRaKLedp5VLQCQE83eZG7fjuBWtFPXGiveBmIE';
static String generate({ /// LiveKit-Beitritts-Token vom Pyramid-Server holen.
required String roomName, ///
required String identity, /// Früher hat die App die Tokens selbst signiert – dafür steckte das
String? displayName, /// LiveKit-Secret im App-Code, und wer die App hatte, konnte sich für jeden
int ttlSeconds = 3600, // Valid for 1 hour by default /// Raum und jede Identität ein Token ausstellen. Jetzt signiert nur noch der
}) { /// Server (`/api/livekit-token` im Dashboard-Server auf dem Pi): Er prüft den
final jwt = JWT( /// Matrix-Login per whoami und setzt die LiveKit-Identität selbst auf die
{ /// geprüfte Matrix-ID. Siehe docs/LIVEKIT_TOKEN_MIGRATION.md.
'video': { class LiveKitTokenService {
'roomJoin': true, static const endpoint =
'room': roomName, 'https://dashboard.steggi-matrix.work/api/livekit-token';
'canPublish': true, static const fallbackUrl = 'wss://livekit.steggi-matrix.work';
'canSubscribe': true, static const _timeout = Duration(seconds: 15);
'canPublishData': true,
},
'metadata': displayName ?? identity,
},
issuer: apiKey,
subject: identity,
);
// Sign the token with your secret /// Liefert Token und LiveKit-Adresse für [room]. Wirft
final token = jwt.sign( /// [LiveKitTokenException] mit einer verständlichen Meldung.
SecretKey(apiSecret), static Future<({String token, String url})> fetch({
expiresIn: Duration(seconds: ttlSeconds), required String room,
); required String matrixToken,
http.Client? httpClient,
return token; }) async {
final client = httpClient ?? http.Client();
try {
final res = await client
.post(
Uri.parse(endpoint),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'room': room, 'matrix_token': matrixToken}),
)
.timeout(_timeout);
if (res.statusCode == 401) {
throw const LiveKitTokenException(
'Anmeldung vom Anrufserver abgelehnt – bitte neu anmelden.',
);
}
if (res.statusCode != 200) {
throw LiveKitTokenException(
'Anrufserver antwortet nicht richtig (HTTP ${res.statusCode}).',
);
}
final data = jsonDecode(res.body);
final token = data is Map ? data['token'] : null;
if (token is! String || token.isEmpty) {
throw const LiveKitTokenException(
'Anrufserver hat kein Zugangstoken geliefert.',
);
}
final url = (data as Map)['url'];
return (
token: token,
url: url is String && url.startsWith('wss://') ? url : fallbackUrl,
);
} on LiveKitTokenException {
rethrow;
} on TimeoutException {
throw const LiveKitTokenException(
'Anrufserver nicht erreichbar (Zeitüberschreitung).',
);
} on SocketException {
throw const LiveKitTokenException(
'Anrufserver nicht erreichbar – Internetverbindung prüfen.',
);
} on http.ClientException {
throw const LiveKitTokenException(
'Anrufserver nicht erreichbar – Internetverbindung prüfen.',
);
} on FormatException {
throw const LiveKitTokenException(
'Anrufserver hat eine unlesbare Antwort geschickt.',
);
} finally {
if (httpClient == null) client.close();
}
} }
} }
class LiveKitTokenException implements Exception {
final String message;
const LiveKitTokenException(this.message);
@override
String toString() => message;
}
+9 -3
View File
@@ -13,6 +13,7 @@ import 'package:pyramid/core/background_push.dart';
import 'package:pyramid/core/fcm_push_service.dart'; import 'package:pyramid/core/fcm_push_service.dart';
import 'package:pyramid/core/matrix_client.dart'; import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/notification_content.dart'; import 'package:pyramid/core/notification_content.dart';
import 'package:pyramid/core/read_markers.dart';
import 'package:pyramid/core/settings_prefs.dart'; import 'package:pyramid/core/settings_prefs.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:windows_taskbar/windows_taskbar.dart'; import 'package:windows_taskbar/windows_taskbar.dart';
@@ -205,9 +206,14 @@ Future<void> _initAndroid(WidgetRef ref) async {
if (roomId != null && eventId != null) { if (roomId != null && eventId != null) {
try { try {
final client = await ref.read(matrixClientProvider.future); final client = await ref.read(matrixClientProvider.future);
await client final room = client.getRoomById(roomId);
.getRoomById(roomId) if (room != null) {
?.setReadMarker(eventId, mRead: eventId); await markRoomReadUpTo(
room,
eventId,
publicReceipt: ref.read(privacyReadReceiptsProvider),
);
}
debugPrint('[NOTIF-FG] markReadFromNotification: $roomId bis $eventId'); debugPrint('[NOTIF-FG] markReadFromNotification: $roomId bis $eventId');
} catch (e) { } catch (e) {
debugPrint('[NOTIF-FG] markReadFromNotification error: $e'); debugPrint('[NOTIF-FG] markReadFromNotification error: $e');
+25
View File
@@ -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);
+2 -1
View File
@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:pyramid/core/appearance.dart'; import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/read_markers.dart';
import 'package:pyramid/core/theme.dart'; import 'package:pyramid/core/theme.dart';
// ── Generic SharedPreferences-backed bool notifier ────────────────────────── // ── Generic SharedPreferences-backed bool notifier ──────────────────────────
@@ -87,7 +88,7 @@ final privacyPresenceProvider =
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('privacy_presence', true)); StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('privacy_presence', true));
final privacyReadReceiptsProvider = final privacyReadReceiptsProvider =
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('privacy_read_receipts', true)); StateNotifierProvider<BoolPref, bool>((ref) => BoolPref(readReceiptsPrefKey, true));
final privacyTypingProvider = final privacyTypingProvider =
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('privacy_typing', true)); StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('privacy_typing', true));
@@ -49,6 +49,11 @@ abstract class CallSignalingService extends ChangeNotifier {
Future<void> changeQuality(String key); Future<void> changeQuality(String key);
/// Handy: Ton über den Lautsprecher (true) oder die Hörmuschel (false).
/// Auf dem PC ohne Bedeutung.
bool get isSpeakerOn => true;
Future<void> toggleSpeaker() async {}
/// Wendet die in den Einstellungen gewählte Ausgabelautstärke auf den /// Wendet die in den Einstellungen gewählte Ausgabelautstärke auf den
/// Remote-Stream an. Ohne [value] wird der Pref-Wert gelesen. /// Remote-Stream an. Ohne [value] wird der Pref-Wert gelesen.
Future<void> applyOutputVolume([double? value]); Future<void> applyOutputVolume([double? value]);
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:webrtc_interface/webrtc_interface.dart'; import 'package:webrtc_interface/webrtc_interface.dart';
import 'package:pyramid/core/call_foreground.dart';
import 'package:pyramid/core/ice_servers.dart'; import 'package:pyramid/core/ice_servers.dart';
import 'package:pyramid/core/settings_prefs.dart'; import 'package:pyramid/core/settings_prefs.dart';
import 'package:pyramid/features/call_signaling/call_signaling_service.dart'; import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
@@ -35,6 +36,34 @@ class PyramidVoipManager extends CallSignalingService
bool _isDeafened = false; bool _isDeafened = false;
bool _isUpdating = false; bool _isUpdating = false;
String? _selectedSourceId; String? _selectedSourceId;
// Handy: Lautsprecher statt Hörmuschel (wie Discord). Das SDK schaltet bei
// Sprachanrufen auf die Hörmuschel – hält man das Handy vor sich, hört man
// dann praktisch nichts (Bernds Test 2026-10-07).
bool _speakerOn = true;
bool get _isMobile =>
!kIsWeb &&
(defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS);
@override
bool get isSpeakerOn => _speakerOn;
@override
Future<void> toggleSpeaker() async {
_speakerOn = !_speakerOn;
notifyListeners();
await _applySpeaker();
}
Future<void> _applySpeaker() async {
if (!_isMobile) return;
try {
await rtc.Helper.setSpeakerphoneOn(_speakerOn);
} catch (e) {
debugPrint('[VOIP] Lautsprecher: $e');
}
}
String _currentQualityKey = 'hd'; String _currentQualityKey = 'hd';
@override @override
@@ -104,6 +133,7 @@ class PyramidVoipManager extends CallSignalingService
await _ensureRenderers(); await _ensureRenderers();
currentCall = call; currentCall = call;
_isCameraMuted = call.type == CallType.kVoice; _isCameraMuted = call.type == CallType.kVoice;
_speakerOn = true;
_setupCallListeners(call); _setupCallListeners(call);
_updateInternalStates(); _updateInternalStates();
notifyListeners(); notifyListeners();
@@ -120,9 +150,19 @@ class PyramidVoipManager extends CallSignalingService
debugPrint('[VOIP] Ensuring camera is unmuted on connect'); debugPrint('[VOIP] Ensuring camera is unmuted on connect');
call.setLocalVideoMuted(false); call.setLocalVideoMuted(false);
} }
_applySpeaker();
// Android: Mikrofon im Hintergrund + „Anruf läuft“-Benachrichtigung.
CallForeground.callStarted('voip',
title: call.room.getLocalizedDisplayname());
} }
if (state == CallState.kEnded) CallForeground.callEnded('voip');
_updateRenderers(); _updateRenderers();
}); });
// Das SDK setzt bei jedem neuen eigenen Medienstrom die Hörmuschel –
// danach unsere Wahl erneut anwenden.
call.onCallEventChanged.stream.listen((event) {
if (event == CallStateChange.kFeedsChanged) _applySpeaker();
});
_renderRefreshTimer?.cancel(); _renderRefreshTimer?.cancel();
_renderRefreshTimer = Timer.periodic( _renderRefreshTimer = Timer.periodic(
@@ -259,6 +299,7 @@ class PyramidVoipManager extends CallSignalingService
_isRemoteScreensharing = false; _isRemoteScreensharing = false;
notifyListeners(); notifyListeners();
} }
CallForeground.callEnded('voip');
} }
// ── Ausgabelautstärke (Settings-Slider "voice_output_volume") ───────────── // ── Ausgabelautstärke (Settings-Slider "voice_output_volume") ─────────────
@@ -292,6 +333,7 @@ class PyramidVoipManager extends CallSignalingService
currentCall = null; currentCall = null;
notifyListeners(); notifyListeners();
} }
CallForeground.callEnded('voip');
} }
@override @override
@@ -361,6 +403,7 @@ class PyramidVoipManager extends CallSignalingService
if (_isScreensharing) { if (_isScreensharing) {
await safeAction(() => call.setScreensharingEnabled(false)); await safeAction(() => call.setScreensharingEnabled(false));
await CallForeground.screenCaptureStopped('voip');
return; return;
} }
@@ -390,6 +433,7 @@ class PyramidVoipManager extends CallSignalingService
await currentCall!.hangup(reason: CallErrorCode.userHangup); await currentCall!.hangup(reason: CallErrorCode.userHangup);
currentCall = null; currentCall = null;
}); });
await CallForeground.callEnded('voip');
} }
@override @override
@@ -399,6 +443,7 @@ class PyramidVoipManager extends CallSignalingService
await _ensureRenderers(); await _ensureRenderers();
_isCameraMuted = !video; _isCameraMuted = !video;
_speakerOn = true;
debugPrint('[VOIP] Starting call in room $roomId (video=$video)'); debugPrint('[VOIP] Starting call in room $roomId (video=$video)');
final call = await voip.inviteToCall(room, video ? CallType.kVideo : CallType.kVoice); final call = await voip.inviteToCall(room, video ? CallType.kVideo : CallType.kVoice);
currentCall = call; currentCall = call;
@@ -525,6 +570,13 @@ class MediaDevicesWrapper extends MediaDevices {
@override @override
Future<rtc.MediaStream> getDisplayMedia(Map<String, dynamic> constraints) async { Future<rtc.MediaStream> getDisplayMedia(Map<String, dynamic> constraints) async {
// Android 14+: ohne laufenden mediaProjection-Dienst stürzt die App beim
// Mitschnitt ab – erst Zustimmung, dann Dienst, dann Mitschnitt.
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
if (!await CallForeground.prepareScreenCapture('voip')) {
throw Exception('Bildschirmübertragung wurde nicht erlaubt');
}
}
final String? sourceId = _manager._selectedSourceId; final String? sourceId = _manager._selectedSourceId;
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.windows && sourceId != null) { if (!kIsWeb && defaultTargetPlatform == TargetPlatform.windows && sourceId != null) {
final desktopConstraints = { final desktopConstraints = {
@@ -51,6 +51,9 @@ class _CallControls extends StatelessWidget {
final VoidCallback onToggleScreen; final VoidCallback onToggleScreen;
final VoidCallback onHangUp; final VoidCallback onHangUp;
final PyramidTheme pt; final PyramidTheme pt;
// Handy: Lautsprecher/Hörmuschel statt „Ton aus“ (Platz in der Leiste).
final bool isSpeakerOn;
final VoidCallback? onToggleSpeaker;
const _CallControls({ const _CallControls({
required this.isMuted, required this.isMuted,
@@ -63,10 +66,15 @@ class _CallControls extends StatelessWidget {
required this.onToggleScreen, required this.onToggleScreen,
required this.onHangUp, required this.onHangUp,
required this.pt, required this.pt,
this.isSpeakerOn = true,
this.onToggleSpeaker,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final platform = Theme.of(context).platform;
final showSpeaker = onToggleSpeaker != null &&
(platform == TargetPlatform.android || platform == TargetPlatform.iOS);
return SafeArea( return SafeArea(
top: false, top: false,
child: Container( child: Container(
@@ -77,7 +85,15 @@ class _CallControls extends StatelessWidget {
children: [ children: [
_VoiceCtrlBtn(icon: isMuted ? Icons.mic_off_rounded : Icons.mic_rounded, danger: isMuted, pt: pt, onTap: onToggleMic), _VoiceCtrlBtn(icon: isMuted ? Icons.mic_off_rounded : Icons.mic_rounded, danger: isMuted, pt: pt, onTap: onToggleMic),
const SizedBox(width: 16), const SizedBox(width: 16),
_VoiceCtrlBtn(icon: isDeafened ? Icons.headset_off_rounded : Icons.headphones_rounded, danger: isDeafened, pt: pt, onTap: onToggleDeafen), if (showSpeaker)
_VoiceCtrlBtn(
icon: isSpeakerOn ? Icons.volume_up_rounded : Icons.phone_in_talk_rounded,
active: isSpeakerOn,
pt: pt,
onTap: onToggleSpeaker!,
)
else
_VoiceCtrlBtn(icon: isDeafened ? Icons.headset_off_rounded : Icons.headphones_rounded, danger: isDeafened, pt: pt, onTap: onToggleDeafen),
const SizedBox(width: 16), const SizedBox(width: 16),
_VoiceCtrlBtn(icon: isCameraOff ? Icons.videocam_off_rounded : Icons.videocam_rounded, active: !isCameraOff, pt: pt, onTap: onToggleCam), _VoiceCtrlBtn(icon: isCameraOff ? Icons.videocam_off_rounded : Icons.videocam_rounded, active: !isCameraOff, pt: pt, onTap: onToggleCam),
const SizedBox(width: 16), const SizedBox(width: 16),
+6
View File
@@ -17,6 +17,8 @@ class _UnifiedCallView extends StatefulWidget {
final bool isScreenSharing; final bool isScreenSharing;
final Function(Offset)? onQualityMenu; final Function(Offset)? onQualityMenu;
final PyramidTheme pt; final PyramidTheme pt;
final bool isSpeakerOn;
final VoidCallback? onToggleSpeaker;
const _UnifiedCallView({ const _UnifiedCallView({
required this.participants, required this.participants,
@@ -35,6 +37,8 @@ class _UnifiedCallView extends StatefulWidget {
required this.isScreenSharing, required this.isScreenSharing,
this.onQualityMenu, this.onQualityMenu,
required this.pt, required this.pt,
this.isSpeakerOn = true,
this.onToggleSpeaker,
}); });
@override @override
@@ -148,6 +152,8 @@ class _UnifiedCallViewState extends State<_UnifiedCallView> {
onToggleScreen: widget.onToggleScreen, onToggleScreen: widget.onToggleScreen,
onHangUp: widget.onHangUp, onHangUp: widget.onHangUp,
pt: pt, pt: pt,
isSpeakerOn: widget.isSpeakerOn,
onToggleSpeaker: widget.onToggleSpeaker,
), ),
], ],
), ),
@@ -3,6 +3,11 @@ part of '../voice_channel.dart';
void _handleLiveKitScreenShare(BuildContext context, VoiceChannelService call) async { void _handleLiveKitScreenShare(BuildContext context, VoiceChannelService call) async {
if (call.isScreenSharing) { if (call.isScreenSharing) {
await call.stopScreenShare(); await call.stopScreenShare();
} else if (Theme.of(context).platform == TargetPlatform.android ||
Theme.of(context).platform == TargetPlatform.iOS) {
// Handy: ganzer Bildschirm über die System-Zustimmung (kein
// Fenster-Auswähler wie am PC).
await call.toggleScreenShare();
} else { } else {
final source = await ScreenSharePicker.show(context); final source = await ScreenSharePicker.show(context);
if (source != null) await call.startScreenShare(source.id); if (source != null) await call.startScreenShare(source.id);
@@ -58,6 +58,8 @@ class VoiceChannelView extends ConsumerWidget {
isScreenSharing: voip.isScreensharing, isScreenSharing: voip.isScreensharing,
onQualityMenu: (pos) => _showVoipQualityMenu(context, voip, pos), onQualityMenu: (pos) => _showVoipQualityMenu(context, voip, pos),
pt: pt, pt: pt,
isSpeakerOn: voip.isSpeakerOn,
onToggleSpeaker: voip.toggleSpeaker,
); );
} }
@@ -86,6 +88,8 @@ class VoiceChannelView extends ConsumerWidget {
isScreenSharing: call.isScreenSharing, isScreenSharing: call.isScreenSharing,
onQualityMenu: (pos) => _showLiveKitQualityMenu(context, call, pos), onQualityMenu: (pos) => _showLiveKitQualityMenu(context, call, pos),
pt: pt, pt: pt,
isSpeakerOn: call.isSpeakerOn,
onToggleSpeaker: call.toggleSpeaker,
); );
} }
+98 -23
View File
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'dart:ui' as ui;
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -7,7 +8,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:pyramid/core/settings_prefs.dart'; import 'package:pyramid/core/settings_prefs.dart';
import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/theme.dart'; import 'package:pyramid/core/theme.dart';
import 'package:pyramid/widgets/spring_press.dart';
import 'package:pyramid/features/chat/attachment_dialog.dart'; import 'package:pyramid/features/chat/attachment_dialog.dart';
import 'package:pyramid/features/chat/emoji_picker.dart'; import 'package:pyramid/features/chat/emoji_picker.dart';
import 'package:pyramid/features/chat/gif_sticker_picker.dart'; import 'package:pyramid/features/chat/gif_sticker_picker.dart';
@@ -32,6 +35,10 @@ class ChatComposer extends ConsumerStatefulWidget {
} }
class _ChatComposerState extends ConsumerState<ChatComposer> { class _ChatComposerState extends ConsumerState<ChatComposer> {
/// Angefangene Nachrichten je Raum (nur im Speicher): Beim Raumwechsel
/// bleibt der Text beim richtigen Raum, statt im nächsten Chat zu landen.
static final _drafts = <String, String>{};
final _ctrl = TextEditingController(); final _ctrl = TextEditingController();
late final FocusNode _focus; late final FocusNode _focus;
bool _canSend = false; bool _canSend = false;
@@ -44,6 +51,12 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
void initState() { void initState() {
super.initState(); super.initState();
_focus = FocusNode(onKeyEvent: _handleKeyEvent); _focus = FocusNode(onKeyEvent: _handleKeyEvent);
// Entwurf vor dem Listener setzen – sonst ginge sofort „tippt…“ raus.
final draft = _drafts.remove(widget.room.id);
if (draft != null) {
_ctrl.text = draft;
_canSend = draft.trim().isNotEmpty;
}
_ctrl.addListener(_onTextChanged); _ctrl.addListener(_onTextChanged);
} }
@@ -118,6 +131,8 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
@override @override
void dispose() { void dispose() {
final text = _ctrl.text;
if (text.trim().isNotEmpty) _drafts[widget.room.id] = text;
_typingTimer?.cancel(); _typingTimer?.cancel();
if (_isTyping) widget.room.setTyping(false).catchError((_) {}); if (_isTyping) widget.room.setTyping(false).catchError((_) {});
_gifOverlay?.remove(); _gifOverlay?.remove();
@@ -300,6 +315,8 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
final pt = PyramidTheme.of(context); final pt = PyramidTheme.of(context);
final size = MediaQuery.sizeOf(context); final size = MediaQuery.sizeOf(context);
final isSmall = size.width < 400; final isSmall = size.width < 400;
// Handy: größere Symbole (vorher 12–13 px, Bernds Feedback 2026-10-07).
final mobile = isMobileLayout(size);
final roomName = widget.room.getLocalizedDisplayname(); final roomName = widget.room.getLocalizedDisplayname();
final placeholder = widget.room.isDirectChat final placeholder = widget.room.isDirectChat
@@ -310,12 +327,28 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (widget.replyTo != null) // Antwort-Leiste federt auf und klappt beim Schließen weich zu.
_ReplyBar( AnimatedSwitcher(
event: widget.replyTo!, duration: motionDuration(context, 300),
onClear: widget.onClearReply ?? () {}, reverseDuration: motionDuration(context, 160),
pt: pt, switchInCurve: Curves.easeOutBack,
switchOutCurve: Curves.easeIn,
transitionBuilder: (child, anim) => SizeTransition(
sizeFactor: anim,
alignment: Alignment.bottomCenter,
child: FadeTransition(opacity: anim, child: child),
), ),
child: widget.replyTo != null
? KeyedSubtree(
key: ValueKey('reply:${widget.replyTo!.eventId}'),
child: _ReplyBar(
event: widget.replyTo!,
onClear: widget.onClearReply ?? () {},
pt: pt,
),
)
: const SizedBox(key: ValueKey('reply:none'), width: double.infinity),
),
Padding( Padding(
// Bottom inset matches the profile block in the rooms list (8px) so // Bottom inset matches the profile block in the rooms list (8px) so
// the composer sits flush with it. // the composer sits flush with it.
@@ -327,8 +360,10 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
// block in the rooms list (~89px on the user's display). Grows for // block in the rooms list (~89px on the user's display). Grows for
// multiline. // multiline.
constraints: const BoxConstraints(minHeight: 53), constraints: const BoxConstraints(minHeight: 53),
// Rahmen + Fokus-Ring außen (würden vom Zuschnitt sonst
// abgeschnitten), innen Milchglas: der Chat läuft unter dem
// schwebenden Feld durch und scheint unscharf durch.
decoration: BoxDecoration( decoration: BoxDecoration(
color: pt.bg2,
borderRadius: BorderRadius.circular(pt.rBase + 4), borderRadius: BorderRadius.circular(pt.rBase + 4),
border: Border.all( border: Border.all(
color: _focus.hasFocus ? pt.accent : pt.border, color: _focus.hasFocus ? pt.accent : pt.border,
@@ -337,7 +372,13 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
? [BoxShadow(color: pt.accentSoft, blurRadius: 0, spreadRadius: 3)] ? [BoxShadow(color: pt.accentSoft, blurRadius: 0, spreadRadius: 3)]
: null, : null,
), ),
child: Focus( child: ClipRRect(
borderRadius: BorderRadius.circular(pt.rBase + 3),
child: BackdropFilter(
filter: ui.ImageFilter.blur(sigmaX: 12, sigmaY: 12),
child: ColoredBox(
color: pt.bg2.withValues(alpha: 0.78),
child: Focus(
onFocusChange: (_) => setState(() {}), onFocusChange: (_) => setState(() {}),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
@@ -347,7 +388,7 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
icon: Icons.add_rounded, icon: Icons.add_rounded,
tooltip: 'Datei anhängen', tooltip: 'Datei anhängen',
pt: pt, pt: pt,
size: size.width < 600 ? 24 : 30, size: mobile ? 42 : 30,
onTap: _pickAttachment, onTap: _pickAttachment,
), ),
// Text input // Text input
@@ -397,14 +438,14 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
icon: Icons.gif_box_outlined, icon: Icons.gif_box_outlined,
tooltip: 'GIF', tooltip: 'GIF',
pt: pt, pt: pt,
size: size.width < 600 ? 22 : 26, size: mobile ? 40 : 26,
onTap: _toggleGifPicker, onTap: _toggleGifPicker,
), ),
_ComposerIconBtn( _ComposerIconBtn(
icon: Icons.tag_faces_rounded, icon: Icons.tag_faces_rounded,
tooltip: 'Emoji', tooltip: 'Emoji',
pt: pt, pt: pt,
size: size.width < 600 ? 22 : 26, size: mobile ? 40 : 26,
onTap: _toggleEmojiPicker, onTap: _toggleEmojiPicker,
), ),
if (size.width > 600) if (size.width > 600)
@@ -424,10 +465,14 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
canSend: _canSend, canSend: _canSend,
pt: pt, pt: pt,
onTap: _send, onTap: _send,
size: mobile ? 42 : 36,
), ),
), ),
], ],
), ),
),
),
),
), ),
), ),
), ),
@@ -461,9 +506,7 @@ class _ComposerIconBtnState extends State<_ComposerIconBtn> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final platform = Theme.of(context).platform; final isMobile = isMobileLayout(MediaQuery.sizeOf(context));
final isMobile =
platform == TargetPlatform.android || platform == TargetPlatform.iOS;
final touchSize = isMobile ? 44.0 : widget.size; final touchSize = isMobile ? 44.0 : widget.size;
return MouseRegion( return MouseRegion(
@@ -475,7 +518,9 @@ class _ComposerIconBtnState extends State<_ComposerIconBtn> {
child: Tooltip( child: Tooltip(
message: widget.tooltip, message: widget.tooltip,
waitDuration: const Duration(milliseconds: 500), waitDuration: const Duration(milliseconds: 500),
child: SizedBox( child: SpringPress(
enabled: widget.onTap != null,
child: SizedBox(
width: touchSize, width: touchSize,
height: touchSize, height: touchSize,
child: Center( child: Center(
@@ -497,6 +542,7 @@ class _ComposerIconBtnState extends State<_ComposerIconBtn> {
), ),
), ),
), ),
),
), ),
), ),
); );
@@ -507,8 +553,14 @@ class _SendButton extends StatefulWidget {
final bool canSend; final bool canSend;
final PyramidTheme pt; final PyramidTheme pt;
final VoidCallback onTap; final VoidCallback onTap;
final double size;
const _SendButton({required this.canSend, required this.pt, required this.onTap}); const _SendButton({
required this.canSend,
required this.pt,
required this.onTap,
this.size = 36,
});
@override @override
State<_SendButton> createState() => _SendButtonState(); State<_SendButton> createState() => _SendButtonState();
@@ -528,11 +580,14 @@ class _SendButtonState extends State<_SendButton> {
onExit: (_) => setState(() => _hovered = false), onExit: (_) => setState(() => _hovered = false),
child: GestureDetector( child: GestureDetector(
onTap: active ? widget.onTap : null, onTap: active ? widget.onTap : null,
child: AnimatedContainer( child: SpringPress(
enabled: active,
pressedScale: 0.82,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180), duration: const Duration(milliseconds: 180),
curve: Curves.easeOutBack, curve: Curves.easeOutBack,
width: 36, width: widget.size,
height: 36, height: widget.size,
decoration: BoxDecoration( decoration: BoxDecoration(
color: active ? pt.accent : pt.bg3, color: active ? pt.accent : pt.bg3,
borderRadius: BorderRadius.circular(pt.rSm), borderRadius: BorderRadius.circular(pt.rSm),
@@ -547,13 +602,22 @@ class _SendButtonState extends State<_SendButton> {
: Matrix4.identity(), : Matrix4.identity(),
transformAlignment: Alignment.center, transformAlignment: Alignment.center,
child: Center( child: Center(
child: Icon( // Sobald gesendet werden kann, ploppt der Pfeil federnd auf.
Icons.send_rounded, child: AnimatedScale(
size: 16, scale: active ? 1 : 0.8,
color: active ? pt.accentFg : pt.fgDim, duration: active
? motionDuration(context, 520)
: const Duration(milliseconds: 140),
curve: active ? Curves.elasticOut : Curves.easeOut,
child: Icon(
Icons.send_rounded,
size: widget.size * 0.45,
color: active ? pt.accentFg : pt.fgDim,
),
), ),
), ),
), ),
),
), ),
); );
} }
@@ -599,9 +663,20 @@ class _ReplyBar extends StatelessWidget {
], ],
), ),
), ),
// Handy: größeres X mit fingergerechter Trefferfläche.
GestureDetector( GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onClear, onTap: onClear,
child: Icon(Icons.close_rounded, size: 16, color: pt.fgDim), child: SpringPress(
child: Padding(
padding: const EdgeInsets.all(6),
child: Icon(
Icons.close_rounded,
size: isMobileLayout(MediaQuery.sizeOf(context)) ? 22 : 16,
color: pt.fgDim,
),
),
),
), ),
], ],
), ),
+56
View File
@@ -10,7 +10,9 @@ import 'package:pyramid/core/matrix_client.dart';
class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> { class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
Timeline? _timeline; Timeline? _timeline;
StreamSubscription<String>? _roomUpdateSub; StreamSubscription<String>? _roomUpdateSub;
StreamSubscription<SyncUpdate>? _syncSub;
Timer? _redecryptTimer; Timer? _redecryptTimer;
String _receiptSig = '';
@override @override
Future<Timeline> build(String arg) async { Future<Timeline> build(String arg) async {
@@ -21,6 +23,7 @@ class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
_timeline?.cancelSubscriptions(); _timeline?.cancelSubscriptions();
_roomUpdateSub?.cancel(); _roomUpdateSub?.cancel();
_syncSub?.cancel();
_redecryptTimer?.cancel(); _redecryptTimer?.cancel();
final diag = ref.read(e2eeDiagnosticsProvider.notifier); final diag = ref.read(e2eeDiagnosticsProvider.notifier);
@@ -49,6 +52,28 @@ class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
}); });
}); });
// Nach jedem Sync (erst dann sind Lesebestätigungen verarbeitet – das
// Raum-Update kommt im SDK VORHER):
// • Einladung angenommen (invite → join): Verlauf neu aufbauen; der beim
// Einladen gebaute blieb sonst leer, bis man die App neu startete.
// • Lesebestätigung eines anderen hat sich geändert: neu zeichnen, damit
// ✓✓ live erscheint. Neue Nachrichten allein lösen _onUpdate schon aus,
// eine Bestätigung ohne neue Nachricht bisher nicht.
final membershipAtBuild = room.membership;
_receiptSig = _receiptSignature(room);
_syncSub = client.onSync.stream.listen((_) {
if (membershipAtBuild != Membership.join &&
room.membership == Membership.join) {
ref.invalidateSelf();
return;
}
final sig = _receiptSignature(room);
if (sig != _receiptSig) {
_receiptSig = sig;
_onUpdate();
}
});
// Ensure device keys are downloaded for all room members so outbound // Ensure device keys are downloaded for all room members so outbound
// Megolm session key sharing works on the first send. // Megolm session key sharing works on the first send.
if (room.encrypted && client.encryptionEnabled) { if (room.encrypted && client.encryptionEnabled) {
@@ -57,6 +82,7 @@ class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
ref.onDispose(() { ref.onDispose(() {
_roomUpdateSub?.cancel(); _roomUpdateSub?.cancel();
_syncSub?.cancel();
_redecryptTimer?.cancel(); _redecryptTimer?.cancel();
_timeline?.cancelSubscriptions(); _timeline?.cancelSubscriptions();
_timeline = null; _timeline = null;
@@ -70,6 +96,36 @@ class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
} }
} }
/// Kurzer Fingerabdruck der Lesepositionen der anderen Mitglieder.
String _receiptSignature(Room room) {
final b = StringBuffer();
for (final users in [
room.receiptState.global.otherUsers,
room.receiptState.mainThread?.otherUsers ?? const {},
]) {
users.forEach((userId, data) => b
..write(userId)
..write('=')
..write(data.eventId)
..write(';'));
}
return b.toString();
}
/// Einladung annehmen und warten, bis der Raum als beigetreten
/// synchronisiert ist – erst dann hat der Verlauf Inhalt.
Future<void> acceptInvite(WidgetRef ref, Room room) async {
final client = room.client;
// Vor dem Beitreten anfangen zu warten, sonst kann der Sync durchrutschen.
final synced = client
.waitForRoomInSync(room.id, join: true)
.timeout(const Duration(seconds: 20))
.then<void>((_) {}, onError: (_) {});
await room.join();
await synced;
ref.invalidate(timelineProvider(room.id));
}
final timelineProvider = final timelineProvider =
AsyncNotifierProvider.family<TimelineNotifier, Timeline, String>( AsyncNotifierProvider.family<TimelineNotifier, Timeline, String>(
TimelineNotifier.new, TimelineNotifier.new,
+4
View File
@@ -10,6 +10,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:pyramid/core/app_state.dart'; import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/appearance.dart' show isMobileLayout;
import 'package:pyramid/core/matrix_client.dart'; import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/settings_prefs.dart'; import 'package:pyramid/core/settings_prefs.dart';
import 'package:pyramid/core/theme.dart'; import 'package:pyramid/core/theme.dart';
@@ -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/message_group.dart';
import 'package:pyramid/features/chat/pinned_messages_panel.dart'; import 'package:pyramid/features/chat/pinned_messages_panel.dart';
import 'package:pyramid/widgets/hover_region.dart'; import 'package:pyramid/widgets/hover_region.dart';
import 'package:pyramid/widgets/measure_size.dart';
import 'package:pyramid/widgets/spring_press.dart';
import 'package:pyramid/core/read_markers.dart';
import 'package:pyramid/widgets/mxc_image.dart'; import 'package:pyramid/widgets/mxc_image.dart';
import 'package:pyramid/core/notification_service.dart'; import 'package:pyramid/core/notification_service.dart';
+1 -1
View File
@@ -6,7 +6,7 @@ import 'package:archive/archive.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:pdfx/pdfx.dart'; import 'package:pdfx/pdfx.dart';
+13 -2
View File
@@ -6,6 +6,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:pyramid/core/theme.dart'; import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/chat/media_player.dart'; import 'package:pyramid/features/chat/media_player.dart';
import 'package:pyramid/utils/gif_favorite_service.dart'; import 'package:pyramid/utils/gif_favorite_service.dart';
import 'package:pyramid/widgets/spring_press.dart' show motionDuration;
/// Overlay viewer for images, GIFs and videos. /// Overlay viewer for images, GIFs and videos.
/// ///
@@ -50,9 +51,19 @@ class MediaViewer extends StatefulWidget {
// Den Hintergrund zeichnet der Viewer selbst (damit er beim Wischen // Den Hintergrund zeichnet der Viewer selbst (damit er beim Wischen
// ausblenden kann). // ausblenden kann).
barrierColor: Colors.transparent, barrierColor: Colors.transparent,
transitionDuration: const Duration(milliseconds: 180), transitionDuration: motionDuration(context, 300),
transitionBuilder: (context, animation, secondaryAnimation, child) => transitionBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child), FadeTransition(
opacity: animation,
child: ScaleTransition(
scale: Tween(begin: 0.9, end: 1.0).animate(CurvedAnimation(
parent: animation,
curve: Curves.easeOutBack,
reverseCurve: Curves.easeIn,
)),
child: child,
),
),
pageBuilder: (context, animation, secondaryAnimation) => MediaViewer( pageBuilder: (context, animation, secondaryAnimation) => MediaViewer(
senderName: senderName, senderName: senderName,
filename: filename, filename: filename,
@@ -1,5 +1,15 @@
part of '../message_group.dart'; part of '../message_group.dart';
/// Bereits federnd eingeblendete Nachrichten (begrenzt, älteste fallen raus).
final _poppedMessageIds = <String>{};
void _rememberPopped(String id) {
_poppedMessageIds.add(id);
if (_poppedMessageIds.length > 300) {
_poppedMessageIds.remove(_poppedMessageIds.first);
}
}
String _formatMessageTime(DateTime t) { String _formatMessageTime(DateTime t) {
final h = t.hour.toString().padLeft(2, '0'); final h = t.hour.toString().padLeft(2, '0');
final m = t.minute.toString().padLeft(2, '0'); final m = t.minute.toString().padLeft(2, '0');
@@ -28,6 +38,10 @@ class MessageGroup extends StatefulWidget {
final GlobalKey? jumpKey; final GlobalKey? jumpKey;
final String? highlightEventId; final String? highlightEventId;
final bool highlightOn; final bool highlightOn;
/// Nachrichten, die nach diesem Zeitpunkt eintreffen (oder gerade
/// gesendet werden), ploppen beim ersten Erscheinen federnd auf. null = aus
/// (z. B. solange der Chat gerade erst geöffnet wird).
final DateTime? popInAfter;
const MessageGroup({ const MessageGroup({
super.key, super.key,
@@ -45,6 +59,7 @@ class MessageGroup extends StatefulWidget {
this.jumpKey, this.jumpKey,
this.highlightEventId, this.highlightEventId,
this.highlightOn = false, this.highlightOn = false,
this.popInAfter,
}); });
@override @override
@@ -301,15 +316,39 @@ class _MessageGroupState extends State<MessageGroup> {
), ),
); );
if (_isMobile) return row; if (_isMobile) return _popIn(event, row);
// Desktop: per-row hover highlight + actions. // Desktop: per-row hover highlight + actions.
return MouseRegion( return _popIn(event, MouseRegion(
onEnter: (_) => setState(() => _hoveredEventId = event.eventId), onEnter: (_) => setState(() => _hoveredEventId = event.eventId),
onExit: (_) => setState(() { onExit: (_) => setState(() {
if (_hoveredEventId == event.eventId) _hoveredEventId = null; if (_hoveredEventId == event.eventId) _hoveredEventId = null;
}), }),
child: row, child: row,
));
}
// Neu angekommene oder gerade gesendete Nachricht: federnd einblenden.
// Der Schlüssel (Transaktions-ID bleibt beim Senden gleich) hält den
// Zustand der Zeile, die Merkliste verhindert ein zweites Abspielen, falls
// die Gruppe neu aufgebaut wird (z. B. lokale Kopie → Server-Event).
Widget _popIn(Event event, Widget row) {
final id = event.transactionId ?? event.eventId;
final after = widget.popInAfter;
final fresh = after != null &&
!_poppedMessageIds.contains(id) &&
(event.status.isSending ||
(event.originServerTs.isAfter(after) &&
DateTime.now().difference(event.originServerTs) <
const Duration(minutes: 2)));
if (fresh) _rememberPopped(id);
return PopIn(
key: ValueKey('row:$id'),
play: fresh,
alignment: event.senderId == widget.currentUserId
? Alignment.bottomRight
: Alignment.bottomLeft,
child: row,
); );
} }
@@ -159,7 +159,18 @@ class _ReplyPreview extends StatelessWidget {
// matching WhatsApp/Discord. Falls back to stripped text for m.text. // matching WhatsApp/Discord. Falls back to stripped text for m.text.
final (IconData? icon, String body) = _previewFor(replyEvent); final (IconData? icon, String body) = _previewFor(replyEvent);
return Padding( // Antippen springt zur zitierten Nachricht und lässt sie kurz aufleuchten
// (gleicher Mechanismus wie der Sprung aus der Suche).
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => ProviderScope.containerOf(context, listen: false)
.read(pendingJumpEventProvider.notifier)
.state = replyEvent.eventId,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: SpringPress(
pressedScale: 0.97,
child: Padding(
padding: const EdgeInsets.only(bottom: 4), padding: const EdgeInsets.only(bottom: 4),
child: IntrinsicHeight( child: IntrinsicHeight(
child: ClipRRect( child: ClipRRect(
@@ -204,6 +215,9 @@ class _ReplyPreview extends StatelessWidget {
), ),
), ),
), ),
),
),
),
), ),
); );
} }
@@ -22,11 +22,15 @@ class _ReactionsRow extends StatelessWidget {
// Group by emoji key → list of senders // Group by emoji key → list of senders
final Map<String, List<String>> grouped = {}; final Map<String, List<String>> grouped = {};
final newest = <String, DateTime>{};
for (final r in reactionEvents) { for (final r in reactionEvents) {
final key = r.content.tryGetMap<String, dynamic>('m.relates_to')?['key'] as String?; final key = r.content.tryGetMap<String, dynamic>('m.relates_to')?['key'] as String?;
if (key == null || key.isEmpty) continue; if (key == null || key.isEmpty) continue;
(grouped[key] ??= []).add(r.senderId); (grouped[key] ??= []).add(r.senderId);
final ts = r.originServerTs;
if (newest[key] == null || ts.isAfter(newest[key]!)) newest[key] = ts;
} }
final now = DateTime.now();
if (grouped.isEmpty) return const SizedBox.shrink(); if (grouped.isEmpty) return const SizedBox.shrink();
return Padding( return Padding(
@@ -37,6 +41,9 @@ class _ReactionsRow extends StatelessWidget {
children: grouped.entries.map((e) { children: grouped.entries.map((e) {
final myReaction = e.value.contains(currentUserId); final myReaction = e.value.contains(currentUserId);
return _ReactionChip( return _ReactionChip(
key: ValueKey(e.key),
// Gerade erst vergeben → federnd aufploppen.
fresh: now.difference(newest[e.key]!) < const Duration(seconds: 4),
emoji: e.key, emoji: e.key,
count: e.value.length, count: e.value.length,
mine: myReaction, mine: myReaction,
@@ -69,8 +76,11 @@ class _ReactionChip extends StatefulWidget {
final bool mine; final bool mine;
final PyramidTheme pt; final PyramidTheme pt;
final VoidCallback onTap; final VoidCallback onTap;
final bool fresh;
const _ReactionChip({ const _ReactionChip({
super.key,
this.fresh = false,
required this.emoji, required this.emoji,
required this.count, required this.count,
required this.mine, required this.mine,
@@ -88,12 +98,22 @@ class _ReactionChipState extends State<_ReactionChip> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final pt = widget.pt; final pt = widget.pt;
return MouseRegion( return PopIn(
play: widget.fresh,
fromScale: 0.3,
offsetY: 0,
alignment: Alignment.center,
milliseconds: 520,
child: SpringBump(
value: widget.count,
child: MouseRegion(
cursor: SystemMouseCursors.click, cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true), onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false), onExit: (_) => setState(() => _hovered = false),
child: GestureDetector( child: GestureDetector(
onTap: widget.onTap, onTap: widget.onTap,
child: SpringPress(
pressedScale: 0.85,
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 120), duration: const Duration(milliseconds: 120),
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
@@ -122,6 +142,9 @@ class _ReactionChipState extends State<_ReactionChip> {
], ],
), ),
), ),
),
),
),
), ),
); );
} }
+3 -1
View File
@@ -9,7 +9,8 @@ import 'package:pyramid/features/chat/media_player.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:pyramid/core/app_state.dart' show messageStyleProvider; import 'package:pyramid/core/app_state.dart'
show messageStyleProvider, pendingJumpEventProvider;
import 'package:pyramid/core/appearance.dart'; import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/matrix_client.dart'; import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/media_cache.dart'; import 'package:pyramid/core/media_cache.dart';
@@ -21,6 +22,7 @@ import 'package:pyramid/features/chat/media_viewer.dart';
import 'package:pyramid/features/chat/reaction_service.dart'; import 'package:pyramid/features/chat/reaction_service.dart';
import 'package:pyramid/utils/gif_favorite_service.dart'; import 'package:pyramid/utils/gif_favorite_service.dart';
import 'package:pyramid/widgets/mxc_image.dart'; import 'package:pyramid/widgets/mxc_image.dart';
import 'package:pyramid/widgets/spring_press.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
part 'message/message_group_core.dart'; part 'message/message_group_core.dart';
+309 -203
View File
@@ -42,11 +42,13 @@ class _ChatHeader extends ConsumerWidget {
children: [ children: [
// Back button on mobile // Back button on mobile
if (isMobile) ...[ if (isMobile) ...[
GestureDetector( PyrIconBtn(
onTap: () => ref.read(activeRoomIdProvider.notifier).state = null, icon: Icon(Icons.arrow_back_rounded, color: pt.fg),
child: Icon(Icons.arrow_back_rounded, size: 22, color: pt.fg), tooltip: 'Zurück',
onPressed: () =>
ref.read(activeRoomIdProvider.notifier).state = null,
), ),
const SizedBox(width: 12), const SizedBox(width: 4),
], ],
// Room icon / avatar // Room icon / avatar
if (isDm && client != null) ...[ if (isDm && client != null) ...[
@@ -141,19 +143,27 @@ class _ChatHeader extends ConsumerWidget {
onPressed: onTogglePinned, onPressed: onTogglePinned,
), ),
], ],
PyrIconBtn( if (isMobile)
size: 28, _ChatOverflowMenu(
icon: const Icon(Icons.search_rounded), pt: pt,
tooltip: 'Suchen', onSearch: onOpenSearch,
onPressed: onOpenSearch, onMembers: onToggleMembers,
), )
PyrIconBtn( else ...[
size: 28, PyrIconBtn(
icon: const Icon(Icons.group_rounded), size: 28,
tooltip: 'Mitglieder', icon: const Icon(Icons.search_rounded),
active: membersOpen, tooltip: 'Suchen',
onPressed: onToggleMembers, onPressed: onOpenSearch,
), ),
PyrIconBtn(
size: 28,
icon: const Icon(Icons.group_rounded),
tooltip: 'Mitglieder',
active: membersOpen,
onPressed: onToggleMembers,
),
],
], ],
), ),
], ],
@@ -176,8 +186,10 @@ class _ChatHeader extends ConsumerWidget {
await ref.read(voiceChannelProvider).startCall( await ref.read(voiceChannelProvider).startCall(
roomName: roomId, roomName: roomId,
roomDisplayName: room.getLocalizedDisplayname(), roomDisplayName: room.getLocalizedDisplayname(),
identity: 'user_${DateTime.now().millisecondsSinceEpoch}', identity: room.client.userID ?? '',
audioOnly: !cam, audioOnly: !cam,
// Für das Beitritts-Token (Server prüft den Matrix-Login).
matrixClient: room.client,
); );
} }
} }
@@ -222,6 +234,20 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
Profile? _profile; Profile? _profile;
String? _bannerMxcUri; String? _bannerMxcUri;
// Breite der Symbolleiste (gemessen nach dem Zeichnen) – eingeklappt reicht
// der Name genau bis davor.
final _actionsKey = GlobalKey();
double _actionsWidth = 140;
void _measureActions() {
final box = _actionsKey.currentContext?.findRenderObject() as RenderBox?;
if (box == null || !box.hasSize) return;
final w = box.size.width;
if ((w - _actionsWidth).abs() > 0.5 && mounted) {
setState(() => _actionsWidth = w);
}
}
String? _statusMsg; String? _statusMsg;
bool _online = false; bool _online = false;
@@ -291,6 +317,7 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
WidgetsBinding.instance.addPostFrameCallback((_) => _measureActions());
final pt = widget.pt; final pt = widget.pt;
final expanded = widget.expanded; final expanded = widget.expanded;
final size = MediaQuery.sizeOf(context); final size = MediaQuery.sizeOf(context);
@@ -361,198 +388,222 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
), ),
), ),
), ),
Padding( // Profilbereich: gleitet beim Aufklappen unter die oberste Zeile und
padding: const EdgeInsets.symmetric(horizontal: 16), // nutzt dann die volle Breite (vorher teilte er sie mit den Symbolen –
child: Row( // aufgeklappt wurde der Name zu „p…“ gekürzt).
children: [ AnimatedPositioned(
if (isMobile) ...[ duration: _animDuration,
GestureDetector( curve: _animCurve,
onTap: () => left: expanded ? 16 : (isMobile ? 54 : 16),
ref.read(activeRoomIdProvider.notifier).state = null, right: expanded ? 16 : _actionsWidth + (isMobile ? 14 : 24),
child: top: expanded ? 44 : 0,
Icon(Icons.arrow_back_rounded, size: 22, color: pt.fg), bottom: 0,
), child: MouseRegion(
const SizedBox(width: 12), cursor: SystemMouseCursors.click,
], child: GestureDetector(
// Klickbarer Profilbereich: Avatar + Name + Details behavior: HitTestBehavior.opaque,
Expanded( onTap: widget.onToggle,
child: MouseRegion( child: Row(
cursor: SystemMouseCursors.click, children: [
child: GestureDetector( // Avatar — skaliert mit (rund, wie im Mockup)
behavior: HitTestBehavior.opaque, AnimatedContainer(
onTap: widget.onToggle, duration: _animDuration,
child: Row( curve: _animCurve,
children: [ width: avatarSize,
// Avatar — skaliert mit (rund, wie im Mockup) height: avatarSize,
AnimatedContainer( decoration: BoxDecoration(
duration: _animDuration, shape: BoxShape.circle,
curve: _animCurve, color: pt.bg3,
width: avatarSize, ),
height: avatarSize, clipBehavior: Clip.antiAlias,
decoration: BoxDecoration( child: avatarUrl != null && client != null
shape: BoxShape.circle, ? MxcImage(
color: pt.bg3, mxcUri: avatarUrl.toString(),
), client: client,
clipBehavior: Clip.antiAlias, fit: BoxFit.cover,
child: avatarUrl != null && client != null placeholder:
? MxcImage( _initialBox(initial, pt, avatarSize),
mxcUri: avatarUrl.toString(), )
client: client, : _initialBox(initial, pt, avatarSize),
fit: BoxFit.cover, ),
placeholder: _initialBox(initial, pt), AnimatedContainer(
) duration: _animDuration,
: _initialBox(initial, pt), curve: _animCurve,
), width: expanded ? 20 : 10,
AnimatedContainer( ),
duration: _animDuration, Expanded(
curve: _animCurve, child: Column(
width: expanded ? 20 : 10, mainAxisAlignment: MainAxisAlignment.center,
), crossAxisAlignment: CrossAxisAlignment.start,
Expanded( children: [
child: Column( // Name skaliert mit; Status blendet daneben ein
mainAxisAlignment: MainAxisAlignment.center, Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
// Name skaliert mit; Status blendet daneben ein Flexible(
Row( child: AnimatedDefaultTextStyle(
crossAxisAlignment: CrossAxisAlignment.center, duration: _animDuration,
children: [ curve: _animCurve,
Flexible( style: TextStyle(
child: AnimatedDefaultTextStyle( color: pt.fg,
duration: _animDuration, fontSize: expanded ? 24 : 15,
curve: _animCurve, fontWeight: expanded
style: TextStyle( ? FontWeight.w700
color: pt.fg, : FontWeight.w600,
fontSize: expanded ? 24 : 15,
fontWeight: expanded
? FontWeight.w700
: FontWeight.w600,
),
child: Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
), ),
AnimatedOpacity( child: Text(
opacity: expanded ? 1 : 0, name,
duration: _animDuration, maxLines: 1,
child: Padding( overflow: TextOverflow.ellipsis,
padding:
const EdgeInsets.only(left: 8),
child: Text(
_online ? 'online' : 'offline',
style: TextStyle(
color: _online
? pt.online
: pt.fgMuted,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
), ),
], ),
), ),
// Eingeklappt: "zuletzt online vor…", AnimatedOpacity(
// aufgeklappt: Handle + Biographie. opacity: expanded ? 1 : 0,
AnimatedCrossFade(
duration: _animDuration, duration: _animDuration,
sizeCurve: _animCurve, child: Padding(
crossFadeState: expanded padding:
? CrossFadeState.showSecond const EdgeInsets.only(left: 8),
: CrossFadeState.showFirst, child: Text(
firstChild: client != null _online ? 'online' : 'offline',
? _PresenceLine( style: TextStyle(
userId: _partnerId, color: _online
client: client, ? pt.online
pt: pt, : pt.fgMuted,
) fontSize: 13,
: const SizedBox.shrink(), fontWeight: FontWeight.w500,
secondChild: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text(
userId,
style: TextStyle(
color: pt.fgMuted, fontSize: 13),
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
if (_statusMsg != null && ),
_statusMsg!.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
_statusMsg!,
style: TextStyle(
color: pt.fgMuted,
fontSize: 13,
height: 1.35),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
), ),
), ),
], ],
), ),
), // Eingeklappt: "zuletzt online vor…",
], // aufgeklappt: Handle + Biographie.
AnimatedCrossFade(
duration: _animDuration,
sizeCurve: _animCurve,
crossFadeState: expanded
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
firstChild: client != null
? _PresenceLine(
userId: _partnerId,
client: client,
pt: pt,
)
: const SizedBox.shrink(),
secondChild: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text(
userId,
style: TextStyle(
color: pt.fgMuted, fontSize: 13),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (_statusMsg != null &&
_statusMsg!.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
_statusMsg!,
style: TextStyle(
color: pt.fgMuted,
fontSize: 13,
height: 1.35),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
],
),
), ),
), ],
), ),
),
),
),
// Zurück-Pfeil und Aktionen: fest in der obersten 52-px-Zeile –
// sie rutschen beim Aufklappen nicht mehr in die Mitte.
if (isMobile)
Positioned(
left: 6,
top: 0,
height: 52,
child: Center(
child: PyrIconBtn(
icon: Icon(Icons.arrow_back_rounded, color: pt.fg),
tooltip: 'Zurück',
onPressed: () =>
ref.read(activeRoomIdProvider.notifier).state = null,
), ),
const SizedBox(width: 12), ),
// Aktionen — bleiben immer sichtbar ),
Row( Positioned(
right: isMobile ? 6 : 16,
top: 0,
height: 52,
child: KeyedSubtree(
key: _actionsKey,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
PyrIconBtn( PyrIconBtn(
size: 28, size: 28,
icon: const Icon(Icons.phone_rounded), icon: const Icon(Icons.phone_rounded),
tooltip: 'Voice call', tooltip: 'Voice call',
onPressed: () => _startCall(cam: false), onPressed: () => _startCall(cam: false),
), ),
PyrIconBtn( PyrIconBtn(
size: 28, size: 28,
icon: const Icon(Icons.videocam_rounded), icon: const Icon(Icons.videocam_rounded),
tooltip: 'Video call', tooltip: 'Video call',
onPressed: () => _startCall(cam: true), onPressed: () => _startCall(cam: true),
), ),
if (size.width > 700) if (size.width > 700)
Container( Container(
width: 1, width: 1,
height: 20, height: 20,
margin: const EdgeInsets.symmetric(horizontal: 4), margin: const EdgeInsets.symmetric(horizontal: 4),
color: pt.border, color: pt.border,
), ),
if (size.width > 800) if (size.width > 800)
PyrIconBtn( PyrIconBtn(
size: 28, size: 28,
icon: const Icon(Icons.push_pin_rounded), icon: const Icon(Icons.push_pin_rounded),
tooltip: 'Angepinnte Nachrichten', tooltip: 'Angepinnte Nachrichten',
active: widget.pinnedOpen, active: widget.pinnedOpen,
onPressed: widget.onTogglePinned, onPressed: widget.onTogglePinned,
), ),
PyrIconBtn( if (isMobile)
size: 28, _ChatOverflowMenu(
icon: const Icon(Icons.search_rounded), pt: pt,
tooltip: 'Suchen', onSearch: widget.onOpenSearch,
onPressed: widget.onOpenSearch, onMembers: widget.onToggleMembers,
), )
PyrIconBtn( else ...[
size: 28, PyrIconBtn(
icon: const Icon(Icons.group_rounded), size: 28,
tooltip: 'Mitglieder', icon: const Icon(Icons.search_rounded),
active: widget.membersOpen, tooltip: 'Suchen',
onPressed: widget.onToggleMembers, onPressed: widget.onOpenSearch,
), ),
], PyrIconBtn(
size: 28,
icon: const Icon(Icons.group_rounded),
tooltip: 'Mitglieder',
active: widget.membersOpen,
onPressed: widget.onToggleMembers,
), ),
], ],
],
),
), ),
), ),
], ],
@@ -560,15 +611,25 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
); );
} }
Widget _initialBox(String initial, PyramidTheme pt) => Center( // Kürzel exakt mittig: Schrift wächst mit dem Avatar mit (vorher feste
child: FittedBox( // 28 px in FittedBox mit Innenabstand – wirkte aufgeklappt verschoben).
fit: BoxFit.scaleDown, Widget _initialBox(String initial, PyramidTheme pt, double avatarSize) =>
child: Padding( Center(
padding: const EdgeInsets.all(6), child: AnimatedDefaultTextStyle(
child: Text( duration: _animDuration,
initial, curve: _animCurve,
style: TextStyle( textAlign: TextAlign.center,
color: pt.fg, fontSize: 28, fontWeight: FontWeight.w700), style: TextStyle(
color: pt.fg,
fontSize: avatarSize * 0.42,
fontWeight: FontWeight.w700,
height: 1.0,
),
child: Text(
initial,
textHeightBehavior: const TextHeightBehavior(
applyHeightToFirstAscent: false,
applyHeightToLastDescent: false,
), ),
), ),
), ),
@@ -733,3 +794,48 @@ class _PresenceLineState extends State<_PresenceLine> {
return 'vor ${(diff.inDays / 365).floor()}j'; return 'vor ${(diff.inDays / 365).floor()}j';
} }
} }
/// Handy: Suche und Mitglieder im ⋮-Menü statt als eigene Symbole
/// (Bernds Feedback 2026-10-07 – mehr Platz, größere Hauptsymbole).
class _ChatOverflowMenu extends StatelessWidget {
final PyramidTheme pt;
final VoidCallback onSearch;
final VoidCallback onMembers;
const _ChatOverflowMenu({
required this.pt,
required this.onSearch,
required this.onMembers,
});
@override
Widget build(BuildContext context) {
PopupMenuItem<VoidCallback> item(
IconData icon, String label, VoidCallback action) =>
PopupMenuItem(
value: action,
child: Row(
children: [
Icon(icon, size: 20, color: pt.fgMuted),
const SizedBox(width: 12),
Text(label, style: TextStyle(color: pt.fg, fontSize: 14)),
],
),
);
return PopupMenuButton<VoidCallback>(
tooltip: 'Mehr',
icon: Icon(Icons.more_vert_rounded, size: 22, color: pt.fgMuted),
color: pt.bg2,
elevation: 6,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rBase),
side: BorderSide(color: pt.border),
),
onSelected: (action) => action(),
itemBuilder: (_) => [
item(Icons.search_rounded, 'Suchen', onSearch),
item(Icons.group_rounded, 'Mitglieder', onMembers),
],
);
}
}
+11 -1
View File
@@ -19,6 +19,13 @@ class _MessageList extends StatelessWidget {
final String? highlightEventId; final String? highlightEventId;
final bool highlightOn; final bool highlightOn;
/// Höhe der darüber schwebenden Eingabeleiste: so viel Platz bleibt unten
/// frei, damit die neueste Nachricht ganz über die Leiste scrollen kann.
final double bottomInset;
/// Siehe MessageGroup.popInAfter.
final DateTime? popInAfter;
const _MessageList({ const _MessageList({
required this.timeline, required this.timeline,
required this.scrollCtrl, required this.scrollCtrl,
@@ -35,6 +42,8 @@ class _MessageList extends StatelessWidget {
this.jumpKey, this.jumpKey,
this.highlightEventId, this.highlightEventId,
this.highlightOn = false, this.highlightOn = false,
this.bottomInset = 0,
this.popInAfter,
}); });
@override @override
@@ -241,7 +250,7 @@ class _MessageList extends StatelessWidget {
controller: scrollCtrl, controller: scrollCtrl,
reverse: true, reverse: true,
physics: const ClampingScrollPhysics(), physics: const ClampingScrollPhysics(),
padding: const EdgeInsets.only(bottom: 8), padding: EdgeInsets.only(bottom: 8 + bottomInset),
scrollCacheExtent: const ScrollCacheExtent.pixels(2500), scrollCacheExtent: const ScrollCacheExtent.pixels(2500),
itemCount: itemKeys.length, itemCount: itemKeys.length,
findChildIndexCallback: (Key key) { findChildIndexCallback: (Key key) {
@@ -287,6 +296,7 @@ class _MessageList extends StatelessWidget {
jumpKey: jumpKey, jumpKey: jumpKey,
highlightEventId: highlightEventId, highlightEventId: highlightEventId,
highlightOn: highlightOn, highlightOn: highlightOn,
popInAfter: popInAfter,
); );
}, },
); );
+226
View File
@@ -129,3 +129,229 @@ class NoChatSelected extends StatelessWidget {
); );
} }
} }
/// Fläche der schwebenden Eingabeleiste: um das Eingabefeld herum völlig
/// klar – der Chat ist dort normal zu sehen –, fängt aber alle Berührungen
/// ab (ColoredBox ist auch transparent „opak“ für Treffer), damit dort nur
/// die Eingabe reagiert und nicht der Chat dahinter. Die Unschärfe sitzt nur
/// hinter dem Eingabefeld selbst (chat_composer.dart).
class _FloatingComposerArea extends StatelessWidget {
final PyramidTheme pt;
final Widget child;
const _FloatingComposerArea({required this.pt, required this.child});
@override
Widget build(BuildContext context) {
return ColoredBox(
color: Colors.transparent,
child: Padding(
padding: const EdgeInsets.only(top: 6),
child: child,
),
);
}
}
/// Einladung in diesen Chat (z. B. aus einer Benachrichtigung geöffnet):
/// Annehmen/Ablehnen direkt hier statt eines leeren Verlaufs – vorher ging
/// das nur in der Chatliste (Bernd 2026-10-07).
class _InvitePanel extends ConsumerStatefulWidget {
final Room room;
final PyramidTheme pt;
const _InvitePanel({required this.room, required this.pt});
@override
ConsumerState<_InvitePanel> createState() => _InvitePanelState();
}
class _InvitePanelState extends ConsumerState<_InvitePanel> {
bool _busy = false;
String? _error;
String get _inviter {
final me = widget.room.client.userID;
final sender = me == null
? null
: widget.room.getState(EventTypes.RoomMember, me)?.senderId;
if (sender == null) return 'Jemand';
return widget.room.unsafeGetUserFromMemoryOrFallback(sender).calcDisplayname();
}
Future<void> _accept() async {
setState(() {
_busy = true;
_error = null;
});
try {
await acceptInvite(ref, widget.room);
} catch (_) {
if (mounted) {
setState(() {
_busy = false;
_error = 'Annehmen hat nicht geklappt – bitte noch einmal versuchen.';
});
}
}
}
Future<void> _decline() async {
setState(() {
_busy = true;
_error = null;
});
try {
await widget.room.leave();
if (mounted) ref.read(activeRoomIdProvider.notifier).state = null;
} catch (_) {
if (mounted) {
setState(() {
_busy = false;
_error = 'Ablehnen hat nicht geklappt – bitte noch einmal versuchen.';
});
}
}
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final isDm = widget.room.isDirectChat;
return Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(28),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isDm ? Icons.person_add_alt_1_rounded : Icons.group_add_rounded,
size: 48,
color: pt.accent,
),
const SizedBox(height: 16),
Text(
isDm
? '$_inviter möchte mit dir chatten'
: '$_inviter lädt dich in „${widget.room.getLocalizedDisplayname()}“ ein',
textAlign: TextAlign.center,
style: TextStyle(
color: pt.fg,
fontSize: 17,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
'Nach dem Annehmen siehst du die Nachrichten und kannst antworten.',
textAlign: TextAlign.center,
style: TextStyle(color: pt.fgMuted, fontSize: 13.5),
),
const SizedBox(height: 24),
if (_busy)
SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(strokeWidth: 2.5, color: pt.accent),
)
else
Wrap(
spacing: 12,
runSpacing: 12,
alignment: WrapAlignment.center,
children: [
OutlinedButton(
onPressed: _decline,
style: OutlinedButton.styleFrom(
foregroundColor: pt.fgMuted,
side: BorderSide(color: pt.border),
padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 14),
),
child: const Text('Ablehnen'),
),
FilledButton(
onPressed: _accept,
style: FilledButton.styleFrom(
backgroundColor: pt.accent,
foregroundColor: pt.accentFg,
padding: const EdgeInsets.symmetric(horizontal: 26, vertical: 14),
),
child: const Text('Annehmen'),
),
],
),
if (_error != null) ...[
const SizedBox(height: 14),
Text(_error!,
textAlign: TextAlign.center,
style: TextStyle(color: pt.danger, fontSize: 13)),
],
],
),
),
);
}
}
/// Handy: Läuft ein Anruf, während ein Chat offen ist, zeigt diese Leiste den
/// Weg zurück (am PC übernimmt das die Anrufleiste in der Seitenleiste).
/// Vorher war im Chat nirgends zu sehen, dass/wie man zurück in den Anruf
/// kommt (Bernd 2026-10-07).
class _ActiveCallBar extends ConsumerWidget {
const _ActiveCallBar();
@override
Widget build(BuildContext context, WidgetRef ref) {
if (!isMobileLayout(MediaQuery.sizeOf(context))) return const SizedBox.shrink();
final voip = ref.watch(callSignalingProvider);
final lk = ref.watch(voiceChannelProvider);
final voipCall = voip.currentCall;
final String? name;
if (voipCall != null && voipCall.state != CallState.kEnded) {
name = voipCall.room.getLocalizedDisplayname();
} else if (lk.isActive || lk.isConnecting) {
name = lk.displayName;
} else {
name = null;
}
final pt = PyramidTheme.of(context);
return AnimatedSize(
duration: motionDuration(context, 220),
curve: Curves.easeOutCubic,
child: name == null
? const SizedBox(width: double.infinity)
: Material(
color: pt.online.withValues(alpha: 0.16),
child: InkWell(
onTap: () =>
ref.read(viewModeProvider.notifier).state = ViewMode.voice,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
children: [
Icon(Icons.call_rounded, size: 18, color: pt.online),
const SizedBox(width: 10),
Expanded(
child: Text(
'Anruf läuft · $name',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: pt.fg,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
Text('Zurück',
style: TextStyle(
color: pt.online,
fontSize: 13.5,
fontWeight: FontWeight.w700)),
Icon(Icons.chevron_right_rounded, size: 20, color: pt.online),
],
),
),
),
),
);
}
}
+214 -42
View File
@@ -28,9 +28,25 @@ class _ChatViewState extends ConsumerState<ChatView> {
// Fade-out of the "Neue Nachrichten" divider once the user reaches the bottom. // Fade-out of the "Neue Nachrichten" divider once the user reaches the bottom.
bool _unreadFading = false; bool _unreadFading = false;
Timer? _unreadFadeTimer; Timer? _unreadFadeTimer;
// So lange bleibt der Trenner stehen, nachdem man bei den neuesten
// Nachrichten angekommen ist (Bernd 2026-10-07: verschwand beim Öffnen aus
// der Benachrichtigung sofort).
static const _unreadDwell = Duration(seconds: 6);
Timer? _unreadDwellTimer;
// Zuletzt von hier als gelesen gemeldetes Event. Verhindert, dass jeder
// Neuaufbau erneut eine Lesebestätigung schickt, und markiert nach dem
// Zurückkehren in die App, ab wo „Neue Nachrichten“ beginnen.
String? _lastMarkedEventId;
// Was danach eintrifft, ploppt federnd auf (beim Öffnen gesetzt, damit die
// schon vorhandenen Nachrichten ruhig erscheinen).
DateTime? _popInAfter;
// Jump-to-message (from search): scroll to + briefly highlight a target event. // Jump-to-message (from search): scroll to + briefly highlight a target event.
final _jumpKey = GlobalKey(); final _jumpKey = GlobalKey();
// Höhe der schwebenden Eingabeleiste (gemessen, wächst mit Antwort-Leiste
// oder mehrzeiligem Text).
double _composerHeight = 72;
String? _jumpEventId; String? _jumpEventId;
String? _highlightEventId; String? _highlightEventId;
bool _highlightOn = false; bool _highlightOn = false;
@@ -77,11 +93,25 @@ class _ChatViewState extends ConsumerState<ChatView> {
setState(() { _jumpInProgress = false; _highlightEventId = null; _jumpEventId = null; _highlightOn = false; }); setState(() { _jumpInProgress = false; _highlightEventId = null; _jumpEventId = null; _highlightOn = false; });
return; return;
} }
// Not built yet — load older history toward the target every few tries. // Not built yet. Die Liste baut nur sichtbare Einträge (lazy, reverse):
// Ist das Ziel schon geladen, Richtung ältere Nachrichten scrollen, bis
// es gezeichnet ist (Antwort-Zitate zeigen fast immer nach oben); sonst
// zusätzlich ältere Historie nachladen.
final timeline = ref.read(timelineProvider(widget.roomId)).valueOrNull; final timeline = ref.read(timelineProvider(widget.roomId)).valueOrNull;
if (timeline != null && timeline.canRequestHistory && attempt % 3 == 0) { final loaded =
timeline?.events.any((e) => e.eventId == _jumpEventId) ?? false;
if (!loaded &&
timeline != null &&
timeline.canRequestHistory &&
attempt % 3 == 0) {
timeline.requestHistory(historyCount: 100).catchError((_) {}); timeline.requestHistory(historyCount: 100).catchError((_) {});
} }
if (_scrollCtrl.hasClients && (loaded || attempt % 3 == 1)) {
final pos = _scrollCtrl.position;
final step = pos.viewportDimension * 0.8;
final target = (pos.pixels + step).clamp(0.0, pos.maxScrollExtent);
if (target > pos.pixels) _scrollCtrl.jumpTo(target);
}
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 120), () => _attemptJump(attempt + 1)); Future.delayed(const Duration(milliseconds: 120), () => _attemptJump(attempt + 1));
}); });
@@ -93,6 +123,9 @@ class _ChatViewState extends ConsumerState<ChatView> {
bool get _inSelectionMode => _selectedIds.isNotEmpty; bool get _inSelectionMode => _selectedIds.isNotEmpty;
double get _scrollBtnSize =>
isMobileLayout(MediaQuery.sizeOf(context)) ? 44 : 36;
void _toggleSelect(String eventId) { void _toggleSelect(String eventId) {
setState(() { setState(() {
if (_selectedIds.contains(eventId)) { if (_selectedIds.contains(eventId)) {
@@ -109,12 +142,22 @@ class _ChatViewState extends ConsumerState<ChatView> {
void initState() { void initState() {
super.initState(); super.initState();
_scrollCtrl.addListener(_onScroll); _scrollCtrl.addListener(_onScroll);
// App/Fenster wieder im Vordergrund: was inzwischen kam, jetzt als
// gelesen melden – mit „Neue Nachrichten“-Trenner davor.
ref.listenManual<bool>(windowFocusedProvider, (prev, focused) {
if (focused && prev == false) _onFocusRegained();
});
} }
@override @override
void didUpdateWidget(ChatView oldWidget) { void didUpdateWidget(ChatView oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.roomId != widget.roomId) { if (oldWidget.roomId != widget.roomId) {
// Antwort/Auswahl gehören zum alten Raum – sonst ginge eine Antwort
// auf eine Nachricht aus Raum A in Raum B raus.
_replyTo = null;
_selectedIds.clear();
_showScrollDown = false;
_profileExpanded = false; _profileExpanded = false;
_sessionFullyReadId = null; _sessionFullyReadId = null;
_sessionHasUnread = false; _sessionHasUnread = false;
@@ -124,6 +167,9 @@ class _ChatViewState extends ConsumerState<ChatView> {
_historyExhausted = false; _historyExhausted = false;
_unreadFading = false; _unreadFading = false;
_unreadFadeTimer?.cancel(); _unreadFadeTimer?.cancel();
_unreadDwellTimer?.cancel();
_lastMarkedEventId = null;
_popInAfter = null;
_jumpEventId = null; _jumpEventId = null;
_highlightEventId = null; _highlightEventId = null;
_highlightOn = false; _highlightOn = false;
@@ -137,6 +183,7 @@ class _ChatViewState extends ConsumerState<ChatView> {
@override @override
void dispose() { void dispose() {
_unreadFadeTimer?.cancel(); _unreadFadeTimer?.cancel();
_unreadDwellTimer?.cancel();
_highlightTimer?.cancel(); _highlightTimer?.cancel();
_scrollCtrl.dispose(); _scrollCtrl.dispose();
super.dispose(); super.dispose();
@@ -174,6 +221,7 @@ class _ChatViewState extends ConsumerState<ChatView> {
setState(() { setState(() {
_sessionFullyReadId = newFullyReadId; _sessionFullyReadId = newFullyReadId;
_sessionHasUnread = newHasUnread; _sessionHasUnread = newHasUnread;
_popInAfter = events.firstOrNull?.originServerTs ?? DateTime.now();
}); });
if (newHasUnread) { if (newHasUnread) {
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToUnread(timeline)); WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToUnread(timeline));
@@ -206,10 +254,12 @@ class _ChatViewState extends ConsumerState<ChatView> {
duration: const Duration(milliseconds: 250), duration: const Duration(milliseconds: 250),
curve: Curves.easeOut, curve: Curves.easeOut,
); );
_scheduleUnreadFadeIfAtNewest();
return; return;
} }
if (attempt >= 24) { if (attempt >= 24) {
_didScrollToUnread = true; // give up — stay at the newest messages _didScrollToUnread = true; // give up — stay at the newest messages
_scheduleUnreadFadeIfAtNewest();
return; return;
} }
if (attempt > 0 && attempt % 4 == 0 && _scrollCtrl.hasClients) { if (attempt > 0 && attempt % 4 == 0 && _scrollCtrl.hasClients) {
@@ -224,21 +274,100 @@ class _ChatViewState extends ConsumerState<ChatView> {
} }
void _markRead([Timeline? tl]) { void _markRead([Timeline? tl]) {
// Nur als gelesen melden, was wirklich zu sehen ist: Läuft die App im
// Hintergrund (oder hat das Fenster keinen Fokus), bleibt alles ungelesen
// und die Benachrichtigung stehen – vorher wurde sie beim nächsten
// Neuaufbau sofort wieder entfernt und die Nachricht als gelesen gemeldet.
if (!ref.read(windowFocusedProvider)) return;
// Cancel any pending notification for this room (user is now reading it). // Cancel any pending notification for this room (user is now reading it).
cancelNotificationForRoom(widget.roomId); cancelNotificationForRoom(widget.roomId);
final sendReceipts = ref.read(privacyReadReceiptsProvider);
if (!sendReceipts) return;
final room = ref.read(roomProvider(widget.roomId)); final room = ref.read(roomProvider(widget.roomId));
if (room == null) return; if (room == null || room.membership != Membership.join) return;
if (room.markedUnread) room.markUnread(false).catchError((_) {});
final timeline = tl ?? ref.read(timelineProvider(widget.roomId)).valueOrNull; final timeline = tl ?? ref.read(timelineProvider(widget.roomId)).valueOrNull;
final latest = timeline?.events.firstOrNull; final latest = timeline?.events
if (latest != null && latest.status.isSynced) { .where((e) => e.status.isSynced)
room.setReadMarker(latest.eventId, mRead: latest.eventId) .firstOrNull;
.catchError((_) {}); if (latest == null || latest.eventId == _lastMarkedEventId) return;
} else { _lastMarkedEventId = latest.eventId;
room.markUnread(false).catchError((_) {}); markRoomReadUpTo(
room,
latest.eventId,
publicReceipt: ref.read(privacyReadReceiptsProvider),
).catchError((_) {
// Beim nächsten Anlass erneut versuchen.
if (_lastMarkedEventId == latest.eventId) _lastMarkedEventId = null;
});
}
/// Zurück in der App mit diesem Chat offen: Kamen inzwischen Nachrichten
/// von anderen, erscheint der „Neue Nachrichten“-Trenner hinter dem zuletzt
/// gelesenen Stand; danach wird bis zur neuesten Nachricht gelesen gemeldet.
void _onFocusRegained() {
if (!mounted || !_sessionCaptured) return;
final timeline = ref.read(timelineProvider(widget.roomId)).valueOrNull;
if (timeline == null) return;
final since = _lastMarkedEventId;
if (since != null && !_sessionHasUnread) {
final me = timeline.room.client.userID;
final events = timeline.events; // newest-first
final idx = events.indexWhere((e) => e.eventId == since);
final hasNew = idx > 0 &&
events
.take(idx)
.any((e) => _MessageList._isVisible(e) && e.senderId != me);
if (hasNew) {
_unreadFadeTimer?.cancel();
_unreadDwellTimer?.cancel();
setState(() {
_sessionFullyReadId = since;
_sessionHasUnread = true;
_unreadFading = false;
});
}
} }
if (_sessionHasUnread) _scheduleUnreadFadeIfAtNewest();
_markRead(timeline);
}
// Neueste Nachrichten sind zu sehen: höchstens eine halbe Bildschirmhöhe
// hochgescrollt. (Nach dem Sprung zum Trenner steht die Liste meist ein
// Stück oberhalb des Endes – mit der strengen 60-px-Grenze blieb der
// Trenner dann für immer stehen.)
bool get _atNewest {
if (!_scrollCtrl.hasClients) return true;
final pos = _scrollCtrl.position;
return pos.pixels < 60 || pos.pixels < pos.viewportDimension * 0.5;
}
/// „Neue Nachrichten“-Trenner nach [_unreadDwell] weich ausblenden – nur
/// wenn man dann noch bei den neuesten Nachrichten ist. Wer zwischendurch
/// hochscrollt, behält ihn; unten angekommen beginnt die Wartezeit neu.
void _scheduleUnreadFade() {
if (!_sessionHasUnread || _unreadFading) return;
if (_unreadDwellTimer?.isActive ?? false) return;
_unreadDwellTimer = Timer(_unreadDwell, () {
if (!mounted || !_sessionHasUnread) return;
// App im Hintergrund oder weggescrollt: stehen lassen, beim nächsten
// Scrollen/Zurückkommen beginnt die Wartezeit neu.
if (!_atNewest || !ref.read(windowFocusedProvider)) return;
setState(() => _unreadFading = true);
_unreadFadeTimer?.cancel();
_unreadFadeTimer = Timer(const Duration(milliseconds: 900), () {
if (mounted) setState(() { _sessionHasUnread = false; _unreadFading = false; });
});
});
}
// Nach dem Sprung zum Trenner (Animation ~250 ms) prüfen: Passt alles
// Ungelesene auf den Bildschirm, scrollt niemand mehr – dann läuft die
// Verweildauer trotzdem an.
void _scheduleUnreadFadeIfAtNewest() {
Future.delayed(const Duration(milliseconds: 400), () {
if (mounted && _atNewest) _scheduleUnreadFade();
});
} }
Future<void> _onScroll() async { Future<void> _onScroll() async {
@@ -246,18 +375,10 @@ class _ChatViewState extends ConsumerState<ChatView> {
final pixels = _scrollCtrl.position.pixels; final pixels = _scrollCtrl.position.pixels;
final maxExtent = _scrollCtrl.position.maxScrollExtent; final maxExtent = _scrollCtrl.position.maxScrollExtent;
if (pixels < 60) { if (pixels < 60) _markRead(null);
_markRead(null); // Bei den neuesten Nachrichten angekommen → Trenner nach einer
// Reached the newest messages → the "Neue Nachrichten" divider is no // Verweildauer ausblenden.
// longer useful. Fade it out, then remove it. if (_atNewest) _scheduleUnreadFade();
if (_sessionHasUnread && !_unreadFading) {
setState(() => _unreadFading = true);
_unreadFadeTimer?.cancel();
_unreadFadeTimer = Timer(const Duration(milliseconds: 900), () {
if (mounted) setState(() { _sessionHasUnread = false; _unreadFading = false; });
});
}
}
final shouldShow = pixels > 400; final shouldShow = pixels > 400;
if (shouldShow != _showScrollDown) { if (shouldShow != _showScrollDown) {
setState(() => _showScrollDown = shouldShow); setState(() => _showScrollDown = shouldShow);
@@ -313,7 +434,9 @@ class _ChatViewState extends ConsumerState<ChatView> {
// listen) so it also fires when this ChatView is freshly mounted for a // listen) so it also fires when this ChatView is freshly mounted for a
// DIFFERENT room — listen would miss a value set before registration. // DIFFERENT room — listen would miss a value set before registration.
final pendingJump = ref.watch(pendingJumpEventProvider); final pendingJump = ref.watch(pendingJumpEventProvider);
if (pendingJump != null && !_jumpInProgress && pendingJump != _jumpEventId) { // (Kein Vergleich mit _jumpEventId mehr: dasselbe Zitat darf zweimal
// hintereinander angetippt werden – _startJump verbraucht die Anfrage.)
if (pendingJump != null && !_jumpInProgress) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && if (mounted &&
!_jumpInProgress && !_jumpInProgress &&
@@ -324,6 +447,9 @@ class _ChatViewState extends ConsumerState<ChatView> {
} }
final isDesktop = Platform.isWindows || Platform.isMacOS || Platform.isLinux; final isDesktop = Platform.isWindows || Platform.isMacOS || Platform.isLinux;
// Noch nicht beigetreten (Einladung): statt eines leeren Verlaufs gleich
// hier Annehmen/Ablehnen anbieten.
final isInvite = room?.membership == Membership.invite;
final body = Stack( final body = Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
@@ -394,8 +520,11 @@ class _ChatViewState extends ConsumerState<ChatView> {
setState(() => _pinnedOpen = !_pinnedOpen), setState(() => _pinnedOpen = !_pinnedOpen),
), ),
const _ConnectionBanner(), const _ConnectionBanner(),
const _ActiveCallBar(),
Expanded( Expanded(
child: Stack( child: isInvite
? _InvitePanel(room: room!, pt: pt)
: Stack(
children: [ children: [
timelineAsync.when( timelineAsync.when(
loading: () => Center( loading: () => Center(
@@ -429,28 +558,36 @@ class _ChatViewState extends ConsumerState<ChatView> {
jumpKey: _jumpKey, jumpKey: _jumpKey,
highlightEventId: _highlightEventId, highlightEventId: _highlightEventId,
highlightOn: _highlightOn, highlightOn: _highlightOn,
bottomInset: room != null ? _composerHeight : 0,
popInAfter: _popInAfter,
); );
}, },
), ),
if (_showScrollDown) Positioned(
Positioned(
right: 16, right: 16,
bottom: 12, bottom: (room != null ? _composerHeight : 0) + 12,
child: AnimatedOpacity( child: IgnorePointer(
opacity: _showScrollDown ? 1 : 0, ignoring: !_showScrollDown,
duration: const Duration(milliseconds: 150), child: AnimatedScale(
scale: _showScrollDown ? 1 : 0,
duration: motionDuration(
context, _showScrollDown ? 520 : 180),
curve: _showScrollDown
? Curves.elasticOut
: Curves.easeInBack,
child: GestureDetector( child: GestureDetector(
onTap: () => _scrollCtrl.animateTo( onTap: () => _scrollCtrl.animateTo(
0, 0,
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
curve: Curves.easeOut, curve: Curves.easeOut,
), ),
child: SpringPress(
child: Container( child: Container(
width: 36, width: _scrollBtnSize,
height: 36, height: _scrollBtnSize,
decoration: BoxDecoration( decoration: BoxDecoration(
color: pt.bg0, color: pt.bg0,
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(_scrollBtnSize / 2),
border: Border.all(color: pt.border), border: Border.all(color: pt.border),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
@@ -460,7 +597,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 +647,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),
),
], ],
), ),
), ),
@@ -16,7 +16,7 @@ class _InviteItemState extends ConsumerState<_InviteItem> {
Future<void> _accept() async { Future<void> _accept() async {
setState(() => _accepting = true); setState(() => _accepting = true);
try { try {
await widget.room.join(); await acceptInvite(ref, widget.room);
if (mounted) { if (mounted) {
ref.read(activeRoomIdProvider.notifier).state = widget.room.id; ref.read(activeRoomIdProvider.notifier).state = widget.room.id;
ref.read(viewModeProvider.notifier).state = ViewMode.chat; ref.read(viewModeProvider.notifier).state = ViewMode.chat;
@@ -95,10 +95,18 @@ class _MobileRoomTile extends StatelessWidget {
), ),
), ),
), ),
if (unread > 0) ...[ // Immer im Baum, damit der Zähler beim Erscheinen und
const SizedBox(width: 8), // Hochzählen federnd aufploppen kann.
_UnreadBadge(count: unread, isMention: mention, pt: pt), SpringBump(
], value: unread,
child: unread > 0
? Padding(
padding: const EdgeInsets.only(left: 8),
child: _UnreadBadge(
count: unread, isMention: mention, pt: pt),
)
: const SizedBox.shrink(),
),
], ],
), ),
], ],
+2
View File
@@ -12,6 +12,7 @@ import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/notification_content.dart' show notificationBody; import 'package:pyramid/core/notification_content.dart' show notificationBody;
import 'package:pyramid/core/theme.dart'; import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/rooms/rooms_provider.dart'; import 'package:pyramid/features/rooms/rooms_provider.dart';
import 'package:pyramid/features/chat/chat_provider.dart' show acceptInvite;
import 'package:pyramid/features/call_ui/mini_call_widget.dart'; import 'package:pyramid/features/call_ui/mini_call_widget.dart';
import 'package:pyramid/features/call_signaling/call_signaling_service.dart'; import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
import 'package:pyramid/features/voice_channel/voice_channel_service.dart'; import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
@@ -22,6 +23,7 @@ import 'package:pyramid/widgets/create_join_dialog.dart';
import 'package:pyramid/widgets/hover_region.dart'; import 'package:pyramid/widgets/hover_region.dart';
import 'package:pyramid/widgets/mxc_image.dart'; import 'package:pyramid/widgets/mxc_image.dart';
import 'package:pyramid/widgets/new_dm_dialog.dart'; import 'package:pyramid/widgets/new_dm_dialog.dart';
import 'package:pyramid/widgets/spring_press.dart' show SpringBump;
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'package:livekit_client/livekit_client.dart'; import 'package:livekit_client/livekit_client.dart';
import 'package:matrix/matrix.dart' hide Room; import 'package:matrix/matrix.dart' hide Room;
import 'package:pyramid/core/call_foreground.dart';
import 'package:pyramid/core/ice_servers.dart'; import 'package:pyramid/core/ice_servers.dart';
import 'package:pyramid/core/livekit_token.dart'; import 'package:pyramid/core/livekit_token.dart';
import 'package:pyramid/core/settings_prefs.dart'; import 'package:pyramid/core/settings_prefs.dart';
@@ -98,8 +99,35 @@ class LiveKitCallManager extends VoiceChannelService {
void _onRoomChanged() => notifyListeners(); void _onRoomChanged() => notifyListeners();
// Handy: Lautsprecher statt Hörmuschel (wie Discord).
bool _speakerOn = true;
bool get _isMobile =>
!kIsWeb &&
(defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS);
@override
bool get isSpeakerOn => _speakerOn;
@override
Future<void> toggleSpeaker() async {
_speakerOn = !_speakerOn;
notifyListeners();
await _applySpeaker();
}
Future<void> _applySpeaker() async {
if (!_isMobile) return;
try {
await rtc.Helper.setSpeakerphoneOn(_speakerOn);
} catch (e) {
debugPrint('[LiveKit] Lautsprecher: $e');
}
}
void _cleanUpAfterDisconnect() { void _cleanUpAfterDisconnect() {
_callGeneration++; _callGeneration++;
CallForeground.callEnded('livekit');
_stopStatsLogging(); _stopStatsLogging();
_presenceHeartbeat?.cancel(); _presenceHeartbeat?.cancel();
_presenceHeartbeat = null; _presenceHeartbeat = null;
@@ -173,8 +201,6 @@ class LiveKitCallManager extends VoiceChannelService {
if (isActive) return; if (isActive) return;
final gen = ++_callGeneration; final gen = ++_callGeneration;
final url = 'wss://livekit.steggi-matrix.work';
isConnecting = true; isConnecting = true;
isVoiceChannel = voiceChannel; isVoiceChannel = voiceChannel;
error = null; error = null;
@@ -191,12 +217,21 @@ class LiveKitCallManager extends VoiceChannelService {
notifyListeners(); notifyListeners();
try { try {
final token = LiveKitTokenGenerator.generate( // Token holt der Server (prüft den Matrix-Login, Identität = eigene
roomName: roomName, // Matrix-ID); das LiveKit-Secret liegt nicht mehr in der App.
identity: identity, // [identity] wird dafür nicht mehr gebraucht.
displayName: identity, final accessToken = matrixClient?.accessToken;
ttlSeconds: 21600, 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( final room = Room(
roomOptions: const RoomOptions( roomOptions: const RoomOptions(
@@ -247,6 +282,10 @@ class LiveKitCallManager extends VoiceChannelService {
_room = room; _room = room;
isConnecting = false; isConnecting = false;
notifyListeners(); notifyListeners();
_speakerOn = true;
unawaited(_applySpeaker());
// Android: Mikrofon im Hintergrund + „Anruf läuft“-Benachrichtigung.
unawaited(CallForeground.callStarted('livekit', title: roomDisplayName));
if (voiceChannel && matrixClient != null && matrixRoomId != null) { if (voiceChannel && matrixClient != null && matrixRoomId != null) {
await _writePresence(true); await _writePresence(true);
@@ -262,6 +301,7 @@ class LiveKitCallManager extends VoiceChannelService {
isScreenSharing = false; isScreenSharing = false;
_currentScreenShareSourceId = null; _currentScreenShareSourceId = null;
_screenShareTrack = null; _screenShareTrack = null;
CallForeground.screenCaptureStopped('livekit');
} }
notifyListeners(); notifyListeners();
}) })
@@ -480,6 +520,11 @@ class LiveKitCallManager extends VoiceChannelService {
} }
} }
// Android 14+: erst Zustimmung + mediaProjection-Dienst, sonst
// stürzt die App beim Mitschnitt ab.
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
if (!await CallForeground.prepareScreenCapture('livekit')) return;
}
await _room?.localParticipant?.setScreenShareEnabled(true); await _room?.localParticipant?.setScreenShareEnabled(true);
isScreenSharing = true; isScreenSharing = true;
notifyListeners(); notifyListeners();
@@ -580,6 +625,7 @@ class LiveKitCallManager extends VoiceChannelService {
} }
} }
try { await _room?.localParticipant?.setScreenShareEnabled(false); } catch (_) {} try { await _room?.localParticipant?.setScreenShareEnabled(false); } catch (_) {}
await CallForeground.screenCaptureStopped('livekit');
isScreenSharing = false; isScreenSharing = false;
_currentScreenShareSourceId = null; _currentScreenShareSourceId = null;
_screenShareTrack = null; _screenShareTrack = null;
@@ -590,6 +636,7 @@ class LiveKitCallManager extends VoiceChannelService {
Future<void> hangUp() async { Future<void> hangUp() async {
// Entwertet einen evtl. noch laufenden Verbindungsaufbau (s. _callGeneration). // Entwertet einen evtl. noch laufenden Verbindungsaufbau (s. _callGeneration).
_callGeneration++; _callGeneration++;
unawaited(CallForeground.callEnded('livekit'));
_stopStatsLogging(); _stopStatsLogging();
_presenceHeartbeat?.cancel(); _presenceHeartbeat?.cancel();
_presenceHeartbeat = null; _presenceHeartbeat = null;
@@ -53,6 +53,11 @@ abstract class VoiceChannelService extends ChangeNotifier {
Future<void> hangUp(); Future<void> hangUp();
Future<void> toggleMute(); Future<void> toggleMute();
/// Handy: Ton über den Lautsprecher (true) oder die Hörmuschel (false).
/// Auf dem PC ohne Bedeutung.
bool get isSpeakerOn => true;
Future<void> toggleSpeaker() async {}
Future<void> toggleDeafen(); Future<void> toggleDeafen();
Future<void> toggleCamera(); Future<void> toggleCamera();
Future<void> toggleScreenShare(); Future<void> toggleScreenShare();
+34 -13
View File
@@ -17,6 +17,8 @@ import 'package:pyramid/features/chat/chat_view.dart';
import 'package:pyramid/features/members/members_panel.dart'; import 'package:pyramid/features/members/members_panel.dart';
import 'package:pyramid/features/rooms/rooms_panel.dart'; import 'package:pyramid/features/rooms/rooms_panel.dart';
import 'package:pyramid/features/spaces/spaces_rail.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/search_modal.dart';
import 'package:pyramid/widgets/settings_modal.dart'; import 'package:pyramid/widgets/settings_modal.dart';
import 'package:pyramid/widgets/share_target_dialog.dart'; import 'package:pyramid/widgets/share_target_dialog.dart';
@@ -335,14 +337,18 @@ class _MobileShell extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final voip = ref.watch(callSignalingProvider); final voip = ref.watch(callSignalingProvider);
Widget body; // Die Chatliste bleibt aufgebaut; Chat bzw. Sprachkanal gleiten federnd
if (viewMode == ViewMode.voice) { // darüber (Bernds Wunsch 2026-10-07: mehr Übergänge/Elastizität).
body = const VoiceChannelView(); final Widget? front = viewMode == ViewMode.voice
} else if (activeRoomId != null) { ? const VoiceChannelView()
body = ChatView(roomId: activeRoomId!); : activeRoomId != null
} else { ? ChatView(roomId: activeRoomId!)
body = const RoomsPanel(); : null;
} final body = MobilePageStack(
base: const RoomsPanel(),
front: front,
background: pt.bg1,
);
// Symbole der Statusleiste passend zum Theme (hell auf dunkel und umgekehrt). // Symbole der Statusleiste passend zum Theme (hell auf dunkel und umgekehrt).
final overlayStyle = (pt.isDark final overlayStyle = (pt.isDark
@@ -387,7 +393,12 @@ class _MobileShell extends ConsumerWidget {
Positioned.fill( Positioned.fill(
child: GestureDetector( child: GestureDetector(
onTap: () => ref.read(membersPanelOpenProvider.notifier).state = false, 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( Positioned(
@@ -396,10 +407,20 @@ class _MobileShell extends ConsumerWidget {
bottom: 0, bottom: 0,
child: Builder(builder: (ctx) { child: Builder(builder: (ctx) {
final w = MediaQuery.sizeOf(ctx).width; final w = MediaQuery.sizeOf(ctx).width;
return MembersPanel( // Gleitet von rechts herein.
roomId: activeRoomId!, return TweenAnimationBuilder<double>(
width: (w * 0.82).clamp(240.0, 320.0), tween: Tween(begin: 1, end: 0),
onClose: () => ref.read(membersPanelOpenProvider.notifier).state = false, 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,
),
); );
}), }),
), ),
+166
View File
@@ -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,
),
),
),
),
],
);
},
);
},
);
}
}
+40 -29
View File
@@ -1,4 +1,9 @@
import 'dart:math' as math;
import 'package:flutter/material.dart'; 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 // Simple hover-aware widget for desktop hover effects
class HoverRegion extends StatefulWidget { 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 { class PyrIconBtn extends StatefulWidget {
final Widget icon; final Widget icon;
final VoidCallback? onPressed; final VoidCallback? onPressed;
@@ -39,6 +47,9 @@ class PyrIconBtn extends StatefulWidget {
final Color? dangerColor; final Color? dangerColor;
final double size; final double size;
/// Symbolgröße; ohne Angabe 16 (PC) bzw. 22 (Handy-Layout).
final double? iconSize;
const PyrIconBtn({ const PyrIconBtn({
super.key, super.key,
required this.icon, required this.icon,
@@ -48,6 +59,7 @@ class PyrIconBtn extends StatefulWidget {
this.activeColor, this.activeColor,
this.dangerColor, this.dangerColor,
this.size = 32, this.size = 32,
this.iconSize,
}); });
@override @override
@@ -59,20 +71,16 @@ class _PyrIconBtnState extends State<PyrIconBtn> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final pt = PyramidTheme.of(context);
final isDark = theme.brightness == Brightness.dark; final mobile = isMobileLayout(MediaQuery.sizeOf(context));
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 color = widget.active final color = widget.active
? (widget.activeColor ?? const Color(0xFFF5A614)) ? (widget.activeColor ?? pt.accent)
: _hovered ? fg : fgMuted; : _hovered ? pt.fg : pt.fgMuted;
final platform = Theme.of(context).platform; final boxSize = mobile ? math.max(widget.size, 40.0) : widget.size;
final isMobile = platform == TargetPlatform.android || platform == TargetPlatform.iOS; final touchSize = mobile ? math.max(boxSize, 44.0) : boxSize;
// On mobile, use a slightly larger touch target, but respect the requested size final iconSize = widget.iconSize ?? (mobile ? 22.0 : 16.0);
final touchSize = isMobile ? (widget.size + 8).clamp(32.0, 48.0) : widget.size;
final btn = MouseRegion( final btn = MouseRegion(
cursor: SystemMouseCursors.click, cursor: SystemMouseCursors.click,
@@ -80,23 +88,26 @@ class _PyrIconBtnState extends State<PyrIconBtn> {
onExit: (_) => setState(() => _hovered = false), onExit: (_) => setState(() => _hovered = false),
child: GestureDetector( child: GestureDetector(
onTap: widget.onPressed, onTap: widget.onPressed,
child: SizedBox( child: SpringPress(
width: touchSize, enabled: widget.onPressed != null,
height: touchSize, child: SizedBox(
child: Center( width: touchSize,
child: AnimatedContainer( height: touchSize,
duration: const Duration(milliseconds: 150), child: Center(
curve: Curves.easeOutCubic, child: AnimatedContainer(
width: widget.size, duration: const Duration(milliseconds: 150),
height: widget.size, curve: Curves.easeOutCubic,
decoration: BoxDecoration( width: boxSize,
color: _hovered ? bgHover : Colors.transparent, height: boxSize,
borderRadius: BorderRadius.circular(7), decoration: BoxDecoration(
), color: _hovered ? pt.bgHover : Colors.transparent,
child: Center( borderRadius: BorderRadius.circular(mobile ? boxSize / 2 : 7),
child: IconTheme( ),
data: IconThemeData(color: color, size: 16), child: Center(
child: widget.icon, child: IconTheme(
data: IconThemeData(color: color, size: iconSize),
child: widget.icon,
),
), ),
), ),
), ),
+37
View File
@@ -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));
}
}
+214
View File
@@ -0,0 +1,214 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:pyramid/core/settings_prefs.dart';
import 'package:pyramid/core/theme.dart';
/// Mikrofontest mit Pegelanzeige (Einstellungen → Sprache & Video).
///
/// Nimmt über DENSELBEN Weg wie ein Anruf auf (gewähltes Gerät aus den
/// Einstellungen, WebRTC) und zeigt den Pegel live. Anlass: Bernds Test
/// 2026-10-07 – das Handy hörte nichts vom PC, weil (mit Voicemeeter und
/// vielen virtuellen Geräten) das Standardgerät nur Stille lieferte. Ohne
/// Pegelanzeige war das nicht zu erkennen.
///
/// Technik: lokale Verbindung zu sich selbst (zwei PeerConnections im selben
/// Prozess) – erst dann startet libwebrtc die Aufnahme und liefert den Pegel
/// in den Statistiken. Es wird nichts verschickt oder gespeichert. Nach
/// 30 Sekunden endet der Test von selbst.
class MicLevelTest extends StatefulWidget {
const MicLevelTest({super.key, required this.pt, this.enabled = true});
final PyramidTheme pt;
/// false z. B. während eines Anrufs (zwei Aufnahmen gleichzeitig vermeiden).
final bool enabled;
@override
State<MicLevelTest> createState() => _MicLevelTestState();
}
class _MicLevelTestState extends State<MicLevelTest> {
MediaStream? _stream;
RTCPeerConnection? _a;
RTCPeerConnection? _b;
Timer? _poll;
Timer? _autoStop;
bool _running = false;
bool _starting = false;
double _level = 0; // 0..1 (geglättet)
double _peak = 0;
String? _error;
@override
void dispose() {
_stop(notify: false);
super.dispose();
}
Future<void> _start() async {
setState(() {
_starting = true;
_error = null;
_peak = 0;
_level = 0;
});
try {
final mic = (await VoicePrefs.loadDevices()).mic;
final audio = mic == null
? true
: {
'deviceId': mic,
'optional': [
{'sourceId': mic},
],
};
final stream = await navigator.mediaDevices
.getUserMedia({'audio': audio, 'video': false});
final a = await createPeerConnection({});
final b = await createPeerConnection({});
a.onIceCandidate = (c) => b.addCandidate(c);
b.onIceCandidate = (c) => a.addCandidate(c);
await a.addTrack(stream.getAudioTracks().first, stream);
final offer = await a.createOffer();
await a.setLocalDescription(offer);
await b.setRemoteDescription(offer);
final answer = await b.createAnswer();
await b.setLocalDescription(answer);
await a.setRemoteDescription(answer);
if (!mounted) {
await _release(stream, a, b);
return;
}
_stream = stream;
_a = a;
_b = b;
_poll = Timer.periodic(const Duration(milliseconds: 120), (_) => _read());
_autoStop = Timer(const Duration(seconds: 30), _stop);
setState(() {
_running = true;
_starting = false;
});
} catch (e) {
if (mounted) {
setState(() {
_starting = false;
_error = 'Mikrofon ließ sich nicht öffnen: ${e.toString().split('\n').first}';
});
}
}
}
Future<void> _read() async {
final a = _a;
if (a == null) return;
try {
for (final r in await a.getStats()) {
if (r.type != 'media-source') continue;
final raw = (r.values['audioLevel'] as num?)?.toDouble() ?? 0;
// Wurzel-Skala: leise Sprache soll sichtbar ausschlagen.
final shown = math.sqrt(raw.clamp(0.0, 1.0));
if (!mounted) return;
setState(() {
_level = _level * 0.4 + shown * 0.6;
_peak = math.max(_peak, shown);
});
}
} catch (_) {}
}
Future<void> _release(MediaStream? s, RTCPeerConnection? a, RTCPeerConnection? b) async {
for (final t in s?.getTracks() ?? <MediaStreamTrack>[]) {
try {
await t.stop();
} catch (_) {}
}
try {
await s?.dispose();
} catch (_) {}
try {
await a?.close();
} catch (_) {}
try {
await b?.close();
} catch (_) {}
}
void _stop({bool notify = true}) {
_poll?.cancel();
_autoStop?.cancel();
_poll = null;
_autoStop = null;
final s = _stream, a = _a, b = _b;
_stream = null;
_a = null;
_b = null;
_release(s, a, b);
if (notify && mounted) {
setState(() {
_running = false;
_level = 0;
});
} else {
_running = false;
}
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final silent = _running && _peak < 0.02;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.mic_rounded, size: 15, color: pt.fgMuted),
const SizedBox(width: 8),
Text('Mikrofontest',
style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500)),
const Spacer(),
TextButton(
onPressed: !widget.enabled || _starting
? null
: (_running ? _stop : _start),
child: Text(_running ? 'Beenden' : 'Testen'),
),
],
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Container(
height: 8,
color: pt.bg3,
alignment: Alignment.centerLeft,
child: AnimatedFractionallySizedBox(
duration: const Duration(milliseconds: 110),
widthFactor: _level.clamp(0.0, 1.0),
heightFactor: 1,
child: Container(color: _level > 0.85 ? pt.danger : pt.accent),
),
),
),
const SizedBox(height: 6),
Text(
!widget.enabled
? 'Während eines Anrufs nicht möglich.'
: _error ??
(_running
? (silent
? 'Kein Ton zu hören – sprich etwas. Bleibt der Balken leer, oben ein anderes Mikrofon wählen.'
: 'Sprich etwas – der Balken zeigt, was die anderen hören würden.')
: 'Prüft das oben gewählte Mikrofon. Es wird nichts gesendet oder gespeichert.'),
style: TextStyle(
color: _error != null || silent ? pt.away : pt.fgDim,
fontSize: 11,
),
),
],
);
}
}
+16
View File
@@ -270,6 +270,22 @@ class _VoiceSectionState extends ConsumerState<_VoiceSection> {
]), ]),
const SizedBox(height: 16), const SizedBox(height: 16),
_SettingsGroup(title: 'Pegel', pt: pt, children: [ _SettingsGroup(title: 'Pegel', pt: pt, children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Builder(builder: (context) {
final voip = ref.watch(callSignalingProvider);
final lk = ref.watch(voiceChannelProvider);
final inCall = voip.currentCall != null || lk.isActive;
// Schlüssel je gewähltem Mikrofon: Gerätewechsel beendet
// einen laufenden Test (sonst misst er das alte Gerät).
return MicLevelTest(
key: ValueKey('mictest:$_selectedMic'),
pt: pt,
enabled: !inCall,
);
}),
),
_Divider(pt: pt),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Column( child: Column(
+1
View File
@@ -36,6 +36,7 @@ import 'package:pyramid/widgets/update_download_dialog.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:pyramid/widgets/banner_crop_dialog.dart'; import 'package:pyramid/widgets/banner_crop_dialog.dart';
import 'package:pyramid/widgets/hover_region.dart'; import 'package:pyramid/widgets/hover_region.dart';
import 'package:pyramid/widgets/mic_level_test.dart';
import 'package:pyramid/widgets/mxc_image.dart'; import 'package:pyramid/widgets/mxc_image.dart';
import 'package:pyramid/widgets/pyramid_loader.dart'; import 'package:pyramid/widgets/pyramid_loader.dart';
+206
View File
@@ -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,
),
);
}
}
+5 -5
View File
@@ -202,7 +202,7 @@ packages:
source: hosted source: hosted
version: "1.0.2" version: "1.0.2"
dart_jsonwebtoken: dart_jsonwebtoken:
dependency: "direct main" dependency: transitive
description: description:
name: dart_jsonwebtoken name: dart_jsonwebtoken
sha256: ad84e60181696513d04d5f2078e0bbc20365b911f46f647797317414bdc88fbe sha256: ad84e60181696513d04d5f2078e0bbc20365b911f46f647797317414bdc88fbe
@@ -400,14 +400,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_markdown: flutter_markdown_plus:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_markdown name: flutter_markdown_plus
sha256: "08fb8315236099ff8e90cb87bb2b935e0a724a3af1623000a9cec930468e0f27" sha256: fce641d6c2106cc495de1cd603f97a4f9d615a97a64011928ded380dbdade935
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.7+1" version: "1.0.12"
flutter_plugin_android_lifecycle: flutter_plugin_android_lifecycle:
dependency: transitive dependency: transitive
description: description:
+2 -3
View File
@@ -1,7 +1,7 @@
name: pyramid name: pyramid
description: A flexible Matrix client with resizable panel layout. description: A flexible Matrix client with resizable panel layout.
publish_to: none publish_to: none
version: 0.1.37+38 version: 0.1.39+40
environment: environment:
sdk: ">=3.11.1 <4.0.0" sdk: ">=3.11.1 <4.0.0"
@@ -26,7 +26,6 @@ dependencies:
livekit_client: ^2.4.0 livekit_client: ^2.4.0
flutter_webrtc: ^1.4.1 flutter_webrtc: ^1.4.1
webrtc_interface: ^1.5.1 webrtc_interface: ^1.5.1
dart_jsonwebtoken: ^3.3.1
# State management & routing # State management & routing
flutter_riverpod: ^2.6.1 flutter_riverpod: ^2.6.1
@@ -68,7 +67,7 @@ dependencies:
# Document viewer # Document viewer
pdfx: ^2.7.0 pdfx: ^2.7.0
archive: ^4.0.0 archive: ^4.0.0
flutter_markdown: ^0.7.7 flutter_markdown_plus: ^1.0.3
# Clipboard image paste (Windows FFI) & image decoding # Clipboard image paste (Windows FFI) & image decoding
ffi: ^2.1.0 ffi: ^2.1.0
+14 -3
View File
@@ -109,15 +109,26 @@ if ($Ui) {
$uiProfile = Join-Path $env:USERPROFILE ".pyramid-autopilot\profile1" $uiProfile = Join-Path $env:USERPROFILE ".pyramid-autopilot\profile1"
if (-not (Test-Path (Join-Path $uiProfile "pyramid.sqlite"))) { if (-not (Test-Path (Join-Path $uiProfile "pyramid.sqlite"))) {
Add-Skipped "UI-Durchlauf (Handy-Format)" "Testprofil fehlt: $uiProfile" Add-Skipped "UI-Durchlauf (Handy-Format)" "Testprofil fehlt: $uiProfile"
} elseif (Get-Process pyramid -ErrorAction SilentlyContinue) { } elseif (Get-Process pyramid -ErrorAction SilentlyContinue |
# Nie zwei Matrix-Clients auf derselben DB – und Bernds Fenster nicht stören. Where-Object { $_.Path -like "*\build\windows\*" }) {
Add-Skipped "UI-Durchlauf (Handy-Format)" "Pyramid läuft gerade – erst beenden" # Nie zwei Matrix-Clients auf derselben DB: ein noch laufender Testlauf
# (Build-Ordner) nutzt profile1. Bernds echte Pyramid und seine
# Testinstanz (profile2, C:\Users\nordm\pyramid-testinstanz) stören nicht.
Add-Skipped "UI-Durchlauf (Handy-Format)" "Ein Testlauf läuft noch – erst beenden"
} else { } else {
$env:PYRAMID_PROFILE_DIR = $uiProfile $env:PYRAMID_PROFILE_DIR = $uiProfile
try { try {
Invoke-Step "UI-Durchlauf (Handy-Format, Testprofil)" { Invoke-Step "UI-Durchlauf (Handy-Format, Testprofil)" {
flutter test integration_test/mobile_ui_screens_test.dart -d windows flutter test integration_test/mobile_ui_screens_test.dart -d windows
} }
$accounts = Join-Path $env:USERPROFILE ".pyramid-autopilot\test-accounts.txt"
if (Test-Path $accounts) {
Invoke-Step "Live: Lesebestätigung + Einladung (2 Testkonten)" {
flutter test integration_test/live_receipts_invite_test.dart -d windows
}
} else {
Add-Skipped "Live: Lesebestätigung + Einladung" "test-accounts.txt fehlt"
}
} finally { } finally {
Remove-Item Env:PYRAMID_PROFILE_DIR -ErrorAction SilentlyContinue Remove-Item Env:PYRAMID_PROFILE_DIR -ErrorAction SilentlyContinue
} }
+42
View File
@@ -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);
});
}
+87
View File
@@ -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')));
});
}
+150
View File
@@ -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')),
);
}
+8 -1
View File
@@ -27,7 +27,14 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
FlutterWindow window(project); FlutterWindow window(project);
Win32Window::Point origin(10, 10); Win32Window::Point origin(10, 10);
Win32Window::Size size(1280, 720); Win32Window::Size size(1280, 720);
if (!window.Create(L"Pyramid", origin, size)) { // Testprofil (PYRAMID_PROFILE_DIR, z. B. die Testinstanz mit einem
// Testkonto) im Fenstertitel kenntlich machen, damit sie nicht mit der
// echten Pyramid verwechselt wird. Ohne die Variable bleibt alles wie immer.
wchar_t profile_probe[2] = {0};
const bool test_profile =
::GetEnvironmentVariableW(L"PYRAMID_PROFILE_DIR", profile_probe, 2) > 0;
if (!window.Create(test_profile ? L"Pyramid - TESTINSTANZ" : L"Pyramid",
origin, size)) {
return EXIT_FAILURE; return EXIT_FAILURE;
} }
window.SetQuitOnClose(true); window.SetQuitOnClose(true);