Compare commits
+135
@@ -13,6 +13,141 @@ Schritt (und beim Abbruch mitten im Schritt den Zwischenstand). Format:
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-08 (Teil 2) – Anrufe im Live-Test nachgestellt: Teilen, Verbindung, Sprech-Ring → 0.1.40
|
||||
|
||||
**Erledigt (8074928, Version 064d730, Gitea-Vorabversion v0.1.40):**
|
||||
- **Neuer Live-Anruftest** `integration_test/live_call_test.dart`: App
|
||||
(pyramidtest1) + zweiter Matrix-Client (pyramidtest2, kurzlebiges Gerät)
|
||||
im selben Prozess, eigener unverschlüsselter Raum. Teil 1: Gegenseite ruft
|
||||
an, App nimmt über den echten Knopf an; Ton beide Richtungen (Bytes),
|
||||
Bildschirm der Gegenseite erscheint in der App, App teilt über den echten
|
||||
Quellen-Auswähler. Teil 2: App ruft an. In `scripts/test.ps1 -Ui`.
|
||||
- **Bildschirm teilen** wurde nie angezeigt: `_updateRenderers` rief ein
|
||||
SDK-`getFeeds()` auf, das es nicht gibt (Fehler geschluckt). Jetzt
|
||||
typisierte Zugriffe, `stopped` beachtet.
|
||||
- **Verbindungsaufbau:** matrix-SDK 6.2 verwirft Kandidaten, die vor der
|
||||
fertig verarbeiteten Einladung (eigener Listener je Sync, nicht
|
||||
serialisiert) bzw. während `answer()` eintreffen; Windows-
|
||||
`getRemoteDescription` wirft statt null. Pyramid puffert Kandidaten der
|
||||
Gegenseite (`_bufferRemoteCandidates`) und reicht sie direkt an die
|
||||
PeerConnection nach. Vorher im Test NIE verbunden, jetzt 0,5 s / 2,7 s.
|
||||
- **SDK-Zone** in `voip_manager.dart`: unbehandelte SDK/Plugin-Fehler
|
||||
werden protokolliert; `_inSdkZone` reicht Ergebnis/Fehler per Completer
|
||||
über die Zonengrenze (sonst würde der Aufrufer bei Fehlern ewig warten).
|
||||
- **Sprech-Ring** bei 1:1-Anrufen (audioLevel aus getStats, Hysterese);
|
||||
großes Bild springt nicht mehr beim Sprechen.
|
||||
- **Trenner** mitten in einer Gruppe (Inline-Variante) blieb stehen → nur mit
|
||||
Ungelesenem, blendet aus, trägt den Sprungschlüssel; Live-Test Teil 3 in
|
||||
`live_receipts_invite_test.dart`.
|
||||
|
||||
**Offen/Nächster Schritt:** Bernd testet 0.1.40 + Testinstanz (übernimmt
|
||||
`app-neu` beim nächsten Start). PC→Handy-Ton: Transport ist belegt (Bytes
|
||||
fließen), offen ist, ob das PC-Mikrofon Ton liefert (Messung: alle Geräte
|
||||
still; Voicemeeter) → Sprech-Ring / Mikrofontest prüfen.
|
||||
|
||||
**Stolperfallen:** (1) `what:`-Texte in Warte-Helfern werden beim Aufruf
|
||||
ausgewertet, nicht beim Timeout – Zustände per Callback ausgeben.
|
||||
(2) SDK-Mitglieder-Cache: `getParticipants()`/`getState(RoomMember)` sind
|
||||
ohne Nachladen leer – nicht darauf warten. (3) Dart: Fehler überqueren
|
||||
Error-Zone-Grenzen nicht – Completer verwenden. (4) Analyzer-Warnungen
|
||||
beginnen ohne Einrückung („warning - …“) – beim grep beachten.
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-08 – Bernds Anruf-/Chat-Test mit PC-Testinstanz → Test-Version 0.1.39
|
||||
|
||||
**Erledigt (je ein Commit, alles gepusht):**
|
||||
- **PC-Testinstanz** (a6461fe + außerhalb des Repos): `C:\Users\nordm\pyramid-testinstanz\`
|
||||
(Release-Build, `Pyramid-Testinstanz.cmd`, Desktop-Verknüpfung), Profil
|
||||
`profile2` = pyramidtest2, Fenstertitel „Pyramid - TESTINSTANZ“,
|
||||
Doppelstart-Schutz, übernimmt bereitgelegte Updates (`app-neu`) beim
|
||||
nächsten Start. DM pyramidtest2 ↔ Bernd angelegt.
|
||||
- **Haken live / Einladungen / Trenner** (c817de9): ✓✓ zeichnet nach jedem
|
||||
Sync neu, wenn sich Lesepositionen ändern (Raum-Update kommt im SDK VOR der
|
||||
Bestätigung); Einladung im Chat annehmen, Verlauf baut sich bei
|
||||
invite→join neu auf; Trenner blendet 6 s nach Erreichen der neuesten
|
||||
Nachrichten aus, auch ohne Scrollen. Live-Test mit beiden Testkonten
|
||||
(`integration_test/live_receipts_invite_test.dart`, Gegenprobe rot ohne
|
||||
Fix), in `scripts/test.ps1 -Ui`.
|
||||
- **Anrufe** (441cc6e): Bildschirm teilen auf Android 14+ stürzte ab
|
||||
(kein mediaProjection-Vordergrunddienst; Manifest verwies auf nicht
|
||||
existierende flutter_webrtc-Klasse) → `CallService.kt` +
|
||||
`lib/core/call_foreground.dart` (Zustimmung → Dienst, wartet auf echten
|
||||
startForeground → Mitschnitt). Emulator-Test Android 16
|
||||
(`integration_test/android_screen_capture_test.dart`): mit Fix läuft der
|
||||
Mitschnitt, Gegenprobe wirft die erwartete SecurityException. Dienst hält
|
||||
während jedes Anrufs das Mikrofon im Hintergrund aktiv + „Anruf läuft“-
|
||||
Benachrichtigung. Handy: Lautsprecher statt Hörmuschel (SDK schaltet
|
||||
Sprachanrufe auf Hörmuschel) + Umschalter. „Anruf läuft · Zurück“-Leiste im
|
||||
Chat. LiveKit-Teilen am Handy ohne PC-Quellen-Auswähler. Mikrofontest mit
|
||||
Pegel in den Einstellungen.
|
||||
- **Version 0.1.39+40** (775cf71) als Gitea-Vorabversion.
|
||||
|
||||
**Befund PC-Mikrofon:** Lokale Pegelmessung über WebRTC lieferte auf ALLEN
|
||||
10 Aufnahmegeräten (Voicemeeter-Ausgänge, Steam, Razer Barracuda X) nur
|
||||
digitale Stille (audioLevel 1/32767). Entweder war das Headset aus, oder das
|
||||
Mikrofon läuft über Voicemeeter und Pyramid nutzt das falsche Gerät – das
|
||||
erklärt „Handy hört den PC nicht“. Bernd prüft mit dem neuen Mikrofontest.
|
||||
|
||||
**Offen/Nächster Schritt:** Bernd testet 0.1.39 + Testinstanz: Teilen am
|
||||
Handy (System-Dialog „Gesamten Bildschirm teilen“), Ton beide Richtungen
|
||||
(PC-Mikrofon im Mikrofontest wählen), Teilen vom PC, Verbindungsdauer
|
||||
außerhalb des Heimnetzes (ICE/TURN ist ok: TURN-Daten werden alle 4 h
|
||||
erneuert; Verzögerung vermutlich SDK-Kandidaten-Bündelung 2 s + verschlüsselte
|
||||
Signalisierung – erst messen, dann ändern).
|
||||
|
||||
**Stolperfallen:** (1) Git-Bash schreibt `/sdcard/...` in Windows-Pfade um →
|
||||
`MSYS_NO_PATHCONV=1` für adb. (2) Android 16 schlägt beim Teilen „eine App“
|
||||
vor und wechselt in diese App – das sah wie „App verkleinert sich“ aus.
|
||||
(3) Ein Dienst mit Typ microphone ohne RECORD_AUDIO-Berechtigung wird KOMPLETT
|
||||
verweigert – Typen nur mit Berechtigung anfordern. (4) Python-urllib wird von
|
||||
Cloudflare ohne User-Agent abgewiesen (Aufräumskript). (5) Testkonten:
|
||||
Schlüssel in test-accounts.txt heißen user1/pass1/user2/pass2.
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-07 (Teil 4) – Handy-UI-Feinschliff, Animationen, Lesebestätigung, LiveKit ohne Secret → Test-Version 0.1.38
|
||||
|
||||
**Erledigt (je ein Commit, alles gepusht):**
|
||||
- **Handy-UI** (382747c): größere Symbole (Anruf, Video, Anhang, GIF, Emoji),
|
||||
Suche/Mitglieder im ⋮-Menü, aufgeklapptes Kontakt-Banner (Kürzel mittig,
|
||||
Symbole bleiben an ihrem Platz), Schrift-Standard 110 %, federnde Knöpfe.
|
||||
- **Antwort-Zitat antippen** springt zur Nachricht (7063ebf).
|
||||
- **Schwebende Eingabeleiste** (223c944): Chat läuft darunter weiter,
|
||||
Milchglas nur im Eingabefeld, Fläche fängt Berührungen ab.
|
||||
- **Lesebestätigung** (1d0ccfd): nur bei Fokus/Vordergrund, je Event einmal
|
||||
(vorher bei jedem Neuaufbau, auch im Hintergrund – die gerade gezeigte
|
||||
Benachrichtigung verschwand sofort); „Neue Nachrichten“-Trenner nach
|
||||
Rückkehr in die App; „Lesebestätigungen senden“ aus = private Bestätigung
|
||||
statt gar keiner (Ungelesen-Zähler stimmen). Haken-Logik geprüft: Uhr =
|
||||
wird gesendet, ✓ = beim Server, goldenes ✓✓ = gelesen. Matrix kennt kein
|
||||
„auf dem Gerät angekommen“ – daher keine zweistufigen grauen Haken.
|
||||
- **Animationen** (ae6f651): Chat gleitet auf dem Handy federnd über die
|
||||
Liste (Liste bleibt aufgebaut), neue Nachrichten/Reaktionen ploppen auf,
|
||||
Zähler hüpfen, Scroll-Knopf/Antwort-Leiste/Senden-Pfeil federn, Vollansicht
|
||||
zoomt auf; alles mit Einstellung „Animationen“ skaliert. Fix dabei: beim
|
||||
Raumwechsel blieben Antwort-Ziel und getippter Text am neuen Raum hängen –
|
||||
jetzt Entwurf je Raum.
|
||||
- **LiveKit-Secret aus der App** (756486d): Token kommt von
|
||||
`/api/livekit-token`; echter Beitritt mit Testkonto grün.
|
||||
- **flutter_markdown_plus** statt eingestelltem flutter_markdown (0ad06e3).
|
||||
- **Version 0.1.38+39** (df377d7).
|
||||
|
||||
**Offen/Nächster Schritt:** Bernd testet 0.1.38 auf dem Handy (Push, neue
|
||||
Animationen, schwebende Leiste, Anruf/Sprachkanal wegen LiveKit-Umbau).
|
||||
Danach: LiveKit-Secret rotieren (Bernd, wenn alle Geräte aktualisiert),
|
||||
SQLCipher-Nachfolger (sqlite3 3.x), matrix-SDK-Sprung.
|
||||
|
||||
**Stolperfallen:** (1) Wischen-zum-Antworten gibt es nur auf echten Handys
|
||||
(Platform-Check) – im Windows-UI-Test über Lange-Drücken → Antworten, und
|
||||
NEBEN die Sprechblase drücken (im Text greift am PC die Textauswahl und
|
||||
der Test bricht undurchsichtig ab). (2) `Matrix4.getMaxScaleOnAxis` liefert
|
||||
bei 2D-Skalierung < 1 immer 1 (z-Achse) – `storage[0]` lesen.
|
||||
(3) LiveKit protokolliert auf dem Pi nur Warnungen – Beitritte sind dort
|
||||
nicht sichtbar; Nachweis über die Identität aus der Beitrittsantwort.
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-07 (Teil 3) – Beta-Feedback umgesetzt: Push-Verlauf, Handy-UI, neues Aussehen → Test-Version 0.1.37
|
||||
|
||||
**Erledigt (je ein Commit, alles gepusht):**
|
||||
|
||||
+14
-8
@@ -129,9 +129,11 @@ Punkte abhaken (`[x]`), wenn erledigt UND in `PROGRESS.md` protokolliert.
|
||||
PROGRESS.md 2026-07-04). `POST /api/livekit-token` läuft live in `server.py`
|
||||
(whoami-Auth, stdlib-HS256-Minting aus `livekit.yaml`), headless geprüft
|
||||
(401/400/413/200, JWT-Signatur unabhängig gegen `livekit.yaml` gültig, Identität =
|
||||
geprüfte user_id). **Offen:** Client-Umstellung (`livekit_token.dart`/
|
||||
`livekit_call_manager.dart`) + Secret-Rotation – beides PC/Gerät (heiliger
|
||||
Call-Pfad), siehe `docs/LIVEKIT_TOKEN_MIGRATION.md`.
|
||||
geprüfte user_id). **Client umgestellt 2026-10-07 (756486d):** kein Secret
|
||||
mehr in der App, echter LiveKit-Beitritt mit Testkonto grün
|
||||
(`integration_test/livekit_token_connect_test.dart`). **Offen:** Anruftest
|
||||
auf dem Handy, danach Secret-Rotation durch Bernd (erst wenn alle Geräte die
|
||||
neue Version haben), siehe `docs/LIVEKIT_TOKEN_MIGRATION.md`.
|
||||
- [ ] **SICHERHEIT: geleakte Secrets rotieren + aus dem Client holen** (Fable-5-Review (k),
|
||||
PROGRESS.md 2026-07-04). Das Gitea-Repo ist öffentlich (`private:false`), und es lagen
|
||||
vier aktive Secrets drin – teils schon bereinigt (release.ps1, Admin-Doku), aber:
|
||||
@@ -140,6 +142,7 @@ Punkte abhaken (`[x]`), wenn erledigt UND in `PROGRESS.md` protokolliert.
|
||||
`apiSecret`, Giphy-Key. Alte jeweils widerrufen.
|
||||
- **LiveKit-Token server-seitig minten:** kleiner Token-Endpoint auf dem Pi, damit
|
||||
`apiSecret` aus `lib/core/livekit_token.dart` verschwindet (App holt nur das JWT).
|
||||
**Client-Teil ERLEDIGT 2026-10-07** (Secret aus dem Code; Rotation steht noch aus).
|
||||
**Umsetzungsplan liegt fertig in `docs/LIVEKIT_TOKEN_MIGRATION.md`** (2026-07-04,
|
||||
gegen Code + Pi-Konfig geschrieben) – braucht echten Call-Test auf einem Gerät.
|
||||
- **E2EE-Diagnose-Upload: ERLEDIGT (2026-07-04).** Der Dashboard-Server auf dem Pi
|
||||
@@ -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
|
||||
(Changelog geprüft 2026-10-07) → Dummy-PeerConnection-Workaround
|
||||
bleibt nötig.
|
||||
- Android-Build: Gradle 8.13 → 8.14.3 ERLEDIGT (2026-10-07). Offen: Umstieg auf
|
||||
„Built-in Kotlin“ (Flutter kündigt Build-Fehler für das Kotlin-Plugin an;
|
||||
betrifft auch Plugins wie flutter_webrtc/livekit_client).
|
||||
- `flutter_markdown` ist eingestellt (pub: „replaced by
|
||||
flutter_markdown_plus“) → umstellen (Markdown-Vorschau im Dokument-Viewer).
|
||||
- Android-Build: Gradle 8.13 → 8.14.3 ERLEDIGT (2026-10-07). „Built-in Kotlin“
|
||||
**bewusst noch nicht** (Entscheidung 2026-10-07): AGP bleibt 8.11; die vom
|
||||
Flutter-Migrator gesetzten Flags `android.builtInKotlin=false` /
|
||||
`android.newDsl=false` halten den Build stabil. Umstieg erst zusammen mit
|
||||
AGP 9 und wenn flutter_webrtc, livekit_client, media_kit & Co. ihn
|
||||
unterstützen – vorher Release-APK + Anruftest.
|
||||
- ERLEDIGT 2026-10-07 (0ad06e3): `flutter_markdown` → `flutter_markdown_plus`
|
||||
(Rendertest `test/document_markdown_test.dart`).
|
||||
|
||||
## M3 – Calls: Logik, UI & Audio (Discord als Referenz)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
@@ -26,11 +27,15 @@
|
||||
android:allowBackup="false"
|
||||
android:fullBackupContent="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules">
|
||||
<!-- Laufender Anruf: hält das Mikrofon im Hintergrund am Leben und
|
||||
ist Pflicht für Bildschirmübertragung ab Android 14 (CallService.kt).
|
||||
Der frühere Eintrag com.cloudwebrtc...FlutterForegroundService
|
||||
verwies auf eine Klasse, die flutter_webrtc 1.x nicht mehr hat. -->
|
||||
<service
|
||||
android:name="com.cloudwebrtc.webrtc.FlutterForegroundService"
|
||||
android:name=".CallService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaProjection" />
|
||||
android:foregroundServiceType="microphone|mediaProjection" />
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package chat.pyramid.pyramid
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
/**
|
||||
* Vordergrunddienst für laufende Anrufe (1:1 und Sprachkanäle).
|
||||
*
|
||||
* Warum: Android schaltet das Mikrofon einer App im Hintergrund stumm, und
|
||||
* seit Android 14 stürzt jede Bildschirmübertragung ab, wenn vorher kein
|
||||
* Dienst vom Typ „mediaProjection“ läuft (flutter_webrtc startet keinen).
|
||||
* Genau das war Bernds Fehlerbild 2026-10-07: Teilen gestartet → App weg →
|
||||
* beim Öffnen kein Anruf mehr.
|
||||
*
|
||||
* Typen: immer „microphone“ (ab Android 11), bei laufender Übertragung
|
||||
* zusätzlich „mediaProjection“. Der Dienst darf mit mediaProjection erst
|
||||
* NACH der Zustimmung des Nutzers gestartet werden – das stellt die
|
||||
* Dart-Seite sicher (lib/core/call_foreground.dart).
|
||||
*/
|
||||
class CallService : Service() {
|
||||
companion object {
|
||||
private const val TAG = "PYRAMID-CALL"
|
||||
private const val CHANNEL_ID = "pyramid_call"
|
||||
private const val NOTIF_ID = 7301
|
||||
private const val EXTRA_TITLE = "title"
|
||||
private const val EXTRA_SCREEN = "screen"
|
||||
|
||||
// Wer auf den tatsächlichen startForeground wartet (Hauptthread).
|
||||
// Die Bildschirmübertragung darf erst danach beginnen.
|
||||
private val waiters = mutableListOf<(Boolean) -> Unit>()
|
||||
|
||||
fun whenStarted(callback: (Boolean) -> Unit) {
|
||||
waiters.add(callback)
|
||||
}
|
||||
|
||||
private fun notifyWaiters(ok: Boolean) {
|
||||
val list = waiters.toList()
|
||||
waiters.clear()
|
||||
list.forEach { it(ok) }
|
||||
}
|
||||
|
||||
fun start(ctx: Context, title: String, screen: Boolean) {
|
||||
val intent = Intent(ctx, CallService::class.java)
|
||||
.putExtra(EXTRA_TITLE, title)
|
||||
.putExtra(EXTRA_SCREEN, screen)
|
||||
ContextCompat.startForegroundService(ctx, intent)
|
||||
}
|
||||
|
||||
fun stop(ctx: Context) {
|
||||
ctx.stopService(Intent(ctx, CallService::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val title = intent?.getStringExtra(EXTRA_TITLE) ?: "Pyramid"
|
||||
val screen = intent?.getBooleanExtra(EXTRA_SCREEN, false) ?: false
|
||||
ensureChannel()
|
||||
|
||||
val launch = packageManager.getLaunchIntentForPackage(packageName)?.apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
|
||||
}
|
||||
val open = launch?.let {
|
||||
PendingIntent.getActivity(
|
||||
this, 0, it,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
}
|
||||
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(if (screen) "Bildschirm wird übertragen" else "Anruf läuft")
|
||||
.setContentText("$title – tippen, um zurückzukehren")
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setSilent(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_CALL)
|
||||
.setContentIntent(open)
|
||||
.build()
|
||||
|
||||
// Typ „microphone“ nur mit Mikrofon-Berechtigung (sonst verweigert
|
||||
// Android den GANZEN Dienst – auch den für die Übertragung).
|
||||
val micAllowed = ContextCompat.checkSelfPermission(
|
||||
this, Manifest.permission.RECORD_AUDIO
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
val candidates = mutableListOf<Int>()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
val mic = ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
|
||||
val proj = ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
|
||||
if (screen) {
|
||||
if (micAllowed) candidates.add(mic or proj)
|
||||
candidates.add(proj)
|
||||
} else if (micAllowed) {
|
||||
candidates.add(mic)
|
||||
}
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
candidates.add(if (screen) ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION else 0)
|
||||
} else {
|
||||
candidates.add(-1) // ohne Typ
|
||||
}
|
||||
|
||||
var ok = false
|
||||
for (type in candidates) {
|
||||
try {
|
||||
if (type == -1) startForeground(NOTIF_ID, notification)
|
||||
else startForeground(NOTIF_ID, notification, type)
|
||||
ok = true
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
// Nächste, schmalere Variante versuchen – lieber ohne Dienst
|
||||
// weiter als die App abstürzen zu lassen.
|
||||
Log.e(TAG, "startForeground fehlgeschlagen (screen=$screen, type=$type): ${e.message}")
|
||||
}
|
||||
}
|
||||
if (!ok) stopSelf()
|
||||
notifyWaiters(ok)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun ensureChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val nm = getSystemService(NotificationManager::class.java)
|
||||
if (nm.getNotificationChannel(CHANNEL_ID) != null) return
|
||||
nm.createNotificationChannel(
|
||||
NotificationChannel(CHANNEL_ID, "Laufende Anrufe", NotificationManager.IMPORTANCE_LOW).apply {
|
||||
description = "Zeigt an, dass ein Anruf oder eine Bildschirmübertragung läuft."
|
||||
setShowBadge(false)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,39 @@ class MainActivity : FlutterActivity() {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
// Cache so ReplyReceiver can call back into Dart without starting a new engine.
|
||||
FlutterEngineCache.getInstance().put("main", flutterEngine)
|
||||
// Anruf-Vordergrunddienst (lib/core/call_foreground.dart ↔ CallService.kt).
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "chat.pyramid.pyramid/call")
|
||||
.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"start" -> {
|
||||
// Erst antworten, wenn der Dienst wirklich im Vordergrund
|
||||
// läuft (oder nach 3 s aufgeben) – eine Übertragung vor
|
||||
// dem startForeground würde die App abstürzen lassen.
|
||||
val done = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||
val reply = { ok: Boolean ->
|
||||
if (done.compareAndSet(false, true)) result.success(ok)
|
||||
}
|
||||
try {
|
||||
CallService.whenStarted(reply)
|
||||
CallService.start(
|
||||
this,
|
||||
call.argument<String>("title") ?: "Pyramid",
|
||||
call.argument<Boolean>("screen") ?: false,
|
||||
)
|
||||
android.os.Handler(mainLooper).postDelayed({ reply(false) }, 3000)
|
||||
} catch (e: Exception) {
|
||||
// z. B. Start aus dem Hintergrund verboten (Android 12+).
|
||||
Log.e("PYRAMID-CALL", "CallService-Start abgelehnt: ${e.message}")
|
||||
reply(false)
|
||||
}
|
||||
}
|
||||
"stop" -> {
|
||||
CallService.stop(this)
|
||||
result.success(true)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channel)
|
||||
.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
|
||||
@@ -1,9 +1,32 @@
|
||||
# LiveKit-Token server-seitig minten (apiSecret aus dem Client entfernen)
|
||||
|
||||
**Status:** geplant, NICHT umgesetzt (braucht echten Call-Test auf einem Gerät –
|
||||
Calls sind laut CLAUDE.md „heilig"). Dieser Plan ist gegen den echten Code und die
|
||||
echte Pi-Konfiguration geschrieben (2026-07-04), damit ein PC-Termin direkt starten
|
||||
kann. Analog zu `docs/SQLCIPHER_MIGRATION.md`.
|
||||
**Status (2026-10-07):** Server-Route UND Client umgestellt.
|
||||
|
||||
- Server: `POST /api/livekit-token` im Dashboard-Server auf dem Pi
|
||||
(`/home/steggi/matrix/server.py`) – whoami-Prüfung, Identität = geprüfte
|
||||
Matrix-ID, Secret aus `livekit.yaml`. Geprüft: ungültiger Token → 401
|
||||
(öffentlich über `dashboard.steggi-matrix.work`), gültiger Token → JWT mit
|
||||
`sub` = Matrix-ID und denselben Grants wie früher.
|
||||
- Client: `lib/core/livekit_token.dart` holt das Token per
|
||||
`LiveKitTokenService.fetch` (kein Secret, kein JWT-Signieren mehr in der App);
|
||||
`livekit_call_manager.dart` nutzt es für alle LiveKit-Beitritte. Fehler
|
||||
(offline, 401, Serverfehler) erscheinen als lesbare Meldung im Anruf.
|
||||
- Tests: `test/livekit_token_test.dart` (Anfrage, Fehlerpfade, kein Secret im
|
||||
Code) und `integration_test/livekit_token_connect_test.dart` (ECHTER
|
||||
LiveKit-Beitritt mit Server-Token, Testkonto pyramidtest1, Wegwerf-Raum) –
|
||||
grün am 2026-10-07.
|
||||
|
||||
**Offen:**
|
||||
1. Anruf/Sprachkanal auf dem echten Handy prüfen (Ton, Video, Bildschirm
|
||||
teilen, zweiter Teilnehmer, Verlassen/Wiederbeitreten).
|
||||
2. Erst wenn ALLE genutzten Geräte (Bernd + Uta) die neue Version haben:
|
||||
Secret rotieren (siehe „Rotation“ unten) – das macht Bernd, nicht Claude.
|
||||
Bis dahin funktionieren alte und neue App-Versionen parallel.
|
||||
3. Neue Abhängigkeit beachten: Anrufe brauchen jetzt zusätzlich den
|
||||
Dashboard-Server (`matrix-stats.service`). Ist er aus, meldet die App
|
||||
„Anrufserver nicht erreichbar“.
|
||||
|
||||
Die ursprüngliche Planung (2026-07-04) folgt unverändert.
|
||||
|
||||
## Problem
|
||||
|
||||
|
||||
@@ -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,525 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:pyramid/core/matrix_client.dart';
|
||||
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
||||
import 'package:pyramid/main.dart' as app;
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import 'package:webrtc_interface/webrtc_interface.dart';
|
||||
|
||||
/// Echter 1:1-Anruf zwischen der App (pyramidtest1, Testprofil) und einem
|
||||
/// zweiten Matrix-Client im selben Prozess (pyramidtest2, kurzlebiges Gerät,
|
||||
/// wird am Ende abgemeldet). Bernds Fehlerbericht 2026-10-08: Ton kam nicht
|
||||
/// an, Bildschirm teilen ging weder am PC noch am Handy.
|
||||
///
|
||||
/// Geprüft wird: Verbindung steht, Ton fließt in BEIDE Richtungen (Bytes),
|
||||
/// Bildschirm der Gegenseite wird in der App angezeigt, und die App kann
|
||||
/// über den echten Quellen-Auswähler ihren Bildschirm teilen, der bei der
|
||||
/// Gegenseite ankommt. (Ob das Mikrofon hörbaren Ton liefert, kann ein
|
||||
/// Automat nicht prüfen – dafür gibt es den Mikrofontest und den Sprech-Ring.)
|
||||
///
|
||||
/// $env:PYRAMID_PROFILE_DIR="$env:USERPROFILE\.pyramid-autopilot\profile1"
|
||||
/// flutter test integration_test/live_call_test.dart -d windows
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final profile = Platform.environment['PYRAMID_PROFILE_DIR'];
|
||||
final accounts = _loadAccounts();
|
||||
|
||||
testWidgets(
|
||||
'1:1-Anruf: Ton beide Richtungen, Bildschirm teilen beide Richtungen',
|
||||
(tester) async {
|
||||
Logs().level = Level.verbose;
|
||||
app.main();
|
||||
await _pumpUntil(tester, () => find.byType(MaterialApp).evaluate().isNotEmpty);
|
||||
final container = ProviderScope.containerOf(
|
||||
tester.element(find.byType(MaterialApp).first),
|
||||
);
|
||||
final me = (await tester.runAsync(
|
||||
() => container.read(matrixClientProvider.future),
|
||||
))!;
|
||||
expect(me.userID, '@pyramidtest1:steggi-matrix.work');
|
||||
|
||||
final partner = (await tester.runAsync(
|
||||
() => _Partner.start(accounts!['user2']!, accounts['pass2']!),
|
||||
))!;
|
||||
String? roomId;
|
||||
addTearDown(() => tester.runAsync(() async {
|
||||
await partner.dispose(roomId);
|
||||
final r = roomId == null ? null : me.getRoomById(roomId);
|
||||
await r?.leave().catchError((_) {});
|
||||
await r?.forget().catchError((_) {});
|
||||
}));
|
||||
|
||||
// Eigener, unverschlüsselter Testraum (nur für diesen Lauf).
|
||||
roomId = await tester.runAsync(() => partner.client.createRoom(
|
||||
invite: [me.userID!],
|
||||
preset: CreateRoomPreset.privateChat,
|
||||
name: 'Anruftest (automatisch)',
|
||||
));
|
||||
_log('Raum angelegt: $roomId');
|
||||
await _pumpUntil(
|
||||
tester,
|
||||
() => me.getRoomById(roomId!)?.membership == Membership.invite,
|
||||
what: 'Einladung kommt bei der App an',
|
||||
);
|
||||
await tester.runAsync(() => me.getRoomById(roomId!)!.join());
|
||||
_log('App ist beigetreten');
|
||||
// Die Gegenseite ist Ersteller und damit im Raum; ihr Mitglieder-Cache
|
||||
// wird vom SDK nur bei Bedarf geladen – kurz warten genügt.
|
||||
await _pumpFor(tester, const Duration(seconds: 3));
|
||||
|
||||
final voip = container.read(callSignalingProvider);
|
||||
|
||||
// ═══ Teil 1: Gegenseite ruft an, App nimmt über den echten Knopf an ═══
|
||||
// (prüft die Kandidaten-Pufferung der App mit einem ungepatchten
|
||||
// Anrufer – genau Bernds Fall „lange/kein Verbindungsaufbau“)
|
||||
_log('Teil 1: Gegenseite ruft an');
|
||||
await tester.runAsync(() => partner.run(() => partner.voip.inviteToCall(
|
||||
partner.client.getRoomById(roomId!)!,
|
||||
CallType.kVoice,
|
||||
)));
|
||||
final answerBtn = find.byWidgetPredicate(
|
||||
(w) => w is FloatingActionButton && w.heroTag == 'answer_call',
|
||||
);
|
||||
await _pumpUntil(tester, () => answerBtn.evaluate().isNotEmpty,
|
||||
what: 'Annehmen-Knopf erscheint');
|
||||
await _pumpFor(tester, const Duration(seconds: 2)); // klingeln lassen
|
||||
await tester.tap(answerBtn.first);
|
||||
await _waitConnected(tester, voip, partner);
|
||||
|
||||
await _checkAudio(tester, voip, partner);
|
||||
|
||||
// Stumm über den echten Knopf → Gegenseite sieht „stumm“, Spur ist aus.
|
||||
bool partnerSeesMuted() =>
|
||||
partner.delegate.call?.remoteUserMediaStream?.audioMuted == true;
|
||||
await tester.tap(find.byIcon(Icons.mic_rounded).first);
|
||||
await _pumpUntil(tester, partnerSeesMuted,
|
||||
timeout: const Duration(seconds: 15),
|
||||
what: 'Gegenseite sieht: App ist stumm');
|
||||
expect(voip.isMicMuted, isTrue);
|
||||
expect(
|
||||
voip.currentCall!.localUserMediaStream!.stream!.getAudioTracks().first.enabled,
|
||||
isFalse,
|
||||
reason: 'Mikrofonspur ist trotz Stumm noch an',
|
||||
);
|
||||
await tester.tap(find.byIcon(Icons.mic_off_rounded).first);
|
||||
await _pumpUntil(tester, () => !partnerSeesMuted() && !voip.isMicMuted,
|
||||
timeout: const Duration(seconds: 15),
|
||||
what: 'Stumm wieder aufgehoben');
|
||||
_log('Stumm/Entstummen über den Knopf: ok');
|
||||
|
||||
// Taub → Gegenseite nicht mehr hören UND selbst stumm; danach zurück.
|
||||
await tester.tap(find.byIcon(Icons.headphones_rounded).first);
|
||||
await _pumpUntil(
|
||||
tester,
|
||||
() => voip.isDeafened && partnerSeesMuted(),
|
||||
timeout: const Duration(seconds: 15),
|
||||
what: 'Taub: selbst stumm',
|
||||
);
|
||||
final remoteAudio = voip.currentCall!.getRemoteStreams
|
||||
.expand((w) => w.stream?.getAudioTracks() ?? const <MediaStreamTrack>[]);
|
||||
expect(remoteAudio, isNotEmpty);
|
||||
expect(remoteAudio.every((t) => !t.enabled), isTrue,
|
||||
reason: 'Ton der Gegenseite ist trotz Taub noch an');
|
||||
await tester.tap(find.byIcon(Icons.headset_off_rounded).first);
|
||||
await _pumpUntil(
|
||||
tester,
|
||||
() => !voip.isDeafened && !partnerSeesMuted() && !voip.isMicMuted,
|
||||
timeout: const Duration(seconds: 15),
|
||||
what: 'Taub aufgehoben, Mikro wieder an',
|
||||
);
|
||||
expect(
|
||||
voip.currentCall!.getRemoteStreams
|
||||
.expand((w) => w.stream?.getAudioTracks() ?? const <MediaStreamTrack>[])
|
||||
.every((t) => t.enabled),
|
||||
isTrue,
|
||||
);
|
||||
_log('Taub/Hören über den Knopf: ok');
|
||||
|
||||
// Gegenseite teilt Bildschirm → App zeigt ihn
|
||||
await tester.runAsync(
|
||||
() => partner.run(() => partner.delegate.call!.setScreensharingEnabled(true)),
|
||||
);
|
||||
await _pumpUntil(
|
||||
tester,
|
||||
() =>
|
||||
voip.isRemoteScreensharing &&
|
||||
(voip.remoteRenderer.srcObject?.getVideoTracks().isNotEmpty ?? false),
|
||||
timeout: const Duration(seconds: 30),
|
||||
what: 'Bildschirm der Gegenseite erscheint in der App',
|
||||
);
|
||||
await _pumpFor(tester, const Duration(seconds: 3));
|
||||
final appVideoIn = (await tester.runAsync(
|
||||
() => _rtp(voip.currentCall!.pc!, 'video'),
|
||||
))!;
|
||||
_log('BILD bei der App (von der Gegenseite): $appVideoIn');
|
||||
expect(appVideoIn.received, greaterThan(0),
|
||||
reason: 'Bildschirm der Gegenseite kommt nicht an');
|
||||
await tester.runAsync(
|
||||
() => partner.run(() => partner.delegate.call!.setScreensharingEnabled(false)),
|
||||
);
|
||||
await _pumpUntil(tester, () => !voip.isRemoteScreensharing,
|
||||
what: 'Bildschirm der Gegenseite verschwindet wieder');
|
||||
|
||||
// App teilt ihren Bildschirm über den echten Auswähler
|
||||
await _pumpUntil(
|
||||
tester,
|
||||
() => find.byIcon(Icons.screen_share_rounded).evaluate().isNotEmpty,
|
||||
what: 'Teilen-Knopf in der Anrufleiste',
|
||||
);
|
||||
await tester.tap(find.byIcon(Icons.screen_share_rounded).first);
|
||||
final tile = find.byWidgetPredicate(
|
||||
(w) => w.runtimeType.toString() == '_SourceTile',
|
||||
);
|
||||
await _pumpUntil(tester, () => tile.evaluate().isNotEmpty,
|
||||
timeout: const Duration(seconds: 20), what: 'Quellen-Auswähler');
|
||||
await tester.tap(tile.first);
|
||||
await _pumpUntil(
|
||||
tester,
|
||||
() {
|
||||
final s = partner.delegate.call?.remoteScreenSharingStream?.stream;
|
||||
return voip.isScreensharing &&
|
||||
s != null &&
|
||||
s.getVideoTracks().isNotEmpty;
|
||||
},
|
||||
timeout: const Duration(seconds: 30),
|
||||
what: 'Bildschirm der App kommt bei der Gegenseite an',
|
||||
);
|
||||
await _pumpFor(tester, const Duration(seconds: 3));
|
||||
final partnerVideoIn = (await tester.runAsync(
|
||||
() => _rtp(partner.delegate.call!.pc!, 'video'),
|
||||
))!;
|
||||
_log('BILD bei der Gegenseite (von der App): $partnerVideoIn');
|
||||
expect(partnerVideoIn.received, greaterThan(0),
|
||||
reason: 'Bildschirm der App kommt nicht an');
|
||||
|
||||
await tester.runAsync(() => voip.hangup());
|
||||
await _pumpUntil(tester, () => voip.currentCall == null,
|
||||
what: 'aufgelegt (Teil 1)');
|
||||
await _pumpFor(tester, const Duration(seconds: 3));
|
||||
|
||||
// ═══ Teil 2: App ruft an, Gegenseite nimmt an ═══
|
||||
_log('Teil 2: App ruft an');
|
||||
partner.fixCandidates = true; // Gegenseite wie eine gepatchte Pyramid
|
||||
await tester.runAsync(() => voip.startCall(roomId!, video: false));
|
||||
await _waitConnected(tester, voip, partner);
|
||||
await _checkAudio(tester, voip, partner);
|
||||
await tester.runAsync(() => voip.hangup());
|
||||
await _pumpUntil(tester, () => voip.currentCall == null,
|
||||
what: 'aufgelegt (Teil 2)');
|
||||
},
|
||||
skip: profile == null || accounts == null,
|
||||
timeout: const Timeout(Duration(minutes: 5)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _waitConnected(
|
||||
WidgetTester tester,
|
||||
CallSignalingService voip,
|
||||
_Partner partner,
|
||||
) async {
|
||||
final t0 = DateTime.now();
|
||||
await _pumpUntil(
|
||||
tester,
|
||||
() =>
|
||||
voip.currentCall?.state == CallState.kConnected &&
|
||||
partner.delegate.call?.state == CallState.kConnected,
|
||||
timeout: const Duration(seconds: 60),
|
||||
what: 'Anruf verbunden',
|
||||
every5s: () {
|
||||
final a = voip.currentCall;
|
||||
final b = partner.delegate.call;
|
||||
_log('App: ${a?.state} ice=${a?.pc?.iceConnectionState} | '
|
||||
'Gegenseite: ${b?.state} ice=${b?.pc?.iceConnectionState}');
|
||||
},
|
||||
);
|
||||
_log('verbunden nach ${DateTime.now().difference(t0).inMilliseconds} ms');
|
||||
}
|
||||
|
||||
Future<void> _checkAudio(
|
||||
WidgetTester tester,
|
||||
CallSignalingService voip,
|
||||
_Partner partner,
|
||||
) async {
|
||||
await _pumpFor(tester, const Duration(seconds: 4));
|
||||
final appAudio = (await tester.runAsync(
|
||||
() => _rtp(voip.currentCall!.pc!, 'audio'),
|
||||
))!;
|
||||
final partnerAudio = (await tester.runAsync(
|
||||
() => _rtp(partner.delegate.call!.pc!, 'audio'),
|
||||
))!;
|
||||
_log('TON App: $appAudio | Gegenseite: $partnerAudio');
|
||||
expect(appAudio.sent, greaterThan(0), reason: 'App sendet keinen Ton');
|
||||
expect(appAudio.received, greaterThan(0), reason: 'App empfängt keinen Ton');
|
||||
expect(partnerAudio.received, greaterThan(0),
|
||||
reason: 'Gegenseite empfängt keinen Ton der App');
|
||||
}
|
||||
|
||||
class _Rtp {
|
||||
_Rtp(this.sent, this.received);
|
||||
final num sent;
|
||||
final num received;
|
||||
@override
|
||||
String toString() => 'gesendet=$sent B, empfangen=$received B';
|
||||
}
|
||||
|
||||
Future<_Rtp> _rtp(rtc.RTCPeerConnection pc, String kind) async {
|
||||
num sent = 0;
|
||||
num received = 0;
|
||||
for (final r in await pc.getStats()) {
|
||||
if (r.values['kind'] != kind) continue;
|
||||
if (r.type == 'outbound-rtp') sent += (r.values['bytesSent'] as num?) ?? 0;
|
||||
if (r.type == 'inbound-rtp') received += (r.values['bytesReceived'] as num?) ?? 0;
|
||||
}
|
||||
return _Rtp(sent, received);
|
||||
}
|
||||
|
||||
/// Zweiter Teilnehmer: eigener Matrix-Client + VoIP im selben Prozess.
|
||||
class _Partner {
|
||||
_Partner(this.client, this.delegate, this.voip, this.zone) {
|
||||
_sub = client.onCallEvents.stream.listen((events) {
|
||||
for (final e in events) {
|
||||
if (e.type != EventTypes.CallCandidates || e.senderId == client.userID) {
|
||||
continue;
|
||||
}
|
||||
final list = e.content.tryGetList<Map<String, dynamic>>('candidates');
|
||||
if (list != null) _buffer.addAll(list);
|
||||
}
|
||||
});
|
||||
_timer = Timer.periodic(const Duration(milliseconds: 500), (_) async {
|
||||
final pc = delegate.call?.pc;
|
||||
if (!fixCandidates || pc == null) return;
|
||||
try {
|
||||
if (await pc.getRemoteDescription() == null) return;
|
||||
for (final c in List.of(_buffer)) {
|
||||
final cand = c['candidate'] as String?;
|
||||
if (cand == null || !_applied.add(cand)) continue;
|
||||
await pc.addCandidate(RTCIceCandidate(
|
||||
cand,
|
||||
c['sdpMid']?.toString() ?? '',
|
||||
(c['sdpMLineIndex'] as num?)?.round() ?? 0,
|
||||
));
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
final Client client;
|
||||
final _PartnerDelegate delegate;
|
||||
final VoIP voip;
|
||||
final Zone zone;
|
||||
|
||||
/// Aktion der Gegenseite in ihrer Zone ausführen (Fehler über Completer).
|
||||
Future<T> run<T>(Future<T> Function() f) {
|
||||
final done = Completer<T>();
|
||||
zone.run(() async {
|
||||
try {
|
||||
done.complete(await f());
|
||||
} catch (e, s) {
|
||||
done.completeError(e, s);
|
||||
}
|
||||
});
|
||||
return done.future;
|
||||
}
|
||||
|
||||
/// Gegenseite reicht Kandidaten nach wie die gepatchte App.
|
||||
bool fixCandidates = false;
|
||||
final _buffer = <Map<String, dynamic>>[];
|
||||
final _applied = <String>{};
|
||||
StreamSubscription? _sub;
|
||||
Timer? _timer;
|
||||
|
||||
static Future<_Partner> start(String user, String pass) async {
|
||||
final db = await databaseFactoryFfi.openDatabase(inMemoryDatabasePath);
|
||||
final client = Client(
|
||||
'pyramid-call-partner',
|
||||
database: await MatrixSdkDatabase.init('pyramid-call-partner', database: db),
|
||||
);
|
||||
await client.init(waitForFirstSync: false, waitUntilLoadCompletedLoaded: false);
|
||||
await client.checkHomeserver(Uri.parse('https://steggi-matrix.work'));
|
||||
await client.login(
|
||||
LoginType.mLoginPassword,
|
||||
identifier: AuthenticationUserIdentifier(user: user),
|
||||
password: pass,
|
||||
initialDeviceDisplayName: 'Pyramid-Anruftest (kurzlebig)',
|
||||
);
|
||||
final delegate = _PartnerDelegate();
|
||||
// Eigene Zone: Fehler der Gegenseite sichtbar protokollieren statt sie
|
||||
// im Testrahmen zu verlieren.
|
||||
final zone = Zone.current.fork(
|
||||
specification: ZoneSpecification(
|
||||
handleUncaughtError: (self, parent, z, e, s) =>
|
||||
_log('Gegenseite (abgefangen): ${e.toString().split('\n').first}'),
|
||||
),
|
||||
);
|
||||
final voip = zone.run(() => VoIP(client, delegate));
|
||||
return _Partner(client, delegate, voip, zone);
|
||||
}
|
||||
|
||||
Future<void> dispose(String? roomId) async {
|
||||
_timer?.cancel();
|
||||
await _sub?.cancel();
|
||||
try {
|
||||
await delegate.call?.hangup(reason: CallErrorCode.userHangup);
|
||||
} catch (_) {}
|
||||
if (roomId != null) {
|
||||
try {
|
||||
await client.leaveRoom(roomId);
|
||||
await client.forgetRoom(roomId);
|
||||
} catch (_) {}
|
||||
}
|
||||
try {
|
||||
client.backgroundSync = false;
|
||||
await client.logout();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
class _PartnerDelegate implements WebRTCDelegate {
|
||||
CallSession? call;
|
||||
|
||||
@override
|
||||
MediaDevices get mediaDevices => _PartnerMedia(rtc.navigator.mediaDevices);
|
||||
|
||||
@override
|
||||
Future<RTCPeerConnection> createPeerConnection(
|
||||
Map<String, dynamic> configuration, [
|
||||
Map<String, dynamic> constraints = const {},
|
||||
]) =>
|
||||
rtc.createPeerConnection(configuration, constraints);
|
||||
|
||||
@override
|
||||
Future<void> handleNewCall(CallSession session) async {
|
||||
call = session;
|
||||
if (session.direction == CallDirection.kIncoming) {
|
||||
// Wie ein Mensch nach kurzem Klingeln annehmen. Sofortiges Annehmen
|
||||
// trifft eine Lücke im SDK: Kandidaten, die WÄHREND answer()
|
||||
// eintreffen, landen in einer Warteschlange, die nie mehr abgearbeitet
|
||||
// wird (CallSession.onCandidatesReceived vs. answer()).
|
||||
unawaited(Future.delayed(const Duration(seconds: 2), () async {
|
||||
try {
|
||||
await session.answer();
|
||||
} catch (e) {
|
||||
_log('Gegenseite: Annehmen fehlgeschlagen: $e');
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> handleCallEnded(CallSession session) async {}
|
||||
@override
|
||||
Future<void> handleMissedCall(CallSession session) async {}
|
||||
@override
|
||||
Future<void> handleNewGroupCall(GroupCallSession groupCall) async {}
|
||||
@override
|
||||
Future<void> handleGroupCallEnded(GroupCallSession groupCall) async {}
|
||||
@override
|
||||
Future<void> playRingtone() async {}
|
||||
@override
|
||||
Future<void> stopRingtone() async {}
|
||||
@override
|
||||
Future<void> registerListeners(CallSession session) async {}
|
||||
@override
|
||||
bool get isWeb => false;
|
||||
@override
|
||||
bool get canHandleNewCall => true;
|
||||
@override
|
||||
EncryptionKeyProvider? get keyProvider => null;
|
||||
}
|
||||
|
||||
/// Wie die App am PC: Bildschirm über eine Desktop-Quelle (erster Monitor).
|
||||
class _PartnerMedia extends MediaDevices {
|
||||
_PartnerMedia(this._d);
|
||||
final MediaDevices _d;
|
||||
|
||||
@override
|
||||
Future<MediaStream> getUserMedia(Map<String, dynamic> c) => _d.getUserMedia(c);
|
||||
|
||||
@override
|
||||
Future<MediaStream> getDisplayMedia(Map<String, dynamic> c) async {
|
||||
final sources = await rtc.desktopCapturer
|
||||
.getSources(types: [rtc.SourceType.Screen]);
|
||||
return _d.getDisplayMedia({
|
||||
'video': {
|
||||
'mandatory': {
|
||||
'chromeMediaSource': 'desktop',
|
||||
'chromeMediaSourceId': sources.first.id,
|
||||
},
|
||||
'optional': [],
|
||||
},
|
||||
'audio': false,
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<MediaDeviceInfo>> enumerateDevices() => _d.enumerateDevices();
|
||||
@override
|
||||
MediaTrackSupportedConstraints getSupportedConstraints() =>
|
||||
_d.getSupportedConstraints();
|
||||
@override
|
||||
set ondevicechange(Function(dynamic event)? callback) =>
|
||||
_d.ondevicechange = callback;
|
||||
@override
|
||||
Function(dynamic event)? get ondevicechange => _d.ondevicechange;
|
||||
@override
|
||||
// ignore: deprecated_member_use
|
||||
Future<List<dynamic>> getSources() => _d.getSources();
|
||||
@override
|
||||
Future<MediaDeviceInfo> selectAudioOutput([AudioOutputOptions? options]) =>
|
||||
_d.selectAudioOutput(options);
|
||||
}
|
||||
|
||||
Map<String, String>? _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;
|
||||
}
|
||||
|
||||
Future<void> _pumpFor(WidgetTester tester, Duration d) async {
|
||||
final end = DateTime.now().add(d);
|
||||
while (DateTime.now().isBefore(end)) {
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
}
|
||||
}
|
||||
|
||||
void _log(String m) {
|
||||
// ignore: avoid_print
|
||||
print('SCHRITT ${DateTime.now().toIso8601String().substring(11, 19)} $m');
|
||||
}
|
||||
|
||||
Future<void> _pumpUntil(
|
||||
WidgetTester tester,
|
||||
bool Function() condition, {
|
||||
Duration timeout = const Duration(seconds: 60),
|
||||
String what = '',
|
||||
void Function()? onTimeout,
|
||||
void Function()? every5s,
|
||||
}) async {
|
||||
final end = DateTime.now().add(timeout);
|
||||
var next = DateTime.now().add(const Duration(seconds: 5));
|
||||
while (DateTime.now().isBefore(end)) {
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
if (condition()) return;
|
||||
if (every5s != null && DateTime.now().isAfter(next)) {
|
||||
every5s();
|
||||
next = DateTime.now().add(const Duration(seconds: 5));
|
||||
}
|
||||
}
|
||||
_log('ZEITÜBERSCHREITUNG: $what');
|
||||
onTimeout?.call();
|
||||
fail('Zeitüberschreitung: $what');
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
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);
|
||||
|
||||
// ── 3. „Neue Nachrichten“-Trenner MITTEN in einer Gruppe verschwindet ──
|
||||
// (Bernd 2026-10-08: blieb manchmal stehen – bei mehreren Nachrichten
|
||||
// desselben Absenders hintereinander, gelesen bis zur ersten.)
|
||||
container.read(activeRoomIdProvider.notifier).state = null;
|
||||
await _pumpFor(tester, const Duration(seconds: 1));
|
||||
final ids = (await tester.runAsync(() async => [
|
||||
await partner!.sendText(inviteRoomId, 'Gruppe 1'),
|
||||
await partner.sendText(inviteRoomId, 'Gruppe 2'),
|
||||
await partner.sendText(inviteRoomId, 'Gruppe 3'),
|
||||
]))!;
|
||||
final room = client.getRoomById(inviteRoomId)!;
|
||||
// Erst warten, bis alle drei da sind, dann bis „Gruppe 1“ gelesen melden.
|
||||
final tEnd = DateTime.now().add(const Duration(seconds: 30));
|
||||
while (room.lastEvent?.eventId != ids[2] && DateTime.now().isBefore(tEnd)) {
|
||||
await _pumpFor(tester, const Duration(milliseconds: 300));
|
||||
}
|
||||
await tester.runAsync(() => room.setReadMarker(ids[0], mRead: ids[0]));
|
||||
final fEnd = DateTime.now().add(const Duration(seconds: 30));
|
||||
while (room.fullyRead != ids[0] && DateTime.now().isBefore(fEnd)) {
|
||||
await _pumpFor(tester, const Duration(milliseconds: 300));
|
||||
}
|
||||
expect(room.fullyRead, ids[0], reason: 'Lesemarker kam nicht an');
|
||||
container.read(activeRoomIdProvider.notifier).state = inviteRoomId;
|
||||
await _pumpUntil(tester, find.text('Neue Nachrichten'),
|
||||
timeout: const Duration(seconds: 20));
|
||||
// Nach der Verweildauer (6 s) + Ausblenden muss er weg sein.
|
||||
await _pumpUntil(
|
||||
tester,
|
||||
find.text('Neue Nachrichten'),
|
||||
gone: true,
|
||||
timeout: const Duration(seconds: 15),
|
||||
);
|
||||
|
||||
// Aufräumen: Testraum verlassen + vergessen.
|
||||
await tester.runAsync(() async {
|
||||
final r = client.getRoomById(inviteRoomId);
|
||||
await r?.leave();
|
||||
await r?.forget().catchError((_) {});
|
||||
});
|
||||
container.read(activeRoomIdProvider.notifier).state = null;
|
||||
await _pumpFor(tester, const Duration(seconds: 1));
|
||||
},
|
||||
skip: profile == null || accounts == null,
|
||||
timeout: const Timeout(Duration(minutes: 4)),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, String>? _loadAccounts() {
|
||||
final home = Platform.environment['USERPROFILE'] ?? '';
|
||||
final file = File('$home\\.pyramid-autopilot\\test-accounts.txt');
|
||||
if (!file.existsSync()) return null;
|
||||
final map = <String, String>{};
|
||||
for (final line in file.readAsLinesSync()) {
|
||||
final t = line.trim();
|
||||
if (t.isEmpty || t.startsWith('#')) continue;
|
||||
final i = t.indexOf('=');
|
||||
if (i > 0) map[t.substring(0, i)] = t.substring(i + 1);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// Minimaler Matrix-Zugang per Client-Server-API (kurzlebiges Gerät).
|
||||
class _RestUser {
|
||||
_RestUser(this._token);
|
||||
final String _token;
|
||||
int _txn = 0;
|
||||
|
||||
static Future<_RestUser> login(String user, String pass) async {
|
||||
final res = await http.post(
|
||||
Uri.parse('$_hs/_matrix/client/v3/login'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'type': 'm.login.password',
|
||||
'identifier': {'type': 'm.id.user', 'user': user},
|
||||
'password': pass,
|
||||
'initial_device_display_name': 'Pyramid-Livetest (kurzlebig)',
|
||||
}),
|
||||
);
|
||||
if (res.statusCode != 200) throw StateError('Login ${res.statusCode}');
|
||||
return _RestUser(jsonDecode(res.body)['access_token'] as String);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _call(String method, String path,
|
||||
[Object? body]) async {
|
||||
final req = http.Request(method, Uri.parse('$_hs/_matrix/client/v3$path'))
|
||||
..headers['Authorization'] = 'Bearer $_token'
|
||||
..headers['Content-Type'] = 'application/json'
|
||||
..body = jsonEncode(body ?? {});
|
||||
final res = await http.Response.fromStream(await req.send());
|
||||
if (res.statusCode != 200) {
|
||||
throw StateError('$method $path → ${res.statusCode}');
|
||||
}
|
||||
return jsonDecode(res.body) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
String _enc(String s) => Uri.encodeComponent(s);
|
||||
|
||||
Future<void> readReceipt(String roomId, String eventId) => _call(
|
||||
'POST',
|
||||
'/rooms/${_enc(roomId)}/read_markers',
|
||||
{'m.fully_read': eventId, 'm.read': eventId},
|
||||
);
|
||||
|
||||
Future<String> createRoom(String name) async =>
|
||||
(await _call('POST', '/createRoom', {
|
||||
'name': name,
|
||||
'preset': 'private_chat',
|
||||
}))['room_id'] as String;
|
||||
|
||||
Future<String> sendText(String roomId, String text) async =>
|
||||
(await _call(
|
||||
'PUT',
|
||||
'/rooms/${_enc(roomId)}/send/m.room.message/t${DateTime.now().microsecondsSinceEpoch}${_txn++}',
|
||||
{'msgtype': 'm.text', 'body': text},
|
||||
))['event_id'] as String;
|
||||
|
||||
Future<void> invite(String roomId, String userId) =>
|
||||
_call('POST', '/rooms/${_enc(roomId)}/invite', {'user_id': userId});
|
||||
|
||||
Future<void> leave(String roomId) async {
|
||||
try {
|
||||
await _call('POST', '/rooms/${_enc(roomId)}/leave');
|
||||
await _call('POST', '/rooms/${_enc(roomId)}/forget');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
try {
|
||||
await _call('POST', '/logout');
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pumpFor(WidgetTester tester, Duration d) async {
|
||||
final end = DateTime.now().add(d);
|
||||
while (DateTime.now().isBefore(end)) {
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pumpUntil(
|
||||
WidgetTester tester,
|
||||
Finder finder, {
|
||||
bool gone = false,
|
||||
Duration timeout = const Duration(seconds: 60),
|
||||
}) async {
|
||||
final end = DateTime.now().add(timeout);
|
||||
while (DateTime.now().isBefore(end)) {
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
final found = finder.evaluate().isNotEmpty;
|
||||
if (found != gone) return;
|
||||
}
|
||||
fail('Zeitüberschreitung: $finder ${gone ? "verschwindet nicht" : "nicht gefunden"}');
|
||||
}
|
||||
@@ -0,0 +1,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/theme.dart';
|
||||
import 'package:pyramid/main.dart' as app;
|
||||
import 'package:pyramid/widgets/spring_press.dart';
|
||||
|
||||
/// UI-Durchlauf im HANDY-Format mit der echten App (Windows-Build) und einem
|
||||
/// TEST-Profil – erzeugt Screenshots unter build/ui_screens/.
|
||||
@@ -59,10 +60,63 @@ void main() {
|
||||
|
||||
// ── Direktnachricht: Sprechblasen (Handy-Standard) und Liste ──
|
||||
await tester.tap(find.text(_otherUser).first);
|
||||
// Chat gleitet federnd über die Liste.
|
||||
await tester.pump(const Duration(milliseconds: 90));
|
||||
await _shot(tester, '01b_chat_gleitet_herein');
|
||||
await _pumpFor(tester, const Duration(seconds: 6));
|
||||
await _scrollToNewest(tester);
|
||||
await _shot(tester, '02_chat');
|
||||
|
||||
// Kontakt-Banner aufklappen (Tipp auf den Namen im Kopf) und wieder zu.
|
||||
await tester.tap(find.text(_otherUser).first);
|
||||
await _pumpFor(tester, const Duration(seconds: 1));
|
||||
await _shot(tester, '02c_dm_kopf_aufgeklappt');
|
||||
await tester.tap(find.text(_otherUser).first);
|
||||
await _pumpFor(tester, const Duration(seconds: 1));
|
||||
|
||||
// Antwort-Zitat antippen → springt zur zitierten Nachricht.
|
||||
final quote = find.byWidgetPredicate(
|
||||
(w) => w is SpringPress && w.pressedScale == 0.97,
|
||||
);
|
||||
await tester.scrollUntilVisible(
|
||||
quote.first,
|
||||
300,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
maxScrolls: 40,
|
||||
);
|
||||
await _pumpFor(tester, const Duration(milliseconds: 600));
|
||||
await _shot(tester, '02e_zitat_vor_dem_tippen');
|
||||
await tester.tap(quote.first, warnIfMissed: false);
|
||||
await _pumpFor(tester, const Duration(milliseconds: 900));
|
||||
await _shot(tester, '02f_sprung_zum_zitat');
|
||||
await _scrollToNewest(tester);
|
||||
|
||||
// ⋮-Menü mit Suchen/Mitglieder.
|
||||
await tester.tap(find.byIcon(Icons.more_vert_rounded).first);
|
||||
await _pumpFor(tester, const Duration(seconds: 1));
|
||||
expect(find.text('Mitglieder'), findsWidgets);
|
||||
await _shot(tester, '02d_menue');
|
||||
await tester.tapAt(Offset(size.width / 2, size.height / 2));
|
||||
await _pumpFor(tester, const Duration(seconds: 1));
|
||||
|
||||
// Antworten → Antwort-Leiste federt auf. (Wischen zum Antworten gibt
|
||||
// es nur auf echten Handys; hier über Lange-Drücken → Antworten.)
|
||||
// Neben die eigene Sprechblase drücken (nicht in den Text – dort
|
||||
// greift am PC die Textauswahl).
|
||||
final ownMsg = tester.getCenter(find.text('Danke, sieht gut aus!').first);
|
||||
await tester.longPressAt(Offset(size.width * 0.15, ownMsg.dy));
|
||||
await _pumpFor(tester, const Duration(milliseconds: 500));
|
||||
await tester.tap(find.byIcon(Icons.reply_rounded).first);
|
||||
await tester.pump(const Duration(milliseconds: 60));
|
||||
await _shot(tester, '02g_antwort_leiste_federt');
|
||||
await _pumpFor(tester, const Duration(milliseconds: 800));
|
||||
await _shot(tester, '02h_antwort_leiste');
|
||||
final closeReply = find.byIcon(Icons.close_rounded);
|
||||
if (closeReply.evaluate().isNotEmpty) {
|
||||
await tester.tap(closeReply.first);
|
||||
await _pumpFor(tester, const Duration(milliseconds: 600));
|
||||
}
|
||||
|
||||
container.read(messageStyleProvider.notifier).state = MessageStyle.list;
|
||||
await _pumpFor(tester, const Duration(seconds: 2));
|
||||
await _shot(tester, '02b_chat_liste');
|
||||
@@ -177,6 +231,14 @@ void main() {
|
||||
await _shot(tester, '10_einstellungen_text_chat');
|
||||
container.read(activeModalProvider.notifier).state = ModalKind.none;
|
||||
await _pumpFor(tester, const Duration(seconds: 1));
|
||||
|
||||
// ── Zurück zur Chatliste: Chat gleitet hinaus, Liste bleibt erhalten ──
|
||||
container.read(activeRoomIdProvider.notifier).state = null;
|
||||
await tester.pump(const Duration(milliseconds: 90));
|
||||
await _shot(tester, '11_zurueck_gleitet_hinaus');
|
||||
await _pumpFor(tester, const Duration(seconds: 1));
|
||||
expect(find.text(_groupName), findsWidgets);
|
||||
await _shot(tester, '12_liste_nach_zurueck');
|
||||
},
|
||||
skip: profile == null,
|
||||
timeout: const Timeout(Duration(minutes: 6)),
|
||||
|
||||
@@ -10,8 +10,9 @@ bool isMobileLayout(Size size) => size.width < 900 || size.height < 500;
|
||||
// ── Schriftgröße ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Standard-Schriftfaktor, solange der Nutzer nichts eingestellt hat: auf dem
|
||||
/// Handy größer (Bernds Wunsch 2026-10-07: ≈ 16 statt 14 px), am PC wie bisher.
|
||||
double defaultTextScale({required bool mobile}) => mobile ? 1.15 : 1.0;
|
||||
/// Handy größer (Bernd 2026-10-07: 115 % ausprobiert, 110 % als Standard
|
||||
/// gewünscht), am PC wie bisher.
|
||||
double defaultTextScale({required bool mobile}) => mobile ? 1.10 : 1.0;
|
||||
|
||||
const minTextScale = 0.85;
|
||||
const maxTextScale = 1.5;
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod;
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:pyramid/core/app_database.dart';
|
||||
import 'package:pyramid/core/notification_content.dart';
|
||||
import 'package:pyramid/core/read_markers.dart';
|
||||
import 'package:pyramid/core/soft_logout_guard.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
@@ -167,9 +168,11 @@ Future<bool> _bgMarkRead(String? roomId, String? eventId) async {
|
||||
if (client == null) return false;
|
||||
try {
|
||||
final room = client.getRoomById(roomId) ?? Room(id: roomId, client: client);
|
||||
await room
|
||||
.setReadMarker(eventId, mRead: eventId)
|
||||
.timeout(const Duration(seconds: 15));
|
||||
await markRoomReadUpTo(
|
||||
room,
|
||||
eventId,
|
||||
publicReceipt: await loadReadReceiptsPublic(),
|
||||
).timeout(const Duration(seconds: 15));
|
||||
debugPrint('[NOTIF-BGENGINE] read marker set for $roomId');
|
||||
return true;
|
||||
} catch (e) {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
||||
|
||||
/// Anruf-Vordergrunddienst auf Android (android/.../CallService.kt).
|
||||
///
|
||||
/// * Solange ein Anruf läuft, hält der Dienst das Mikrofon auch im
|
||||
/// Hintergrund aktiv (Android schaltet es sonst stumm) und zeigt eine
|
||||
/// „Anruf läuft“-Benachrichtigung, über die man zurück in die App kommt.
|
||||
/// * Für eine Bildschirmübertragung MUSS ab Android 14 vorher ein Dienst vom
|
||||
/// Typ mediaProjection laufen – und der darf erst NACH der Zustimmung des
|
||||
/// Nutzers starten. [prepareScreenCapture] erledigt beides in der richtigen
|
||||
/// Reihenfolge; flutter_webrtc benutzt danach die gespeicherte Zustimmung.
|
||||
///
|
||||
/// Beide Anrufwege (1:1 über Matrix, Sprachkanäle über LiveKit) melden sich
|
||||
/// mit eigenem Namen an; der Dienst endet erst, wenn keiner mehr läuft.
|
||||
/// Auf anderen Plattformen passiert nichts.
|
||||
class CallForeground {
|
||||
CallForeground._();
|
||||
|
||||
static const _channel = MethodChannel('chat.pyramid.pyramid/call');
|
||||
static final _owners = <String>{};
|
||||
static final _screenOwners = <String>{};
|
||||
static String _title = 'Pyramid';
|
||||
|
||||
static bool get _android => !kIsWeb && Platform.isAndroid;
|
||||
|
||||
/// true = Dienst läuft (bzw. wurde beendet) wie gewünscht.
|
||||
static Future<bool> _apply() async {
|
||||
if (!_android) return true;
|
||||
try {
|
||||
if (_owners.isEmpty) {
|
||||
await _channel.invokeMethod('stop');
|
||||
return true;
|
||||
}
|
||||
final ok = await _channel.invokeMethod<bool>('start', {
|
||||
'title': _title,
|
||||
'screen': _screenOwners.isNotEmpty,
|
||||
});
|
||||
return ok ?? false;
|
||||
} catch (e) {
|
||||
debugPrint('[CallForeground] $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Anruf von [owner] läuft (z. B. 'voip', 'livekit').
|
||||
static Future<void> callStarted(String owner, {required String title}) async {
|
||||
_owners.add(owner);
|
||||
_title = title;
|
||||
await _apply();
|
||||
}
|
||||
|
||||
/// Anruf von [owner] ist beendet.
|
||||
static Future<void> callEnded(String owner) async {
|
||||
final changed = _owners.remove(owner) | _screenOwners.remove(owner);
|
||||
if (changed) await _apply();
|
||||
}
|
||||
|
||||
/// Vor einer Bildschirmübertragung auf Android: Zustimmung einholen, dann
|
||||
/// den Dienst um „mediaProjection“ erweitern. false = Nutzer hat abgelehnt.
|
||||
static Future<bool> prepareScreenCapture(String owner) async {
|
||||
if (!_android) return true;
|
||||
final granted = await rtc.Helper.requestCapturePermission();
|
||||
if (!granted) return false;
|
||||
_owners.add(owner);
|
||||
_screenOwners.add(owner);
|
||||
if (!await _apply()) {
|
||||
// Ohne laufenden mediaProjection-Dienst würde der Mitschnitt die App
|
||||
// abstürzen lassen – dann lieber gar nicht übertragen.
|
||||
_screenOwners.remove(owner);
|
||||
await _apply();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Übertragung von [owner] beendet – Dienst wieder ohne mediaProjection.
|
||||
static Future<void> screenCaptureStopped(String owner) async {
|
||||
if (_screenOwners.remove(owner)) await _apply();
|
||||
}
|
||||
}
|
||||
+84
-32
@@ -1,37 +1,89 @@
|
||||
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
class LiveKitTokenGenerator {
|
||||
// Your LiveKit Credentials
|
||||
static const String apiKey = 'LKMatrixPi';
|
||||
static const String apiSecret = 'rYUT2PRaKLedp5VLQCQE83eZG7fjuBWtFPXGiveBmIE';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
static String generate({
|
||||
required String roomName,
|
||||
required String identity,
|
||||
String? displayName,
|
||||
int ttlSeconds = 3600, // Valid for 1 hour by default
|
||||
}) {
|
||||
final jwt = JWT(
|
||||
{
|
||||
'video': {
|
||||
'roomJoin': true,
|
||||
'room': roomName,
|
||||
'canPublish': true,
|
||||
'canSubscribe': true,
|
||||
'canPublishData': true,
|
||||
},
|
||||
'metadata': displayName ?? identity,
|
||||
},
|
||||
issuer: apiKey,
|
||||
subject: identity,
|
||||
);
|
||||
/// LiveKit-Beitritts-Token vom Pyramid-Server holen.
|
||||
///
|
||||
/// Früher hat die App die Tokens selbst signiert – dafür steckte das
|
||||
/// LiveKit-Secret im App-Code, und wer die App hatte, konnte sich für jeden
|
||||
/// Raum und jede Identität ein Token ausstellen. Jetzt signiert nur noch der
|
||||
/// Server (`/api/livekit-token` im Dashboard-Server auf dem Pi): Er prüft den
|
||||
/// Matrix-Login per whoami und setzt die LiveKit-Identität selbst auf die
|
||||
/// geprüfte Matrix-ID. Siehe docs/LIVEKIT_TOKEN_MIGRATION.md.
|
||||
class LiveKitTokenService {
|
||||
static const endpoint =
|
||||
'https://dashboard.steggi-matrix.work/api/livekit-token';
|
||||
static const fallbackUrl = 'wss://livekit.steggi-matrix.work';
|
||||
static const _timeout = Duration(seconds: 15);
|
||||
|
||||
// Sign the token with your secret
|
||||
final token = jwt.sign(
|
||||
SecretKey(apiSecret),
|
||||
expiresIn: Duration(seconds: ttlSeconds),
|
||||
);
|
||||
|
||||
return token;
|
||||
/// Liefert Token und LiveKit-Adresse für [room]. Wirft
|
||||
/// [LiveKitTokenException] mit einer verständlichen Meldung.
|
||||
static Future<({String token, String url})> fetch({
|
||||
required String room,
|
||||
required String matrixToken,
|
||||
http.Client? httpClient,
|
||||
}) async {
|
||||
final client = httpClient ?? http.Client();
|
||||
try {
|
||||
final res = await client
|
||||
.post(
|
||||
Uri.parse(endpoint),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'room': room, 'matrix_token': matrixToken}),
|
||||
)
|
||||
.timeout(_timeout);
|
||||
if (res.statusCode == 401) {
|
||||
throw const LiveKitTokenException(
|
||||
'Anmeldung vom Anrufserver abgelehnt – bitte neu anmelden.',
|
||||
);
|
||||
}
|
||||
if (res.statusCode != 200) {
|
||||
throw LiveKitTokenException(
|
||||
'Anrufserver antwortet nicht richtig (HTTP ${res.statusCode}).',
|
||||
);
|
||||
}
|
||||
final data = jsonDecode(res.body);
|
||||
final token = data is Map ? data['token'] : null;
|
||||
if (token is! String || token.isEmpty) {
|
||||
throw const LiveKitTokenException(
|
||||
'Anrufserver hat kein Zugangstoken geliefert.',
|
||||
);
|
||||
}
|
||||
final url = (data as Map)['url'];
|
||||
return (
|
||||
token: token,
|
||||
url: url is String && url.startsWith('wss://') ? url : fallbackUrl,
|
||||
);
|
||||
} on LiveKitTokenException {
|
||||
rethrow;
|
||||
} on TimeoutException {
|
||||
throw const LiveKitTokenException(
|
||||
'Anrufserver nicht erreichbar (Zeitüberschreitung).',
|
||||
);
|
||||
} on SocketException {
|
||||
throw const LiveKitTokenException(
|
||||
'Anrufserver nicht erreichbar – Internetverbindung prüfen.',
|
||||
);
|
||||
} on http.ClientException {
|
||||
throw const LiveKitTokenException(
|
||||
'Anrufserver nicht erreichbar – Internetverbindung prüfen.',
|
||||
);
|
||||
} on FormatException {
|
||||
throw const LiveKitTokenException(
|
||||
'Anrufserver hat eine unlesbare Antwort geschickt.',
|
||||
);
|
||||
} finally {
|
||||
if (httpClient == null) client.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class LiveKitTokenException implements Exception {
|
||||
final String message;
|
||||
const LiveKitTokenException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'package:pyramid/core/background_push.dart';
|
||||
import 'package:pyramid/core/fcm_push_service.dart';
|
||||
import 'package:pyramid/core/matrix_client.dart';
|
||||
import 'package:pyramid/core/notification_content.dart';
|
||||
import 'package:pyramid/core/read_markers.dart';
|
||||
import 'package:pyramid/core/settings_prefs.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:windows_taskbar/windows_taskbar.dart';
|
||||
@@ -205,9 +206,14 @@ Future<void> _initAndroid(WidgetRef ref) async {
|
||||
if (roomId != null && eventId != null) {
|
||||
try {
|
||||
final client = await ref.read(matrixClientProvider.future);
|
||||
await client
|
||||
.getRoomById(roomId)
|
||||
?.setReadMarker(eventId, mRead: eventId);
|
||||
final room = client.getRoomById(roomId);
|
||||
if (room != null) {
|
||||
await markRoomReadUpTo(
|
||||
room,
|
||||
eventId,
|
||||
publicReceipt: ref.read(privacyReadReceiptsProvider),
|
||||
);
|
||||
}
|
||||
debugPrint('[NOTIF-FG] markReadFromNotification: $roomId bis $eventId');
|
||||
} catch (e) {
|
||||
debugPrint('[NOTIF-FG] markReadFromNotification error: $e');
|
||||
|
||||
@@ -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);
|
||||
@@ -3,6 +3,7 @@ import 'dart:convert';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:pyramid/core/appearance.dart';
|
||||
import 'package:pyramid/core/read_markers.dart';
|
||||
import 'package:pyramid/core/theme.dart';
|
||||
|
||||
// ── Generic SharedPreferences-backed bool notifier ──────────────────────────
|
||||
@@ -87,7 +88,7 @@ final privacyPresenceProvider =
|
||||
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('privacy_presence', true));
|
||||
|
||||
final privacyReadReceiptsProvider =
|
||||
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('privacy_read_receipts', true));
|
||||
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref(readReceiptsPrefKey, true));
|
||||
|
||||
final privacyTypingProvider =
|
||||
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('privacy_typing', true));
|
||||
|
||||
@@ -49,6 +49,16 @@ abstract class CallSignalingService extends ChangeNotifier {
|
||||
|
||||
Future<void> changeQuality(String key);
|
||||
|
||||
/// Spricht gerade jemand? (Pegel aus den WebRTC-Statistiken, für den
|
||||
/// grünen Sprech-Ring.)
|
||||
bool get isLocalSpeaking => false;
|
||||
bool get isRemoteSpeaking => false;
|
||||
|
||||
/// Handy: Ton über den Lautsprecher (true) oder die Hörmuschel (false).
|
||||
/// Auf dem PC ohne Bedeutung.
|
||||
bool get isSpeakerOn => true;
|
||||
Future<void> toggleSpeaker() async {}
|
||||
|
||||
/// Wendet die in den Einstellungen gewählte Ausgabelautstärke auf den
|
||||
/// Remote-Stream an. Ohne [value] wird der Pref-Wert gelesen.
|
||||
Future<void> applyOutputVolume([double? value]);
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:webrtc_interface/webrtc_interface.dart';
|
||||
import 'package:pyramid/core/call_foreground.dart';
|
||||
import 'package:pyramid/core/ice_servers.dart';
|
||||
import 'package:pyramid/core/settings_prefs.dart';
|
||||
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
||||
@@ -19,6 +20,7 @@ class PyramidVoipManager extends CallSignalingService
|
||||
|
||||
final Client client;
|
||||
late final VoIP voip;
|
||||
late final Zone _sdkZone;
|
||||
@override
|
||||
CallSession? currentCall;
|
||||
|
||||
@@ -35,6 +37,34 @@ class PyramidVoipManager extends CallSignalingService
|
||||
bool _isDeafened = false;
|
||||
bool _isUpdating = false;
|
||||
String? _selectedSourceId;
|
||||
|
||||
// Handy: Lautsprecher statt Hörmuschel (wie Discord). Das SDK schaltet bei
|
||||
// Sprachanrufen auf die Hörmuschel – hält man das Handy vor sich, hört man
|
||||
// dann praktisch nichts (Bernds Test 2026-10-07).
|
||||
bool _speakerOn = true;
|
||||
bool get _isMobile =>
|
||||
!kIsWeb &&
|
||||
(defaultTargetPlatform == TargetPlatform.android ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS);
|
||||
|
||||
@override
|
||||
bool get isSpeakerOn => _speakerOn;
|
||||
|
||||
@override
|
||||
Future<void> toggleSpeaker() async {
|
||||
_speakerOn = !_speakerOn;
|
||||
notifyListeners();
|
||||
await _applySpeaker();
|
||||
}
|
||||
|
||||
Future<void> _applySpeaker() async {
|
||||
if (!_isMobile) return;
|
||||
try {
|
||||
await rtc.Helper.setSpeakerphoneOn(_speakerOn);
|
||||
} catch (e) {
|
||||
debugPrint('[VOIP] Lautsprecher: $e');
|
||||
}
|
||||
}
|
||||
String _currentQualityKey = 'hd';
|
||||
|
||||
@override
|
||||
@@ -52,7 +82,21 @@ class PyramidVoipManager extends CallSignalingService
|
||||
|
||||
PyramidVoipManager(this.client) {
|
||||
_instance = this;
|
||||
voip = VoIP(client, this);
|
||||
// VOR dem SDK anmelden: Kandidaten der Gegenseite puffern (siehe
|
||||
// _bufferRemoteCandidates).
|
||||
_callEventSub = client.onCallEvents.stream.listen(_bufferRemoteCandidates);
|
||||
// Alles, was das Anruf-SDK anstößt, läuft in einer eigenen Zone: Fehler,
|
||||
// die SDK/WebRTC-Plugin ins Leere werfen (z. B. Lautsprecher-Umschalten
|
||||
// unter Windows, getRemoteDescription wirft statt null), werden
|
||||
// protokolliert statt als „unbehandelt“ durchzuschlagen.
|
||||
_sdkZone = Zone.current.fork(
|
||||
specification: ZoneSpecification(
|
||||
handleUncaughtError: (self, parent, zone, error, stack) {
|
||||
debugPrint('[VOIP] SDK-Fehler (abgefangen): $error');
|
||||
},
|
||||
),
|
||||
);
|
||||
voip = _sdkZone.run(() => VoIP(client, this));
|
||||
// Suppress speakerphone call — not supported on Windows/Desktop
|
||||
try {
|
||||
(voip as dynamic).onConfigSpeakerphone = (bool _) async {};
|
||||
@@ -60,6 +104,29 @@ class PyramidVoipManager extends CallSignalingService
|
||||
_ensureRenderers();
|
||||
}
|
||||
|
||||
/// Führt [f] in der SDK-Zone aus. Ergebnis UND Fehler kommen über einen
|
||||
/// Completer von außen zurück – Fehler können Zonengrenzen sonst nicht
|
||||
/// überqueren, und der Aufrufer würde ewig warten.
|
||||
Future<T> _inSdkZone<T>(Future<T> Function() f) {
|
||||
final done = Completer<T>();
|
||||
_sdkZone.run(() async {
|
||||
try {
|
||||
done.complete(await f());
|
||||
} catch (e, s) {
|
||||
done.completeError(e, s);
|
||||
}
|
||||
});
|
||||
return done.future;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_callEventSub?.cancel();
|
||||
_levelTimer?.cancel();
|
||||
_renderRefreshTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _ensureRenderers() async {
|
||||
if (_renderersInitialized) return;
|
||||
try {
|
||||
@@ -104,13 +171,88 @@ class PyramidVoipManager extends CallSignalingService
|
||||
await _ensureRenderers();
|
||||
currentCall = call;
|
||||
_isCameraMuted = call.type == CallType.kVoice;
|
||||
_speakerOn = true;
|
||||
_isDeafened = false;
|
||||
_isMicMuted = false;
|
||||
_setupCallListeners(call);
|
||||
_updateInternalStates();
|
||||
notifyListeners();
|
||||
unawaited(_applyBufferedCandidates(call));
|
||||
}
|
||||
|
||||
// ── Netzwerk-Kandidaten der Gegenseite puffern ───────────────────────────
|
||||
// Lücke im matrix-SDK (6.2): Kommen die Kandidaten des Anrufers an, bevor
|
||||
// die Einladung fertig verarbeitet ist, verwirft das SDK sie endgültig
|
||||
// („we do not have the call“); Kandidaten, die WÄHREND des Annehmens
|
||||
// eintreffen, landen in einer Warteschlange, die nie mehr abgearbeitet
|
||||
// wird. Dann kennt man keinen Weg zur Gegenseite – im Heimnetz rettet das
|
||||
// oft noch ein direkter Prüfversuch der anderen Seite, über Mobilfunk/NAT
|
||||
// dauert der Aufbau ewig oder scheitert (Bernd 2026-10-08, im Anruftest
|
||||
// nachgestellt). Deshalb: alle Kandidaten mitschreiben und direkt an die
|
||||
// Verbindung geben, sobald sie die Gegenseite kennt. Doppelte ignoriert
|
||||
// WebRTC.
|
||||
StreamSubscription<List<BasicEventWithSender>>? _callEventSub;
|
||||
final _candidateBuffer = <String, List<Map<String, dynamic>>>{};
|
||||
final _appliedCandidates = <String>{};
|
||||
|
||||
void _bufferRemoteCandidates(List<BasicEventWithSender> events) {
|
||||
for (final e in events) {
|
||||
if (e.type != EventTypes.CallCandidates || e.senderId == client.userID) {
|
||||
continue;
|
||||
}
|
||||
final callId = e.content.tryGet<String>('call_id');
|
||||
final list = e.content.tryGetList<Map<String, dynamic>>('candidates');
|
||||
if (callId == null || list == null) continue;
|
||||
final party = e.content.tryGet<String>('party_id');
|
||||
(_candidateBuffer[callId] ??= []).addAll(
|
||||
list.map((c) => {...c, '_party': party}),
|
||||
);
|
||||
// Nur die letzten Anrufe behalten.
|
||||
while (_candidateBuffer.length > 8) {
|
||||
_candidateBuffer.remove(_candidateBuffer.keys.first);
|
||||
}
|
||||
final call = currentCall;
|
||||
if (call != null && call.callId == callId) {
|
||||
unawaited(_applyBufferedCandidates(call));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _applyBufferedCandidates(CallSession call) async {
|
||||
final pc = call.pc;
|
||||
final list = _candidateBuffer[call.callId];
|
||||
if (pc == null || list == null || list.isEmpty) return;
|
||||
try {
|
||||
if (await pc.getRemoteDescription() == null) return;
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
for (final c in List.of(list)) {
|
||||
final cand = c['candidate'];
|
||||
if (cand is! String || cand.isEmpty) continue;
|
||||
final party = c['_party'];
|
||||
final remoteParty = call.remotePartyId;
|
||||
if (remoteParty != null && party != null && party != remoteParty) continue;
|
||||
if (!_appliedCandidates.add('${call.callId}|$cand')) continue;
|
||||
try {
|
||||
await pc.addCandidate(rtc.RTCIceCandidate(
|
||||
cand,
|
||||
c['sdpMid']?.toString() ?? '',
|
||||
(c['sdpMLineIndex'] as num?)?.round() ?? 0,
|
||||
));
|
||||
} catch (e) {
|
||||
debugPrint('[VOIP] Kandidat nachreichen: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer? _renderRefreshTimer;
|
||||
String? _listenedCallId;
|
||||
void _setupCallListeners(CallSession call) {
|
||||
// Ausgehende Anrufe meldet das SDK zusätzlich über handleNewCall –
|
||||
// Listener nur einmal je Anruf anhängen.
|
||||
if (_listenedCallId == call.callId) return;
|
||||
_listenedCallId = call.callId;
|
||||
call.onCallStateChanged.stream.listen((state) {
|
||||
debugPrint('[VOIP] State: $state');
|
||||
_updateInternalStates();
|
||||
@@ -120,14 +262,36 @@ class PyramidVoipManager extends CallSignalingService
|
||||
debugPrint('[VOIP] Ensuring camera is unmuted on connect');
|
||||
call.setLocalVideoMuted(false);
|
||||
}
|
||||
_applySpeaker();
|
||||
_startLevelPolling();
|
||||
// Android: Mikrofon im Hintergrund + „Anruf läuft“-Benachrichtigung.
|
||||
CallForeground.callStarted('voip',
|
||||
title: call.room.getLocalizedDisplayname());
|
||||
}
|
||||
if (state == CallState.kEnded) {
|
||||
_stopLevelPolling();
|
||||
CallForeground.callEnded('voip');
|
||||
}
|
||||
_updateRenderers();
|
||||
});
|
||||
// Das SDK setzt bei jedem neuen eigenen Medienstrom die Hörmuschel –
|
||||
// danach unsere Wahl erneut anwenden.
|
||||
call.onCallEventChanged.stream.listen((event) {
|
||||
if (event == CallStateChange.kFeedsChanged) _applySpeaker();
|
||||
});
|
||||
|
||||
_renderRefreshTimer?.cancel();
|
||||
_renderRefreshTimer = Timer.periodic(
|
||||
const Duration(milliseconds: 1000), // Faster polling to catch feeds
|
||||
(_) => _updateRenderers(),
|
||||
(_) {
|
||||
_updateRenderers();
|
||||
// Gepufferte Kandidaten nachreichen, sobald die Verbindung die
|
||||
// Gegenseite kennt (nach Annehmen bzw. Antwort).
|
||||
final c = currentCall;
|
||||
if (c != null && c.state != CallState.kConnected) {
|
||||
_applyBufferedCandidates(c);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
_updateRenderers();
|
||||
@@ -144,99 +308,68 @@ class PyramidVoipManager extends CallSignalingService
|
||||
}
|
||||
|
||||
Future<void> _updateRenderers() async {
|
||||
if (currentCall == null || _isUpdating) return;
|
||||
final call = currentCall;
|
||||
if (call == null || _isUpdating) return;
|
||||
_isUpdating = true;
|
||||
|
||||
try {
|
||||
await _ensureRenderers();
|
||||
final dynamic c = currentCall;
|
||||
rtc.MediaStream? remote;
|
||||
rtc.MediaStream? local;
|
||||
|
||||
rtc.MediaStream? extract(dynamic obj) {
|
||||
if (obj == null) return null;
|
||||
if (obj is rtc.MediaStream) return obj;
|
||||
try { return obj.stream as rtc.MediaStream?; } catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
final List<dynamic> feeds = [];
|
||||
try {
|
||||
final dynamic sdkFeeds = c.getFeeds();
|
||||
if (sdkFeeds is Iterable) feeds.addAll(sdkFeeds.toList());
|
||||
} catch (_) {}
|
||||
|
||||
bool remoteScreenFound = false;
|
||||
bool localScreenFound = false;
|
||||
|
||||
for (final dynamic f in feeds) {
|
||||
final s = extract(f);
|
||||
if (s == null) continue;
|
||||
|
||||
bool isLocal = false;
|
||||
try { isLocal = f.isLocal == true || f.userId == client.userID; } catch (_) {}
|
||||
|
||||
String purpose = 'm.usermedia';
|
||||
try { purpose = f.purpose ?? 'm.usermedia'; } catch (_) {}
|
||||
|
||||
if (isLocal) {
|
||||
if (local == null || purpose == 'm.screenshare') {
|
||||
local = s;
|
||||
if (purpose == 'm.screenshare') localScreenFound = true;
|
||||
}
|
||||
} else {
|
||||
if (remote == null || purpose == 'm.screenshare') {
|
||||
remote = s;
|
||||
if (purpose == 'm.screenshare') remoteScreenFound = true;
|
||||
}
|
||||
// Typisierte SDK-Zugriffe. Früher lief das über ein dynamisches
|
||||
// `getFeeds()` – das gibt es im SDK gar nicht; der Fehler wurde
|
||||
// geschluckt, und Bildschirmübertragungen wurden weder beim Empfänger
|
||||
// angezeigt noch beim Sender als aktiv erkannt (Bernd 2026-10-08).
|
||||
rtc.MediaStream? video(WrappedMediaStream? w, {bool respectMute = false}) {
|
||||
final s = w?.stream;
|
||||
// stopped: Gegenseite hat die Übertragung beendet (das SDK lässt den
|
||||
// Strom stehen und markiert ihn nur).
|
||||
if (w == null || w.stopped || s == null || s.getVideoTracks().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
if (respectMute && w.videoMuted) return null;
|
||||
return s;
|
||||
}
|
||||
|
||||
_isScreensharing = localScreenFound;
|
||||
_isRemoteScreensharing = remoteScreenFound;
|
||||
final remoteScreen = video(call.remoteScreenSharingStream);
|
||||
final localScreen = video(call.localScreenSharingStream);
|
||||
final remoteUser = call.remoteUserMediaStream?.stream;
|
||||
|
||||
if (remote == null) {
|
||||
try { remote = extract(c.remoteUserMediaStream); } catch (_) {}
|
||||
}
|
||||
if (remote == null) {
|
||||
try { remote = extract(c.remoteStream); } catch (_) {}
|
||||
}
|
||||
if (local == null) {
|
||||
try { local = extract(c.localUserMediaStream); } catch (_) {}
|
||||
var changed = false;
|
||||
final remoteSharing = remoteScreen != null;
|
||||
final localSharing = call.localScreenSharingStream != null;
|
||||
if (remoteSharing != _isRemoteScreensharing ||
|
||||
localSharing != _isScreensharing) {
|
||||
changed = true;
|
||||
}
|
||||
_isRemoteScreensharing = remoteSharing;
|
||||
_isScreensharing = localSharing;
|
||||
|
||||
// Ausgabelautstärke (Settings-Slider) auf den Remote-Stream anwenden —
|
||||
if (_isDeafened) _applyDeafen(call);
|
||||
|
||||
// Ausgabelautstärke (Settings-Slider) auf den Ton der Gegenseite –
|
||||
// nur einmal pro Stream, _updateRenderers läuft auch per Timer.
|
||||
if (remote != null && remote.id != _volumeAppliedStreamId) {
|
||||
_volumeAppliedStreamId = remote.id;
|
||||
_remoteAudioStream = remote;
|
||||
if (remoteUser != null && remoteUser.id != _volumeAppliedStreamId) {
|
||||
_volumeAppliedStreamId = remoteUser.id;
|
||||
_remoteAudioStream = remoteUser;
|
||||
applyOutputVolume();
|
||||
}
|
||||
|
||||
// Final check for tracks before assigning
|
||||
bool changed = false;
|
||||
if (remote != null && remote.getVideoTracks().isNotEmpty) {
|
||||
if (remoteRenderer.srcObject?.id != remote.id) {
|
||||
debugPrint('[VOIP] Assigning Remote Renderer: ${remote.id} (${remote.getVideoTracks().length} video tracks)');
|
||||
remoteRenderer.srcObject = remote;
|
||||
changed = true;
|
||||
}
|
||||
} else if (remoteRenderer.srcObject != null) {
|
||||
remoteRenderer.srcObject = null;
|
||||
// Bildschirm hat Vorrang vor der Kamera.
|
||||
final remoteVideo =
|
||||
remoteScreen ?? video(call.remoteUserMediaStream, respectMute: true);
|
||||
final localVideo =
|
||||
localScreen ?? video(call.localUserMediaStream, respectMute: true);
|
||||
|
||||
if (remoteRenderer.srcObject?.id != remoteVideo?.id) {
|
||||
debugPrint('[VOIP] Remote-Video: ${remoteVideo?.id} (Bildschirm: $remoteSharing)');
|
||||
remoteRenderer.srcObject = remoteVideo;
|
||||
changed = true;
|
||||
}
|
||||
if (localRenderer.srcObject?.id != localVideo?.id) {
|
||||
debugPrint('[VOIP] Lokales Video: ${localVideo?.id} (Bildschirm: $localSharing)');
|
||||
localRenderer.srcObject = localVideo;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (local != null && local.getVideoTracks().isNotEmpty) {
|
||||
if (localRenderer.srcObject?.id != local.id) {
|
||||
debugPrint('[VOIP] Assigning Local Renderer: ${local.id} (${local.getVideoTracks().length} video tracks)');
|
||||
localRenderer.srcObject = local;
|
||||
changed = true;
|
||||
}
|
||||
} else if (localRenderer.srcObject != null) {
|
||||
localRenderer.srcObject = null;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('[VOIP] Error in _updateRenderers: $e');
|
||||
@@ -245,11 +378,64 @@ class PyramidVoipManager extends CallSignalingService
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wer spricht gerade? (Pegel aus den WebRTC-Statistiken) ───────────────
|
||||
// Zeigt den grünen Sprech-Ring wie bei Discord – und macht sichtbar, wo
|
||||
// Ton verloren geht: leuchtet „Du“ beim Sprechen nicht, kommt vom eigenen
|
||||
// Mikrofon nichts an; leuchtet die Gegenseite nicht, kommt nichts an.
|
||||
Timer? _levelTimer;
|
||||
bool _localSpeaking = false;
|
||||
bool _remoteSpeaking = false;
|
||||
|
||||
@override
|
||||
bool get isLocalSpeaking => _localSpeaking;
|
||||
@override
|
||||
bool get isRemoteSpeaking => _remoteSpeaking;
|
||||
|
||||
void _startLevelPolling() {
|
||||
_levelTimer?.cancel();
|
||||
_levelTimer = Timer.periodic(
|
||||
const Duration(milliseconds: 300),
|
||||
(_) => _pollLevels(),
|
||||
);
|
||||
}
|
||||
|
||||
void _stopLevelPolling() {
|
||||
_levelTimer?.cancel();
|
||||
_levelTimer = null;
|
||||
_localSpeaking = false;
|
||||
_remoteSpeaking = false;
|
||||
}
|
||||
|
||||
Future<void> _pollLevels() async {
|
||||
final pc = currentCall?.pc;
|
||||
if (pc == null) return;
|
||||
try {
|
||||
double local = 0;
|
||||
double remote = 0;
|
||||
for (final r in await pc.getStats()) {
|
||||
if (r.values['kind'] != 'audio') continue;
|
||||
final level = (r.values['audioLevel'] as num?)?.toDouble() ?? 0;
|
||||
if (r.type == 'media-source' && level > local) local = level;
|
||||
if (r.type == 'inbound-rtp' && level > remote) remote = level;
|
||||
}
|
||||
// Hysterese: an ab 0,03, aus unter 0,01 – kein Flackern.
|
||||
bool speaking(bool was, double level) => was ? level > 0.01 : level > 0.03;
|
||||
final ls = speaking(_localSpeaking, _isMicMuted ? 0 : local);
|
||||
final rs = speaking(_remoteSpeaking, remote);
|
||||
if (ls != _localSpeaking || rs != _remoteSpeaking) {
|
||||
_localSpeaking = ls;
|
||||
_remoteSpeaking = rs;
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> handleCallEnded(CallSession session) async {
|
||||
if (currentCall?.callId == session.callId) {
|
||||
_renderRefreshTimer?.cancel();
|
||||
_renderRefreshTimer = null;
|
||||
_stopLevelPolling();
|
||||
localRenderer.srcObject = null;
|
||||
remoteRenderer.srcObject = null;
|
||||
_remoteAudioStream = null;
|
||||
@@ -259,6 +445,7 @@ class PyramidVoipManager extends CallSignalingService
|
||||
_isRemoteScreensharing = false;
|
||||
notifyListeners();
|
||||
}
|
||||
CallForeground.callEnded('voip');
|
||||
}
|
||||
|
||||
// ── Ausgabelautstärke (Settings-Slider "voice_output_volume") ─────────────
|
||||
@@ -292,6 +479,7 @@ class PyramidVoipManager extends CallSignalingService
|
||||
currentCall = null;
|
||||
notifyListeners();
|
||||
}
|
||||
CallForeground.callEnded('voip');
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -312,7 +500,7 @@ class PyramidVoipManager extends CallSignalingService
|
||||
@override
|
||||
Future<void> safeAction(Future<dynamic> Function() action) async {
|
||||
try {
|
||||
await action();
|
||||
await _inSdkZone(action);
|
||||
await Future.delayed(const Duration(milliseconds: 1000));
|
||||
} catch (e) {
|
||||
debugPrint('[VOIP] Action error: $e');
|
||||
@@ -322,16 +510,24 @@ class PyramidVoipManager extends CallSignalingService
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> toggleMic() async {
|
||||
if (currentCall == null) return;
|
||||
_isMicMuted = !_isMicMuted;
|
||||
Future<void> toggleMic() => _setMicMuted(!_isMicMuted);
|
||||
|
||||
Future<void> _setMicMuted(bool muted) async {
|
||||
final c = currentCall;
|
||||
if (c == null) return;
|
||||
_isMicMuted = muted;
|
||||
notifyListeners();
|
||||
try {
|
||||
await currentCall!.setMicrophoneMuted(_isMicMuted);
|
||||
await _inSdkZone(() => c.setMicrophoneMuted(muted));
|
||||
} catch (e) {
|
||||
_isMicMuted = !_isMicMuted;
|
||||
notifyListeners();
|
||||
// Das SDK schaltet die Spur VOR dem Senden der Status-Info um. Schlägt
|
||||
// nur das Senden fehl, ist das Mikro trotzdem (ent)stummt – früher
|
||||
// wurde hier blind zurückgeschaltet, und der Knopf zeigte danach das
|
||||
// Gegenteil des echten Zustands (Bernd 2026-10-08).
|
||||
debugPrint('[VOIP] Stummschalten: $e');
|
||||
}
|
||||
_isMicMuted = c.isMicrophoneMuted;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -340,7 +536,8 @@ class PyramidVoipManager extends CallSignalingService
|
||||
_isCameraMuted = !_isCameraMuted;
|
||||
notifyListeners();
|
||||
try {
|
||||
await currentCall!.setLocalVideoMuted(_isCameraMuted);
|
||||
final c = currentCall!;
|
||||
await _inSdkZone(() => c.setLocalVideoMuted(_isCameraMuted));
|
||||
for (var i = 0; i < 5; i++) {
|
||||
await Future.delayed(Duration(milliseconds: 300 + (i * 200)));
|
||||
await _updateRenderers();
|
||||
@@ -361,6 +558,7 @@ class PyramidVoipManager extends CallSignalingService
|
||||
|
||||
if (_isScreensharing) {
|
||||
await safeAction(() => call.setScreensharingEnabled(false));
|
||||
await CallForeground.screenCaptureStopped('voip');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -379,8 +577,31 @@ class PyramidVoipManager extends CallSignalingService
|
||||
|
||||
@override
|
||||
Future<void> toggleDeafen() async {
|
||||
// Wie Discord: taub = Gegenseite nicht mehr hören UND selbst stumm;
|
||||
// beim Aufheben den Mikro-Zustand von vorher wiederherstellen. (Früher
|
||||
// setzte der Knopf nur ein Häkchen ohne Wirkung.)
|
||||
_isDeafened = !_isDeafened;
|
||||
notifyListeners();
|
||||
final c = currentCall;
|
||||
if (c == null) return;
|
||||
_applyDeafen(c);
|
||||
if (_isDeafened) {
|
||||
_micMutedBeforeDeafen = _isMicMuted;
|
||||
if (!_isMicMuted) await _setMicMuted(true);
|
||||
} else if (!_micMutedBeforeDeafen && _isMicMuted) {
|
||||
await _setMicMuted(false);
|
||||
}
|
||||
}
|
||||
|
||||
bool _micMutedBeforeDeafen = false;
|
||||
|
||||
/// Ton der Gegenseite an/aus (deaktivierte Empfangsspur = stumm).
|
||||
void _applyDeafen(CallSession call) {
|
||||
for (final w in call.getRemoteStreams) {
|
||||
for (final t in w.stream?.getAudioTracks() ?? const <MediaStreamTrack>[]) {
|
||||
if (t.enabled == _isDeafened) t.enabled = !_isDeafened;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -390,6 +611,7 @@ class PyramidVoipManager extends CallSignalingService
|
||||
await currentCall!.hangup(reason: CallErrorCode.userHangup);
|
||||
currentCall = null;
|
||||
});
|
||||
await CallForeground.callEnded('voip');
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -399,15 +621,17 @@ class PyramidVoipManager extends CallSignalingService
|
||||
await _ensureRenderers();
|
||||
_isCameraMuted = !video;
|
||||
|
||||
_speakerOn = true;
|
||||
debugPrint('[VOIP] Starting call in room $roomId (video=$video)');
|
||||
final call = await voip.inviteToCall(room, video ? CallType.kVideo : CallType.kVoice);
|
||||
final call = await _inSdkZone(
|
||||
() => voip.inviteToCall(room, video ? CallType.kVideo : CallType.kVoice));
|
||||
currentCall = call;
|
||||
|
||||
_setupCallListeners(call);
|
||||
|
||||
if (video) {
|
||||
await Future.delayed(const Duration(milliseconds: 800));
|
||||
await call.setLocalVideoMuted(false);
|
||||
await _inSdkZone(() => call.setLocalVideoMuted(false));
|
||||
await _updateRenderers();
|
||||
}
|
||||
|
||||
@@ -525,6 +749,13 @@ class MediaDevicesWrapper extends MediaDevices {
|
||||
|
||||
@override
|
||||
Future<rtc.MediaStream> getDisplayMedia(Map<String, dynamic> constraints) async {
|
||||
// Android 14+: ohne laufenden mediaProjection-Dienst stürzt die App beim
|
||||
// Mitschnitt ab – erst Zustimmung, dann Dienst, dann Mitschnitt.
|
||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||
if (!await CallForeground.prepareScreenCapture('voip')) {
|
||||
throw Exception('Bildschirmübertragung wurde nicht erlaubt');
|
||||
}
|
||||
}
|
||||
final String? sourceId = _manager._selectedSourceId;
|
||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.windows && sourceId != null) {
|
||||
final desktopConstraints = {
|
||||
|
||||
@@ -51,6 +51,9 @@ class _CallControls extends StatelessWidget {
|
||||
final VoidCallback onToggleScreen;
|
||||
final VoidCallback onHangUp;
|
||||
final PyramidTheme pt;
|
||||
// Handy: Lautsprecher/Hörmuschel statt „Ton aus“ (Platz in der Leiste).
|
||||
final bool isSpeakerOn;
|
||||
final VoidCallback? onToggleSpeaker;
|
||||
|
||||
const _CallControls({
|
||||
required this.isMuted,
|
||||
@@ -63,10 +66,15 @@ class _CallControls extends StatelessWidget {
|
||||
required this.onToggleScreen,
|
||||
required this.onHangUp,
|
||||
required this.pt,
|
||||
this.isSpeakerOn = true,
|
||||
this.onToggleSpeaker,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final platform = Theme.of(context).platform;
|
||||
final showSpeaker = onToggleSpeaker != null &&
|
||||
(platform == TargetPlatform.android || platform == TargetPlatform.iOS);
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Container(
|
||||
@@ -77,7 +85,15 @@ class _CallControls extends StatelessWidget {
|
||||
children: [
|
||||
_VoiceCtrlBtn(icon: isMuted ? Icons.mic_off_rounded : Icons.mic_rounded, danger: isMuted, pt: pt, onTap: onToggleMic),
|
||||
const SizedBox(width: 16),
|
||||
_VoiceCtrlBtn(icon: isDeafened ? Icons.headset_off_rounded : Icons.headphones_rounded, danger: isDeafened, pt: pt, onTap: onToggleDeafen),
|
||||
if (showSpeaker)
|
||||
_VoiceCtrlBtn(
|
||||
icon: isSpeakerOn ? Icons.volume_up_rounded : Icons.phone_in_talk_rounded,
|
||||
active: isSpeakerOn,
|
||||
pt: pt,
|
||||
onTap: onToggleSpeaker!,
|
||||
)
|
||||
else
|
||||
_VoiceCtrlBtn(icon: isDeafened ? Icons.headset_off_rounded : Icons.headphones_rounded, danger: isDeafened, pt: pt, onTap: onToggleDeafen),
|
||||
const SizedBox(width: 16),
|
||||
_VoiceCtrlBtn(icon: isCameraOff ? Icons.videocam_off_rounded : Icons.videocam_rounded, active: !isCameraOff, pt: pt, onTap: onToggleCam),
|
||||
const SizedBox(width: 16),
|
||||
|
||||
@@ -17,6 +17,8 @@ class _UnifiedCallView extends StatefulWidget {
|
||||
final bool isScreenSharing;
|
||||
final Function(Offset)? onQualityMenu;
|
||||
final PyramidTheme pt;
|
||||
final bool isSpeakerOn;
|
||||
final VoidCallback? onToggleSpeaker;
|
||||
|
||||
const _UnifiedCallView({
|
||||
required this.participants,
|
||||
@@ -35,6 +37,8 @@ class _UnifiedCallView extends StatefulWidget {
|
||||
required this.isScreenSharing,
|
||||
this.onQualityMenu,
|
||||
required this.pt,
|
||||
this.isSpeakerOn = true,
|
||||
this.onToggleSpeaker,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -83,7 +87,14 @@ class _UnifiedCallViewState extends State<_UnifiedCallView> {
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
final all = _sorted(widget.participants);
|
||||
final hero = all.firstWhereOrNull((p) => p.id == _heroId) ?? (all.isNotEmpty ? all.first : null);
|
||||
// Ohne gewählten Hero nicht nach „spricht gerade“ wählen – sonst springt
|
||||
// das große Bild bei jedem Sprechwechsel hin und her.
|
||||
final stable = [...widget.participants]..sort((a, b) {
|
||||
int r(_ParticipantData p) => p.isScreenShare ? 0 : (p.hasVideo ? 1 : 2);
|
||||
return r(a).compareTo(r(b));
|
||||
});
|
||||
final hero = all.firstWhereOrNull((p) => p.id == _heroId) ??
|
||||
(stable.isNotEmpty ? stable.first : null);
|
||||
|
||||
if (widget.isConnecting) {
|
||||
return Scaffold(
|
||||
@@ -148,6 +159,8 @@ class _UnifiedCallViewState extends State<_UnifiedCallView> {
|
||||
onToggleScreen: widget.onToggleScreen,
|
||||
onHangUp: widget.onHangUp,
|
||||
pt: pt,
|
||||
isSpeakerOn: widget.isSpeakerOn,
|
||||
onToggleSpeaker: widget.onToggleSpeaker,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -3,6 +3,11 @@ part of '../voice_channel.dart';
|
||||
void _handleLiveKitScreenShare(BuildContext context, VoiceChannelService call) async {
|
||||
if (call.isScreenSharing) {
|
||||
await call.stopScreenShare();
|
||||
} else if (Theme.of(context).platform == TargetPlatform.android ||
|
||||
Theme.of(context).platform == TargetPlatform.iOS) {
|
||||
// Handy: ganzer Bildschirm über die System-Zustimmung (kein
|
||||
// Fenster-Auswähler wie am PC).
|
||||
await call.toggleScreenShare();
|
||||
} else {
|
||||
final source = await ScreenSharePicker.show(context);
|
||||
if (source != null) await call.startScreenShare(source.id);
|
||||
|
||||
@@ -58,6 +58,8 @@ class VoiceChannelView extends ConsumerWidget {
|
||||
isScreenSharing: voip.isScreensharing,
|
||||
onQualityMenu: (pos) => _showVoipQualityMenu(context, voip, pos),
|
||||
pt: pt,
|
||||
isSpeakerOn: voip.isSpeakerOn,
|
||||
onToggleSpeaker: voip.toggleSpeaker,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -86,6 +88,8 @@ class VoiceChannelView extends ConsumerWidget {
|
||||
isScreenSharing: call.isScreenSharing,
|
||||
onQualityMenu: (pos) => _showLiveKitQualityMenu(context, call, pos),
|
||||
pt: pt,
|
||||
isSpeakerOn: call.isSpeakerOn,
|
||||
onToggleSpeaker: call.toggleSpeaker,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -147,6 +151,7 @@ class VoiceChannelView extends ConsumerWidget {
|
||||
name: voip.currentCall?.room.getLocalizedDisplayname() ?? 'Remote',
|
||||
videoWidget: hasRemoteVideo ? rtc.RTCVideoView(voip.remoteRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null,
|
||||
isMuted: false,
|
||||
isSpeaking: voip.isRemoteSpeaking,
|
||||
hasVideo: hasRemoteVideo,
|
||||
isScreenShare: isRemoteScreen,
|
||||
avatarColor: _avatarColors[1],
|
||||
@@ -161,6 +166,7 @@ class VoiceChannelView extends ConsumerWidget {
|
||||
name: 'Du',
|
||||
videoWidget: hasLocalVideo ? rtc.RTCVideoView(voip.localRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null,
|
||||
isMuted: voip.isMicMuted,
|
||||
isSpeaking: voip.isLocalSpeaking,
|
||||
hasVideo: hasLocalVideo,
|
||||
isScreenShare: isLocalScreen,
|
||||
avatarColor: _avatarColors[0],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -7,7 +8,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:pyramid/core/settings_prefs.dart';
|
||||
import 'package:pyramid/core/appearance.dart';
|
||||
import 'package:pyramid/core/theme.dart';
|
||||
import 'package:pyramid/widgets/spring_press.dart';
|
||||
import 'package:pyramid/features/chat/attachment_dialog.dart';
|
||||
import 'package:pyramid/features/chat/emoji_picker.dart';
|
||||
import 'package:pyramid/features/chat/gif_sticker_picker.dart';
|
||||
@@ -32,6 +35,10 @@ class ChatComposer extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _ChatComposerState extends ConsumerState<ChatComposer> {
|
||||
/// Angefangene Nachrichten je Raum (nur im Speicher): Beim Raumwechsel
|
||||
/// bleibt der Text beim richtigen Raum, statt im nächsten Chat zu landen.
|
||||
static final _drafts = <String, String>{};
|
||||
|
||||
final _ctrl = TextEditingController();
|
||||
late final FocusNode _focus;
|
||||
bool _canSend = false;
|
||||
@@ -44,6 +51,12 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focus = FocusNode(onKeyEvent: _handleKeyEvent);
|
||||
// Entwurf vor dem Listener setzen – sonst ginge sofort „tippt…“ raus.
|
||||
final draft = _drafts.remove(widget.room.id);
|
||||
if (draft != null) {
|
||||
_ctrl.text = draft;
|
||||
_canSend = draft.trim().isNotEmpty;
|
||||
}
|
||||
_ctrl.addListener(_onTextChanged);
|
||||
}
|
||||
|
||||
@@ -118,6 +131,8 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
final text = _ctrl.text;
|
||||
if (text.trim().isNotEmpty) _drafts[widget.room.id] = text;
|
||||
_typingTimer?.cancel();
|
||||
if (_isTyping) widget.room.setTyping(false).catchError((_) {});
|
||||
_gifOverlay?.remove();
|
||||
@@ -300,6 +315,8 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
|
||||
final pt = PyramidTheme.of(context);
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
final isSmall = size.width < 400;
|
||||
// Handy: größere Symbole (vorher 12–13 px, Bernds Feedback 2026-10-07).
|
||||
final mobile = isMobileLayout(size);
|
||||
|
||||
final roomName = widget.room.getLocalizedDisplayname();
|
||||
final placeholder = widget.room.isDirectChat
|
||||
@@ -310,12 +327,28 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.replyTo != null)
|
||||
_ReplyBar(
|
||||
event: widget.replyTo!,
|
||||
onClear: widget.onClearReply ?? () {},
|
||||
pt: pt,
|
||||
// Antwort-Leiste federt auf und klappt beim Schließen weich zu.
|
||||
AnimatedSwitcher(
|
||||
duration: motionDuration(context, 300),
|
||||
reverseDuration: motionDuration(context, 160),
|
||||
switchInCurve: Curves.easeOutBack,
|
||||
switchOutCurve: Curves.easeIn,
|
||||
transitionBuilder: (child, anim) => SizeTransition(
|
||||
sizeFactor: anim,
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: FadeTransition(opacity: anim, child: child),
|
||||
),
|
||||
child: widget.replyTo != null
|
||||
? KeyedSubtree(
|
||||
key: ValueKey('reply:${widget.replyTo!.eventId}'),
|
||||
child: _ReplyBar(
|
||||
event: widget.replyTo!,
|
||||
onClear: widget.onClearReply ?? () {},
|
||||
pt: pt,
|
||||
),
|
||||
)
|
||||
: const SizedBox(key: ValueKey('reply:none'), width: double.infinity),
|
||||
),
|
||||
Padding(
|
||||
// Bottom inset matches the profile block in the rooms list (8px) so
|
||||
// the composer sits flush with it.
|
||||
@@ -327,8 +360,10 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
|
||||
// block in the rooms list (~89px on the user's display). Grows for
|
||||
// multiline.
|
||||
constraints: const BoxConstraints(minHeight: 53),
|
||||
// Rahmen + Fokus-Ring außen (würden vom Zuschnitt sonst
|
||||
// abgeschnitten), innen Milchglas: der Chat läuft unter dem
|
||||
// schwebenden Feld durch und scheint unscharf durch.
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg2,
|
||||
borderRadius: BorderRadius.circular(pt.rBase + 4),
|
||||
border: Border.all(
|
||||
color: _focus.hasFocus ? pt.accent : pt.border,
|
||||
@@ -337,7 +372,13 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
|
||||
? [BoxShadow(color: pt.accentSoft, blurRadius: 0, spreadRadius: 3)]
|
||||
: null,
|
||||
),
|
||||
child: Focus(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(pt.rBase + 3),
|
||||
child: BackdropFilter(
|
||||
filter: ui.ImageFilter.blur(sigmaX: 12, sigmaY: 12),
|
||||
child: ColoredBox(
|
||||
color: pt.bg2.withValues(alpha: 0.78),
|
||||
child: Focus(
|
||||
onFocusChange: (_) => setState(() {}),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
@@ -347,7 +388,7 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
|
||||
icon: Icons.add_rounded,
|
||||
tooltip: 'Datei anhängen',
|
||||
pt: pt,
|
||||
size: size.width < 600 ? 24 : 30,
|
||||
size: mobile ? 42 : 30,
|
||||
onTap: _pickAttachment,
|
||||
),
|
||||
// Text input
|
||||
@@ -397,14 +438,14 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
|
||||
icon: Icons.gif_box_outlined,
|
||||
tooltip: 'GIF',
|
||||
pt: pt,
|
||||
size: size.width < 600 ? 22 : 26,
|
||||
size: mobile ? 40 : 26,
|
||||
onTap: _toggleGifPicker,
|
||||
),
|
||||
_ComposerIconBtn(
|
||||
icon: Icons.tag_faces_rounded,
|
||||
tooltip: 'Emoji',
|
||||
pt: pt,
|
||||
size: size.width < 600 ? 22 : 26,
|
||||
size: mobile ? 40 : 26,
|
||||
onTap: _toggleEmojiPicker,
|
||||
),
|
||||
if (size.width > 600)
|
||||
@@ -424,10 +465,14 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
|
||||
canSend: _canSend,
|
||||
pt: pt,
|
||||
onTap: _send,
|
||||
size: mobile ? 42 : 36,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -461,9 +506,7 @@ class _ComposerIconBtnState extends State<_ComposerIconBtn> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final platform = Theme.of(context).platform;
|
||||
final isMobile =
|
||||
platform == TargetPlatform.android || platform == TargetPlatform.iOS;
|
||||
final isMobile = isMobileLayout(MediaQuery.sizeOf(context));
|
||||
final touchSize = isMobile ? 44.0 : widget.size;
|
||||
|
||||
return MouseRegion(
|
||||
@@ -475,7 +518,9 @@ class _ComposerIconBtnState extends State<_ComposerIconBtn> {
|
||||
child: Tooltip(
|
||||
message: widget.tooltip,
|
||||
waitDuration: const Duration(milliseconds: 500),
|
||||
child: SizedBox(
|
||||
child: SpringPress(
|
||||
enabled: widget.onTap != null,
|
||||
child: SizedBox(
|
||||
width: touchSize,
|
||||
height: touchSize,
|
||||
child: Center(
|
||||
@@ -497,6 +542,7 @@ class _ComposerIconBtnState extends State<_ComposerIconBtn> {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -507,8 +553,14 @@ class _SendButton extends StatefulWidget {
|
||||
final bool canSend;
|
||||
final PyramidTheme pt;
|
||||
final VoidCallback onTap;
|
||||
final double size;
|
||||
|
||||
const _SendButton({required this.canSend, required this.pt, required this.onTap});
|
||||
const _SendButton({
|
||||
required this.canSend,
|
||||
required this.pt,
|
||||
required this.onTap,
|
||||
this.size = 36,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_SendButton> createState() => _SendButtonState();
|
||||
@@ -528,11 +580,14 @@ class _SendButtonState extends State<_SendButton> {
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: active ? widget.onTap : null,
|
||||
child: AnimatedContainer(
|
||||
child: SpringPress(
|
||||
enabled: active,
|
||||
pressedScale: 0.82,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOutBack,
|
||||
width: 36,
|
||||
height: 36,
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
decoration: BoxDecoration(
|
||||
color: active ? pt.accent : pt.bg3,
|
||||
borderRadius: BorderRadius.circular(pt.rSm),
|
||||
@@ -547,13 +602,22 @@ class _SendButtonState extends State<_SendButton> {
|
||||
: Matrix4.identity(),
|
||||
transformAlignment: Alignment.center,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.send_rounded,
|
||||
size: 16,
|
||||
color: active ? pt.accentFg : pt.fgDim,
|
||||
// Sobald gesendet werden kann, ploppt der Pfeil federnd auf.
|
||||
child: AnimatedScale(
|
||||
scale: active ? 1 : 0.8,
|
||||
duration: active
|
||||
? motionDuration(context, 520)
|
||||
: const Duration(milliseconds: 140),
|
||||
curve: active ? Curves.elasticOut : Curves.easeOut,
|
||||
child: Icon(
|
||||
Icons.send_rounded,
|
||||
size: widget.size * 0.45,
|
||||
color: active ? pt.accentFg : pt.fgDim,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -599,9 +663,20 @@ class _ReplyBar extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
// Handy: größeres X mit fingergerechter Trefferfläche.
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onClear,
|
||||
child: Icon(Icons.close_rounded, size: 16, color: pt.fgDim),
|
||||
child: SpringPress(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Icon(
|
||||
Icons.close_rounded,
|
||||
size: isMobileLayout(MediaQuery.sizeOf(context)) ? 22 : 16,
|
||||
color: pt.fgDim,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -10,7 +10,9 @@ import 'package:pyramid/core/matrix_client.dart';
|
||||
class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
|
||||
Timeline? _timeline;
|
||||
StreamSubscription<String>? _roomUpdateSub;
|
||||
StreamSubscription<SyncUpdate>? _syncSub;
|
||||
Timer? _redecryptTimer;
|
||||
String _receiptSig = '';
|
||||
|
||||
@override
|
||||
Future<Timeline> build(String arg) async {
|
||||
@@ -21,6 +23,7 @@ class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
|
||||
|
||||
_timeline?.cancelSubscriptions();
|
||||
_roomUpdateSub?.cancel();
|
||||
_syncSub?.cancel();
|
||||
_redecryptTimer?.cancel();
|
||||
|
||||
final diag = ref.read(e2eeDiagnosticsProvider.notifier);
|
||||
@@ -49,6 +52,28 @@ class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
|
||||
});
|
||||
});
|
||||
|
||||
// Nach jedem Sync (erst dann sind Lesebestätigungen verarbeitet – das
|
||||
// Raum-Update kommt im SDK VORHER):
|
||||
// • Einladung angenommen (invite → join): Verlauf neu aufbauen; der beim
|
||||
// Einladen gebaute blieb sonst leer, bis man die App neu startete.
|
||||
// • Lesebestätigung eines anderen hat sich geändert: neu zeichnen, damit
|
||||
// ✓✓ live erscheint. Neue Nachrichten allein lösen _onUpdate schon aus,
|
||||
// eine Bestätigung ohne neue Nachricht bisher nicht.
|
||||
final membershipAtBuild = room.membership;
|
||||
_receiptSig = _receiptSignature(room);
|
||||
_syncSub = client.onSync.stream.listen((_) {
|
||||
if (membershipAtBuild != Membership.join &&
|
||||
room.membership == Membership.join) {
|
||||
ref.invalidateSelf();
|
||||
return;
|
||||
}
|
||||
final sig = _receiptSignature(room);
|
||||
if (sig != _receiptSig) {
|
||||
_receiptSig = sig;
|
||||
_onUpdate();
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure device keys are downloaded for all room members so outbound
|
||||
// Megolm session key sharing works on the first send.
|
||||
if (room.encrypted && client.encryptionEnabled) {
|
||||
@@ -57,6 +82,7 @@ class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
|
||||
|
||||
ref.onDispose(() {
|
||||
_roomUpdateSub?.cancel();
|
||||
_syncSub?.cancel();
|
||||
_redecryptTimer?.cancel();
|
||||
_timeline?.cancelSubscriptions();
|
||||
_timeline = null;
|
||||
@@ -70,6 +96,36 @@ class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Kurzer Fingerabdruck der Lesepositionen der anderen Mitglieder.
|
||||
String _receiptSignature(Room room) {
|
||||
final b = StringBuffer();
|
||||
for (final users in [
|
||||
room.receiptState.global.otherUsers,
|
||||
room.receiptState.mainThread?.otherUsers ?? const {},
|
||||
]) {
|
||||
users.forEach((userId, data) => b
|
||||
..write(userId)
|
||||
..write('=')
|
||||
..write(data.eventId)
|
||||
..write(';'));
|
||||
}
|
||||
return b.toString();
|
||||
}
|
||||
|
||||
/// Einladung annehmen und warten, bis der Raum als beigetreten
|
||||
/// synchronisiert ist – erst dann hat der Verlauf Inhalt.
|
||||
Future<void> acceptInvite(WidgetRef ref, Room room) async {
|
||||
final client = room.client;
|
||||
// Vor dem Beitreten anfangen zu warten, sonst kann der Sync durchrutschen.
|
||||
final synced = client
|
||||
.waitForRoomInSync(room.id, join: true)
|
||||
.timeout(const Duration(seconds: 20))
|
||||
.then<void>((_) {}, onError: (_) {});
|
||||
await room.join();
|
||||
await synced;
|
||||
ref.invalidate(timelineProvider(room.id));
|
||||
}
|
||||
|
||||
final timelineProvider =
|
||||
AsyncNotifierProvider.family<TimelineNotifier, Timeline, String>(
|
||||
TimelineNotifier.new,
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:pyramid/core/app_state.dart';
|
||||
import 'package:pyramid/core/appearance.dart' show isMobileLayout;
|
||||
import 'package:pyramid/core/matrix_client.dart';
|
||||
import 'package:pyramid/core/settings_prefs.dart';
|
||||
import 'package:pyramid/core/theme.dart';
|
||||
@@ -19,6 +20,9 @@ import 'package:pyramid/features/chat/chat_provider.dart';
|
||||
import 'package:pyramid/features/chat/message_group.dart';
|
||||
import 'package:pyramid/features/chat/pinned_messages_panel.dart';
|
||||
import 'package:pyramid/widgets/hover_region.dart';
|
||||
import 'package:pyramid/widgets/measure_size.dart';
|
||||
import 'package:pyramid/widgets/spring_press.dart';
|
||||
import 'package:pyramid/core/read_markers.dart';
|
||||
import 'package:pyramid/widgets/mxc_image.dart';
|
||||
|
||||
import 'package:pyramid/core/notification_service.dart';
|
||||
|
||||
@@ -6,7 +6,7 @@ import 'package:archive/archive.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:pdfx/pdfx.dart';
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:path_provider/path_provider.dart';
|
||||
import 'package:pyramid/core/theme.dart';
|
||||
import 'package:pyramid/features/chat/media_player.dart';
|
||||
import 'package:pyramid/utils/gif_favorite_service.dart';
|
||||
import 'package:pyramid/widgets/spring_press.dart' show motionDuration;
|
||||
|
||||
/// Overlay viewer for images, GIFs and videos.
|
||||
///
|
||||
@@ -50,9 +51,19 @@ class MediaViewer extends StatefulWidget {
|
||||
// Den Hintergrund zeichnet der Viewer selbst (damit er beim Wischen
|
||||
// ausblenden kann).
|
||||
barrierColor: Colors.transparent,
|
||||
transitionDuration: const Duration(milliseconds: 180),
|
||||
transitionDuration: motionDuration(context, 300),
|
||||
transitionBuilder: (context, animation, secondaryAnimation, child) =>
|
||||
FadeTransition(opacity: animation, child: child),
|
||||
FadeTransition(
|
||||
opacity: animation,
|
||||
child: ScaleTransition(
|
||||
scale: Tween(begin: 0.9, end: 1.0).animate(CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.easeOutBack,
|
||||
reverseCurve: Curves.easeIn,
|
||||
)),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
pageBuilder: (context, animation, secondaryAnimation) => MediaViewer(
|
||||
senderName: senderName,
|
||||
filename: filename,
|
||||
|
||||
@@ -103,7 +103,7 @@ extension _BubbleBuilder on _MessageGroupState {
|
||||
rows.add(_ReadMarker(pt: pt));
|
||||
}
|
||||
if (widget.fullyReadId == event.eventId && !isLast) {
|
||||
rows.add(_InlineUnreadDivider(pt: pt));
|
||||
rows.add(_InlineUnreadDivider(key: widget.unreadDividerKey, pt: pt, fading: widget.unreadFading));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
part of '../message_group.dart';
|
||||
|
||||
/// Bereits federnd eingeblendete Nachrichten (begrenzt, älteste fallen raus).
|
||||
final _poppedMessageIds = <String>{};
|
||||
|
||||
void _rememberPopped(String id) {
|
||||
_poppedMessageIds.add(id);
|
||||
if (_poppedMessageIds.length > 300) {
|
||||
_poppedMessageIds.remove(_poppedMessageIds.first);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatMessageTime(DateTime t) {
|
||||
final h = t.hour.toString().padLeft(2, '0');
|
||||
final m = t.minute.toString().padLeft(2, '0');
|
||||
@@ -22,12 +32,19 @@ class MessageGroup extends StatefulWidget {
|
||||
/// When non-null and pointing to a non-last event in this group, an inline
|
||||
/// "Neue Nachrichten" divider is rendered after that event's row.
|
||||
final String? fullyReadId;
|
||||
/// Ausblenden des Inline-Trenners + Schlüssel für den Sprung zu ihm.
|
||||
final bool unreadFading;
|
||||
final GlobalKey? unreadDividerKey;
|
||||
/// Jump-to-message (from search): the row matching [jumpEventId] gets
|
||||
/// [jumpKey] attached (for ensureVisible) and [highlightEventId] is tinted.
|
||||
final String? jumpEventId;
|
||||
final GlobalKey? jumpKey;
|
||||
final String? highlightEventId;
|
||||
final bool highlightOn;
|
||||
/// Nachrichten, die nach diesem Zeitpunkt eintreffen (oder gerade
|
||||
/// gesendet werden), ploppen beim ersten Erscheinen federnd auf. null = aus
|
||||
/// (z. B. solange der Chat gerade erst geöffnet wird).
|
||||
final DateTime? popInAfter;
|
||||
|
||||
const MessageGroup({
|
||||
super.key,
|
||||
@@ -41,10 +58,13 @@ class MessageGroup extends StatefulWidget {
|
||||
this.showStatusIndicator = false,
|
||||
this.readEventIds = const {},
|
||||
this.fullyReadId,
|
||||
this.unreadFading = false,
|
||||
this.unreadDividerKey,
|
||||
this.jumpEventId,
|
||||
this.jumpKey,
|
||||
this.highlightEventId,
|
||||
this.highlightOn = false,
|
||||
this.popInAfter,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -140,7 +160,7 @@ class _MessageGroupState extends State<MessageGroup> {
|
||||
// Inline unread divider after the first event when it's the fullyRead
|
||||
// marker and there are more events in this group.
|
||||
if (widget.fullyReadId == firstEvent.eventId && widget.events.length > 1) {
|
||||
rows.add(_InlineUnreadDivider(pt: pt));
|
||||
rows.add(_InlineUnreadDivider(key: widget.unreadDividerKey, pt: pt, fading: widget.unreadFading));
|
||||
}
|
||||
|
||||
// ── Continuation event rows (siblings, same indentation) ──
|
||||
@@ -159,7 +179,7 @@ class _MessageGroupState extends State<MessageGroup> {
|
||||
if (widget.readEventIds.contains(event.eventId)) rows.add(_ReadMarker(pt: pt));
|
||||
// Inline divider after this event if it's the fullyRead and not the last.
|
||||
if (widget.fullyReadId == event.eventId && j < widget.events.length - 1) {
|
||||
rows.add(_InlineUnreadDivider(pt: pt));
|
||||
rows.add(_InlineUnreadDivider(key: widget.unreadDividerKey, pt: pt, fading: widget.unreadFading));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,15 +321,39 @@ class _MessageGroupState extends State<MessageGroup> {
|
||||
),
|
||||
);
|
||||
|
||||
if (_isMobile) return row;
|
||||
if (_isMobile) return _popIn(event, row);
|
||||
|
||||
// Desktop: per-row hover highlight + actions.
|
||||
return MouseRegion(
|
||||
return _popIn(event, MouseRegion(
|
||||
onEnter: (_) => setState(() => _hoveredEventId = event.eventId),
|
||||
onExit: (_) => setState(() {
|
||||
if (_hoveredEventId == event.eventId) _hoveredEventId = null;
|
||||
}),
|
||||
child: row,
|
||||
));
|
||||
}
|
||||
|
||||
// Neu angekommene oder gerade gesendete Nachricht: federnd einblenden.
|
||||
// Der Schlüssel (Transaktions-ID bleibt beim Senden gleich) hält den
|
||||
// Zustand der Zeile, die Merkliste verhindert ein zweites Abspielen, falls
|
||||
// die Gruppe neu aufgebaut wird (z. B. lokale Kopie → Server-Event).
|
||||
Widget _popIn(Event event, Widget row) {
|
||||
final id = event.transactionId ?? event.eventId;
|
||||
final after = widget.popInAfter;
|
||||
final fresh = after != null &&
|
||||
!_poppedMessageIds.contains(id) &&
|
||||
(event.status.isSending ||
|
||||
(event.originServerTs.isAfter(after) &&
|
||||
DateTime.now().difference(event.originServerTs) <
|
||||
const Duration(minutes: 2)));
|
||||
if (fresh) _rememberPopped(id);
|
||||
return PopIn(
|
||||
key: ValueKey('row:$id'),
|
||||
play: fresh,
|
||||
alignment: event.senderId == widget.currentUserId
|
||||
? Alignment.bottomRight
|
||||
: Alignment.bottomLeft,
|
||||
child: row,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -159,7 +159,18 @@ class _ReplyPreview extends StatelessWidget {
|
||||
// matching WhatsApp/Discord. Falls back to stripped text for m.text.
|
||||
final (IconData? icon, String body) = _previewFor(replyEvent);
|
||||
|
||||
return Padding(
|
||||
// Antippen springt zur zitierten Nachricht und lässt sie kurz aufleuchten
|
||||
// (gleicher Mechanismus wie der Sprung aus der Suche).
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => ProviderScope.containerOf(context, listen: false)
|
||||
.read(pendingJumpEventProvider.notifier)
|
||||
.state = replyEvent.eventId,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: SpringPress(
|
||||
pressedScale: 0.97,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: IntrinsicHeight(
|
||||
child: ClipRRect(
|
||||
@@ -204,6 +215,9 @@ class _ReplyPreview extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,11 +22,15 @@ class _ReactionsRow extends StatelessWidget {
|
||||
|
||||
// Group by emoji key → list of senders
|
||||
final Map<String, List<String>> grouped = {};
|
||||
final newest = <String, DateTime>{};
|
||||
for (final r in reactionEvents) {
|
||||
final key = r.content.tryGetMap<String, dynamic>('m.relates_to')?['key'] as String?;
|
||||
if (key == null || key.isEmpty) continue;
|
||||
(grouped[key] ??= []).add(r.senderId);
|
||||
final ts = r.originServerTs;
|
||||
if (newest[key] == null || ts.isAfter(newest[key]!)) newest[key] = ts;
|
||||
}
|
||||
final now = DateTime.now();
|
||||
if (grouped.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Padding(
|
||||
@@ -37,6 +41,9 @@ class _ReactionsRow extends StatelessWidget {
|
||||
children: grouped.entries.map((e) {
|
||||
final myReaction = e.value.contains(currentUserId);
|
||||
return _ReactionChip(
|
||||
key: ValueKey(e.key),
|
||||
// Gerade erst vergeben → federnd aufploppen.
|
||||
fresh: now.difference(newest[e.key]!) < const Duration(seconds: 4),
|
||||
emoji: e.key,
|
||||
count: e.value.length,
|
||||
mine: myReaction,
|
||||
@@ -69,8 +76,11 @@ class _ReactionChip extends StatefulWidget {
|
||||
final bool mine;
|
||||
final PyramidTheme pt;
|
||||
final VoidCallback onTap;
|
||||
final bool fresh;
|
||||
|
||||
const _ReactionChip({
|
||||
super.key,
|
||||
this.fresh = false,
|
||||
required this.emoji,
|
||||
required this.count,
|
||||
required this.mine,
|
||||
@@ -88,12 +98,22 @@ class _ReactionChipState extends State<_ReactionChip> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
return MouseRegion(
|
||||
return PopIn(
|
||||
play: widget.fresh,
|
||||
fromScale: 0.3,
|
||||
offsetY: 0,
|
||||
alignment: Alignment.center,
|
||||
milliseconds: 520,
|
||||
child: SpringBump(
|
||||
value: widget.count,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: SpringPress(
|
||||
pressedScale: 0.85,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
||||
@@ -122,6 +142,9 @@ class _ReactionChipState extends State<_ReactionChip> {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -93,11 +93,19 @@ class _InitialBox extends StatelessWidget {
|
||||
// fullyRead event is in the middle of the group (same sender, <5 min window).
|
||||
class _InlineUnreadDivider extends StatelessWidget {
|
||||
final PyramidTheme pt;
|
||||
const _InlineUnreadDivider({required this.pt});
|
||||
final bool fading;
|
||||
const _InlineUnreadDivider({super.key, required this.pt, this.fading = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
// Blendet aus wie der Gruppen-Trenner (vorher blieb dieser „Neue
|
||||
// Nachrichten“-Trenner mitten in einer Gruppe bis zum erneuten Öffnen
|
||||
// stehen – Bernd 2026-10-08).
|
||||
return AnimatedOpacity(
|
||||
opacity: fading ? 0.0 : 1.0,
|
||||
duration: const Duration(milliseconds: 700),
|
||||
curve: Curves.easeOut,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(0, 8, 0, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -112,6 +120,7 @@ class _InlineUnreadDivider extends StatelessWidget {
|
||||
Expanded(child: Divider(color: pt.accent.withAlpha(160), height: 1)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ import 'package:pyramid/features/chat/media_player.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:pyramid/core/app_state.dart' show messageStyleProvider;
|
||||
import 'package:pyramid/core/app_state.dart'
|
||||
show messageStyleProvider, pendingJumpEventProvider;
|
||||
import 'package:pyramid/core/appearance.dart';
|
||||
import 'package:pyramid/core/matrix_client.dart';
|
||||
import 'package:pyramid/core/media_cache.dart';
|
||||
@@ -21,6 +22,7 @@ import 'package:pyramid/features/chat/media_viewer.dart';
|
||||
import 'package:pyramid/features/chat/reaction_service.dart';
|
||||
import 'package:pyramid/utils/gif_favorite_service.dart';
|
||||
import 'package:pyramid/widgets/mxc_image.dart';
|
||||
import 'package:pyramid/widgets/spring_press.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
part 'message/message_group_core.dart';
|
||||
|
||||
@@ -42,11 +42,13 @@ class _ChatHeader extends ConsumerWidget {
|
||||
children: [
|
||||
// Back button on mobile
|
||||
if (isMobile) ...[
|
||||
GestureDetector(
|
||||
onTap: () => ref.read(activeRoomIdProvider.notifier).state = null,
|
||||
child: Icon(Icons.arrow_back_rounded, size: 22, color: pt.fg),
|
||||
PyrIconBtn(
|
||||
icon: Icon(Icons.arrow_back_rounded, color: pt.fg),
|
||||
tooltip: 'Zurück',
|
||||
onPressed: () =>
|
||||
ref.read(activeRoomIdProvider.notifier).state = null,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
// Room icon / avatar
|
||||
if (isDm && client != null) ...[
|
||||
@@ -141,19 +143,27 @@ class _ChatHeader extends ConsumerWidget {
|
||||
onPressed: onTogglePinned,
|
||||
),
|
||||
],
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.search_rounded),
|
||||
tooltip: 'Suchen',
|
||||
onPressed: onOpenSearch,
|
||||
),
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.group_rounded),
|
||||
tooltip: 'Mitglieder',
|
||||
active: membersOpen,
|
||||
onPressed: onToggleMembers,
|
||||
),
|
||||
if (isMobile)
|
||||
_ChatOverflowMenu(
|
||||
pt: pt,
|
||||
onSearch: onOpenSearch,
|
||||
onMembers: onToggleMembers,
|
||||
)
|
||||
else ...[
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.search_rounded),
|
||||
tooltip: 'Suchen',
|
||||
onPressed: onOpenSearch,
|
||||
),
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.group_rounded),
|
||||
tooltip: 'Mitglieder',
|
||||
active: membersOpen,
|
||||
onPressed: onToggleMembers,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -176,8 +186,10 @@ class _ChatHeader extends ConsumerWidget {
|
||||
await ref.read(voiceChannelProvider).startCall(
|
||||
roomName: roomId,
|
||||
roomDisplayName: room.getLocalizedDisplayname(),
|
||||
identity: 'user_${DateTime.now().millisecondsSinceEpoch}',
|
||||
identity: room.client.userID ?? '',
|
||||
audioOnly: !cam,
|
||||
// Für das Beitritts-Token (Server prüft den Matrix-Login).
|
||||
matrixClient: room.client,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -222,6 +234,20 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
|
||||
|
||||
Profile? _profile;
|
||||
String? _bannerMxcUri;
|
||||
|
||||
// Breite der Symbolleiste (gemessen nach dem Zeichnen) – eingeklappt reicht
|
||||
// der Name genau bis davor.
|
||||
final _actionsKey = GlobalKey();
|
||||
double _actionsWidth = 140;
|
||||
|
||||
void _measureActions() {
|
||||
final box = _actionsKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (box == null || !box.hasSize) return;
|
||||
final w = box.size.width;
|
||||
if ((w - _actionsWidth).abs() > 0.5 && mounted) {
|
||||
setState(() => _actionsWidth = w);
|
||||
}
|
||||
}
|
||||
String? _statusMsg;
|
||||
bool _online = false;
|
||||
|
||||
@@ -291,6 +317,7 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _measureActions());
|
||||
final pt = widget.pt;
|
||||
final expanded = widget.expanded;
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
@@ -361,198 +388,222 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
if (isMobile) ...[
|
||||
GestureDetector(
|
||||
onTap: () =>
|
||||
ref.read(activeRoomIdProvider.notifier).state = null,
|
||||
child:
|
||||
Icon(Icons.arrow_back_rounded, size: 22, color: pt.fg),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
// Klickbarer Profilbereich: Avatar + Name + Details
|
||||
Expanded(
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.onToggle,
|
||||
child: Row(
|
||||
children: [
|
||||
// Avatar — skaliert mit (rund, wie im Mockup)
|
||||
AnimatedContainer(
|
||||
duration: _animDuration,
|
||||
curve: _animCurve,
|
||||
width: avatarSize,
|
||||
height: avatarSize,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: pt.bg3,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: avatarUrl != null && client != null
|
||||
? MxcImage(
|
||||
mxcUri: avatarUrl.toString(),
|
||||
client: client,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: _initialBox(initial, pt),
|
||||
)
|
||||
: _initialBox(initial, pt),
|
||||
),
|
||||
AnimatedContainer(
|
||||
duration: _animDuration,
|
||||
curve: _animCurve,
|
||||
width: expanded ? 20 : 10,
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
// Profilbereich: gleitet beim Aufklappen unter die oberste Zeile und
|
||||
// nutzt dann die volle Breite (vorher teilte er sie mit den Symbolen –
|
||||
// aufgeklappt wurde der Name zu „p…“ gekürzt).
|
||||
AnimatedPositioned(
|
||||
duration: _animDuration,
|
||||
curve: _animCurve,
|
||||
left: expanded ? 16 : (isMobile ? 54 : 16),
|
||||
right: expanded ? 16 : _actionsWidth + (isMobile ? 14 : 24),
|
||||
top: expanded ? 44 : 0,
|
||||
bottom: 0,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.onToggle,
|
||||
child: Row(
|
||||
children: [
|
||||
// Avatar — skaliert mit (rund, wie im Mockup)
|
||||
AnimatedContainer(
|
||||
duration: _animDuration,
|
||||
curve: _animCurve,
|
||||
width: avatarSize,
|
||||
height: avatarSize,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: pt.bg3,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: avatarUrl != null && client != null
|
||||
? MxcImage(
|
||||
mxcUri: avatarUrl.toString(),
|
||||
client: client,
|
||||
fit: BoxFit.cover,
|
||||
placeholder:
|
||||
_initialBox(initial, pt, avatarSize),
|
||||
)
|
||||
: _initialBox(initial, pt, avatarSize),
|
||||
),
|
||||
AnimatedContainer(
|
||||
duration: _animDuration,
|
||||
curve: _animCurve,
|
||||
width: expanded ? 20 : 10,
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Name skaliert mit; Status blendet daneben ein
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Name skaliert mit; Status blendet daneben ein
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: AnimatedDefaultTextStyle(
|
||||
duration: _animDuration,
|
||||
curve: _animCurve,
|
||||
style: TextStyle(
|
||||
color: pt.fg,
|
||||
fontSize: expanded ? 24 : 15,
|
||||
fontWeight: expanded
|
||||
? FontWeight.w700
|
||||
: FontWeight.w600,
|
||||
),
|
||||
child: Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: AnimatedDefaultTextStyle(
|
||||
duration: _animDuration,
|
||||
curve: _animCurve,
|
||||
style: TextStyle(
|
||||
color: pt.fg,
|
||||
fontSize: expanded ? 24 : 15,
|
||||
fontWeight: expanded
|
||||
? FontWeight.w700
|
||||
: FontWeight.w600,
|
||||
),
|
||||
AnimatedOpacity(
|
||||
opacity: expanded ? 1 : 0,
|
||||
duration: _animDuration,
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.only(left: 8),
|
||||
child: Text(
|
||||
_online ? 'online' : 'offline',
|
||||
style: TextStyle(
|
||||
color: _online
|
||||
? pt.online
|
||||
: pt.fgMuted,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Eingeklappt: "zuletzt online vor…",
|
||||
// aufgeklappt: Handle + Biographie.
|
||||
AnimatedCrossFade(
|
||||
AnimatedOpacity(
|
||||
opacity: expanded ? 1 : 0,
|
||||
duration: _animDuration,
|
||||
sizeCurve: _animCurve,
|
||||
crossFadeState: expanded
|
||||
? CrossFadeState.showSecond
|
||||
: CrossFadeState.showFirst,
|
||||
firstChild: client != null
|
||||
? _PresenceLine(
|
||||
userId: _partnerId,
|
||||
client: client,
|
||||
pt: pt,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
secondChild: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
userId,
|
||||
style: TextStyle(
|
||||
color: pt.fgMuted, fontSize: 13),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.only(left: 8),
|
||||
child: Text(
|
||||
_online ? 'online' : 'offline',
|
||||
style: TextStyle(
|
||||
color: _online
|
||||
? pt.online
|
||||
: pt.fgMuted,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
if (_statusMsg != null &&
|
||||
_statusMsg!.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_statusMsg!,
|
||||
style: TextStyle(
|
||||
color: pt.fgMuted,
|
||||
fontSize: 13,
|
||||
height: 1.35),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
// Eingeklappt: "zuletzt online vor…",
|
||||
// aufgeklappt: Handle + Biographie.
|
||||
AnimatedCrossFade(
|
||||
duration: _animDuration,
|
||||
sizeCurve: _animCurve,
|
||||
crossFadeState: expanded
|
||||
? CrossFadeState.showSecond
|
||||
: CrossFadeState.showFirst,
|
||||
firstChild: client != null
|
||||
? _PresenceLine(
|
||||
userId: _partnerId,
|
||||
client: client,
|
||||
pt: pt,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
secondChild: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
userId,
|
||||
style: TextStyle(
|
||||
color: pt.fgMuted, fontSize: 13),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (_statusMsg != null &&
|
||||
_statusMsg!.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_statusMsg!,
|
||||
style: TextStyle(
|
||||
color: pt.fgMuted,
|
||||
fontSize: 13,
|
||||
height: 1.35),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Zurück-Pfeil und Aktionen: fest in der obersten 52-px-Zeile –
|
||||
// sie rutschen beim Aufklappen nicht mehr in die Mitte.
|
||||
if (isMobile)
|
||||
Positioned(
|
||||
left: 6,
|
||||
top: 0,
|
||||
height: 52,
|
||||
child: Center(
|
||||
child: PyrIconBtn(
|
||||
icon: Icon(Icons.arrow_back_rounded, color: pt.fg),
|
||||
tooltip: 'Zurück',
|
||||
onPressed: () =>
|
||||
ref.read(activeRoomIdProvider.notifier).state = null,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Aktionen — bleiben immer sichtbar
|
||||
Row(
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: isMobile ? 6 : 16,
|
||||
top: 0,
|
||||
height: 52,
|
||||
child: KeyedSubtree(
|
||||
key: _actionsKey,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.phone_rounded),
|
||||
tooltip: 'Voice call',
|
||||
onPressed: () => _startCall(cam: false),
|
||||
),
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.videocam_rounded),
|
||||
tooltip: 'Video call',
|
||||
onPressed: () => _startCall(cam: true),
|
||||
),
|
||||
if (size.width > 700)
|
||||
Container(
|
||||
width: 1,
|
||||
height: 20,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
color: pt.border,
|
||||
),
|
||||
if (size.width > 800)
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.push_pin_rounded),
|
||||
tooltip: 'Angepinnte Nachrichten',
|
||||
active: widget.pinnedOpen,
|
||||
onPressed: widget.onTogglePinned,
|
||||
),
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.search_rounded),
|
||||
tooltip: 'Suchen',
|
||||
onPressed: widget.onOpenSearch,
|
||||
),
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.group_rounded),
|
||||
tooltip: 'Mitglieder',
|
||||
active: widget.membersOpen,
|
||||
onPressed: widget.onToggleMembers,
|
||||
),
|
||||
],
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.phone_rounded),
|
||||
tooltip: 'Voice call',
|
||||
onPressed: () => _startCall(cam: false),
|
||||
),
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.videocam_rounded),
|
||||
tooltip: 'Video call',
|
||||
onPressed: () => _startCall(cam: true),
|
||||
),
|
||||
if (size.width > 700)
|
||||
Container(
|
||||
width: 1,
|
||||
height: 20,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
color: pt.border,
|
||||
),
|
||||
if (size.width > 800)
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.push_pin_rounded),
|
||||
tooltip: 'Angepinnte Nachrichten',
|
||||
active: widget.pinnedOpen,
|
||||
onPressed: widget.onTogglePinned,
|
||||
),
|
||||
if (isMobile)
|
||||
_ChatOverflowMenu(
|
||||
pt: pt,
|
||||
onSearch: widget.onOpenSearch,
|
||||
onMembers: widget.onToggleMembers,
|
||||
)
|
||||
else ...[
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.search_rounded),
|
||||
tooltip: 'Suchen',
|
||||
onPressed: widget.onOpenSearch,
|
||||
),
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.group_rounded),
|
||||
tooltip: 'Mitglieder',
|
||||
active: widget.membersOpen,
|
||||
onPressed: widget.onToggleMembers,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -560,15 +611,25 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _initialBox(String initial, PyramidTheme pt) => Center(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Text(
|
||||
initial,
|
||||
style: TextStyle(
|
||||
color: pt.fg, fontSize: 28, fontWeight: FontWeight.w700),
|
||||
// Kürzel exakt mittig: Schrift wächst mit dem Avatar mit (vorher feste
|
||||
// 28 px in FittedBox mit Innenabstand – wirkte aufgeklappt verschoben).
|
||||
Widget _initialBox(String initial, PyramidTheme pt, double avatarSize) =>
|
||||
Center(
|
||||
child: AnimatedDefaultTextStyle(
|
||||
duration: _animDuration,
|
||||
curve: _animCurve,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: pt.fg,
|
||||
fontSize: avatarSize * 0.42,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.0,
|
||||
),
|
||||
child: Text(
|
||||
initial,
|
||||
textHeightBehavior: const TextHeightBehavior(
|
||||
applyHeightToFirstAscent: false,
|
||||
applyHeightToLastDescent: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -733,3 +794,48 @@ class _PresenceLineState extends State<_PresenceLine> {
|
||||
return 'vor ${(diff.inDays / 365).floor()}j';
|
||||
}
|
||||
}
|
||||
|
||||
/// Handy: Suche und Mitglieder im ⋮-Menü statt als eigene Symbole
|
||||
/// (Bernds Feedback 2026-10-07 – mehr Platz, größere Hauptsymbole).
|
||||
class _ChatOverflowMenu extends StatelessWidget {
|
||||
final PyramidTheme pt;
|
||||
final VoidCallback onSearch;
|
||||
final VoidCallback onMembers;
|
||||
|
||||
const _ChatOverflowMenu({
|
||||
required this.pt,
|
||||
required this.onSearch,
|
||||
required this.onMembers,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
PopupMenuItem<VoidCallback> item(
|
||||
IconData icon, String label, VoidCallback action) =>
|
||||
PopupMenuItem(
|
||||
value: action,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 20, color: pt.fgMuted),
|
||||
const SizedBox(width: 12),
|
||||
Text(label, style: TextStyle(color: pt.fg, fontSize: 14)),
|
||||
],
|
||||
),
|
||||
);
|
||||
return PopupMenuButton<VoidCallback>(
|
||||
tooltip: 'Mehr',
|
||||
icon: Icon(Icons.more_vert_rounded, size: 22, color: pt.fgMuted),
|
||||
color: pt.bg2,
|
||||
elevation: 6,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(pt.rBase),
|
||||
side: BorderSide(color: pt.border),
|
||||
),
|
||||
onSelected: (action) => action(),
|
||||
itemBuilder: (_) => [
|
||||
item(Icons.search_rounded, 'Suchen', onSearch),
|
||||
item(Icons.group_rounded, 'Mitglieder', onMembers),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,13 @@ class _MessageList extends StatelessWidget {
|
||||
final String? highlightEventId;
|
||||
final bool highlightOn;
|
||||
|
||||
/// Höhe der darüber schwebenden Eingabeleiste: so viel Platz bleibt unten
|
||||
/// frei, damit die neueste Nachricht ganz über die Leiste scrollen kann.
|
||||
final double bottomInset;
|
||||
|
||||
/// Siehe MessageGroup.popInAfter.
|
||||
final DateTime? popInAfter;
|
||||
|
||||
const _MessageList({
|
||||
required this.timeline,
|
||||
required this.scrollCtrl,
|
||||
@@ -35,6 +42,8 @@ class _MessageList extends StatelessWidget {
|
||||
this.jumpKey,
|
||||
this.highlightEventId,
|
||||
this.highlightOn = false,
|
||||
this.bottomInset = 0,
|
||||
this.popInAfter,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -207,10 +216,11 @@ class _MessageList extends StatelessWidget {
|
||||
// Pass fullyReadId to the group only when the event is mid-group
|
||||
// (not the last event) so MessageGroup can render an inline divider.
|
||||
// groupContainsFullyRead already guarantees fullyReadId != null.
|
||||
final passedFullyReadId =
|
||||
(groupContainsFullyRead && group.last.eventId != fullyReadId)
|
||||
? fullyReadId
|
||||
: null;
|
||||
final passedFullyReadId = (hasUnread &&
|
||||
groupContainsFullyRead &&
|
||||
group.last.eventId != fullyReadId)
|
||||
? fullyReadId
|
||||
: null;
|
||||
itemFullyReadIds.add(passedFullyReadId);
|
||||
|
||||
if (isLastOfDay) {
|
||||
@@ -241,7 +251,7 @@ class _MessageList extends StatelessWidget {
|
||||
controller: scrollCtrl,
|
||||
reverse: true,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
padding: EdgeInsets.only(bottom: 8 + bottomInset),
|
||||
scrollCacheExtent: const ScrollCacheExtent.pixels(2500),
|
||||
itemCount: itemKeys.length,
|
||||
findChildIndexCallback: (Key key) {
|
||||
@@ -283,10 +293,14 @@ class _MessageList extends StatelessWidget {
|
||||
showStatusIndicator: showStatus,
|
||||
readEventIds: readEventIds,
|
||||
fullyReadId: itemFullyReadIds[i],
|
||||
unreadFading: unreadFading,
|
||||
unreadDividerKey:
|
||||
itemFullyReadIds[i] != null ? unreadDividerKey : null,
|
||||
jumpEventId: jumpEventId,
|
||||
jumpKey: jumpKey,
|
||||
highlightEventId: highlightEventId,
|
||||
highlightOn: highlightOn,
|
||||
popInAfter: popInAfter,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -129,3 +129,232 @@ class NoChatSelected extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fläche der schwebenden Eingabeleiste: um das Eingabefeld herum völlig
|
||||
/// klar – der Chat ist dort normal zu sehen –, fängt aber alle Berührungen
|
||||
/// ab (ColoredBox ist auch transparent „opak“ für Treffer), damit dort nur
|
||||
/// die Eingabe reagiert und nicht der Chat dahinter. Die Unschärfe sitzt nur
|
||||
/// hinter dem Eingabefeld selbst (chat_composer.dart).
|
||||
class _FloatingComposerArea extends StatelessWidget {
|
||||
final PyramidTheme pt;
|
||||
final Widget child;
|
||||
const _FloatingComposerArea({required this.pt, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColoredBox(
|
||||
color: Colors.transparent,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Einladung in diesen Chat (z. B. aus einer Benachrichtigung geöffnet):
|
||||
/// Annehmen/Ablehnen direkt hier statt eines leeren Verlaufs – vorher ging
|
||||
/// das nur in der Chatliste (Bernd 2026-10-07).
|
||||
class _InvitePanel extends ConsumerStatefulWidget {
|
||||
final Room room;
|
||||
final PyramidTheme pt;
|
||||
const _InvitePanel({required this.room, required this.pt});
|
||||
|
||||
@override
|
||||
ConsumerState<_InvitePanel> createState() => _InvitePanelState();
|
||||
}
|
||||
|
||||
class _InvitePanelState extends ConsumerState<_InvitePanel> {
|
||||
bool _busy = false;
|
||||
String? _error;
|
||||
|
||||
String get _inviter {
|
||||
final me = widget.room.client.userID;
|
||||
final sender = me == null
|
||||
? null
|
||||
: widget.room.getState(EventTypes.RoomMember, me)?.senderId;
|
||||
if (sender == null) return 'Jemand';
|
||||
return widget.room.unsafeGetUserFromMemoryOrFallback(sender).calcDisplayname();
|
||||
}
|
||||
|
||||
Future<void> _accept() async {
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
await acceptInvite(ref, widget.room);
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_error = 'Annehmen hat nicht geklappt – bitte noch einmal versuchen.';
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _decline() async {
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
await widget.room.leave();
|
||||
if (mounted) ref.read(activeRoomIdProvider.notifier).state = null;
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_error = 'Ablehnen hat nicht geklappt – bitte noch einmal versuchen.';
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
final isDm = widget.room.isDirectChat;
|
||||
return Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(28),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
isDm ? Icons.person_add_alt_1_rounded : Icons.group_add_rounded,
|
||||
size: 48,
|
||||
color: pt.accent,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
isDm
|
||||
? '$_inviter möchte mit dir chatten'
|
||||
: '$_inviter lädt dich in „${widget.room.getLocalizedDisplayname()}“ ein',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: pt.fg,
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Nach dem Annehmen siehst du die Nachrichten und kannst antworten.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: pt.fgMuted, fontSize: 13.5),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (_busy)
|
||||
SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: CircularProgressIndicator(strokeWidth: 2.5, color: pt.accent),
|
||||
)
|
||||
else
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
OutlinedButton(
|
||||
onPressed: _decline,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: pt.fgMuted,
|
||||
side: BorderSide(color: pt.border),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 14),
|
||||
),
|
||||
child: const Text('Ablehnen'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _accept,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: pt.accent,
|
||||
foregroundColor: pt.accentFg,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 26, vertical: 14),
|
||||
),
|
||||
child: const Text('Annehmen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 14),
|
||||
Text(_error!,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: pt.danger, fontSize: 13)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handy: Läuft ein Anruf, während ein Chat offen ist, zeigt diese Leiste den
|
||||
/// Weg zurück (am PC übernimmt das die Anrufleiste in der Seitenleiste).
|
||||
/// Vorher war im Chat nirgends zu sehen, dass/wie man zurück in den Anruf
|
||||
/// kommt (Bernd 2026-10-07).
|
||||
class _ActiveCallBar extends ConsumerWidget {
|
||||
const _ActiveCallBar();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
if (!isMobileLayout(MediaQuery.sizeOf(context))) return const SizedBox.shrink();
|
||||
final voip = ref.watch(callSignalingProvider);
|
||||
final lk = ref.watch(voiceChannelProvider);
|
||||
final voipCall = voip.currentCall;
|
||||
final String? name;
|
||||
// Klingeln zählt nicht als „läuft“ – dafür gibt es das Annehmen-Fenster.
|
||||
if (voipCall != null &&
|
||||
voipCall.state != CallState.kEnded &&
|
||||
voipCall.state != CallState.kRinging) {
|
||||
name = voipCall.room.getLocalizedDisplayname();
|
||||
} else if (lk.isActive || lk.isConnecting) {
|
||||
name = lk.displayName;
|
||||
} else {
|
||||
name = null;
|
||||
}
|
||||
final pt = PyramidTheme.of(context);
|
||||
return AnimatedSize(
|
||||
duration: motionDuration(context, 220),
|
||||
curve: Curves.easeOutCubic,
|
||||
child: name == null
|
||||
? const SizedBox(width: double.infinity)
|
||||
: Material(
|
||||
color: pt.online.withValues(alpha: 0.16),
|
||||
child: InkWell(
|
||||
onTap: () =>
|
||||
ref.read(viewModeProvider.notifier).state = ViewMode.voice,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.call_rounded, size: 18, color: pt.online),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Anruf läuft · $name',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: pt.fg,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text('Zurück',
|
||||
style: TextStyle(
|
||||
color: pt.online,
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w700)),
|
||||
Icon(Icons.chevron_right_rounded, size: 20, color: pt.online),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,9 +28,25 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
||||
// Fade-out of the "Neue Nachrichten" divider once the user reaches the bottom.
|
||||
bool _unreadFading = false;
|
||||
Timer? _unreadFadeTimer;
|
||||
// So lange bleibt der Trenner stehen, nachdem man bei den neuesten
|
||||
// Nachrichten angekommen ist (Bernd 2026-10-07: verschwand beim Öffnen aus
|
||||
// der Benachrichtigung sofort).
|
||||
static const _unreadDwell = Duration(seconds: 6);
|
||||
Timer? _unreadDwellTimer;
|
||||
// Zuletzt von hier als gelesen gemeldetes Event. Verhindert, dass jeder
|
||||
// Neuaufbau erneut eine Lesebestätigung schickt, und markiert nach dem
|
||||
// Zurückkehren in die App, ab wo „Neue Nachrichten“ beginnen.
|
||||
String? _lastMarkedEventId;
|
||||
// Was danach eintrifft, ploppt federnd auf (beim Öffnen gesetzt, damit die
|
||||
// schon vorhandenen Nachrichten ruhig erscheinen).
|
||||
DateTime? _popInAfter;
|
||||
|
||||
// Jump-to-message (from search): scroll to + briefly highlight a target event.
|
||||
final _jumpKey = GlobalKey();
|
||||
|
||||
// Höhe der schwebenden Eingabeleiste (gemessen, wächst mit Antwort-Leiste
|
||||
// oder mehrzeiligem Text).
|
||||
double _composerHeight = 72;
|
||||
String? _jumpEventId;
|
||||
String? _highlightEventId;
|
||||
bool _highlightOn = false;
|
||||
@@ -77,11 +93,25 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
||||
setState(() { _jumpInProgress = false; _highlightEventId = null; _jumpEventId = null; _highlightOn = false; });
|
||||
return;
|
||||
}
|
||||
// Not built yet — load older history toward the target every few tries.
|
||||
// Not built yet. Die Liste baut nur sichtbare Einträge (lazy, reverse):
|
||||
// Ist das Ziel schon geladen, Richtung ältere Nachrichten scrollen, bis
|
||||
// es gezeichnet ist (Antwort-Zitate zeigen fast immer nach oben); sonst
|
||||
// zusätzlich ältere Historie nachladen.
|
||||
final timeline = ref.read(timelineProvider(widget.roomId)).valueOrNull;
|
||||
if (timeline != null && timeline.canRequestHistory && attempt % 3 == 0) {
|
||||
final loaded =
|
||||
timeline?.events.any((e) => e.eventId == _jumpEventId) ?? false;
|
||||
if (!loaded &&
|
||||
timeline != null &&
|
||||
timeline.canRequestHistory &&
|
||||
attempt % 3 == 0) {
|
||||
timeline.requestHistory(historyCount: 100).catchError((_) {});
|
||||
}
|
||||
if (_scrollCtrl.hasClients && (loaded || attempt % 3 == 1)) {
|
||||
final pos = _scrollCtrl.position;
|
||||
final step = pos.viewportDimension * 0.8;
|
||||
final target = (pos.pixels + step).clamp(0.0, pos.maxScrollExtent);
|
||||
if (target > pos.pixels) _scrollCtrl.jumpTo(target);
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
Future.delayed(const Duration(milliseconds: 120), () => _attemptJump(attempt + 1));
|
||||
});
|
||||
@@ -93,6 +123,9 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
||||
|
||||
bool get _inSelectionMode => _selectedIds.isNotEmpty;
|
||||
|
||||
double get _scrollBtnSize =>
|
||||
isMobileLayout(MediaQuery.sizeOf(context)) ? 44 : 36;
|
||||
|
||||
void _toggleSelect(String eventId) {
|
||||
setState(() {
|
||||
if (_selectedIds.contains(eventId)) {
|
||||
@@ -109,12 +142,22 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollCtrl.addListener(_onScroll);
|
||||
// App/Fenster wieder im Vordergrund: was inzwischen kam, jetzt als
|
||||
// gelesen melden – mit „Neue Nachrichten“-Trenner davor.
|
||||
ref.listenManual<bool>(windowFocusedProvider, (prev, focused) {
|
||||
if (focused && prev == false) _onFocusRegained();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ChatView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.roomId != widget.roomId) {
|
||||
// Antwort/Auswahl gehören zum alten Raum – sonst ginge eine Antwort
|
||||
// auf eine Nachricht aus Raum A in Raum B raus.
|
||||
_replyTo = null;
|
||||
_selectedIds.clear();
|
||||
_showScrollDown = false;
|
||||
_profileExpanded = false;
|
||||
_sessionFullyReadId = null;
|
||||
_sessionHasUnread = false;
|
||||
@@ -124,6 +167,9 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
||||
_historyExhausted = false;
|
||||
_unreadFading = false;
|
||||
_unreadFadeTimer?.cancel();
|
||||
_unreadDwellTimer?.cancel();
|
||||
_lastMarkedEventId = null;
|
||||
_popInAfter = null;
|
||||
_jumpEventId = null;
|
||||
_highlightEventId = null;
|
||||
_highlightOn = false;
|
||||
@@ -137,6 +183,7 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
||||
@override
|
||||
void dispose() {
|
||||
_unreadFadeTimer?.cancel();
|
||||
_unreadDwellTimer?.cancel();
|
||||
_highlightTimer?.cancel();
|
||||
_scrollCtrl.dispose();
|
||||
super.dispose();
|
||||
@@ -174,6 +221,7 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
||||
setState(() {
|
||||
_sessionFullyReadId = newFullyReadId;
|
||||
_sessionHasUnread = newHasUnread;
|
||||
_popInAfter = events.firstOrNull?.originServerTs ?? DateTime.now();
|
||||
});
|
||||
if (newHasUnread) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToUnread(timeline));
|
||||
@@ -206,10 +254,12 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
_scheduleUnreadFadeIfAtNewest();
|
||||
return;
|
||||
}
|
||||
if (attempt >= 24) {
|
||||
_didScrollToUnread = true; // give up — stay at the newest messages
|
||||
_scheduleUnreadFadeIfAtNewest();
|
||||
return;
|
||||
}
|
||||
if (attempt > 0 && attempt % 4 == 0 && _scrollCtrl.hasClients) {
|
||||
@@ -224,21 +274,100 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
||||
}
|
||||
|
||||
void _markRead([Timeline? tl]) {
|
||||
// Nur als gelesen melden, was wirklich zu sehen ist: Läuft die App im
|
||||
// Hintergrund (oder hat das Fenster keinen Fokus), bleibt alles ungelesen
|
||||
// und die Benachrichtigung stehen – vorher wurde sie beim nächsten
|
||||
// Neuaufbau sofort wieder entfernt und die Nachricht als gelesen gemeldet.
|
||||
if (!ref.read(windowFocusedProvider)) return;
|
||||
|
||||
// Cancel any pending notification for this room (user is now reading it).
|
||||
cancelNotificationForRoom(widget.roomId);
|
||||
|
||||
final sendReceipts = ref.read(privacyReadReceiptsProvider);
|
||||
if (!sendReceipts) return;
|
||||
final room = ref.read(roomProvider(widget.roomId));
|
||||
if (room == null) return;
|
||||
if (room == null || room.membership != Membership.join) return;
|
||||
if (room.markedUnread) room.markUnread(false).catchError((_) {});
|
||||
final timeline = tl ?? ref.read(timelineProvider(widget.roomId)).valueOrNull;
|
||||
final latest = timeline?.events.firstOrNull;
|
||||
if (latest != null && latest.status.isSynced) {
|
||||
room.setReadMarker(latest.eventId, mRead: latest.eventId)
|
||||
.catchError((_) {});
|
||||
} else {
|
||||
room.markUnread(false).catchError((_) {});
|
||||
final latest = timeline?.events
|
||||
.where((e) => e.status.isSynced)
|
||||
.firstOrNull;
|
||||
if (latest == null || latest.eventId == _lastMarkedEventId) return;
|
||||
_lastMarkedEventId = latest.eventId;
|
||||
markRoomReadUpTo(
|
||||
room,
|
||||
latest.eventId,
|
||||
publicReceipt: ref.read(privacyReadReceiptsProvider),
|
||||
).catchError((_) {
|
||||
// Beim nächsten Anlass erneut versuchen.
|
||||
if (_lastMarkedEventId == latest.eventId) _lastMarkedEventId = null;
|
||||
});
|
||||
}
|
||||
|
||||
/// Zurück in der App mit diesem Chat offen: Kamen inzwischen Nachrichten
|
||||
/// von anderen, erscheint der „Neue Nachrichten“-Trenner hinter dem zuletzt
|
||||
/// gelesenen Stand; danach wird bis zur neuesten Nachricht gelesen gemeldet.
|
||||
void _onFocusRegained() {
|
||||
if (!mounted || !_sessionCaptured) return;
|
||||
final timeline = ref.read(timelineProvider(widget.roomId)).valueOrNull;
|
||||
if (timeline == null) return;
|
||||
final since = _lastMarkedEventId;
|
||||
if (since != null && !_sessionHasUnread) {
|
||||
final me = timeline.room.client.userID;
|
||||
final events = timeline.events; // newest-first
|
||||
final idx = events.indexWhere((e) => e.eventId == since);
|
||||
final hasNew = idx > 0 &&
|
||||
events
|
||||
.take(idx)
|
||||
.any((e) => _MessageList._isVisible(e) && e.senderId != me);
|
||||
if (hasNew) {
|
||||
_unreadFadeTimer?.cancel();
|
||||
_unreadDwellTimer?.cancel();
|
||||
setState(() {
|
||||
_sessionFullyReadId = since;
|
||||
_sessionHasUnread = true;
|
||||
_unreadFading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
if (_sessionHasUnread) _scheduleUnreadFadeIfAtNewest();
|
||||
_markRead(timeline);
|
||||
}
|
||||
|
||||
// Neueste Nachrichten sind zu sehen: höchstens eine halbe Bildschirmhöhe
|
||||
// hochgescrollt. (Nach dem Sprung zum Trenner steht die Liste meist ein
|
||||
// Stück oberhalb des Endes – mit der strengen 60-px-Grenze blieb der
|
||||
// Trenner dann für immer stehen.)
|
||||
bool get _atNewest {
|
||||
if (!_scrollCtrl.hasClients) return true;
|
||||
final pos = _scrollCtrl.position;
|
||||
return pos.pixels < 60 || pos.pixels < pos.viewportDimension * 0.5;
|
||||
}
|
||||
|
||||
/// „Neue Nachrichten“-Trenner nach [_unreadDwell] weich ausblenden – nur
|
||||
/// wenn man dann noch bei den neuesten Nachrichten ist. Wer zwischendurch
|
||||
/// hochscrollt, behält ihn; unten angekommen beginnt die Wartezeit neu.
|
||||
void _scheduleUnreadFade() {
|
||||
if (!_sessionHasUnread || _unreadFading) return;
|
||||
if (_unreadDwellTimer?.isActive ?? false) return;
|
||||
_unreadDwellTimer = Timer(_unreadDwell, () {
|
||||
if (!mounted || !_sessionHasUnread) return;
|
||||
// App im Hintergrund oder weggescrollt: stehen lassen, beim nächsten
|
||||
// Scrollen/Zurückkommen beginnt die Wartezeit neu.
|
||||
if (!_atNewest || !ref.read(windowFocusedProvider)) return;
|
||||
setState(() => _unreadFading = true);
|
||||
_unreadFadeTimer?.cancel();
|
||||
_unreadFadeTimer = Timer(const Duration(milliseconds: 900), () {
|
||||
if (mounted) setState(() { _sessionHasUnread = false; _unreadFading = false; });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Nach dem Sprung zum Trenner (Animation ~250 ms) prüfen: Passt alles
|
||||
// Ungelesene auf den Bildschirm, scrollt niemand mehr – dann läuft die
|
||||
// Verweildauer trotzdem an.
|
||||
void _scheduleUnreadFadeIfAtNewest() {
|
||||
Future.delayed(const Duration(milliseconds: 400), () {
|
||||
if (mounted && _atNewest) _scheduleUnreadFade();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onScroll() async {
|
||||
@@ -246,18 +375,10 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
||||
final pixels = _scrollCtrl.position.pixels;
|
||||
final maxExtent = _scrollCtrl.position.maxScrollExtent;
|
||||
|
||||
if (pixels < 60) {
|
||||
_markRead(null);
|
||||
// Reached the newest messages → the "Neue Nachrichten" divider is no
|
||||
// longer useful. Fade it out, then remove it.
|
||||
if (_sessionHasUnread && !_unreadFading) {
|
||||
setState(() => _unreadFading = true);
|
||||
_unreadFadeTimer?.cancel();
|
||||
_unreadFadeTimer = Timer(const Duration(milliseconds: 900), () {
|
||||
if (mounted) setState(() { _sessionHasUnread = false; _unreadFading = false; });
|
||||
});
|
||||
}
|
||||
}
|
||||
if (pixels < 60) _markRead(null);
|
||||
// Bei den neuesten Nachrichten angekommen → Trenner nach einer
|
||||
// Verweildauer ausblenden.
|
||||
if (_atNewest) _scheduleUnreadFade();
|
||||
final shouldShow = pixels > 400;
|
||||
if (shouldShow != _showScrollDown) {
|
||||
setState(() => _showScrollDown = shouldShow);
|
||||
@@ -313,7 +434,9 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
||||
// listen) so it also fires when this ChatView is freshly mounted for a
|
||||
// DIFFERENT room — listen would miss a value set before registration.
|
||||
final pendingJump = ref.watch(pendingJumpEventProvider);
|
||||
if (pendingJump != null && !_jumpInProgress && pendingJump != _jumpEventId) {
|
||||
// (Kein Vergleich mit _jumpEventId mehr: dasselbe Zitat darf zweimal
|
||||
// hintereinander angetippt werden – _startJump verbraucht die Anfrage.)
|
||||
if (pendingJump != null && !_jumpInProgress) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted &&
|
||||
!_jumpInProgress &&
|
||||
@@ -324,6 +447,9 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
||||
}
|
||||
|
||||
final isDesktop = Platform.isWindows || Platform.isMacOS || Platform.isLinux;
|
||||
// Noch nicht beigetreten (Einladung): statt eines leeren Verlaufs gleich
|
||||
// hier Annehmen/Ablehnen anbieten.
|
||||
final isInvite = room?.membership == Membership.invite;
|
||||
final body = Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
@@ -394,8 +520,11 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
||||
setState(() => _pinnedOpen = !_pinnedOpen),
|
||||
),
|
||||
const _ConnectionBanner(),
|
||||
const _ActiveCallBar(),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
child: isInvite
|
||||
? _InvitePanel(room: room!, pt: pt)
|
||||
: Stack(
|
||||
children: [
|
||||
timelineAsync.when(
|
||||
loading: () => Center(
|
||||
@@ -429,28 +558,36 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
||||
jumpKey: _jumpKey,
|
||||
highlightEventId: _highlightEventId,
|
||||
highlightOn: _highlightOn,
|
||||
bottomInset: room != null ? _composerHeight : 0,
|
||||
popInAfter: _popInAfter,
|
||||
);
|
||||
},
|
||||
),
|
||||
if (_showScrollDown)
|
||||
Positioned(
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: 12,
|
||||
child: AnimatedOpacity(
|
||||
opacity: _showScrollDown ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
bottom: (room != null ? _composerHeight : 0) + 12,
|
||||
child: IgnorePointer(
|
||||
ignoring: !_showScrollDown,
|
||||
child: AnimatedScale(
|
||||
scale: _showScrollDown ? 1 : 0,
|
||||
duration: motionDuration(
|
||||
context, _showScrollDown ? 520 : 180),
|
||||
curve: _showScrollDown
|
||||
? Curves.elasticOut
|
||||
: Curves.easeInBack,
|
||||
child: GestureDetector(
|
||||
onTap: () => _scrollCtrl.animateTo(
|
||||
0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
),
|
||||
child: SpringPress(
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
width: _scrollBtnSize,
|
||||
height: _scrollBtnSize,
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg0,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
borderRadius: BorderRadius.circular(_scrollBtnSize / 2),
|
||||
border: Border.all(color: pt.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
@@ -460,7 +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 {
|
||||
setState(() => _accepting = true);
|
||||
try {
|
||||
await widget.room.join();
|
||||
await acceptInvite(ref, widget.room);
|
||||
if (mounted) {
|
||||
ref.read(activeRoomIdProvider.notifier).state = widget.room.id;
|
||||
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||
|
||||
@@ -95,10 +95,18 @@ class _MobileRoomTile extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (unread > 0) ...[
|
||||
const SizedBox(width: 8),
|
||||
_UnreadBadge(count: unread, isMention: mention, pt: pt),
|
||||
],
|
||||
// Immer im Baum, damit der Zähler beim Erscheinen und
|
||||
// Hochzählen federnd aufploppen kann.
|
||||
SpringBump(
|
||||
value: unread,
|
||||
child: unread > 0
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: _UnreadBadge(
|
||||
count: unread, isMention: mention, pt: pt),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -131,8 +131,11 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
|
||||
// Im Mobil-Querformat (Höhe knapp) liegen Call-Balken und
|
||||
// Konto-Balken nebeneinander statt übereinander.
|
||||
Builder(builder: (context) {
|
||||
// Klingeln (noch nicht angenommen) zählt nicht – dafür gibt es
|
||||
// das Annehmen-Fenster (Bernd 2026-10-08).
|
||||
final hasVoipCall = voip.currentCall != null &&
|
||||
voip.currentCall!.state != CallState.kEnded;
|
||||
voip.currentCall!.state != CallState.kEnded &&
|
||||
voip.currentCall!.state != CallState.kRinging;
|
||||
final hasCallDock = call.isActive || hasVoipCall;
|
||||
final isLandscapeCompact =
|
||||
MediaQuery.sizeOf(context).height < 500;
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'package:pyramid/core/matrix_client.dart';
|
||||
import 'package:pyramid/core/notification_content.dart' show notificationBody;
|
||||
import 'package:pyramid/core/theme.dart';
|
||||
import 'package:pyramid/features/rooms/rooms_provider.dart';
|
||||
import 'package:pyramid/features/chat/chat_provider.dart' show acceptInvite;
|
||||
import 'package:pyramid/features/call_ui/mini_call_widget.dart';
|
||||
import 'package:pyramid/features/call_signaling/call_signaling_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/mxc_image.dart';
|
||||
import 'package:pyramid/widgets/new_dm_dialog.dart';
|
||||
import 'package:pyramid/widgets/spring_press.dart' show SpringBump;
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
||||
import 'package:livekit_client/livekit_client.dart';
|
||||
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/livekit_token.dart';
|
||||
import 'package:pyramid/core/settings_prefs.dart';
|
||||
@@ -98,8 +99,35 @@ class LiveKitCallManager extends VoiceChannelService {
|
||||
|
||||
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() {
|
||||
_callGeneration++;
|
||||
CallForeground.callEnded('livekit');
|
||||
_stopStatsLogging();
|
||||
_presenceHeartbeat?.cancel();
|
||||
_presenceHeartbeat = null;
|
||||
@@ -173,8 +201,6 @@ class LiveKitCallManager extends VoiceChannelService {
|
||||
if (isActive) return;
|
||||
final gen = ++_callGeneration;
|
||||
|
||||
final url = 'wss://livekit.steggi-matrix.work';
|
||||
|
||||
isConnecting = true;
|
||||
isVoiceChannel = voiceChannel;
|
||||
error = null;
|
||||
@@ -191,12 +217,21 @@ class LiveKitCallManager extends VoiceChannelService {
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final token = LiveKitTokenGenerator.generate(
|
||||
roomName: roomName,
|
||||
identity: identity,
|
||||
displayName: identity,
|
||||
ttlSeconds: 21600,
|
||||
// Token holt der Server (prüft den Matrix-Login, Identität = eigene
|
||||
// Matrix-ID); das LiveKit-Secret liegt nicht mehr in der App.
|
||||
// [identity] wird dafür nicht mehr gebraucht.
|
||||
final accessToken = matrixClient?.accessToken;
|
||||
if (accessToken == null || accessToken.isEmpty) {
|
||||
throw const LiveKitTokenException(
|
||||
'Nicht angemeldet – Beitritt nicht möglich.');
|
||||
}
|
||||
final lk = await LiveKitTokenService.fetch(
|
||||
room: roomName,
|
||||
matrixToken: accessToken,
|
||||
);
|
||||
if (gen != _callGeneration) return; // inzwischen aufgelegt/gewechselt
|
||||
final token = lk.token;
|
||||
final url = lk.url;
|
||||
|
||||
final room = Room(
|
||||
roomOptions: const RoomOptions(
|
||||
@@ -247,6 +282,10 @@ class LiveKitCallManager extends VoiceChannelService {
|
||||
_room = room;
|
||||
isConnecting = false;
|
||||
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) {
|
||||
await _writePresence(true);
|
||||
@@ -262,6 +301,7 @@ class LiveKitCallManager extends VoiceChannelService {
|
||||
isScreenSharing = false;
|
||||
_currentScreenShareSourceId = null;
|
||||
_screenShareTrack = null;
|
||||
CallForeground.screenCaptureStopped('livekit');
|
||||
}
|
||||
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);
|
||||
isScreenSharing = true;
|
||||
notifyListeners();
|
||||
@@ -580,6 +625,7 @@ class LiveKitCallManager extends VoiceChannelService {
|
||||
}
|
||||
}
|
||||
try { await _room?.localParticipant?.setScreenShareEnabled(false); } catch (_) {}
|
||||
await CallForeground.screenCaptureStopped('livekit');
|
||||
isScreenSharing = false;
|
||||
_currentScreenShareSourceId = null;
|
||||
_screenShareTrack = null;
|
||||
@@ -590,6 +636,7 @@ class LiveKitCallManager extends VoiceChannelService {
|
||||
Future<void> hangUp() async {
|
||||
// Entwertet einen evtl. noch laufenden Verbindungsaufbau (s. _callGeneration).
|
||||
_callGeneration++;
|
||||
unawaited(CallForeground.callEnded('livekit'));
|
||||
_stopStatsLogging();
|
||||
_presenceHeartbeat?.cancel();
|
||||
_presenceHeartbeat = null;
|
||||
|
||||
@@ -53,6 +53,11 @@ abstract class VoiceChannelService extends ChangeNotifier {
|
||||
Future<void> hangUp();
|
||||
|
||||
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> toggleCamera();
|
||||
Future<void> toggleScreenShare();
|
||||
|
||||
+34
-13
@@ -17,6 +17,8 @@ import 'package:pyramid/features/chat/chat_view.dart';
|
||||
import 'package:pyramid/features/members/members_panel.dart';
|
||||
import 'package:pyramid/features/rooms/rooms_panel.dart';
|
||||
import 'package:pyramid/features/spaces/spaces_rail.dart';
|
||||
import 'package:pyramid/layout/mobile_page_stack.dart';
|
||||
import 'package:pyramid/widgets/spring_press.dart' show motionDuration;
|
||||
import 'package:pyramid/widgets/search_modal.dart';
|
||||
import 'package:pyramid/widgets/settings_modal.dart';
|
||||
import 'package:pyramid/widgets/share_target_dialog.dart';
|
||||
@@ -335,14 +337,18 @@ class _MobileShell extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final voip = ref.watch(callSignalingProvider);
|
||||
|
||||
Widget body;
|
||||
if (viewMode == ViewMode.voice) {
|
||||
body = const VoiceChannelView();
|
||||
} else if (activeRoomId != null) {
|
||||
body = ChatView(roomId: activeRoomId!);
|
||||
} else {
|
||||
body = const RoomsPanel();
|
||||
}
|
||||
// Die Chatliste bleibt aufgebaut; Chat bzw. Sprachkanal gleiten federnd
|
||||
// darüber (Bernds Wunsch 2026-10-07: mehr Übergänge/Elastizität).
|
||||
final Widget? front = viewMode == ViewMode.voice
|
||||
? const VoiceChannelView()
|
||||
: activeRoomId != null
|
||||
? ChatView(roomId: activeRoomId!)
|
||||
: null;
|
||||
final body = MobilePageStack(
|
||||
base: const RoomsPanel(),
|
||||
front: front,
|
||||
background: pt.bg1,
|
||||
);
|
||||
|
||||
// Symbole der Statusleiste passend zum Theme (hell auf dunkel und umgekehrt).
|
||||
final overlayStyle = (pt.isDark
|
||||
@@ -387,7 +393,12 @@ class _MobileShell extends ConsumerWidget {
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onTap: () => ref.read(membersPanelOpenProvider.notifier).state = false,
|
||||
child: Container(color: Colors.black.withAlpha(110)),
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0, end: 1),
|
||||
duration: motionDuration(context, 220),
|
||||
builder: (_, v, _) =>
|
||||
ColoredBox(color: Colors.black.withAlpha((110 * v).round())),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
@@ -396,10 +407,20 @@ class _MobileShell extends ConsumerWidget {
|
||||
bottom: 0,
|
||||
child: Builder(builder: (ctx) {
|
||||
final w = MediaQuery.sizeOf(ctx).width;
|
||||
return MembersPanel(
|
||||
roomId: activeRoomId!,
|
||||
width: (w * 0.82).clamp(240.0, 320.0),
|
||||
onClose: () => ref.read(membersPanelOpenProvider.notifier).state = false,
|
||||
// Gleitet von rechts herein.
|
||||
return TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 1, end: 0),
|
||||
duration: motionDuration(ctx, 340),
|
||||
curve: Curves.easeOutCubic,
|
||||
builder: (_, v, child) => FractionalTranslation(
|
||||
translation: Offset(v, 0),
|
||||
child: child,
|
||||
),
|
||||
child: MembersPanel(
|
||||
roomId: activeRoomId!,
|
||||
width: (w * 0.82).clamp(240.0, 320.0),
|
||||
onClose: () => ref.read(membersPanelOpenProvider.notifier).state = false,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pyramid/core/appearance.dart';
|
||||
import 'package:pyramid/core/theme.dart';
|
||||
import 'package:pyramid/widgets/spring_press.dart';
|
||||
|
||||
// Simple hover-aware widget for desktop hover effects
|
||||
class HoverRegion extends StatefulWidget {
|
||||
@@ -29,7 +34,10 @@ class _HoverRegionState extends State<HoverRegion> {
|
||||
}
|
||||
}
|
||||
|
||||
// Icon button styled to match Pyramid's .icon-btn
|
||||
// Icon button styled to match Pyramid's .icon-btn.
|
||||
// Handy-Layout (Bernds Feedback 2026-10-07: Symbole zu klein): Symbol 22 px
|
||||
// statt 16, Fläche mind. 40 px, Tippfläche mind. 44 px. Farben folgen dem
|
||||
// gewählten Farbmodus, Druck federt (SpringPress).
|
||||
class PyrIconBtn extends StatefulWidget {
|
||||
final Widget icon;
|
||||
final VoidCallback? onPressed;
|
||||
@@ -39,6 +47,9 @@ class PyrIconBtn extends StatefulWidget {
|
||||
final Color? dangerColor;
|
||||
final double size;
|
||||
|
||||
/// Symbolgröße; ohne Angabe 16 (PC) bzw. 22 (Handy-Layout).
|
||||
final double? iconSize;
|
||||
|
||||
const PyrIconBtn({
|
||||
super.key,
|
||||
required this.icon,
|
||||
@@ -48,6 +59,7 @@ class PyrIconBtn extends StatefulWidget {
|
||||
this.activeColor,
|
||||
this.dangerColor,
|
||||
this.size = 32,
|
||||
this.iconSize,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -59,20 +71,16 @@ class _PyrIconBtnState extends State<PyrIconBtn> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final fgMuted = isDark ? const Color(0xFFA4A4B0) : const Color(0xFF54545E);
|
||||
final fg = isDark ? const Color(0xFFECECF0) : const Color(0xFF1A1A1F);
|
||||
final bgHover = isDark ? const Color(0xFF232330) : const Color(0xFFE9E7E1);
|
||||
final pt = PyramidTheme.of(context);
|
||||
final mobile = isMobileLayout(MediaQuery.sizeOf(context));
|
||||
|
||||
final color = widget.active
|
||||
? (widget.activeColor ?? const Color(0xFFF5A614))
|
||||
: _hovered ? fg : fgMuted;
|
||||
? (widget.activeColor ?? pt.accent)
|
||||
: _hovered ? pt.fg : pt.fgMuted;
|
||||
|
||||
final platform = Theme.of(context).platform;
|
||||
final isMobile = platform == TargetPlatform.android || platform == TargetPlatform.iOS;
|
||||
// On mobile, use a slightly larger touch target, but respect the requested size
|
||||
final touchSize = isMobile ? (widget.size + 8).clamp(32.0, 48.0) : widget.size;
|
||||
final boxSize = mobile ? math.max(widget.size, 40.0) : widget.size;
|
||||
final touchSize = mobile ? math.max(boxSize, 44.0) : boxSize;
|
||||
final iconSize = widget.iconSize ?? (mobile ? 22.0 : 16.0);
|
||||
|
||||
final btn = MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
@@ -80,23 +88,26 @@ class _PyrIconBtnState extends State<PyrIconBtn> {
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onPressed,
|
||||
child: SizedBox(
|
||||
width: touchSize,
|
||||
height: touchSize,
|
||||
child: Center(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: Curves.easeOutCubic,
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
decoration: BoxDecoration(
|
||||
color: _hovered ? bgHover : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: Center(
|
||||
child: IconTheme(
|
||||
data: IconThemeData(color: color, size: 16),
|
||||
child: widget.icon,
|
||||
child: SpringPress(
|
||||
enabled: widget.onPressed != null,
|
||||
child: SizedBox(
|
||||
width: touchSize,
|
||||
height: touchSize,
|
||||
child: Center(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: Curves.easeOutCubic,
|
||||
width: boxSize,
|
||||
height: boxSize,
|
||||
decoration: BoxDecoration(
|
||||
color: _hovered ? pt.bgHover : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(mobile ? boxSize / 2 : 7),
|
||||
),
|
||||
child: Center(
|
||||
child: IconTheme(
|
||||
data: IconThemeData(color: color, size: iconSize),
|
||||
child: widget.icon,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Meldet die gezeichnete Größe des Kindes, sobald sie sich ändert (z. B.
|
||||
/// die Höhe der schwebenden Eingabeleiste, die mit Antwort-Leiste oder
|
||||
/// mehrzeiligem Text wächst). Die Meldung kommt nach dem Layout, damit der
|
||||
/// Empfänger gefahrlos setState aufrufen kann.
|
||||
class MeasureSize extends SingleChildRenderObjectWidget {
|
||||
const MeasureSize({super.key, required this.onChange, super.child});
|
||||
|
||||
final ValueChanged<Size> onChange;
|
||||
|
||||
@override
|
||||
RenderObject createRenderObject(BuildContext context) =>
|
||||
_MeasureSizeRender(onChange);
|
||||
|
||||
@override
|
||||
void updateRenderObject(BuildContext context, RenderObject renderObject) {
|
||||
(renderObject as _MeasureSizeRender).onChange = onChange;
|
||||
}
|
||||
}
|
||||
|
||||
class _MeasureSizeRender extends RenderProxyBox {
|
||||
_MeasureSizeRender(this.onChange);
|
||||
|
||||
ValueChanged<Size> onChange;
|
||||
Size? _last;
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
super.performLayout();
|
||||
final size = this.size;
|
||||
if (size == _last) return;
|
||||
_last = size;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => onChange(size));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,7 @@ class _ScreenSharePickerState extends State<ScreenSharePicker> with SingleTicker
|
||||
padding: const EdgeInsets.fromLTRB(24, 20, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text('Share your screen',
|
||||
Text('Bildschirm teilen',
|
||||
style: TextStyle(color: pt.fg, fontSize: 20, fontWeight: FontWeight.w700)),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
@@ -97,8 +97,8 @@ class _ScreenSharePickerState extends State<ScreenSharePicker> with SingleTicker
|
||||
unselectedLabelColor: pt.fgMuted,
|
||||
dividerColor: pt.border,
|
||||
tabs: const [
|
||||
Tab(text: 'Screens'),
|
||||
Tab(text: 'Windows'),
|
||||
Tab(text: 'Bildschirme'),
|
||||
Tab(text: 'Fenster'),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -124,11 +124,34 @@ class _ScreenSharePickerState extends State<ScreenSharePicker> with SingleTicker
|
||||
border: Border(top: BorderSide(color: pt.border)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
// Bekannte WebRTC-Grenze unter Windows: Bei eingeschaltetem
|
||||
// HDR liefert die Bildschirmaufnahme (DXGI) ein blasses,
|
||||
// zu helles Bild – Zoom/Discord haben dasselbe. Abhilfe:
|
||||
// Fenster teilen oder HDR kurz umschalten (Bernd 2026-10-08).
|
||||
if (Theme.of(context).platform == TargetPlatform.windows)
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.hdr_on_rounded, size: 18, color: pt.fgDim),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'HDR an? Dann wirkt ein geteilter Bildschirm blass. '
|
||||
'Besser ein Fenster teilen oder HDR kurz umschalten '
|
||||
'(Win + Alt + B).',
|
||||
style: TextStyle(color: pt.fgDim, fontSize: 11.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
const Spacer(),
|
||||
const SizedBox(width: 12),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text('Cancel', style: TextStyle(color: pt.fgMuted)),
|
||||
child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -149,7 +172,7 @@ class _SourceGrid extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (sources.isEmpty) {
|
||||
return Center(child: Text('No sources found', style: TextStyle(color: pt.fgDim)));
|
||||
return Center(child: Text('Keine Quellen gefunden', style: TextStyle(color: pt.fgDim)));
|
||||
}
|
||||
|
||||
return GridView.builder(
|
||||
|
||||
@@ -270,6 +270,22 @@ class _VoiceSectionState extends ConsumerState<_VoiceSection> {
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
_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: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
child: Column(
|
||||
|
||||
@@ -36,6 +36,7 @@ import 'package:pyramid/widgets/update_download_dialog.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:pyramid/widgets/banner_crop_dialog.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/pyramid_loader.dart';
|
||||
|
||||
|
||||
@@ -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
@@ -202,7 +202,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
dart_jsonwebtoken:
|
||||
dependency: "direct main"
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dart_jsonwebtoken
|
||||
sha256: ad84e60181696513d04d5f2078e0bbc20365b911f46f647797317414bdc88fbe
|
||||
@@ -400,14 +400,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_markdown:
|
||||
flutter_markdown_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_markdown
|
||||
sha256: "08fb8315236099ff8e90cb87bb2b935e0a724a3af1623000a9cec930468e0f27"
|
||||
name: flutter_markdown_plus
|
||||
sha256: fce641d6c2106cc495de1cd603f97a4f9d615a97a64011928ded380dbdade935
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.7+1"
|
||||
version: "1.0.12"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+2
-3
@@ -1,7 +1,7 @@
|
||||
name: pyramid
|
||||
description: A flexible Matrix client with resizable panel layout.
|
||||
publish_to: none
|
||||
version: 0.1.37+38
|
||||
version: 0.1.41+42
|
||||
environment:
|
||||
sdk: ">=3.11.1 <4.0.0"
|
||||
|
||||
@@ -26,7 +26,6 @@ dependencies:
|
||||
livekit_client: ^2.4.0
|
||||
flutter_webrtc: ^1.4.1
|
||||
webrtc_interface: ^1.5.1
|
||||
dart_jsonwebtoken: ^3.3.1
|
||||
|
||||
# State management & routing
|
||||
flutter_riverpod: ^2.6.1
|
||||
@@ -68,7 +67,7 @@ dependencies:
|
||||
# Document viewer
|
||||
pdfx: ^2.7.0
|
||||
archive: ^4.0.0
|
||||
flutter_markdown: ^0.7.7
|
||||
flutter_markdown_plus: ^1.0.3
|
||||
|
||||
# Clipboard image paste (Windows FFI) & image decoding
|
||||
ffi: ^2.1.0
|
||||
|
||||
+18
-3
@@ -109,15 +109,30 @@ if ($Ui) {
|
||||
$uiProfile = Join-Path $env:USERPROFILE ".pyramid-autopilot\profile1"
|
||||
if (-not (Test-Path (Join-Path $uiProfile "pyramid.sqlite"))) {
|
||||
Add-Skipped "UI-Durchlauf (Handy-Format)" "Testprofil fehlt: $uiProfile"
|
||||
} elseif (Get-Process pyramid -ErrorAction SilentlyContinue) {
|
||||
# Nie zwei Matrix-Clients auf derselben DB – und Bernds Fenster nicht stören.
|
||||
Add-Skipped "UI-Durchlauf (Handy-Format)" "Pyramid läuft gerade – erst beenden"
|
||||
} elseif (Get-Process pyramid -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Path -like "*\build\windows\*" }) {
|
||||
# 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 {
|
||||
$env:PYRAMID_PROFILE_DIR = $uiProfile
|
||||
try {
|
||||
Invoke-Step "UI-Durchlauf (Handy-Format, Testprofil)" {
|
||||
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
|
||||
}
|
||||
# Nutzt kurz Mikrofon + Bildschirm (nur lokal zwischen zwei Testkonten).
|
||||
Invoke-Step "Live: 1:1-Anruf, Ton + Bildschirm teilen (2 Testkonten)" {
|
||||
flutter test integration_test/live_call_test.dart -d windows
|
||||
}
|
||||
} else {
|
||||
Add-Skipped "Live: Lesebestätigung + Einladung" "test-accounts.txt fehlt"
|
||||
}
|
||||
} finally {
|
||||
Remove-Item Env:PYRAMID_PROFILE_DIR -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:pyramid/core/livekit_token.dart';
|
||||
|
||||
/// Beitritts-Token kommt vom Server; das LiveKit-Secret steckt nicht mehr
|
||||
/// in der App (docs/LIVEKIT_TOKEN_MIGRATION.md).
|
||||
void main() {
|
||||
test('schickt Raum + Matrix-Token und liefert Token und Adresse', () async {
|
||||
late Map<String, dynamic> sent;
|
||||
final client = MockClient((req) async {
|
||||
expect(req.url.toString(), LiveKitTokenService.endpoint);
|
||||
sent = jsonDecode(req.body) as Map<String, dynamic>;
|
||||
return http.Response(
|
||||
jsonEncode({'token': 'abc.def.ghi', 'url': 'wss://lk.example'}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
final r = await LiveKitTokenService.fetch(
|
||||
room: '!raum:server',
|
||||
matrixToken: 'mt',
|
||||
httpClient: client,
|
||||
);
|
||||
expect(sent, {'room': '!raum:server', 'matrix_token': 'mt'});
|
||||
expect(r.token, 'abc.def.ghi');
|
||||
expect(r.url, 'wss://lk.example');
|
||||
});
|
||||
|
||||
test('ohne brauchbare Adresse: Standard-LiveKit', () async {
|
||||
final client = MockClient(
|
||||
(_) async => http.Response(jsonEncode({'token': 't'}), 200),
|
||||
);
|
||||
final r = await LiveKitTokenService.fetch(
|
||||
room: 'r',
|
||||
matrixToken: 'm',
|
||||
httpClient: client,
|
||||
);
|
||||
expect(r.url, LiveKitTokenService.fallbackUrl);
|
||||
});
|
||||
|
||||
Future<String> failureFor(MockClient client) async {
|
||||
try {
|
||||
await LiveKitTokenService.fetch(
|
||||
room: 'r',
|
||||
matrixToken: 'm',
|
||||
httpClient: client,
|
||||
);
|
||||
} on LiveKitTokenException catch (e) {
|
||||
return e.message;
|
||||
}
|
||||
fail('LiveKitTokenException erwartet');
|
||||
}
|
||||
|
||||
test('Fehler werden verständlich gemeldet statt still geschluckt', () async {
|
||||
expect(
|
||||
await failureFor(MockClient((_) async => http.Response('{}', 401))),
|
||||
contains('neu anmelden'),
|
||||
);
|
||||
expect(
|
||||
await failureFor(MockClient((_) async => http.Response('kaputt', 500))),
|
||||
contains('HTTP 500'),
|
||||
);
|
||||
expect(
|
||||
await failureFor(MockClient((_) async => http.Response('{}', 200))),
|
||||
contains('kein Zugangstoken'),
|
||||
);
|
||||
expect(
|
||||
await failureFor(MockClient((_) async => http.Response('<html>', 200))),
|
||||
contains('unlesbare'),
|
||||
);
|
||||
expect(
|
||||
await failureFor(
|
||||
MockClient((_) async => throw const SocketException('offline')),
|
||||
),
|
||||
contains('nicht erreichbar'),
|
||||
);
|
||||
});
|
||||
|
||||
test('App-Code enthält kein LiveKit-Secret und signiert nicht selbst', () {
|
||||
final src = File('lib/core/livekit_token.dart').readAsStringSync();
|
||||
expect(src, isNot(contains('apiSecret')));
|
||||
expect(src, isNot(contains('jsonwebtoken')));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,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')),
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,14 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
|
||||
FlutterWindow window(project);
|
||||
Win32Window::Point origin(10, 10);
|
||||
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;
|
||||
}
|
||||
window.SetQuitOnClose(true);
|
||||
|
||||
Reference in New Issue
Block a user