Compare commits
+186
@@ -13,6 +13,192 @@ Schritt (und beim Abbruch mitten im Schritt den Zwischenstand). Format:
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-10 (Teil 2) – Anrufansicht als „aufgeblasene“ Formen → 0.1.43
|
||||
|
||||
**Erledigt (ab84701, Gitea-Vorabversion v0.1.43):** Bernd wollte statt runder
|
||||
Kugeln Formen, die „in die Grenzen ihres Fensters aufgeblasen“ sind (Referenz:
|
||||
aufgepumpte Buchstaben in einer Kiste), vorerst nur als Kontur, animiert,
|
||||
kleiner je mehr Leute. Umgesetzt mit `lib/features/call_ui/inflated_layout.dart`
|
||||
(Potenzdiagramm im Anrufbereich, Lloyd + sanfter Zug ins Raster, Flächen per
|
||||
Gewicht nachgeführt, Sprecher 1,7-fach, Neue pumpen sich aus winzig auf,
|
||||
Gehende schrumpfen weg, gerundete Ecken) und `view/bubble_stage.dart`
|
||||
(Kontur + Innenleuchten, Profilbild mit Kontur + Name, Sprecher grün,
|
||||
Kamera füllt die Form). Tests: `test/inflated_layout_test.dart` (7);
|
||||
Vorschaubilder + Animations-GIF: `integration_test/call_bubbles_demo_test.dart`
|
||||
→ `build/call_demo/` (GIF per scratchpad `make_gif.py`). Alte Kugel-Physik
|
||||
entfernt. Analyse + 76 Unit-Tests + UI-Tour grün.
|
||||
|
||||
**Stolperfalle:** Multiplikative Gewichtsanpassung (Voronoi-Treemap-Rezept)
|
||||
startet bei Gewicht ≈ 0 extrem langsam → Sprecher wurde erst nach Sekunden
|
||||
größer. Additiv (Gewicht += Flächenfehler, gedämpft) konvergiert in ~1 s.
|
||||
|
||||
**Offen:** Bernds Urteil zum neuen Look (Glanz/Füllung später?) und zur
|
||||
Navigation; Gruppenanruf auf echten Geräten.
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-10 – UI-Neuanstrich: Chats, Gruppenanrufe, Bubbles, untere Leiste → 0.1.42
|
||||
|
||||
**Erledigt:**
|
||||
- **UI-Audit** (87b472a, `docs/UI_AUDIT.md`): Navigationskarte, Sackgassen
|
||||
S1–S6 behoben (Anruf endet → zurück zum Chat statt englischer Leerseite,
|
||||
Fehler sichtbar, Threads-Knopf weg, Space öffnet ersten Kanal am PC, keine
|
||||
doppelte Anrufsteuerung), englische Reste, Annehmen-Fenster mit Avatar.
|
||||
Screenshot-Tour `integration_test/ui_audit_tour_test.dart` (Handy + PC).
|
||||
- **Bernds Entscheidungen (2026-10-09) umgesetzt** (de24053, 391ebf0):
|
||||
- „Alles in Chats“: DMs + Gruppen ohne Server in einer Liste nach
|
||||
Aktivität, Filter-Chips, Leerzustände mit Handlung; virtuelles „Räume“
|
||||
entfällt, Server-Kanäle nur im Server (`core/room_kinds.dart`).
|
||||
- Gruppenanruf (`features/group_call/group_call.dart`): Anruf-/Video-Knopf
|
||||
im Gruppenkopf, LiveKit + Anwesenheit (`io.pyramid.voice.presence`),
|
||||
Start-Nachricht als m.text (Push; m.notice wäre stumm), Karte
|
||||
„Gruppenanruf läuft · Beitreten“, Hinweis-Karte im Verlauf. Berechtigung
|
||||
für die Anwesenheit wird bei Bedarf gesenkt (nur wer Rechte ändern darf;
|
||||
neue Gruppen beim Erstellen).
|
||||
- Bubble-Anrufansicht (`call_ui/bubble_physics.dart` + `view/bubble_stage.dart`):
|
||||
Federphysik, Abflachung an der Potenzgerade, Sprecher wächst + leuchtet,
|
||||
Kacheln umschaltbar (gemerkt), Bildschirm automatisch groß,
|
||||
Gesprächsdauer, Matrix-Namen/-Avatare statt LiveKit-IDs. Unit-Test
|
||||
`test/bubble_physics_test.dart`.
|
||||
- Untere Leiste als Option (`layout/mobile_tabs.dart`, Einstellung
|
||||
Erscheinungsbild → Navigation; Standard bleibt Seitenmenü): Chats ·
|
||||
Server (Übersicht als Karten) · Du (Profil + Einstellungen).
|
||||
- Gruppen mit rundem Avatar + Mitgliederzahl, Sprachkanal-Kacheln mit
|
||||
„wer ist drin“, Suchfeld-Lupe, „#“ nur bei Kanälen.
|
||||
- Vergleichsbilder für Bernd: `build/ui_vergleich/` (aus der Tour).
|
||||
- Tests: `scripts/test.ps1 -Ui` grün (Analyse, 75 Unit, Handy-UI, Live
|
||||
Lesebestätigung/Einladung, Live-Anruf); Tour grün inkl. echtem zweitem
|
||||
LiveKit-Teilnehmer im Gruppenanruf.
|
||||
- Gitea-Vorabversion **v0.1.42** (Signatur = v0.1.36), PC-Testinstanz in
|
||||
`pyramid-testinstanzpp-neu` bereitgelegt.
|
||||
|
||||
**Offen/Nächster Schritt:** Bernds Urteil Seitenmenü vs. untere Leiste;
|
||||
Gruppenanruf auf echten Geräten (Handy ↔ PC-Testinstanz); Rest aus
|
||||
UI_AUDIT §4 (Schnackbar-Stil, kompaktere Anruf-Pille).
|
||||
|
||||
**Stolperfallen:**
|
||||
- `pumpFor` zeichnet im Integrationstest nur alle 100 ms → Physik/Animationen
|
||||
kommen nicht zur Ruhe; Tour pumpt für Screenshots mit 16 ms.
|
||||
- Zustand der Anrufansicht geht beim Wechsel Handy↔PC-Layout verloren →
|
||||
Ansichtswahl statisch gemerkt.
|
||||
- `addTearDown` darf nach dem Test keinen Provider mehr lesen (Container
|
||||
schon entsorgt).
|
||||
- Bash-Python-Patches: Ausgabe von Emojis scheitert an cp1252 (Datei wird
|
||||
trotzdem geschrieben).
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-08 (Teil 3) – Taub/Stumm, Mini-Leiste, HDR-Befund → 0.1.41
|
||||
|
||||
**Erledigt (f46cc22, Version 5204bb8, Gitea-Vorabversion v0.1.41):** Bernd hört
|
||||
sich jetzt auf beiden Seiten. Behoben: Taub-Knopf war bei 1:1 wirkungslos
|
||||
(nur Häkchen) → Empfangsspuren aus + selbst stumm wie Discord; Stumm-Knopf
|
||||
schaltete bei Fehler im Status-Senden blind zurück (zeigte dann das
|
||||
Gegenteil) → echter SDK-Zustand; Mini-Anrufleiste erschien schon beim
|
||||
Klingeln. Live-Anruftest drückt Stumm/Taub über die echten Knöpfe.
|
||||
|
||||
**HDR-Befund:** Blasses/zu helles Bild beim Teilen unter Windows mit HDR ist
|
||||
eine bekannte Grenze der WebRTC-DXGI-Aufnahme (Zoom/Discord gleich; Chrome
|
||||
erst mit Windows Graphics Capture ab Win11 24H2). flutter_webrtc 1.4.1 /
|
||||
libwebrtc-Wrapper bietet keine Capture-Optionen (`CreateDesktopCapturer(source,
|
||||
showCursor)`). Jetzt Hinweis im Auswähler (Fenster teilen oder Win+Alt+B).
|
||||
Echte Lösung = eigenes natives WGC-Aufnahmemodul mit Tonemapping → ROADMAP.
|
||||
|
||||
---
|
||||
|
||||
## 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):**
|
||||
|
||||
@@ -317,6 +317,12 @@ lassen, ohne dass andere Module angefasst werden müssen.
|
||||
|
||||
## M3 – Calls: Logik, UI & Audio (Discord als Referenz)
|
||||
|
||||
- [ ] **Bildschirm teilen mit HDR (Windows):** WebRTC-DXGI-Aufnahme liefert bei
|
||||
eingeschaltetem HDR ein blasses Bild (bekannte Grenze, Zoom/Discord gleich).
|
||||
Lösung: eigenes Aufnahmemodul über Windows Graphics Capture mit
|
||||
HDR→SDR-Tonemapping (wie Chrome ab Win11 24H2) oder neuere libwebrtc mit
|
||||
WGC-Option. Bis dahin Hinweis im Auswähler (Fenster teilen / Win+Alt+B).
|
||||
|
||||
- [ ] Voice-Channel-Logik robust machen: Beitreten/Verlassen/Wechseln ohne Hänger,
|
||||
klarer Zustand wer drin ist, sauberes Verhalten bei Verbindungsabbruch
|
||||
**Teilfortschritt 2026-07-06 (Nacht, mit 2 echten Test-Accounts durchgeklickt):**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# UI/UX-Audit Pyramid (2026-10-08)
|
||||
|
||||
Grundlage: Code-Analyse der Navigation + automatische Screenshot-Tour
|
||||
`integration_test/ui_audit_tour_test.dart` (35 Zustände, Handy 1080×2340 und
|
||||
PC 1600×1000, zwei Testkonten, Bilder in `build/ui_audit/`, `p_*` = Handy,
|
||||
`d_*` = PC). Neu erzeugen:
|
||||
|
||||
```
|
||||
$env:PYRAMID_PROFILE_DIR="$env:USERPROFILE\.pyramid-autopilot\profile1"
|
||||
flutter test integration_test/ui_audit_tour_test.dart -d windows
|
||||
```
|
||||
|
||||
## 1. Wie die Navigation heute funktioniert
|
||||
|
||||
Vier Zustände steuern, was man sieht (`lib/core/app_state.dart`):
|
||||
|
||||
| Zustand | Werte | Wirkung |
|
||||
|---|---|---|
|
||||
| `activeSpaceIdProvider` | `dms`, `rooms`, Space-ID | welche Liste links/unten steht |
|
||||
| `activeRoomIdProvider` | Raum-ID oder leer | welcher Chat offen ist |
|
||||
| `viewModeProvider` | `chat`, `voice` | Hauptbereich zeigt Chat **oder** Anrufansicht |
|
||||
| `activeModalProvider` | `none`, `settings`, `search` | Overlay |
|
||||
|
||||
**Handy:** Hamburger öffnet ein schmales Seitenmenü (unbeschriftet: Logo =
|
||||
Chats, orangenes „R“ = Räume, Space-Kürzel, „+“, Zahnrad). Darunter die
|
||||
Liste; ein Chat gleitet darüber; „Anruf“ ist ein weiterer Vordergrund.
|
||||
**PC:** Leiste (Spaces) + Liste + Hauptbereich (+ Mitglieder rechts).
|
||||
|
||||
Was wohin führt:
|
||||
- Liste → Chat (alle Listen), Sprachkanal antippen → beitreten (Mini-Leiste
|
||||
unten), nochmal antippen → verlassen.
|
||||
- Chat-Kopf (nur DM) → Anruf/Video → Anrufansicht. **Gruppen: kein Anruf.**
|
||||
- ⋮ (Handy) → Suche, Mitglieder. PC-Kopf: Threads (ohne Funktion), Anheften,
|
||||
Suche, Mitglieder.
|
||||
- Mini-Leiste → Anrufansicht; Anrufansicht „Minimieren“ → zurück zum Chat.
|
||||
- Zahnrad (Profilbalken unten **und** Seitenmenü) → Einstellungen.
|
||||
|
||||
## 2. Sackgassen und tote Elemente (alle behoben, siehe 5.)
|
||||
|
||||
| # | Wo | Was passiert | Schwere |
|
||||
|---|---|---|---|
|
||||
| S1 | Gegenseite legt auf / Anruf scheitert / Sprachkanal bricht ab | Ansicht bleibt auf „Anruf“: englische Leerseite „No active call – Join a voice room“, Handy ohne Zurück (p_18) | hoch |
|
||||
| S2 | dieselbe Leerseite | LiveKit-Fehlermeldung wird dort nicht angezeigt (stiller Fehler) | hoch |
|
||||
| S3 | Gruppenchat (Handy + PC) | kein Anruf-Knopf – Gruppenanruf-Code nicht erreichbar (p_06, d_06) | hoch |
|
||||
| S4 | PC-Chatkopf „Threads“ | Knopf ohne Funktion | mittel |
|
||||
| S5 | Space betreten (PC) | rechts nur „Wähle einen Chat aus“, letzter/erster Kanal öffnet nicht (d_04) | mittel |
|
||||
| S6 | Anruf läuft (PC) | Steuerung doppelt: Anrufansicht + Mini-Leiste links (d_15) | niedrig |
|
||||
|
||||
## 3. Befunde nach Bereich
|
||||
|
||||
**Listen**
|
||||
- Haupt-Navigation am Handy versteckt im Seitenmenü, Symbole unbeschriftet
|
||||
und teils kryptisch (orangenes „R“-Dreieck für „Räume“) (p_02).
|
||||
- „Räume“ listet auch alle Kanäle aus Spaces → doppelt; Sprachkanäle hängen
|
||||
ohne Überschrift unten, sehen anders aus als Chats (p_03, p_17).
|
||||
- Suchfeld ohne Lupe, eingerückter Text.
|
||||
- Profilbalken unten + Anruf-Leiste (zweizeilig) belegen am Handy ~25 % der
|
||||
Höhe (p_12, p_17).
|
||||
|
||||
**Chats**
|
||||
- Gruppen sehen aus wie „#Kanal“ (Kopf, Liste) statt wie Gruppen.
|
||||
- Leerer Chat englisch („No messages yet“), PC-Leerzustand halb englisch.
|
||||
- Mitgliederliste: eckige Avatare (sonst rund), man selbst nicht als „Du“.
|
||||
|
||||
**Anrufe**
|
||||
- Anrufansicht für reine Sprachanrufe: riesige schwarze Video-Kachel mit
|
||||
Initial (p_15). Eigene Kachel zeigt „D“ (von „Du“) statt eigenem Avatar.
|
||||
Keine Gesprächsdauer.
|
||||
- Kopf-Symbole „Hero/Grid“ und „Minimieren“ unklar (englische Tooltips).
|
||||
- Mini-Leiste „VOICE CONNECTED“ (englisch) auch bei 1:1-Anrufen; Mikro/Kopfhörer
|
||||
lila gefüllt = „an“ – liest sich wie Warnung; in der Anrufansicht ist die
|
||||
Logik umgekehrt (rot = stumm).
|
||||
- Annehmen-Fenster: Platzhalter-Symbol statt Avatar, kein „Sprach-/Videoanruf“.
|
||||
|
||||
**Allgemein**
|
||||
- Englische Reste (Bildschirm-Auswähler erledigt; Anrufansicht, Leerzustände).
|
||||
- Statusleisten-Streifen in anderer Farbe über Overlays (p_07, p_09).
|
||||
- „Tastaturkürzel“ in den Handy-Einstellungen.
|
||||
|
||||
## 4. Konzept: modern, aufgeräumt, übersichtlich
|
||||
|
||||
Leitlinie: **WhatsApp für Chats, Discord für Server/Sprachkanäle** – beides
|
||||
in einer klaren Struktur, nichts doppelt, kein Zustand ohne Weg zurück.
|
||||
|
||||
1. **Navigation Handy:** untere Leiste mit beschrifteten Tabs statt
|
||||
Seitenmenü – *Chats* (DMs + Gruppen zusammen, nach Aktivität), *Server*
|
||||
(Spaces als Karten → Kanäle, Sprachkanäle mit Live-Teilnehmern), *Du*
|
||||
(Profil, Status, Einstellungen). Profilbalken unten entfällt.
|
||||
2. **Listen:** einheitliche Kacheln; Space-Kanäle gruppiert in „Textkanäle“ /
|
||||
„Sprachkanäle“; Sprachkanal-Kachel zeigt Teilnehmer-Avatare; Kanäle eines
|
||||
Spaces erscheinen nicht zusätzlich unter „Räume“.
|
||||
3. **Anruf-Logik (ein Modell):** 1:1-Anruf, Gruppenanruf (Gruppenchat) und
|
||||
Sprachkanal (Server) teilen eine Anrufansicht und eine kompakte
|
||||
„Anruf-Pille“. Anruf endet → automatisch zurück zum Chat, mit kurzem
|
||||
Hinweis („Anruf beendet · 3:12“).
|
||||
4. **Anrufansicht:** Sprachanruf = große runde Avatare mit Sprech-Ring,
|
||||
Name, Dauer (wie WhatsApp/Discord); Video/Bildschirm = Kacheln bzw.
|
||||
großes Bild + Filmstreifen. Steuerung einheitlich: neutral = an,
|
||||
rot durchgestrichen = aus.
|
||||
5. **Visuell:** runde Avatare überall (echte Bilder), Kopfzeilen mit Avatar +
|
||||
Untertitel (Mitgliederzahl/online), Leerzustände mit Handlung („Schreib die
|
||||
erste Nachricht“), ruhigere Flächen (Karten statt Linien), einheitliche
|
||||
Abstände/Radien, vollständig deutsch.
|
||||
|
||||
## 5. Sofort behoben (diese Runde)
|
||||
|
||||
S1–S6, englische Reste in Anruf/Leerzuständen, Annehmen-Fenster mit Avatar
|
||||
und Anrufart, eigene Kachel mit eigenem Avatar, „Du“ in der Mitgliederliste,
|
||||
„Tastaturkürzel“ nur am PC – Details in PROGRESS.md.
|
||||
|
||||
## 6. Bernds Entscheidungen (2026-10-09) und Umsetzung (0.1.42)
|
||||
|
||||
| Frage | Entscheidung | Umsetzung |
|
||||
|---|---|---|
|
||||
| Navigation Handy | Seitenmenü; untere Leiste als Mockup zum Vergleich | Einstellung „Navigation“ (Standard Seitenmenü), `layout/mobile_tabs.dart`, Vergleichsbild `build/ui_vergleich/1_navigation_vergleich.png` |
|
||||
| Chatliste | Alles in „Chats“ | DMs + Gruppen ohne Server zusammen, Filter-Chips; Server-Kanäle nur im Server |
|
||||
| Gruppenanruf | Anruf + Beitreten-Karte | Knopf im Gruppenkopf, Start-Nachricht (Push), Karte „Gruppenanruf läuft · Beitreten“ |
|
||||
| Anrufansicht | animierte, aneinandergedrückte Bubbles → (2026-10-10) „aufgeblasene“ Formen bis an die Fensterränder, erst nur Kontur | `call_ui/inflated_layout.dart` + `view/bubble_stage.dart`, Kacheln umschaltbar, Vorschau `integration_test/call_bubbles_demo_test.dart` |
|
||||
|
||||
Noch offen aus §4: einheitliche Hinweis-Meldungen (Snackbar-Stil), kompaktere
|
||||
Anruf-Pille, Abschnitts-Überschriften Text/Sprache in Server-Listen.
|
||||
@@ -0,0 +1,52 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:pyramid/core/call_foreground.dart';
|
||||
|
||||
/// Android 14+: Bildschirmübertragung darf die App nicht abstürzen lassen
|
||||
/// (Bernds Test 2026-10-07: „Teilen“ → App weg → Anruf verloren).
|
||||
///
|
||||
/// Ablauf wie im Anruf: Zustimmung → Anruf-Dienst mit mediaProjection →
|
||||
/// Mitschnitt. Der System-Dialog muss bestätigt werden (auf dem Emulator per
|
||||
/// adb, siehe docs/TESTS_UND_BENCHMARKS.md).
|
||||
///
|
||||
/// flutter test integration_test/android_screen_capture_test.dart -d emulator-5554
|
||||
///
|
||||
/// PYRAMID_SCREEN_NEGATIVE=1 (per --dart-define) überspringt den Dienst –
|
||||
/// Gegenprobe: dann stürzt die App wie früher ab.
|
||||
const _negative = bool.fromEnvironment('PYRAMID_SCREEN_NEGATIVE');
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets(
|
||||
'Bildschirmübertragung startet ohne Absturz',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(const MaterialApp(home: Scaffold()));
|
||||
if (_negative) {
|
||||
expect(await Helper.requestCapturePermission(), isTrue);
|
||||
} else {
|
||||
expect(
|
||||
await CallForeground.prepareScreenCapture('test'),
|
||||
isTrue,
|
||||
reason: 'Zustimmung oder Dienststart fehlgeschlagen',
|
||||
);
|
||||
}
|
||||
final stream = await navigator.mediaDevices
|
||||
.getDisplayMedia({'video': true, 'audio': false});
|
||||
expect(stream.getVideoTracks(), isNotEmpty);
|
||||
await Future.delayed(const Duration(seconds: 3));
|
||||
for (final t in stream.getTracks()) {
|
||||
await t.stop();
|
||||
}
|
||||
await stream.dispose();
|
||||
await CallForeground.screenCaptureStopped('test');
|
||||
await CallForeground.callEnded('test');
|
||||
},
|
||||
skip: !Platform.isAndroid,
|
||||
timeout: const Timeout(Duration(minutes: 3)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:pyramid/core/theme.dart';
|
||||
import 'package:pyramid/features/call_ui/voice_channel.dart' show CallBubblesDemo;
|
||||
|
||||
/// Vorschaubilder der Anrufansicht mit erfundenen Teilnehmern (ohne Login):
|
||||
/// Standbilder für 1–7 Personen (Handy + PC) und eine Bildfolge der
|
||||
/// Animation (Sprecherwechsel, jemand kommt dazu, jemand geht) für ein GIF.
|
||||
/// Bilder: build/call_demo/.
|
||||
///
|
||||
/// flutter test integration_test/call_bubbles_demo_test.dart -d windows
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('Anruf-Formen: Vorschaubilder', (tester) async {
|
||||
final count = ValueNotifier(5);
|
||||
final speaking = ValueNotifier(2);
|
||||
void phone() => tester.view
|
||||
..physicalSize = const Size(1080, 2340)
|
||||
..devicePixelRatio = 2.625;
|
||||
void desktop() => tester.view
|
||||
..physicalSize = const Size(1600, 1000)
|
||||
..devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
Future<void> smooth(Duration d) async {
|
||||
final end = DateTime.now().add(d);
|
||||
while (DateTime.now().isBefore(end)) {
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
}
|
||||
}
|
||||
|
||||
phone();
|
||||
await tester.pumpWidget(PyramidTheme(
|
||||
child: MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: buildPyramidThemeData(ThemeVariant.soft),
|
||||
home: ValueListenableBuilder<int>(
|
||||
valueListenable: count,
|
||||
builder: (_, n, _) => ValueListenableBuilder<int>(
|
||||
valueListenable: speaking,
|
||||
builder: (_, s, _) => CallBubblesDemo(count: n, speaking: s),
|
||||
),
|
||||
),
|
||||
),
|
||||
));
|
||||
|
||||
// ── Standbilder ──
|
||||
for (final n in [1, 2, 3, 5, 7]) {
|
||||
count.value = n;
|
||||
speaking.value = n >= 3 ? 2 : -1;
|
||||
phone();
|
||||
await smooth(const Duration(milliseconds: 2500));
|
||||
await _shot(tester, 'p_${n}_personen');
|
||||
desktop();
|
||||
await smooth(const Duration(milliseconds: 2500));
|
||||
await _shot(tester, 'd_${n}_personen');
|
||||
}
|
||||
|
||||
// ── Animation (Handy) ──
|
||||
phone();
|
||||
count.value = 4;
|
||||
speaking.value = -1;
|
||||
await smooth(const Duration(seconds: 3));
|
||||
var frame = 0;
|
||||
Future<void> record(Duration d) async {
|
||||
final end = DateTime.now().add(d);
|
||||
while (DateTime.now().isBefore(end)) {
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
await _shot(tester, 'anim/f${(frame++).toString().padLeft(4, '0')}',
|
||||
scale: 0.4, log: false);
|
||||
}
|
||||
}
|
||||
|
||||
await record(const Duration(milliseconds: 600));
|
||||
speaking.value = 1; // Anna spricht
|
||||
await record(const Duration(milliseconds: 1600));
|
||||
speaking.value = 3; // Clara spricht
|
||||
await record(const Duration(milliseconds: 1600));
|
||||
speaking.value = -1;
|
||||
count.value = 5; // David kommt dazu
|
||||
await record(const Duration(milliseconds: 1800));
|
||||
count.value = 4; // David geht wieder
|
||||
await record(const Duration(milliseconds: 1800));
|
||||
// ignore: avoid_print
|
||||
print('Animation: $frame Bilder');
|
||||
}, timeout: const Timeout(Duration(minutes: 6)));
|
||||
}
|
||||
|
||||
Future<void> _shot(WidgetTester tester, String name,
|
||||
{double scale = 1, bool log = true}) async {
|
||||
final renderView = tester.binding.renderViews.first;
|
||||
final layer = renderView.debugLayer! as OffsetLayer;
|
||||
final physical = tester.view.physicalSize;
|
||||
final image = await tester.binding.runAsync(
|
||||
() => layer.toImage(Offset.zero & physical, pixelRatio: scale),
|
||||
);
|
||||
final bytes = await tester.binding.runAsync(
|
||||
() => image!.toByteData(format: ui.ImageByteFormat.png),
|
||||
);
|
||||
final file = File('build/call_demo/$name.png');
|
||||
file.parent.createSync(recursive: true);
|
||||
file.writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
// ignore: avoid_print
|
||||
if (log) print('Bild $name');
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:pyramid/core/matrix_client.dart';
|
||||
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
||||
import 'package:pyramid/main.dart' as app;
|
||||
import 'package:webrtc_interface/webrtc_interface.dart';
|
||||
|
||||
import 'support/test_partner.dart';
|
||||
|
||||
/// Echter 1:1-Anruf zwischen der App (pyramidtest1, Testprofil) und einem
|
||||
/// zweiten Matrix-Client im selben Prozess (pyramidtest2, kurzlebiges Gerät,
|
||||
/// wird am Ende abgemeldet). Bernds Fehlerbericht 2026-10-08: Ton kam nicht
|
||||
/// an, Bildschirm teilen ging weder am PC noch am Handy.
|
||||
///
|
||||
/// Geprüft wird: Verbindung steht, Ton fließt in BEIDE Richtungen (Bytes),
|
||||
/// Bildschirm der Gegenseite wird in der App angezeigt, und die App kann
|
||||
/// über den echten Quellen-Auswähler ihren Bildschirm teilen, der bei der
|
||||
/// Gegenseite ankommt. (Ob das Mikrofon hörbaren Ton liefert, kann ein
|
||||
/// Automat nicht prüfen – dafür gibt es den Mikrofontest und den Sprech-Ring.)
|
||||
///
|
||||
/// $env:PYRAMID_PROFILE_DIR="$env:USERPROFILE\.pyramid-autopilot\profile1"
|
||||
/// flutter test integration_test/live_call_test.dart -d windows
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final profile = Platform.environment['PYRAMID_PROFILE_DIR'];
|
||||
final accounts = loadTestAccounts();
|
||||
|
||||
testWidgets(
|
||||
'1:1-Anruf: Ton beide Richtungen, Bildschirm teilen beide Richtungen',
|
||||
(tester) async {
|
||||
Logs().level = Level.verbose;
|
||||
// app.main() ersetzt FlutterError.onError – danach den Empfänger des
|
||||
// Testrahmens zurücksetzen, sonst verschwinden Fehlermeldungen.
|
||||
final testOnError = FlutterError.onError;
|
||||
app.main();
|
||||
await pumpUntil(tester, () => find.byType(MaterialApp).evaluate().isNotEmpty);
|
||||
FlutterError.onError = testOnError;
|
||||
final container = ProviderScope.containerOf(
|
||||
tester.element(find.byType(MaterialApp).first),
|
||||
);
|
||||
final me = (await tester.runAsync(
|
||||
() => container.read(matrixClientProvider.future),
|
||||
))!;
|
||||
expect(me.userID, '@pyramidtest1:steggi-matrix.work');
|
||||
|
||||
final partner = (await tester.runAsync(
|
||||
() => TestPartner.start(accounts!['user2']!, accounts['pass2']!),
|
||||
))!;
|
||||
String? roomId;
|
||||
addTearDown(() => tester.runAsync(() async {
|
||||
await partner.dispose(roomId);
|
||||
final r = roomId == null ? null : me.getRoomById(roomId);
|
||||
await r?.leave().catchError((_) {});
|
||||
await r?.forget().catchError((_) {});
|
||||
}));
|
||||
|
||||
// Eigener, unverschlüsselter Testraum (nur für diesen Lauf).
|
||||
roomId = await tester.runAsync(() => partner.client.createRoom(
|
||||
invite: [me.userID!],
|
||||
preset: CreateRoomPreset.privateChat,
|
||||
name: 'Anruftest (automatisch)',
|
||||
));
|
||||
logStep('Raum angelegt: $roomId');
|
||||
await pumpUntil(
|
||||
tester,
|
||||
() => me.getRoomById(roomId!)?.membership == Membership.invite,
|
||||
what: 'Einladung kommt bei der App an',
|
||||
);
|
||||
await tester.runAsync(() => me.getRoomById(roomId!)!.join());
|
||||
logStep('App ist beigetreten');
|
||||
// Die Gegenseite ist Ersteller und damit im Raum; ihr Mitglieder-Cache
|
||||
// wird vom SDK nur bei Bedarf geladen – kurz warten genügt.
|
||||
await pumpFor(tester, const Duration(seconds: 3));
|
||||
|
||||
final voip = container.read(callSignalingProvider);
|
||||
|
||||
// ═══ Teil 1: Gegenseite ruft an, App nimmt über den echten Knopf an ═══
|
||||
// (prüft die Kandidaten-Pufferung der App mit einem ungepatchten
|
||||
// Anrufer – genau Bernds Fall „lange/kein Verbindungsaufbau“)
|
||||
logStep('Teil 1: Gegenseite ruft an');
|
||||
await tester.runAsync(() => partner.run(() => partner.voip.inviteToCall(
|
||||
partner.client.getRoomById(roomId!)!,
|
||||
CallType.kVoice,
|
||||
)));
|
||||
final answerBtn = find.byWidgetPredicate(
|
||||
(w) => w is FloatingActionButton && w.heroTag == 'answer_call',
|
||||
);
|
||||
await pumpUntil(tester, () => answerBtn.evaluate().isNotEmpty,
|
||||
what: 'Annehmen-Knopf erscheint');
|
||||
await pumpFor(tester, const Duration(seconds: 2)); // klingeln lassen
|
||||
await tester.tap(answerBtn.first);
|
||||
await _waitConnected(tester, voip, partner);
|
||||
|
||||
await _checkAudio(tester, voip, partner);
|
||||
|
||||
// Stumm über den echten Knopf → Gegenseite sieht „stumm“, Spur ist aus.
|
||||
bool partnerSeesMuted() =>
|
||||
partner.delegate.call?.remoteUserMediaStream?.audioMuted == true;
|
||||
await tester.tap(_ctrlBtn(Icons.mic_rounded));
|
||||
await pumpUntil(tester, partnerSeesMuted,
|
||||
timeout: const Duration(seconds: 15),
|
||||
what: 'Gegenseite sieht: App ist stumm');
|
||||
expect(voip.isMicMuted, isTrue);
|
||||
expect(
|
||||
voip.currentCall!.localUserMediaStream!.stream!.getAudioTracks().first.enabled,
|
||||
isFalse,
|
||||
reason: 'Mikrofonspur ist trotz Stumm noch an',
|
||||
);
|
||||
await tester.tap(_ctrlBtn(Icons.mic_off_rounded));
|
||||
await pumpUntil(tester, () => !partnerSeesMuted() && !voip.isMicMuted,
|
||||
timeout: const Duration(seconds: 15),
|
||||
what: 'Stumm wieder aufgehoben');
|
||||
logStep('Stumm/Entstummen über den Knopf: ok');
|
||||
|
||||
// Taub → Gegenseite nicht mehr hören UND selbst stumm; danach zurück.
|
||||
await tester.tap(_ctrlBtn(Icons.headphones_rounded));
|
||||
await pumpUntil(
|
||||
tester,
|
||||
() => voip.isDeafened && partnerSeesMuted(),
|
||||
timeout: const Duration(seconds: 15),
|
||||
what: 'Taub: selbst stumm',
|
||||
);
|
||||
final remoteAudio = voip.currentCall!.getRemoteStreams
|
||||
.expand((w) => w.stream?.getAudioTracks() ?? const <MediaStreamTrack>[]);
|
||||
expect(remoteAudio, isNotEmpty);
|
||||
expect(remoteAudio.every((t) => !t.enabled), isTrue,
|
||||
reason: 'Ton der Gegenseite ist trotz Taub noch an');
|
||||
await tester.tap(_ctrlBtn(Icons.headset_off_rounded));
|
||||
await pumpUntil(
|
||||
tester,
|
||||
() => !voip.isDeafened && !partnerSeesMuted() && !voip.isMicMuted,
|
||||
timeout: const Duration(seconds: 15),
|
||||
what: 'Taub aufgehoben, Mikro wieder an',
|
||||
);
|
||||
expect(
|
||||
voip.currentCall!.getRemoteStreams
|
||||
.expand((w) => w.stream?.getAudioTracks() ?? const <MediaStreamTrack>[])
|
||||
.every((t) => t.enabled),
|
||||
isTrue,
|
||||
);
|
||||
logStep('Taub/Hören über den Knopf: ok');
|
||||
|
||||
// Gegenseite teilt Bildschirm → App zeigt ihn
|
||||
await tester.runAsync(
|
||||
() => partner.run(() => partner.delegate.call!.setScreensharingEnabled(true)),
|
||||
);
|
||||
await pumpUntil(
|
||||
tester,
|
||||
() =>
|
||||
voip.isRemoteScreensharing &&
|
||||
(voip.remoteRenderer.srcObject?.getVideoTracks().isNotEmpty ?? false),
|
||||
timeout: const Duration(seconds: 30),
|
||||
what: 'Bildschirm der Gegenseite erscheint in der App',
|
||||
);
|
||||
await pumpFor(tester, const Duration(seconds: 3));
|
||||
final appVideoIn = (await tester.runAsync(
|
||||
() => _rtp(voip.currentCall!.pc!, 'video'),
|
||||
))!;
|
||||
logStep('BILD bei der App (von der Gegenseite): $appVideoIn');
|
||||
expect(appVideoIn.received, greaterThan(0),
|
||||
reason: 'Bildschirm der Gegenseite kommt nicht an');
|
||||
await tester.runAsync(
|
||||
() => partner.run(() => partner.delegate.call!.setScreensharingEnabled(false)),
|
||||
);
|
||||
await pumpUntil(tester, () => !voip.isRemoteScreensharing,
|
||||
what: 'Bildschirm der Gegenseite verschwindet wieder');
|
||||
|
||||
// App teilt ihren Bildschirm über den echten Auswähler
|
||||
await pumpUntil(
|
||||
tester,
|
||||
() => find.byIcon(Icons.screen_share_rounded).evaluate().isNotEmpty,
|
||||
what: 'Teilen-Knopf in der Anrufleiste',
|
||||
);
|
||||
await tester.tap(find.byIcon(Icons.screen_share_rounded).first);
|
||||
final tile = find.byWidgetPredicate(
|
||||
(w) => w.runtimeType.toString() == '_SourceTile',
|
||||
);
|
||||
await pumpUntil(tester, () => tile.evaluate().isNotEmpty,
|
||||
timeout: const Duration(seconds: 20), what: 'Quellen-Auswähler');
|
||||
await tester.tap(tile.first);
|
||||
await pumpUntil(
|
||||
tester,
|
||||
() {
|
||||
final s = partner.delegate.call?.remoteScreenSharingStream?.stream;
|
||||
return voip.isScreensharing &&
|
||||
s != null &&
|
||||
s.getVideoTracks().isNotEmpty;
|
||||
},
|
||||
timeout: const Duration(seconds: 30),
|
||||
what: 'Bildschirm der App kommt bei der Gegenseite an',
|
||||
);
|
||||
await pumpFor(tester, const Duration(seconds: 3));
|
||||
final partnerVideoIn = (await tester.runAsync(
|
||||
() => _rtp(partner.delegate.call!.pc!, 'video'),
|
||||
))!;
|
||||
logStep('BILD bei der Gegenseite (von der App): $partnerVideoIn');
|
||||
expect(partnerVideoIn.received, greaterThan(0),
|
||||
reason: 'Bildschirm der App kommt nicht an');
|
||||
|
||||
await tester.runAsync(() => voip.hangup());
|
||||
await pumpUntil(tester, () => voip.currentCall == null,
|
||||
what: 'aufgelegt (Teil 1)');
|
||||
await pumpFor(tester, const Duration(seconds: 3));
|
||||
|
||||
// ═══ Teil 2: App ruft an, Gegenseite nimmt an ═══
|
||||
logStep('Teil 2: App ruft an');
|
||||
partner.fixCandidates = true; // Gegenseite wie eine gepatchte Pyramid
|
||||
await tester.runAsync(() => voip.startCall(roomId!, video: false));
|
||||
await _waitConnected(tester, voip, partner);
|
||||
await _checkAudio(tester, voip, partner);
|
||||
await tester.runAsync(() => voip.hangup());
|
||||
await pumpUntil(tester, () => voip.currentCall == null,
|
||||
what: 'aufgelegt (Teil 2)');
|
||||
},
|
||||
skip: profile == null || accounts == null,
|
||||
timeout: const Timeout(Duration(minutes: 5)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _waitConnected(
|
||||
WidgetTester tester,
|
||||
CallSignalingService voip,
|
||||
TestPartner partner,
|
||||
) async {
|
||||
final t0 = DateTime.now();
|
||||
await pumpUntil(
|
||||
tester,
|
||||
() =>
|
||||
voip.currentCall?.state == CallState.kConnected &&
|
||||
partner.delegate.call?.state == CallState.kConnected,
|
||||
timeout: const Duration(seconds: 60),
|
||||
what: 'Anruf verbunden',
|
||||
every5s: () {
|
||||
final a = voip.currentCall;
|
||||
final b = partner.delegate.call;
|
||||
logStep('App: ${a?.state} ice=${a?.pc?.iceConnectionState} | '
|
||||
'Gegenseite: ${b?.state} ice=${b?.pc?.iceConnectionState}');
|
||||
},
|
||||
);
|
||||
logStep('verbunden nach ${DateTime.now().difference(t0).inMilliseconds} ms');
|
||||
}
|
||||
|
||||
Future<void> _checkAudio(
|
||||
WidgetTester tester,
|
||||
CallSignalingService voip,
|
||||
TestPartner partner,
|
||||
) async {
|
||||
await pumpFor(tester, const Duration(seconds: 4));
|
||||
final appAudio = (await tester.runAsync(
|
||||
() => _rtp(voip.currentCall!.pc!, 'audio'),
|
||||
))!;
|
||||
final partnerAudio = (await tester.runAsync(
|
||||
() => _rtp(partner.delegate.call!.pc!, 'audio'),
|
||||
))!;
|
||||
logStep('TON App: $appAudio | Gegenseite: $partnerAudio');
|
||||
expect(appAudio.sent, greaterThan(0), reason: 'App sendet keinen Ton');
|
||||
expect(appAudio.received, greaterThan(0), reason: 'App empfängt keinen Ton');
|
||||
expect(partnerAudio.received, greaterThan(0),
|
||||
reason: 'Gegenseite empfängt keinen Ton der App');
|
||||
}
|
||||
|
||||
class _Rtp {
|
||||
_Rtp(this.sent, this.received);
|
||||
final num sent;
|
||||
final num received;
|
||||
@override
|
||||
String toString() => 'gesendet=$sent B, empfangen=$received B';
|
||||
}
|
||||
|
||||
Future<_Rtp> _rtp(rtc.RTCPeerConnection pc, String kind) async {
|
||||
num sent = 0;
|
||||
num received = 0;
|
||||
for (final r in await pc.getStats()) {
|
||||
if (r.values['kind'] != kind) continue;
|
||||
if (r.type == 'outbound-rtp') sent += (r.values['bytesSent'] as num?) ?? 0;
|
||||
if (r.type == 'inbound-rtp') received += (r.values['bytesReceived'] as num?) ?? 0;
|
||||
}
|
||||
return _Rtp(sent, received);
|
||||
}
|
||||
|
||||
/// Knopf der Anrufleiste mit diesem Symbol (nicht die Stumm-ANZEIGE in der
|
||||
/// Teilnehmer-Kachel, die dasselbe Symbol trägt).
|
||||
Finder _ctrlBtn(IconData icon) => find.descendant(
|
||||
of: find.byWidgetPredicate((w) =>
|
||||
w.runtimeType.toString() == '_VoiceCtrlBtn' &&
|
||||
(w as dynamic).icon == icon),
|
||||
matching: find.byIcon(icon),
|
||||
).first;
|
||||
@@ -0,0 +1,284 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:pyramid/core/app_state.dart';
|
||||
import 'package:pyramid/core/matrix_client.dart';
|
||||
import 'package:pyramid/main.dart' as app;
|
||||
|
||||
/// Live mit beiden Testkonten (Bernds Fehlerbericht 2026-10-07):
|
||||
/// 1. ✓✓ erscheint LIVE, sobald der Partner liest – ohne den Chat neu zu
|
||||
/// öffnen (vorher zeichnete eine reine Lesebestätigung nichts neu).
|
||||
/// 2. Einladung lässt sich direkt im Chat annehmen, danach ist der Verlauf
|
||||
/// sofort da (vorher leerer Chat bis zum App-Neustart).
|
||||
///
|
||||
/// Die App läuft mit dem Testprofil (PYRAMID_PROFILE_DIR, pyramidtest1).
|
||||
/// pyramidtest2 wird per Client-Server-API mit einem kurzlebigen Gerät
|
||||
/// gesteuert, das am Ende abgemeldet wird – andere Sitzungen (z. B. Bernds
|
||||
/// Testinstanz mit profile2) bleiben unberührt.
|
||||
///
|
||||
/// $env:PYRAMID_PROFILE_DIR="$env:USERPROFILE\.pyramid-autopilot\profile1"
|
||||
/// flutter test integration_test/live_receipts_invite_test.dart -d windows
|
||||
const _hs = 'https://steggi-matrix.work';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final profile = Platform.environment['PYRAMID_PROFILE_DIR'];
|
||||
final accounts = _loadAccounts();
|
||||
|
||||
testWidgets(
|
||||
'Lesebestätigung live + Einladung im Chat annehmen',
|
||||
(tester) async {
|
||||
tester.view
|
||||
..physicalSize = const Size(1080, 2340)
|
||||
..devicePixelRatio = 2.625;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
final partner = await tester.runAsync(
|
||||
() => _RestUser.login(accounts!['user2']!, accounts['pass2']!),
|
||||
);
|
||||
addTearDown(() => tester.runAsync(() => partner!.logout()));
|
||||
|
||||
// app.main() ersetzt FlutterError.onError – danach den Empfänger des
|
||||
|
||||
// Testrahmens zurücksetzen, sonst verschwinden Fehlermeldungen.
|
||||
|
||||
final testOnError = FlutterError.onError;
|
||||
|
||||
app.main();
|
||||
await _pumpUntil(tester, find.byType(MaterialApp));
|
||||
|
||||
FlutterError.onError = testOnError;
|
||||
final container = ProviderScope.containerOf(
|
||||
tester.element(find.byType(MaterialApp).first),
|
||||
);
|
||||
final client = (await tester.runAsync(
|
||||
() => container.read(matrixClientProvider.future),
|
||||
))!;
|
||||
expect(client.userID, '@pyramidtest1:steggi-matrix.work');
|
||||
await _pumpFor(tester, const Duration(seconds: 4));
|
||||
|
||||
// ── 2. Einladung im Chat annehmen ──
|
||||
final welcome = 'Willkommen im Einladungstest ${DateTime.now().millisecondsSinceEpoch}';
|
||||
final inviteRoomId = await tester.runAsync(() async {
|
||||
final id = await partner!.createRoom('Einladungstest');
|
||||
await partner.sendText(id, welcome);
|
||||
await partner.invite(id, client.userID!);
|
||||
return id;
|
||||
});
|
||||
addTearDown(() => tester.runAsync(() => partner!.leave(inviteRoomId!)));
|
||||
|
||||
// Einladung kommt per Sync an → Chat öffnen (wie aus der Benachrichtigung).
|
||||
final end = DateTime.now().add(const Duration(seconds: 30));
|
||||
while (client.getRoomById(inviteRoomId!)?.membership != Membership.invite &&
|
||||
DateTime.now().isBefore(end)) {
|
||||
await _pumpFor(tester, const Duration(milliseconds: 500));
|
||||
}
|
||||
container.read(activeRoomIdProvider.notifier).state = inviteRoomId;
|
||||
await _pumpUntil(tester, find.text('Annehmen'));
|
||||
await _pumpFor(tester, const Duration(seconds: 1)); // Hereingleiten abwarten
|
||||
await tester.tap(find.text('Annehmen'));
|
||||
// Ohne App-Neustart: Verlauf mit der Begrüßung erscheint.
|
||||
await _pumpUntil(
|
||||
tester,
|
||||
find.textContaining(welcome),
|
||||
timeout: const Duration(seconds: 40),
|
||||
);
|
||||
expect(find.text('Annehmen'), findsNothing);
|
||||
|
||||
// ── 1. Lesebestätigung live (nach dem Beitritt) ──
|
||||
// (im Wegwerf-Raum aus Teil 2 – die DM soll nicht mit jedem Lauf
|
||||
// wachsen)
|
||||
final dm = client.getRoomById(inviteRoomId)!;
|
||||
container.read(activeRoomIdProvider.notifier).state = dm.id;
|
||||
await _pumpFor(tester, const Duration(seconds: 2));
|
||||
|
||||
final text = 'Lesetest ${DateTime.now().millisecondsSinceEpoch}';
|
||||
final eventId = await tester.runAsync(() => dm.sendTextEvent(text));
|
||||
await _pumpUntil(tester, find.textContaining(text));
|
||||
await _pumpFor(tester, const Duration(seconds: 3));
|
||||
expect(
|
||||
find.byIcon(Icons.done_rounded),
|
||||
findsOneWidget,
|
||||
reason: 'eigene neueste Nachricht sollte noch ✓ (ungelesen) zeigen',
|
||||
);
|
||||
|
||||
await tester.runAsync(() => partner!.readReceipt(dm.id, eventId!));
|
||||
// Ohne den Chat neu zu öffnen: ✓ wird zu ✓✓ direkt unter der Nachricht.
|
||||
await _pumpUntil(
|
||||
tester,
|
||||
find.byIcon(Icons.done_rounded),
|
||||
gone: true,
|
||||
timeout: const Duration(seconds: 30),
|
||||
);
|
||||
final msgY = tester.getCenter(find.textContaining(text).first).dy;
|
||||
final readIcons = find.byIcon(Icons.done_all_rounded).evaluate();
|
||||
expect(
|
||||
readIcons.any((e) {
|
||||
final box = e.renderObject as RenderBox;
|
||||
final y = box.localToGlobal(box.size.center(Offset.zero)).dy;
|
||||
return y > msgY && y - msgY < 120;
|
||||
}),
|
||||
isTrue,
|
||||
reason: '✓✓ erscheint nicht live unter der gelesenen Nachricht',
|
||||
);
|
||||
|
||||
// ── 3. „Neue Nachrichten“-Trenner MITTEN in einer Gruppe verschwindet ──
|
||||
// (Bernd 2026-10-08: blieb manchmal stehen – bei mehreren Nachrichten
|
||||
// desselben Absenders hintereinander, gelesen bis zur ersten.)
|
||||
container.read(activeRoomIdProvider.notifier).state = null;
|
||||
await _pumpFor(tester, const Duration(seconds: 1));
|
||||
final ids = (await tester.runAsync(() async => [
|
||||
await partner!.sendText(inviteRoomId, 'Gruppe 1'),
|
||||
await partner.sendText(inviteRoomId, 'Gruppe 2'),
|
||||
await partner.sendText(inviteRoomId, 'Gruppe 3'),
|
||||
]))!;
|
||||
final room = client.getRoomById(inviteRoomId)!;
|
||||
// Erst warten, bis alle drei da sind, dann bis „Gruppe 1“ gelesen melden.
|
||||
final tEnd = DateTime.now().add(const Duration(seconds: 30));
|
||||
while (room.lastEvent?.eventId != ids[2] && DateTime.now().isBefore(tEnd)) {
|
||||
await _pumpFor(tester, const Duration(milliseconds: 300));
|
||||
}
|
||||
await tester.runAsync(() => room.setReadMarker(ids[0], mRead: ids[0]));
|
||||
final fEnd = DateTime.now().add(const Duration(seconds: 30));
|
||||
while (room.fullyRead != ids[0] && DateTime.now().isBefore(fEnd)) {
|
||||
await _pumpFor(tester, const Duration(milliseconds: 300));
|
||||
}
|
||||
expect(room.fullyRead, ids[0], reason: 'Lesemarker kam nicht an');
|
||||
container.read(activeRoomIdProvider.notifier).state = inviteRoomId;
|
||||
await _pumpUntil(tester, find.text('Neue Nachrichten'),
|
||||
timeout: const Duration(seconds: 20));
|
||||
// Nach der Verweildauer (6 s) + Ausblenden muss er weg sein.
|
||||
await _pumpUntil(
|
||||
tester,
|
||||
find.text('Neue Nachrichten'),
|
||||
gone: true,
|
||||
timeout: const Duration(seconds: 15),
|
||||
);
|
||||
|
||||
// Aufräumen: Testraum verlassen + vergessen.
|
||||
await tester.runAsync(() async {
|
||||
final r = client.getRoomById(inviteRoomId);
|
||||
await r?.leave();
|
||||
await r?.forget().catchError((_) {});
|
||||
});
|
||||
container.read(activeRoomIdProvider.notifier).state = null;
|
||||
await _pumpFor(tester, const Duration(seconds: 1));
|
||||
},
|
||||
skip: profile == null || accounts == null,
|
||||
timeout: const Timeout(Duration(minutes: 4)),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, String>? _loadAccounts() {
|
||||
final home = Platform.environment['USERPROFILE'] ?? '';
|
||||
final file = File('$home\\.pyramid-autopilot\\test-accounts.txt');
|
||||
if (!file.existsSync()) return null;
|
||||
final map = <String, String>{};
|
||||
for (final line in file.readAsLinesSync()) {
|
||||
final t = line.trim();
|
||||
if (t.isEmpty || t.startsWith('#')) continue;
|
||||
final i = t.indexOf('=');
|
||||
if (i > 0) map[t.substring(0, i)] = t.substring(i + 1);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// Minimaler Matrix-Zugang per Client-Server-API (kurzlebiges Gerät).
|
||||
class _RestUser {
|
||||
_RestUser(this._token);
|
||||
final String _token;
|
||||
int _txn = 0;
|
||||
|
||||
static Future<_RestUser> login(String user, String pass) async {
|
||||
final res = await http.post(
|
||||
Uri.parse('$_hs/_matrix/client/v3/login'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'type': 'm.login.password',
|
||||
'identifier': {'type': 'm.id.user', 'user': user},
|
||||
'password': pass,
|
||||
'initial_device_display_name': 'Pyramid-Livetest (kurzlebig)',
|
||||
}),
|
||||
);
|
||||
if (res.statusCode != 200) throw StateError('Login ${res.statusCode}');
|
||||
return _RestUser(jsonDecode(res.body)['access_token'] as String);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _call(String method, String path,
|
||||
[Object? body]) async {
|
||||
final req = http.Request(method, Uri.parse('$_hs/_matrix/client/v3$path'))
|
||||
..headers['Authorization'] = 'Bearer $_token'
|
||||
..headers['Content-Type'] = 'application/json'
|
||||
..body = jsonEncode(body ?? {});
|
||||
final res = await http.Response.fromStream(await req.send());
|
||||
if (res.statusCode != 200) {
|
||||
throw StateError('$method $path → ${res.statusCode}');
|
||||
}
|
||||
return jsonDecode(res.body) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
String _enc(String s) => Uri.encodeComponent(s);
|
||||
|
||||
Future<void> readReceipt(String roomId, String eventId) => _call(
|
||||
'POST',
|
||||
'/rooms/${_enc(roomId)}/read_markers',
|
||||
{'m.fully_read': eventId, 'm.read': eventId},
|
||||
);
|
||||
|
||||
Future<String> createRoom(String name) async =>
|
||||
(await _call('POST', '/createRoom', {
|
||||
'name': name,
|
||||
'preset': 'private_chat',
|
||||
}))['room_id'] as String;
|
||||
|
||||
Future<String> sendText(String roomId, String text) async =>
|
||||
(await _call(
|
||||
'PUT',
|
||||
'/rooms/${_enc(roomId)}/send/m.room.message/t${DateTime.now().microsecondsSinceEpoch}${_txn++}',
|
||||
{'msgtype': 'm.text', 'body': text},
|
||||
))['event_id'] as String;
|
||||
|
||||
Future<void> invite(String roomId, String userId) =>
|
||||
_call('POST', '/rooms/${_enc(roomId)}/invite', {'user_id': userId});
|
||||
|
||||
Future<void> leave(String roomId) async {
|
||||
try {
|
||||
await _call('POST', '/rooms/${_enc(roomId)}/leave');
|
||||
await _call('POST', '/rooms/${_enc(roomId)}/forget');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
try {
|
||||
await _call('POST', '/logout');
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pumpFor(WidgetTester tester, Duration d) async {
|
||||
final end = DateTime.now().add(d);
|
||||
while (DateTime.now().isBefore(end)) {
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pumpUntil(
|
||||
WidgetTester tester,
|
||||
Finder finder, {
|
||||
bool gone = false,
|
||||
Duration timeout = const Duration(seconds: 60),
|
||||
}) async {
|
||||
final end = DateTime.now().add(timeout);
|
||||
while (DateTime.now().isBefore(end)) {
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
final found = finder.evaluate().isNotEmpty;
|
||||
if (found != gone) return;
|
||||
}
|
||||
fail('Zeitüberschreitung: $finder ${gone ? "verschwindet nicht" : "nicht gefunden"}');
|
||||
}
|
||||
@@ -27,12 +27,16 @@ void main() {
|
||||
testWidgets(
|
||||
'LiveKit-Beitritt mit Server-Token: Identität = eigene Matrix-ID',
|
||||
(tester) async {
|
||||
// app.main() ersetzt FlutterError.onError – danach den Empfänger des
|
||||
// Testrahmens zurücksetzen, sonst verschwinden Fehlermeldungen.
|
||||
final testOnError = FlutterError.onError;
|
||||
app.main();
|
||||
final end = DateTime.now().add(const Duration(seconds: 90));
|
||||
while (find.byType(MaterialApp).evaluate().isEmpty &&
|
||||
DateTime.now().isBefore(end)) {
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
}
|
||||
FlutterError.onError = testOnError;
|
||||
final container = ProviderScope.containerOf(
|
||||
tester.element(find.byType(MaterialApp).first),
|
||||
);
|
||||
|
||||
@@ -47,6 +47,12 @@ void main() {
|
||||
addTearDown(tester.view.reset);
|
||||
final size = tester.view.physicalSize / tester.view.devicePixelRatio;
|
||||
|
||||
// app.main() ersetzt FlutterError.onError – danach den Empfänger des
|
||||
|
||||
// Testrahmens zurücksetzen, sonst verschwinden Fehlermeldungen.
|
||||
|
||||
final testOnError = FlutterError.onError;
|
||||
|
||||
app.main();
|
||||
await _pumpUntil(
|
||||
tester,
|
||||
@@ -56,6 +62,8 @@ void main() {
|
||||
final container = ProviderScope.containerOf(
|
||||
tester.element(find.byType(MaterialApp).first),
|
||||
);
|
||||
|
||||
FlutterError.onError = testOnError;
|
||||
await _shot(tester, '01_chatliste');
|
||||
|
||||
// ── Direktnachricht: Sprechblasen (Handy-Standard) und Liste ──
|
||||
@@ -78,12 +86,15 @@ void main() {
|
||||
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,
|
||||
);
|
||||
// Nach oben blättern, bis ein Zitat gebaut ist (die Liste baut nur
|
||||
// sichtbare Einträge; .first auf eine leere Trefferliste wirft).
|
||||
for (var i = 0; i < 60 && quote.evaluate().isEmpty; i++) {
|
||||
await tester.drag(find.byType(Scrollable).first, const Offset(0, 300),
|
||||
warnIfMissed: false);
|
||||
await tester.pump(const Duration(milliseconds: 120));
|
||||
}
|
||||
expect(quote, findsWidgets, reason: 'kein Antwort-Zitat im Test-Chat');
|
||||
await tester.ensureVisible(quote.first);
|
||||
await _pumpFor(tester, const Duration(milliseconds: 600));
|
||||
await _shot(tester, '02e_zitat_vor_dem_tippen');
|
||||
await tester.tap(quote.first, warnIfMissed: false);
|
||||
@@ -103,7 +114,16 @@ void main() {
|
||||
// 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);
|
||||
final ownText = find.text('Danke, sieht gut aus!');
|
||||
for (var i = 0; i < 60 && ownText.evaluate().isEmpty; i++) {
|
||||
await tester.drag(find.byType(Scrollable).first, const Offset(0, 300),
|
||||
warnIfMissed: false);
|
||||
await tester.pump(const Duration(milliseconds: 120));
|
||||
}
|
||||
// Mittig holen – unten läge sie unter der schwebenden Eingabeleiste.
|
||||
await Scrollable.ensureVisible(tester.element(ownText.first), alignment: 0.5);
|
||||
await _pumpFor(tester, const Duration(milliseconds: 600));
|
||||
final ownMsg = tester.getCenter(ownText.first);
|
||||
await tester.longPressAt(Offset(size.width * 0.15, ownMsg.dy));
|
||||
await _pumpFor(tester, const Duration(milliseconds: 500));
|
||||
await tester.tap(find.byIcon(Icons.reply_rounded).first);
|
||||
@@ -205,7 +225,7 @@ void main() {
|
||||
|
||||
// ── Gruppenraum (Namen + Avatare in den Sprechblasen) ──
|
||||
container.read(activeRoomIdProvider.notifier).state = null;
|
||||
container.read(activeSpaceIdProvider.notifier).state = 'rooms';
|
||||
container.read(activeSpaceIdProvider.notifier).state = 'dms';
|
||||
await _pumpUntil(
|
||||
tester,
|
||||
find.text(_groupName),
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import 'package:webrtc_interface/webrtc_interface.dart';
|
||||
|
||||
// Gemeinsame Hilfen für Live-Tests mit zwei Testkonten (Anruftest,
|
||||
// UI-Tour): zweiter Matrix-Client im selben Prozess, Zugangsdaten,
|
||||
// Warte-Helfer. Kein eigenständiger Test.
|
||||
|
||||
/// Zweiter Teilnehmer: eigener Matrix-Client + VoIP im selben Prozess.
|
||||
class TestPartner {
|
||||
TestPartner(this.client, this.delegate, this.voip, this.zone) {
|
||||
_sub = client.onCallEvents.stream.listen((events) {
|
||||
for (final e in events) {
|
||||
if (e.type != EventTypes.CallCandidates || e.senderId == client.userID) {
|
||||
continue;
|
||||
}
|
||||
final list = e.content.tryGetList<Map<String, dynamic>>('candidates');
|
||||
if (list != null) _buffer.addAll(list);
|
||||
}
|
||||
});
|
||||
_timer = Timer.periodic(const Duration(milliseconds: 500), (_) async {
|
||||
final pc = delegate.call?.pc;
|
||||
if (!fixCandidates || pc == null) return;
|
||||
try {
|
||||
if (await pc.getRemoteDescription() == null) return;
|
||||
for (final c in List.of(_buffer)) {
|
||||
final cand = c['candidate'] as String?;
|
||||
if (cand == null || !_applied.add(cand)) continue;
|
||||
await pc.addCandidate(RTCIceCandidate(
|
||||
cand,
|
||||
c['sdpMid']?.toString() ?? '',
|
||||
(c['sdpMLineIndex'] as num?)?.round() ?? 0,
|
||||
));
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
final Client client;
|
||||
final TestPartnerDelegate delegate;
|
||||
final VoIP voip;
|
||||
final Zone zone;
|
||||
|
||||
/// Aktion der Gegenseite in ihrer Zone ausführen (Fehler über Completer).
|
||||
Future<T> run<T>(Future<T> Function() f) {
|
||||
final done = Completer<T>();
|
||||
zone.run(() async {
|
||||
try {
|
||||
done.complete(await f());
|
||||
} catch (e, s) {
|
||||
done.completeError(e, s);
|
||||
}
|
||||
});
|
||||
return done.future;
|
||||
}
|
||||
|
||||
/// Gegenseite reicht Kandidaten nach wie die gepatchte App.
|
||||
bool fixCandidates = false;
|
||||
final _buffer = <Map<String, dynamic>>[];
|
||||
final _applied = <String>{};
|
||||
StreamSubscription? _sub;
|
||||
Timer? _timer;
|
||||
|
||||
static Future<TestPartner> start(String user, String pass) async {
|
||||
final db = await databaseFactoryFfi.openDatabase(inMemoryDatabasePath);
|
||||
final client = Client(
|
||||
'pyramid-call-partner',
|
||||
database: await MatrixSdkDatabase.init('pyramid-call-partner', database: db),
|
||||
);
|
||||
await client.init(waitForFirstSync: false, waitUntilLoadCompletedLoaded: false);
|
||||
await client.checkHomeserver(Uri.parse('https://steggi-matrix.work'));
|
||||
await client.login(
|
||||
LoginType.mLoginPassword,
|
||||
identifier: AuthenticationUserIdentifier(user: user),
|
||||
password: pass,
|
||||
initialDeviceDisplayName: 'Pyramid-Anruftest (kurzlebig)',
|
||||
);
|
||||
final delegate = TestPartnerDelegate();
|
||||
// Eigene Zone: Fehler der Gegenseite sichtbar protokollieren statt sie
|
||||
// im Testrahmen zu verlieren.
|
||||
final zone = Zone.current.fork(
|
||||
specification: ZoneSpecification(
|
||||
handleUncaughtError: (self, parent, z, e, s) =>
|
||||
logStep('Gegenseite (abgefangen): ${e.toString().split('\n').first}'),
|
||||
),
|
||||
);
|
||||
final voip = zone.run(() => VoIP(client, delegate));
|
||||
return TestPartner(client, delegate, voip, zone);
|
||||
}
|
||||
|
||||
Future<void> dispose(String? roomId) async {
|
||||
_timer?.cancel();
|
||||
await _sub?.cancel();
|
||||
try {
|
||||
await delegate.call?.hangup(reason: CallErrorCode.userHangup);
|
||||
} catch (_) {}
|
||||
if (roomId != null) {
|
||||
try {
|
||||
await client.leaveRoom(roomId);
|
||||
await client.forgetRoom(roomId);
|
||||
} catch (_) {}
|
||||
}
|
||||
try {
|
||||
client.backgroundSync = false;
|
||||
await client.logout();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
class TestPartnerDelegate implements WebRTCDelegate {
|
||||
CallSession? call;
|
||||
|
||||
@override
|
||||
MediaDevices get mediaDevices => TestPartnerMedia(rtc.navigator.mediaDevices);
|
||||
|
||||
@override
|
||||
Future<RTCPeerConnection> createPeerConnection(
|
||||
Map<String, dynamic> configuration, [
|
||||
Map<String, dynamic> constraints = const {},
|
||||
]) =>
|
||||
rtc.createPeerConnection(configuration, constraints);
|
||||
|
||||
@override
|
||||
Future<void> handleNewCall(CallSession session) async {
|
||||
call = session;
|
||||
if (session.direction == CallDirection.kIncoming) {
|
||||
// Wie ein Mensch nach kurzem Klingeln annehmen. Sofortiges Annehmen
|
||||
// trifft eine Lücke im SDK: Kandidaten, die WÄHREND answer()
|
||||
// eintreffen, landen in einer Warteschlange, die nie mehr abgearbeitet
|
||||
// wird (CallSession.onCandidatesReceived vs. answer()).
|
||||
unawaited(Future.delayed(const Duration(seconds: 2), () async {
|
||||
try {
|
||||
await session.answer();
|
||||
} catch (e) {
|
||||
logStep('Gegenseite: Annehmen fehlgeschlagen: $e');
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> handleCallEnded(CallSession session) async {}
|
||||
@override
|
||||
Future<void> handleMissedCall(CallSession session) async {}
|
||||
@override
|
||||
Future<void> handleNewGroupCall(GroupCallSession groupCall) async {}
|
||||
@override
|
||||
Future<void> handleGroupCallEnded(GroupCallSession groupCall) async {}
|
||||
@override
|
||||
Future<void> playRingtone() async {}
|
||||
@override
|
||||
Future<void> stopRingtone() async {}
|
||||
@override
|
||||
Future<void> registerListeners(CallSession session) async {}
|
||||
@override
|
||||
bool get isWeb => false;
|
||||
@override
|
||||
bool get canHandleNewCall => true;
|
||||
@override
|
||||
EncryptionKeyProvider? get keyProvider => null;
|
||||
}
|
||||
|
||||
/// Wie die App am PC: Bildschirm über eine Desktop-Quelle (erster Monitor).
|
||||
class TestPartnerMedia extends MediaDevices {
|
||||
TestPartnerMedia(this._d);
|
||||
final MediaDevices _d;
|
||||
|
||||
@override
|
||||
Future<MediaStream> getUserMedia(Map<String, dynamic> c) => _d.getUserMedia(c);
|
||||
|
||||
@override
|
||||
Future<MediaStream> getDisplayMedia(Map<String, dynamic> c) async {
|
||||
final sources = await rtc.desktopCapturer
|
||||
.getSources(types: [rtc.SourceType.Screen]);
|
||||
return _d.getDisplayMedia({
|
||||
'video': {
|
||||
'mandatory': {
|
||||
'chromeMediaSource': 'desktop',
|
||||
'chromeMediaSourceId': sources.first.id,
|
||||
},
|
||||
'optional': [],
|
||||
},
|
||||
'audio': false,
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<MediaDeviceInfo>> enumerateDevices() => _d.enumerateDevices();
|
||||
@override
|
||||
MediaTrackSupportedConstraints getSupportedConstraints() =>
|
||||
_d.getSupportedConstraints();
|
||||
@override
|
||||
set ondevicechange(Function(dynamic event)? callback) =>
|
||||
_d.ondevicechange = callback;
|
||||
@override
|
||||
Function(dynamic event)? get ondevicechange => _d.ondevicechange;
|
||||
@override
|
||||
// ignore: deprecated_member_use
|
||||
Future<List<dynamic>> getSources() => _d.getSources();
|
||||
@override
|
||||
Future<MediaDeviceInfo> selectAudioOutput([AudioOutputOptions? options]) =>
|
||||
_d.selectAudioOutput(options);
|
||||
}
|
||||
|
||||
Map<String, String>? loadTestAccounts() {
|
||||
final home = Platform.environment['USERPROFILE'] ?? '';
|
||||
final file = File('$home\\.pyramid-autopilot\\test-accounts.txt');
|
||||
if (!file.existsSync()) return null;
|
||||
final map = <String, String>{};
|
||||
for (final line in file.readAsLinesSync()) {
|
||||
final t = line.trim();
|
||||
if (t.isEmpty || t.startsWith('#')) continue;
|
||||
final i = t.indexOf('=');
|
||||
if (i > 0) map[t.substring(0, i)] = t.substring(i + 1);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
Future<void> pumpFor(WidgetTester tester, Duration d) async {
|
||||
final end = DateTime.now().add(d);
|
||||
while (DateTime.now().isBefore(end)) {
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
}
|
||||
}
|
||||
|
||||
void logStep(String m) {
|
||||
// ignore: avoid_print
|
||||
print('SCHRITT ${DateTime.now().toIso8601String().substring(11, 19)} $m');
|
||||
}
|
||||
|
||||
Future<void> pumpUntil(
|
||||
WidgetTester tester,
|
||||
bool Function() condition, {
|
||||
Duration timeout = const Duration(seconds: 60),
|
||||
String what = '',
|
||||
void Function()? onTimeout,
|
||||
void Function()? every5s,
|
||||
}) async {
|
||||
final end = DateTime.now().add(timeout);
|
||||
var next = DateTime.now().add(const Duration(seconds: 5));
|
||||
while (DateTime.now().isBefore(end)) {
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
if (condition()) return;
|
||||
if (every5s != null && DateTime.now().isAfter(next)) {
|
||||
every5s();
|
||||
next = DateTime.now().add(const Duration(seconds: 5));
|
||||
}
|
||||
}
|
||||
logStep('ZEITÜBERSCHREITUNG: $what');
|
||||
onTimeout?.call();
|
||||
fail('Zeitüberschreitung: $what');
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:pyramid/core/app_state.dart';
|
||||
import 'package:livekit_client/livekit_client.dart' as lk;
|
||||
import 'package:pyramid/core/livekit_token.dart';
|
||||
import 'package:pyramid/core/matrix_client.dart';
|
||||
import 'package:pyramid/core/settings_prefs.dart' show bottomNavProvider;
|
||||
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
||||
import 'package:pyramid/features/call_ui/voice_channel.dart' show CallBubblesDemo;
|
||||
import 'package:pyramid/features/group_call/group_call.dart';
|
||||
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
|
||||
import 'package:pyramid/layout/mobile_tabs.dart';
|
||||
import 'package:pyramid/main.dart' as app;
|
||||
|
||||
import 'support/test_partner.dart';
|
||||
|
||||
/// UI-Tour für das UX-Audit (Bernd 2026-10-08): fährt alle wichtigen
|
||||
/// Zustände ab und speichert jeden als Screenshot im Handy- UND im
|
||||
/// PC-Format nach build/ui_audit/ (p_* = Handy, d_* = PC).
|
||||
///
|
||||
/// Testkonten: App = pyramidtest1 (Testprofil), Gegenseite = pyramidtest2
|
||||
/// (kurzlebiges Gerät). Legt beim ersten Lauf einen Test-Space mit Text- und
|
||||
/// Sprachkanal an (bleibt für spätere Touren bestehen) und für den Anruf einen
|
||||
/// Wegwerf-Raum. Nutzt kurz Mikrofon (Anruf, Sprachkanal).
|
||||
///
|
||||
/// $env:PYRAMID_PROFILE_DIR="$env:USERPROFILE\.pyramid-autopilot\profile1"
|
||||
/// flutter test integration_test/ui_audit_tour_test.dart -d windows
|
||||
const _spaceName = 'UI-Test Space';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final profile = Platform.environment['PYRAMID_PROFILE_DIR'];
|
||||
final accounts = loadTestAccounts();
|
||||
|
||||
testWidgets(
|
||||
'UI-Tour: alle Zustände, Handy + PC',
|
||||
(tester) async {
|
||||
void phone() => tester.view
|
||||
..physicalSize = const Size(1080, 2340)
|
||||
..devicePixelRatio = 2.625
|
||||
..padding = const FakeViewPadding(top: 63, bottom: 42)
|
||||
..viewPadding = const FakeViewPadding(top: 63, bottom: 42);
|
||||
void desktop() => tester.view
|
||||
..physicalSize = const Size(1600, 1000)
|
||||
..devicePixelRatio = 1.0
|
||||
..padding = FakeViewPadding.zero
|
||||
..viewPadding = FakeViewPadding.zero;
|
||||
addTearDown(tester.view.reset);
|
||||
phone();
|
||||
|
||||
// Flüssig pumpen (~60 Bilder/s), damit Animationen wie auf dem Gerät
|
||||
// zur Ruhe kommen (pumpFor zeichnet nur alle 100 ms).
|
||||
Future<void> smooth(Duration d) async {
|
||||
final end = DateTime.now().add(d);
|
||||
while (DateTime.now().isBefore(end)) {
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> both(String name, {Duration settle = const Duration(milliseconds: 1500)}) async {
|
||||
phone();
|
||||
await smooth(settle);
|
||||
await _shot(tester, 'p_$name');
|
||||
desktop();
|
||||
await smooth(settle);
|
||||
await _shot(tester, 'd_$name');
|
||||
phone();
|
||||
await pumpFor(tester, const Duration(milliseconds: 400));
|
||||
}
|
||||
|
||||
// app.main() ersetzt FlutterError.onError – danach den Empfänger des
|
||||
// Testrahmens zurücksetzen, sonst verschwinden Fehlermeldungen.
|
||||
final testOnError = FlutterError.onError;
|
||||
app.main();
|
||||
await pumpUntil(tester, () => find.byType(MaterialApp).evaluate().isNotEmpty);
|
||||
FlutterError.onError = testOnError;
|
||||
final container = ProviderScope.containerOf(
|
||||
tester.element(find.byType(MaterialApp).first),
|
||||
);
|
||||
final me = (await tester.runAsync(
|
||||
() => container.read(matrixClientProvider.future),
|
||||
))!;
|
||||
await pumpFor(tester, const Duration(seconds: 5));
|
||||
|
||||
final partner = (await tester.runAsync(
|
||||
() => TestPartner.start(accounts!['user2']!, accounts['pass2']!),
|
||||
))!;
|
||||
String? callRoomId;
|
||||
addTearDown(() => tester.runAsync(() async {
|
||||
await partner.dispose(callRoomId);
|
||||
final r = callRoomId == null ? null : me.getRoomById(callRoomId);
|
||||
await r?.leave().catchError((_) {});
|
||||
await r?.forget().catchError((_) {});
|
||||
}));
|
||||
|
||||
// ── Fixture: Test-Space mit Text- und Sprachkanal (einmalig) ──
|
||||
var space = me.rooms.where((r) => r.isSpace && r.name == _spaceName).firstOrNull;
|
||||
if (space == null) {
|
||||
logStep('lege Test-Space an');
|
||||
final ids = (await tester.runAsync(() async {
|
||||
final c = partner.client;
|
||||
final spaceId = await c.createRoom(
|
||||
name: _spaceName,
|
||||
creationContent: {'type': 'm.space'},
|
||||
preset: CreateRoomPreset.privateChat,
|
||||
invite: [me.userID!],
|
||||
);
|
||||
final textId = await c.createRoom(
|
||||
name: 'allgemein',
|
||||
preset: CreateRoomPreset.privateChat,
|
||||
invite: [me.userID!],
|
||||
);
|
||||
final voiceId = await c.createRoom(
|
||||
name: 'Sprachkanal',
|
||||
preset: CreateRoomPreset.privateChat,
|
||||
creationContent: {'type': 'io.pyramid.voice_channel'},
|
||||
invite: [me.userID!],
|
||||
);
|
||||
final pl = Map<String, dynamic>.from(
|
||||
await c.getRoomStateWithKey(voiceId, EventTypes.RoomPowerLevels, ''),
|
||||
);
|
||||
pl['events'] = {
|
||||
...Map<String, dynamic>.from(pl['events'] as Map? ?? {}),
|
||||
'io.pyramid.voice.presence': 0,
|
||||
};
|
||||
await c.setRoomStateWithKey(voiceId, EventTypes.RoomPowerLevels, '', pl);
|
||||
for (final child in [textId, voiceId]) {
|
||||
await c.setRoomStateWithKey(spaceId, 'm.space.child', child, {
|
||||
'via': ['steggi-matrix.work'],
|
||||
});
|
||||
}
|
||||
await c.sendMessage(textId, EventTypes.Message,
|
||||
'm${DateTime.now().microsecondsSinceEpoch}',
|
||||
{'msgtype': 'm.text', 'body': 'Willkommen im Test-Space 👋'});
|
||||
return [spaceId, textId, voiceId];
|
||||
}))!;
|
||||
for (final id in ids) {
|
||||
await pumpUntil(tester, () => me.getRoomById(id)?.membership == Membership.invite,
|
||||
what: 'Einladung $id');
|
||||
await tester.runAsync(() => me.getRoomById(id)!.join());
|
||||
}
|
||||
await pumpUntil(tester, () =>
|
||||
me.rooms.any((r) => r.isSpace && r.name == _spaceName && r.membership == Membership.join),
|
||||
what: 'Space beigetreten');
|
||||
space = me.rooms.firstWhere((r) => r.isSpace && r.name == _spaceName);
|
||||
await pumpFor(tester, const Duration(seconds: 3));
|
||||
}
|
||||
|
||||
// ── 1. Listen ──
|
||||
container.read(activeSpaceIdProvider.notifier).state = 'dms';
|
||||
container.read(activeRoomIdProvider.notifier).state = null;
|
||||
await both('01_dms');
|
||||
tester.state<ScaffoldState>(find.byType(Scaffold).first).openDrawer();
|
||||
await pumpFor(tester, const Duration(seconds: 1));
|
||||
await _shot(tester, 'p_02_drawer');
|
||||
await tester.tapAt(const Offset(390, 400));
|
||||
await pumpFor(tester, const Duration(seconds: 1));
|
||||
final groupsChip = find.text('Gruppen');
|
||||
if (groupsChip.evaluate().isNotEmpty) {
|
||||
await tester.tap(groupsChip.first);
|
||||
await both('03_chats_gruppen');
|
||||
await tester.tap(find.text('Alle').first);
|
||||
await pumpFor(tester, const Duration(milliseconds: 500));
|
||||
}
|
||||
container.read(activeSpaceIdProvider.notifier).state = space.id;
|
||||
await both('04_space');
|
||||
|
||||
// ── 2. Chats ──
|
||||
final dm = me.rooms.firstWhere((r) =>
|
||||
r.membership == Membership.join &&
|
||||
r.directChatMatrixID == '@pyramidtest2:steggi-matrix.work');
|
||||
container.read(activeSpaceIdProvider.notifier).state = 'dms';
|
||||
container.read(activeRoomIdProvider.notifier).state = dm.id;
|
||||
await both('05_dm_chat', settle: const Duration(seconds: 3));
|
||||
final group = me.rooms.firstWhere((r) => r.getLocalizedDisplayname() == 'UI-Test Gruppe');
|
||||
container.read(activeSpaceIdProvider.notifier).state = 'dms';
|
||||
container.read(activeRoomIdProvider.notifier).state = group.id;
|
||||
await both('06_gruppe', settle: const Duration(seconds: 3));
|
||||
container.read(membersPanelOpenProvider.notifier).state = true;
|
||||
await both('07_mitglieder');
|
||||
container.read(membersPanelOpenProvider.notifier).state = false;
|
||||
final textRoom = space.spaceChildren
|
||||
.map((c) => c.roomId == null ? null : me.getRoomById(c.roomId!))
|
||||
.whereType<Room>()
|
||||
.firstWhere((r) => r.getLocalizedDisplayname() == 'allgemein');
|
||||
container.read(activeSpaceIdProvider.notifier).state = space.id;
|
||||
container.read(activeRoomIdProvider.notifier).state = textRoom.id;
|
||||
await both('08_space_textkanal', settle: const Duration(seconds: 3));
|
||||
|
||||
// ── 3. Modale ──
|
||||
container.read(activeModalProvider.notifier).state = ModalKind.settings;
|
||||
await both('09_einstellungen');
|
||||
container.read(activeModalProvider.notifier).state = ModalKind.search;
|
||||
await both('10_suche');
|
||||
container.read(activeModalProvider.notifier).state = ModalKind.none;
|
||||
|
||||
// ── 4. Sackgasse: Anruf-Ansicht ohne Anruf ──
|
||||
container.read(viewModeProvider.notifier).state = ViewMode.voice;
|
||||
await both('11_anrufansicht_ohne_anruf');
|
||||
container.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||
|
||||
// ── 5. Sprachkanal beitreten (echter Tipp in der Space-Liste) ──
|
||||
container.read(activeRoomIdProvider.notifier).state = null;
|
||||
container.read(activeSpaceIdProvider.notifier).state = space.id;
|
||||
await pumpFor(tester, const Duration(seconds: 2));
|
||||
final voiceTile = find.text('Sprachkanal');
|
||||
if (voiceTile.evaluate().isNotEmpty) {
|
||||
await tester.tap(voiceTile.first);
|
||||
final lk = container.read(voiceChannelProvider);
|
||||
await pumpUntil(tester, () => lk.isActive && !lk.isConnecting,
|
||||
timeout: const Duration(seconds: 30), what: 'Sprachkanal verbunden');
|
||||
await both('12_sprachkanal_verbunden_liste', settle: const Duration(seconds: 2));
|
||||
container.read(viewModeProvider.notifier).state = ViewMode.voice;
|
||||
await both('13_sprachkanal_ansicht', settle: const Duration(seconds: 2));
|
||||
container.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||
await tester.runAsync(() => lk.hangUp());
|
||||
await pumpFor(tester, const Duration(seconds: 2));
|
||||
} else {
|
||||
logStep('Sprachkanal nicht in der Liste gefunden');
|
||||
await _shot(tester, 'p_12_sprachkanal_fehlt');
|
||||
}
|
||||
|
||||
// ── 6. 1:1-Anruf: klingelt, läuft, minimiert ──
|
||||
callRoomId = await tester.runAsync(() => partner.client.createRoom(
|
||||
invite: [me.userID!],
|
||||
preset: CreateRoomPreset.privateChat,
|
||||
name: 'pyramidtest2 (Anruftest)',
|
||||
));
|
||||
await pumpUntil(tester, () => me.getRoomById(callRoomId!)?.membership == Membership.invite);
|
||||
await tester.runAsync(() => me.getRoomById(callRoomId!)!.join());
|
||||
await pumpFor(tester, const Duration(seconds: 3));
|
||||
container.read(activeSpaceIdProvider.notifier).state = 'dms';
|
||||
container.read(activeRoomIdProvider.notifier).state = callRoomId;
|
||||
await pumpFor(tester, const Duration(seconds: 2));
|
||||
final voip = container.read(callSignalingProvider);
|
||||
await tester.runAsync(() => partner.run(() => partner.voip.inviteToCall(
|
||||
partner.client.getRoomById(callRoomId!)!,
|
||||
CallType.kVoice,
|
||||
)));
|
||||
final answerBtn = find.byWidgetPredicate(
|
||||
(w) => w is FloatingActionButton && w.heroTag == 'answer_call',
|
||||
);
|
||||
await pumpUntil(tester, () => answerBtn.evaluate().isNotEmpty, what: 'klingelt');
|
||||
await both('14_anruf_klingelt');
|
||||
await tester.tap(answerBtn.first);
|
||||
await pumpUntil(tester, () => voip.currentCall?.state == CallState.kConnected,
|
||||
what: 'verbunden');
|
||||
await both('15_anruf_laeuft', settle: const Duration(seconds: 2));
|
||||
container.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||
await both('16_anruf_minimiert_chat');
|
||||
container.read(activeRoomIdProvider.notifier).state = null;
|
||||
await both('17_anruf_minimiert_liste');
|
||||
// Gegenseite legt auf → wo landet man?
|
||||
container.read(viewModeProvider.notifier).state = ViewMode.voice;
|
||||
await pumpFor(tester, const Duration(seconds: 1));
|
||||
await tester.runAsync(() => partner.run(
|
||||
() => partner.delegate.call!.hangup(reason: CallErrorCode.userHangup)));
|
||||
await pumpUntil(tester, () => voip.currentCall == null, what: 'aufgelegt');
|
||||
// Keine Sackgasse mehr: automatisch zurück zum Chat (UI-Audit S1).
|
||||
await pumpUntil(
|
||||
tester,
|
||||
() => container.read(viewModeProvider) == ViewMode.chat,
|
||||
timeout: const Duration(seconds: 10),
|
||||
what: 'nach Auflegen zurück im Chat',
|
||||
);
|
||||
await pumpFor(tester, const Duration(seconds: 1)); // Ausgleiten abwarten
|
||||
expect(find.text('Kein Anruf'), findsNothing);
|
||||
await both('18_nach_auflegen_durch_gegenseite');
|
||||
container.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||
|
||||
// ── 7. Gruppenanruf: Beitreten-Karte, Anruf mit zwei Personen ──
|
||||
// Anwesenheit für alle erlauben (wer darf, ändert die Rechte).
|
||||
await tester.runAsync(() async {
|
||||
for (final c in [me, partner.client]) {
|
||||
final r = c.getRoomById(group.id);
|
||||
if (r != null && r.canChangePowerLevel) {
|
||||
await allowVoicePresenceForAll(c, group.id).catchError((_) {});
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
await pumpFor(tester, const Duration(seconds: 2));
|
||||
Future<void> partnerPresence(bool active) => tester.runAsync(() =>
|
||||
partner.client.setRoomStateWithKey(
|
||||
group.id,
|
||||
'io.pyramid.voice.presence',
|
||||
partner.client.userID!,
|
||||
active
|
||||
? {
|
||||
'active': true,
|
||||
'joined_at': DateTime.now().millisecondsSinceEpoch,
|
||||
'display_name': 'pyramidtest2',
|
||||
}
|
||||
: {'active': false},
|
||||
).then((_) {}));
|
||||
var presenceWritten = false;
|
||||
try {
|
||||
await partnerPresence(true);
|
||||
presenceWritten = true;
|
||||
} catch (e) {
|
||||
logStep('Gegenseite darf keine Anwesenheit schreiben: $e');
|
||||
}
|
||||
container.read(activeSpaceIdProvider.notifier).state = 'dms';
|
||||
container.read(activeRoomIdProvider.notifier).state = group.id;
|
||||
if (presenceWritten) {
|
||||
await pumpUntil(tester, () => find.text('Gruppenanruf läuft').evaluate().isNotEmpty,
|
||||
timeout: const Duration(seconds: 20), what: 'Beitreten-Karte');
|
||||
}
|
||||
await both('19_gruppenanruf_karte', settle: const Duration(seconds: 2));
|
||||
|
||||
// Gegenseite tritt dem LiveKit-Raum wirklich bei (zweite Bubble).
|
||||
lk.Room? partnerLk;
|
||||
try {
|
||||
partnerLk = await tester.runAsync(() async {
|
||||
final t = await LiveKitTokenService.fetch(
|
||||
room: group.id,
|
||||
matrixToken: partner.client.accessToken!,
|
||||
);
|
||||
final r = lk.Room();
|
||||
await r.connect(t.url, t.token);
|
||||
return r;
|
||||
});
|
||||
} catch (e) {
|
||||
logStep('Gegenseite konnte LiveKit nicht beitreten: $e');
|
||||
}
|
||||
final lkCall = container.read(voiceChannelProvider);
|
||||
await tester.tap(find.byTooltip('Gruppenanruf beitreten').evaluate().isNotEmpty
|
||||
? find.byTooltip('Gruppenanruf beitreten').first
|
||||
: find.byTooltip('Gruppenanruf').first);
|
||||
await pumpUntil(tester, () => lkCall.isActive && !lkCall.isConnecting,
|
||||
timeout: const Duration(seconds: 30), what: 'Gruppenanruf verbunden');
|
||||
await both('20_gruppenanruf_bubbles', settle: const Duration(seconds: 3));
|
||||
await tester.tap(find.byTooltip('Kachelansicht').first);
|
||||
await both('21_gruppenanruf_kacheln', settle: const Duration(seconds: 2));
|
||||
await tester.tap(find.byTooltip('Bubble-Ansicht').first);
|
||||
await pumpFor(tester, const Duration(seconds: 1));
|
||||
container.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||
await both('22_gruppenanruf_minimiert_chat', settle: const Duration(seconds: 2));
|
||||
await tester.runAsync(() => lkCall.hangUp());
|
||||
await tester.runAsync(() async => partnerLk?.disconnect());
|
||||
if (presenceWritten) await partnerPresence(false);
|
||||
await pumpFor(tester, const Duration(seconds: 2));
|
||||
|
||||
// ── 8. Bubbles mit mehreren Personen (Vorführ-Daten) ──
|
||||
final overlay = Overlay.of(
|
||||
tester.element(find.byType(Scaffold).first),
|
||||
rootOverlay: true,
|
||||
);
|
||||
final demo = OverlayEntry(builder: (_) => const CallBubblesDemo());
|
||||
overlay.insert(demo);
|
||||
await both('23_bubbles_5_personen', settle: const Duration(seconds: 3));
|
||||
demo.remove();
|
||||
final demo7 = OverlayEntry(builder: (_) => const CallBubblesDemo(count: 7));
|
||||
overlay.insert(demo7);
|
||||
await both('24_bubbles_7_personen', settle: const Duration(seconds: 3));
|
||||
demo7.remove();
|
||||
await pumpFor(tester, const Duration(milliseconds: 500));
|
||||
|
||||
// ── 9. Handy: untere Leiste statt Seitenmenü (Vergleich) ──
|
||||
phone();
|
||||
container.read(activeRoomIdProvider.notifier).state = null;
|
||||
await tester.runAsync(() => container.read(bottomNavProvider.notifier).set(true));
|
||||
container.read(mobileTabProvider.notifier).state = MobileTab.chats;
|
||||
container.read(activeSpaceIdProvider.notifier).state = 'dms';
|
||||
await pumpFor(tester, const Duration(seconds: 2));
|
||||
await _shot(tester, 'p_30_unten_chats');
|
||||
await tester.tap(find.text('Server').last);
|
||||
await pumpFor(tester, const Duration(seconds: 2));
|
||||
await _shot(tester, 'p_31_unten_server');
|
||||
await tester.tap(find.text(_spaceName).first);
|
||||
await pumpFor(tester, const Duration(seconds: 2));
|
||||
await _shot(tester, 'p_32_unten_server_kanaele');
|
||||
await tester.tap(find.text('Du').last);
|
||||
await pumpFor(tester, const Duration(seconds: 2));
|
||||
await _shot(tester, 'p_33_unten_du');
|
||||
container.read(activeRoomIdProvider.notifier).state = group.id;
|
||||
await pumpFor(tester, const Duration(seconds: 2));
|
||||
await _shot(tester, 'p_34_unten_chat_offen');
|
||||
container.read(activeRoomIdProvider.notifier).state = null;
|
||||
await pumpFor(tester, const Duration(seconds: 1));
|
||||
await tester.tap(find.text('Chats').last);
|
||||
await pumpFor(tester, const Duration(seconds: 1));
|
||||
await tester.runAsync(() => container.read(bottomNavProvider.notifier).set(false));
|
||||
await pumpFor(tester, const Duration(seconds: 1));
|
||||
},
|
||||
skip: profile == null || accounts == null,
|
||||
timeout: const Timeout(Duration(minutes: 12)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _shot(WidgetTester tester, String name) async {
|
||||
await tester.pump();
|
||||
final renderView = tester.binding.renderViews.first;
|
||||
final layer = renderView.debugLayer! as OffsetLayer;
|
||||
final physical = tester.view.physicalSize;
|
||||
final image = await tester.binding.runAsync(
|
||||
() => layer.toImage(Offset.zero & physical),
|
||||
);
|
||||
final bytes = await tester.binding.runAsync(
|
||||
() => image!.toByteData(format: ui.ImageByteFormat.png),
|
||||
);
|
||||
final file = File('build/ui_audit/$name.png');
|
||||
file.parent.createSync(recursive: true);
|
||||
file.writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
logStep('Screenshot $name');
|
||||
}
|
||||
@@ -69,6 +69,10 @@ enum ModalKind { none, settings, search }
|
||||
|
||||
final activeModalProvider = StateProvider<ModalKind>((ref) => ModalKind.none);
|
||||
|
||||
/// Einstellungen direkt in diesem Bereich öffnen (z. B. aus dem Tab „Du“);
|
||||
/// SettingsModal liest und leert es beim Öffnen.
|
||||
final settingsSectionRequestProvider = StateProvider<String?>((ref) => null);
|
||||
|
||||
// ─── Call state ───────────────────────────────────────────────────────────────
|
||||
// Die Call-Provider leben seit dem M2-Call-Pilot in ihren Modulen:
|
||||
// `callSignalingProvider` (features/call_signaling/call_signaling_service.dart)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
/// Raumtyp der Pyramid-Sprachkanäle (creation_content.type).
|
||||
const voiceChannelRoomType = 'io.pyramid.voice_channel';
|
||||
|
||||
/// Ist [room] ein Sprachkanal (statt eines Text-Chats)?
|
||||
bool isVoiceChannelRoom(Room room) =>
|
||||
room.getState(EventTypes.RoomCreate)?.content['type'] == voiceChannelRoomType;
|
||||
|
||||
/// IDs aller Räume, die Kanal eines beigetretenen Servers (Space) sind –
|
||||
/// auch über Unter-Spaces. Diese Räume stehen nur in ihrem Server, nicht
|
||||
/// zusätzlich in „Chats“ (Bernd 2026-10-09: „Alles in Chats“).
|
||||
Set<String> spaceChannelIds(Client client) {
|
||||
final ids = <String>{};
|
||||
for (final space in client.rooms) {
|
||||
if (!space.isSpace || space.membership != Membership.join) continue;
|
||||
for (final child in space.spaceChildren) {
|
||||
final id = child.roomId;
|
||||
if (id != null) ids.add(id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/// Gehört [room] in die gemeinsame Chatliste? Direktnachrichten und Gruppen,
|
||||
/// die zu keinem beigetretenen Server gehören. [channelIds] stammt aus
|
||||
/// [spaceChannelIds] (einmal pro Liste berechnen, nicht pro Raum).
|
||||
bool isChatListRoom(Room room, Set<String> channelIds) {
|
||||
if (room.isSpace) return false;
|
||||
if (room.isDirectChat) return true;
|
||||
return !channelIds.contains(room.id);
|
||||
}
|
||||
|
||||
/// Gruppenchat = kein Space, keine Direktnachricht, kein Sprachkanal.
|
||||
bool isGroupChatRoom(Room room) =>
|
||||
!room.isSpace && !room.isDirectChat && !isVoiceChannelRoom(room);
|
||||
@@ -68,6 +68,13 @@ class StringSetPref extends StateNotifier<Set<String>> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Navigation (Handy) ───────────────────────────────────────────────────────
|
||||
|
||||
/// Handy: untere Leiste (Chats · Server · Du) statt Seitenmenü.
|
||||
/// Standard: Seitenmenü (Bernd 2026-10-09: untere Leiste erst zum Vergleich).
|
||||
final bottomNavProvider =
|
||||
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('nav_bottom_bar', false));
|
||||
|
||||
// ── Notification preferences ─────────────────────────────────────────────────
|
||||
|
||||
final notifDesktopProvider =
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui';
|
||||
|
||||
/// Layout der Anrufansicht als „aufgeblasene“ Formen (Bernd 2026-10-10,
|
||||
/// Referenz: aufgepumpte Buchstaben in einer Kiste): Jede Person bekommt eine
|
||||
/// Zelle, alle Zellen zusammen füllen den ganzen Anrufbereich bis an die
|
||||
/// Ränder. Wo zwei Zellen aneinanderstoßen, sind sie plattgedrückt, außen
|
||||
/// liegen sie am Fensterrand an, die Ecken sind weich gerundet ([outline]).
|
||||
///
|
||||
/// Technik: gewichtetes Voronoi-Diagramm (Potenzdiagramm) im Rechteck. Jede
|
||||
/// Zelle hat einen Anteil an der Fläche ([InflatedCell.share], wer spricht
|
||||
/// bekommt mehr). In jedem Schritt wandert der Mittelpunkt einer Zelle zu
|
||||
/// ihrem Schwerpunkt (gleichmäßige, ruhige Formen) und ihr Gewicht wird so
|
||||
/// nachgeführt, dass die Fläche dem Anteil entspricht. Weil das schrittweise
|
||||
/// geschieht, sieht man es als federndes Auf- und Abpumpen. Je mehr Personen,
|
||||
/// desto kleiner die Zellen.
|
||||
class InflatedCell {
|
||||
InflatedCell(this.id, this.site, this.weight, {this.share = 1});
|
||||
|
||||
final String id;
|
||||
|
||||
/// Mittelpunkt (Erzeuger) der Zelle.
|
||||
Offset site;
|
||||
|
||||
/// Platz im Raster, zu dem die Zelle sanft gezogen wird (aufgeräumte
|
||||
/// Anordnung); null = keiner (z. B. beim Gehen).
|
||||
Offset? slot;
|
||||
|
||||
/// Gewicht im Potenzdiagramm (Einheit: Fläche, wie Radius²).
|
||||
double weight;
|
||||
|
||||
/// Aktueller (animierter) und gewünschter Flächenanteil.
|
||||
double share;
|
||||
double targetShare = 1;
|
||||
bool get leaving => targetShare == 0;
|
||||
|
||||
/// Ergebnis der letzten Berechnung.
|
||||
List<Offset> polygon = const [];
|
||||
double area = 0;
|
||||
Offset centroid = Offset.zero;
|
||||
}
|
||||
|
||||
class InflatedLayout {
|
||||
InflatedLayout({this.gap = 10, this.margin = 12});
|
||||
|
||||
/// Wie viel mehr Fläche bekommt, wer gerade spricht.
|
||||
static const speakingShare = 1.7;
|
||||
|
||||
/// Abstand zwischen zwei Zellen bzw. zum Fensterrand.
|
||||
final double gap;
|
||||
final double margin;
|
||||
|
||||
final Map<String, InflatedCell> cells = {};
|
||||
Size _size = Size.zero;
|
||||
Size get size => _size;
|
||||
|
||||
Rect get bounds => Rect.fromLTWH(
|
||||
margin,
|
||||
margin,
|
||||
math.max(0, _size.width - 2 * margin),
|
||||
math.max(0, _size.height - 2 * margin),
|
||||
);
|
||||
|
||||
/// Gleicht die Zellen mit den Teilnehmern ab ([speaking] = wer spricht).
|
||||
void sync(List<String> ids, Set<String> speaking, Size size) {
|
||||
final old = _size;
|
||||
_size = size;
|
||||
if (size != old && old.width > 0 && old.height > 0 && cells.isNotEmpty) {
|
||||
final flipped = (old.width >= old.height) != (size.width >= size.height);
|
||||
if (flipped) {
|
||||
// Hochkant ↔ quer: neu anordnen, sonst bleiben z. B. zwei Zellen
|
||||
// übereinander, obwohl nebeneinander viel besser passt.
|
||||
_placeOnGrid(cells.values.toList());
|
||||
} else {
|
||||
final sx = size.width / old.width, sy = size.height / old.height;
|
||||
for (final c in cells.values) {
|
||||
c.site = Offset(c.site.dx * sx, c.site.dy * sy);
|
||||
c.weight *= sx * sy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final present = ids.toSet();
|
||||
for (final c in cells.values) {
|
||||
if (!present.contains(c.id)) c.targetShare = 0;
|
||||
}
|
||||
final fresh = ids.where((id) => !cells.containsKey(id)).toList();
|
||||
if (cells.isEmpty) {
|
||||
// Erster Aufbau: gleich voll da, im Raster angeordnet.
|
||||
final created = [for (final id in fresh) InflatedCell(id, Offset.zero, 0)];
|
||||
_placeOnGrid(created);
|
||||
for (final c in created) {
|
||||
cells[c.id] = c;
|
||||
}
|
||||
} else {
|
||||
for (final id in fresh) {
|
||||
// Neu dazu: entsteht in der größten Zelle und pumpt sich dort auf.
|
||||
final host = cells.values.reduce((a, b) => a.area >= b.area ? a : b);
|
||||
final h = id.hashCode;
|
||||
final dir = Offset(math.cos(h % 360.0), math.sin(h % 360.0));
|
||||
final spread = math.sqrt(math.max(host.area, 1)) * 0.2;
|
||||
final site = _inside(host.centroid + dir * spread);
|
||||
// Gewicht knapp über der Grenze, ab der die Zelle leer wäre
|
||||
// (w ≤ w_host − d²): sie beginnt winzig und pumpt sich dann auf.
|
||||
final d2 = (site - host.site).distanceSquared;
|
||||
cells[id] = InflatedCell(
|
||||
id,
|
||||
site,
|
||||
host.weight - d2 * 0.9,
|
||||
share: 0.15,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (final id in ids) {
|
||||
cells[id]!.targetShare = speaking.contains(id) ? speakingShare : 1.0;
|
||||
}
|
||||
final slots = _gridPoints(ids.length);
|
||||
for (var i = 0; i < ids.length; i++) {
|
||||
cells[ids[i]]!.slot = i < slots.length ? slots[i] : null;
|
||||
}
|
||||
for (final c in cells.values) {
|
||||
if (c.leaving) c.slot = null;
|
||||
}
|
||||
_compute();
|
||||
}
|
||||
|
||||
void _placeOnGrid(List<InflatedCell> list) {
|
||||
final points = _gridPoints(list.length);
|
||||
for (var i = 0; i < list.length && i < points.length; i++) {
|
||||
list[i]
|
||||
..site = points[i]
|
||||
..weight = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mittelpunkte eines Rasters für [n] Zellen: möglichst quadratische
|
||||
/// Zellen, wenig Lücken; die letzte Reihe wird mittig verteilt.
|
||||
List<Offset> _gridPoints(int n) {
|
||||
final b = bounds;
|
||||
if (n == 0 || b.isEmpty) return const [];
|
||||
var cols = 1;
|
||||
var best = double.infinity;
|
||||
for (var c = 1; c <= n; c++) {
|
||||
final r = (n / c).ceil();
|
||||
final aspect = (b.width / c) / (b.height / r);
|
||||
final score = math.log(aspect).abs() + 0.35 * (c * r - n);
|
||||
if (score < best) {
|
||||
best = score;
|
||||
cols = c;
|
||||
}
|
||||
}
|
||||
final rows = (n / cols).ceil();
|
||||
return [
|
||||
for (var i = 0; i < n; i++)
|
||||
() {
|
||||
final row = i ~/ cols;
|
||||
final inRow = row == rows - 1 ? n - row * cols : cols;
|
||||
final col = i % cols;
|
||||
// Letzte (unvollständige) Reihe: gleichmäßig über die Breite.
|
||||
final x = row == rows - 1 && inRow < cols
|
||||
? b.left + b.width * (col + 0.5) / inRow
|
||||
: b.left + b.width * (col + 0.5) / cols;
|
||||
return Offset(x, b.top + b.height * (row + 0.5) / rows);
|
||||
}(),
|
||||
];
|
||||
}
|
||||
|
||||
Offset _inside(Offset p) {
|
||||
final b = bounds.deflate(1);
|
||||
return Offset(p.dx.clamp(b.left, b.right), p.dy.clamp(b.top, b.bottom));
|
||||
}
|
||||
|
||||
/// Ein Zeitschritt (Sekunden). `true`, solange sich noch etwas bewegt.
|
||||
bool step(double dt) {
|
||||
if (_size.isEmpty || cells.isEmpty) return false;
|
||||
dt = dt.clamp(0.0, 1 / 20);
|
||||
var moving = false;
|
||||
|
||||
final shareRate = 1 - math.exp(-dt * 6);
|
||||
for (final c in cells.values) {
|
||||
final d = c.targetShare - c.share;
|
||||
if (d.abs() > 0.01) moving = true;
|
||||
c.share += d * shareRate;
|
||||
}
|
||||
cells.removeWhere((_, c) => c.leaving && c.share < 0.03);
|
||||
if (cells.isEmpty) return false;
|
||||
|
||||
_compute();
|
||||
final total = cells.values.fold<double>(0, (s, c) => s + c.area);
|
||||
final shareSum = cells.values.fold<double>(0, (s, c) => s + c.share);
|
||||
final f = math.min(1.0, dt * 10);
|
||||
final move = 1 - math.exp(-dt * 8);
|
||||
for (final c in cells.values) {
|
||||
// Fläche zum Anteil passend machen: Gewicht direkt um den Fehler
|
||||
// verschieben (Gewicht und Fläche haben dieselbe Einheit; ein Plus an
|
||||
// Gewicht schiebt alle Grenzen der Zelle nach außen). Gedämpft, damit
|
||||
// es federnd statt ruckartig wirkt.
|
||||
final target = total * c.share / shareSum;
|
||||
final error = target - c.area;
|
||||
if (error.abs() > target * 0.02) moving = true;
|
||||
c.weight += 0.4 * f * error;
|
||||
// … und den Mittelpunkt zu Schwerpunkt bzw. Rasterplatz ziehen.
|
||||
if (c.area > 1) {
|
||||
final goal = c.slot == null
|
||||
? c.centroid
|
||||
: Offset.lerp(c.centroid, c.slot!, 0.5)!;
|
||||
final d = goal - c.site;
|
||||
// Ruhe, sobald sich nichts mehr sichtbar bewegt (< ~3 px/s).
|
||||
if (d.distance * move > 0.05) moving = true;
|
||||
c.site = _inside(c.site + d * move);
|
||||
}
|
||||
}
|
||||
// Nur Unterschiede zählen – Mittelwert bei 0 halten.
|
||||
final mean =
|
||||
cells.values.fold<double>(0, (s, c) => s + c.weight) / cells.length;
|
||||
for (final c in cells.values) {
|
||||
c.weight -= mean;
|
||||
}
|
||||
_clampWeights();
|
||||
_compute();
|
||||
return moving;
|
||||
}
|
||||
|
||||
/// Ein Erzeuger muss in seiner eigenen Zelle liegen: w_j − w_i ≤ d².
|
||||
void _clampWeights() {
|
||||
final list = cells.values.toList();
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
for (var j = 0; j < list.length; j++) {
|
||||
if (i == j) continue;
|
||||
final d2 = (list[i].site - list[j].site).distanceSquared;
|
||||
if (list[j].weight - list[i].weight > d2) {
|
||||
list[j].weight = list[i].weight + d2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _compute() {
|
||||
final b = bounds;
|
||||
final list = cells.values.toList();
|
||||
for (final c in list) {
|
||||
var poly = <Offset>[b.topLeft, b.topRight, b.bottomRight, b.bottomLeft];
|
||||
for (final o in list) {
|
||||
if (identical(o, c) || poly.isEmpty) continue;
|
||||
final n = (o.site - c.site) * 2;
|
||||
final len = n.distance;
|
||||
if (len < 1e-6) continue;
|
||||
// Grenze des Potenzdiagramms, um die halbe Lücke zu c hin versetzt.
|
||||
final limit = o.site.distanceSquared -
|
||||
c.site.distanceSquared +
|
||||
c.weight -
|
||||
o.weight -
|
||||
gap / 2 * len;
|
||||
poly = _clip(poly, n, limit);
|
||||
}
|
||||
c.polygon = poly;
|
||||
_measure(c);
|
||||
}
|
||||
}
|
||||
|
||||
/// Polygon auf die Halbebene n·x ≤ limit beschneiden.
|
||||
static List<Offset> _clip(List<Offset> poly, Offset n, double limit) {
|
||||
final out = <Offset>[];
|
||||
for (var i = 0; i < poly.length; i++) {
|
||||
final a = poly[i], b = poly[(i + 1) % poly.length];
|
||||
final da = n.dx * a.dx + n.dy * a.dy - limit;
|
||||
final db = n.dx * b.dx + n.dy * b.dy - limit;
|
||||
if (da <= 0) out.add(a);
|
||||
if ((da <= 0) != (db <= 0)) {
|
||||
out.add(a + (b - a) * (da / (da - db)));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static void _measure(InflatedCell c) {
|
||||
final p = c.polygon;
|
||||
if (p.length < 3) {
|
||||
c.area = 0;
|
||||
return;
|
||||
}
|
||||
var a2 = 0.0, cx = 0.0, cy = 0.0;
|
||||
for (var i = 0; i < p.length; i++) {
|
||||
final q = p[i], r = p[(i + 1) % p.length];
|
||||
final cross = q.dx * r.dy - r.dx * q.dy;
|
||||
a2 += cross;
|
||||
cx += (q.dx + r.dx) * cross;
|
||||
cy += (q.dy + r.dy) * cross;
|
||||
}
|
||||
if (a2.abs() < 1e-6) {
|
||||
c.area = 0;
|
||||
return;
|
||||
}
|
||||
c.area = a2.abs() / 2;
|
||||
c.centroid = Offset(cx / (3 * a2), cy / (3 * a2));
|
||||
}
|
||||
|
||||
/// Umriss von [id]: die Zelle mit weich gerundeten Ecken („aufgeblasen“).
|
||||
Path outline(String id) {
|
||||
final c = cells[id];
|
||||
final path = Path();
|
||||
if (c == null) return path;
|
||||
final p = <Offset>[];
|
||||
for (final v in c.polygon) {
|
||||
if (p.isEmpty || (v - p.last).distance > 0.5) p.add(v);
|
||||
}
|
||||
if (p.length > 2 && (p.first - p.last).distance <= 0.5) p.removeLast();
|
||||
if (p.length < 3) return path;
|
||||
final rcMax = math.min(72.0, math.sqrt(c.area) * 0.34);
|
||||
for (var i = 0; i < p.length; i++) {
|
||||
final v = p[i];
|
||||
final prev = p[(i - 1 + p.length) % p.length];
|
||||
final next = p[(i + 1) % p.length];
|
||||
final lp = (prev - v).distance, ln = (next - v).distance;
|
||||
final r = math.min(rcMax, math.min(lp, ln) * 0.5);
|
||||
final a = v + (prev - v) / lp * r;
|
||||
final b = v + (next - v) / ln * r;
|
||||
if (i == 0) {
|
||||
path.moveTo(a.dx, a.dy);
|
||||
} else {
|
||||
path.lineTo(a.dx, a.dy);
|
||||
}
|
||||
// Kubische Ecke (etwas voller als ein Viertelkreis – wirkt prall).
|
||||
final c1 = a + (v - a) * 0.62, c2 = b + (v - b) * 0.62;
|
||||
path.cubicTo(c1.dx, c1.dy, c2.dx, c2.dy, b.dx, b.dy);
|
||||
}
|
||||
path.close();
|
||||
return path;
|
||||
}
|
||||
|
||||
/// Rechteck um die Zelle (für Inhalte wie Video).
|
||||
Rect cellBounds(String id) {
|
||||
final p = cells[id]?.polygon ?? const [];
|
||||
if (p.isEmpty) return Rect.zero;
|
||||
var l = p.first.dx, t = p.first.dy, r = l, b = t;
|
||||
for (final v in p) {
|
||||
l = math.min(l, v.dx);
|
||||
t = math.min(t, v.dy);
|
||||
r = math.max(r, v.dx);
|
||||
b = math.max(b, v.dy);
|
||||
}
|
||||
return Rect.fromLTRB(l, t, r, b);
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ class _MiniCallWidgetState extends ConsumerState<MiniCallWidget> {
|
||||
final call = widget.call;
|
||||
|
||||
final String displayName = isVoip
|
||||
? (call.currentCall?.room.getLocalizedDisplayname() ?? 'Unknown')
|
||||
? (call.currentCall?.room.getLocalizedDisplayname() ?? 'Unbekannt')
|
||||
: call.displayName;
|
||||
|
||||
final bool isConnecting = isVoip ? false : call.isConnecting;
|
||||
@@ -160,7 +160,7 @@ class _MiniCallWidgetState extends ConsumerState<MiniCallWidget> {
|
||||
const _LiveDot(),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
isConnecting ? 'CONNECTING...' : 'VOICE CONNECTED',
|
||||
isConnecting ? 'CONNECTING...' : (isVoip ? 'ANRUF LÄUFT' : (call.isVoiceChannel ? 'SPRACHKANAL' : 'GRUPPENANRUF')),
|
||||
style: TextStyle(
|
||||
color: pt.online,
|
||||
fontSize: 10,
|
||||
|
||||
@@ -14,7 +14,7 @@ class _BigAvatar extends StatelessWidget {
|
||||
gradient: LinearGradient(colors: [data.avatarColor, data.avatarColor.withAlpha(150)], begin: Alignment.topLeft, end: Alignment.bottomRight),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(child: Text(data.name.isNotEmpty ? data.name[0].toUpperCase() : '?', style: const TextStyle(color: Colors.white, fontSize: 40, fontWeight: FontWeight.bold))),
|
||||
child: Center(child: Text(data.initial, style: const TextStyle(color: Colors.white, fontSize: 40, fontWeight: FontWeight.bold))),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -34,7 +34,7 @@ class _SmallAvatar extends StatelessWidget {
|
||||
gradient: LinearGradient(colors: [data.avatarColor, data.avatarColor.withAlpha(150)], begin: Alignment.topLeft, end: Alignment.bottomRight),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(child: Text(data.name.isNotEmpty ? data.name[0].toUpperCase() : '?', style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold))),
|
||||
child: Center(child: Text(data.initial, style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold))),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
@@ -111,14 +127,12 @@ class _NoCall extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
PyrIconBtn(
|
||||
icon: const Icon(Icons.menu_rounded),
|
||||
tooltip: 'Toggle sidebar',
|
||||
onPressed: () => ref.read(railCollapsedProvider.notifier).update((s) => !s),
|
||||
icon: const Icon(Icons.arrow_back_rounded),
|
||||
tooltip: 'Zurück',
|
||||
onPressed: () => ref.read(viewModeProvider.notifier).state = ViewMode.chat,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(Icons.headphones_rounded, size: 16, color: pt.fgMuted),
|
||||
const SizedBox(width: 8),
|
||||
Text('No active call', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)),
|
||||
Text('Kein Anruf', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -127,9 +141,15 @@ class _NoCall extends StatelessWidget {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('𐃏', style: TextStyle(fontSize: 48, color: pt.fgDim.withAlpha(100))),
|
||||
Icon(Icons.call_end_rounded, size: 48, color: pt.fgDim),
|
||||
const SizedBox(height: 12),
|
||||
Text('Join a voice room to start a call', style: TextStyle(color: pt.fgMuted, fontSize: 14)),
|
||||
Text('Gerade läuft kein Anruf.', style: TextStyle(color: pt.fgMuted, fontSize: 14)),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: () => ref.read(viewModeProvider.notifier).state = ViewMode.chat,
|
||||
style: FilledButton.styleFrom(backgroundColor: pt.accent, foregroundColor: pt.accentFg),
|
||||
child: const Text('Zurück zum Chat'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
part of '../voice_channel.dart';
|
||||
|
||||
/// Anrufansicht als „aufgeblasene“ Formen (Layout: inflated_layout.dart,
|
||||
/// Bernd 2026-10-10): Jede Person füllt ihre Zelle bis an die Nachbarn und
|
||||
/// den Fensterrand, dargestellt als Kontur mit weichem Innenleuchten; in der
|
||||
/// Mitte Profilbild und Name. Wer spricht, pumpt sich auf und leuchtet grün,
|
||||
/// die anderen geben federnd nach. Kamera-Bild füllt die Form.
|
||||
class _BubbleStage extends StatefulWidget {
|
||||
final List<_ParticipantData> participants;
|
||||
final Client? client;
|
||||
final PyramidTheme pt;
|
||||
final ValueChanged<_ParticipantData> onSelect;
|
||||
|
||||
const _BubbleStage({
|
||||
super.key,
|
||||
required this.participants,
|
||||
required this.client,
|
||||
required this.pt,
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_BubbleStage> createState() => _BubbleStageState();
|
||||
}
|
||||
|
||||
class _BubbleStageState extends State<_BubbleStage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
final _layout = InflatedLayout();
|
||||
// Letzter bekannter Stand je Person – auch für Gehende, die noch
|
||||
// wegschrumpfen.
|
||||
final Map<String, _ParticipantData> _data = {};
|
||||
late final Ticker _ticker = createTicker(_tick);
|
||||
Duration? _last;
|
||||
double _time = 0;
|
||||
bool _needsSync = true;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_BubbleStage old) {
|
||||
super.didUpdateWidget(old);
|
||||
_needsSync = true;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ticker.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _tick(Duration elapsed) {
|
||||
final dt = _last == null
|
||||
? 1 / 60
|
||||
: (elapsed - _last!).inMicroseconds / Duration.microsecondsPerSecond;
|
||||
_last = elapsed;
|
||||
_time += dt;
|
||||
final moving = _layout.step(dt);
|
||||
_data.removeWhere((id, _) => !_layout.cells.containsKey(id));
|
||||
final speaking = widget.participants.any((p) => p.isSpeaking);
|
||||
setState(() {});
|
||||
// Ruhe = keine Animation (spart Akku); Sprechen hält das Leuchten an.
|
||||
if (!moving && !speaking) {
|
||||
_ticker.stop();
|
||||
_last = null;
|
||||
}
|
||||
}
|
||||
|
||||
void _sync(Size size) {
|
||||
for (final p in widget.participants) {
|
||||
_data[p.id] = p;
|
||||
}
|
||||
_layout.sync(
|
||||
widget.participants.map((p) => p.id).toList(),
|
||||
widget.participants.where((p) => p.isSpeaking).map((p) => p.id).toSet(),
|
||||
size,
|
||||
);
|
||||
if (!_ticker.isActive) {
|
||||
_last = null;
|
||||
_ticker.start();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
return LayoutBuilder(builder: (context, box) {
|
||||
final size = box.biggest;
|
||||
if (_needsSync || size != _layout.size) {
|
||||
_needsSync = false;
|
||||
_sync(size);
|
||||
}
|
||||
final glow = 0.5 + 0.5 * math.sin(_time * 5);
|
||||
final shapes = <_CellShape>[];
|
||||
for (final c in _layout.cells.values) {
|
||||
final d = _data[c.id];
|
||||
if (d == null || c.area < 16) continue;
|
||||
shapes.add(_CellShape(
|
||||
data: d,
|
||||
path: _layout.outline(c.id),
|
||||
bounds: _layout.cellBounds(c.id),
|
||||
center: c.centroid,
|
||||
opacity: c.share.clamp(0.0, 1.0),
|
||||
));
|
||||
}
|
||||
|
||||
return ColoredBox(
|
||||
color: pt.bg1,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
children: [
|
||||
// Kamera-Bild füllt die Form.
|
||||
for (final s in shapes)
|
||||
if (s.data.hasVideo && s.data.coverVideoWidget != null)
|
||||
Positioned.fill(
|
||||
key: ValueKey('video:${s.data.id}'),
|
||||
child: ClipPath(
|
||||
clipper: _PathClipper(s.path),
|
||||
child: Stack(children: [
|
||||
Positioned.fromRect(
|
||||
rect: s.bounds,
|
||||
child: _MirrorWrapper(
|
||||
isLocal: s.data.isLocal,
|
||||
isScreenShare: s.data.isScreenShare,
|
||||
child: s.data.coverVideoWidget!,
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
// Konturen (alle in einem Zeichenvorgang).
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: CustomPaint(
|
||||
painter: _ContourPainter(
|
||||
shapes: shapes,
|
||||
glow: glow,
|
||||
speakingColor: pt.online,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Profilbild + Name in der Mitte jeder Form.
|
||||
for (final s in shapes)
|
||||
Positioned.fromRect(
|
||||
key: ValueKey('content:${s.data.id}'),
|
||||
rect: Rect.fromCenter(
|
||||
center: s.data.hasVideo
|
||||
? Offset(s.center.dx, s.bounds.bottom - 28)
|
||||
: s.center,
|
||||
width: s.bounds.width * 0.86,
|
||||
height: s.data.hasVideo ? 40 : s.bounds.height * 0.82,
|
||||
),
|
||||
child: IgnorePointer(
|
||||
child: Opacity(
|
||||
opacity: s.opacity,
|
||||
child: _CellContent(
|
||||
data: s.data,
|
||||
client: widget.client,
|
||||
pt: pt,
|
||||
cellSize: s.bounds.size,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Antippen: Form mit Kamera-Bild groß zeigen.
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTapUp: (details) {
|
||||
for (final s in shapes) {
|
||||
if (s.path.contains(details.localPosition)) {
|
||||
widget.onSelect(s.data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _CellShape {
|
||||
final _ParticipantData data;
|
||||
final Path path;
|
||||
final Rect bounds;
|
||||
final Offset center;
|
||||
final double opacity;
|
||||
const _CellShape({
|
||||
required this.data,
|
||||
required this.path,
|
||||
required this.bounds,
|
||||
required this.center,
|
||||
required this.opacity,
|
||||
});
|
||||
}
|
||||
|
||||
/// Profilbild (rund, mit Kontur) und Name – skaliert sich in jede Zellgröße.
|
||||
class _CellContent extends StatelessWidget {
|
||||
final _ParticipantData data;
|
||||
final Client? client;
|
||||
final PyramidTheme pt;
|
||||
final Size cellSize;
|
||||
|
||||
const _CellContent({
|
||||
required this.data,
|
||||
required this.client,
|
||||
required this.pt,
|
||||
required this.cellSize,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final avatar = (math.min(cellSize.width, cellSize.height) * 0.36)
|
||||
.clamp(28.0, 132.0)
|
||||
.toDouble();
|
||||
final fontSize = (avatar * 0.15).clamp(11.0, 16.0).toDouble();
|
||||
final nameTag = Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: fontSize * 0.7, vertical: fontSize * 0.25),
|
||||
decoration: BoxDecoration(
|
||||
color: data.hasVideo
|
||||
? Colors.black.withValues(alpha: 0.5)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (data.isMuted) ...[
|
||||
Icon(Icons.mic_off_rounded, size: fontSize + 1, color: pt.danger),
|
||||
SizedBox(width: fontSize * 0.3),
|
||||
],
|
||||
Text(
|
||||
data.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: data.hasVideo ? Colors.white : pt.fg,
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (data.hasVideo) {
|
||||
return FittedBox(fit: BoxFit.scaleDown, child: nameTag);
|
||||
}
|
||||
return FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_OutlinedAvatar(
|
||||
data: data,
|
||||
client: client,
|
||||
size: avatar,
|
||||
ring: data.isSpeaking ? pt.online : data.avatarColor,
|
||||
),
|
||||
SizedBox(height: fontSize * 0.6),
|
||||
nameTag,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rundes Profilbild mit Kontur; ohne Bild nur der Anfangsbuchstabe in der
|
||||
/// Konturfarbe (passt zum reinen Linien-Look).
|
||||
class _OutlinedAvatar extends StatelessWidget {
|
||||
final _ParticipantData data;
|
||||
final Client? client;
|
||||
final double size;
|
||||
final Color ring;
|
||||
|
||||
const _OutlinedAvatar({
|
||||
required this.data,
|
||||
required this.client,
|
||||
required this.size,
|
||||
required this.ring,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget letter(BuildContext _) => Container(
|
||||
alignment: Alignment.center,
|
||||
color: ring.withValues(alpha: 0.08),
|
||||
// Gezeichnet in der 256er-Fläche, danach auf [size] skaliert.
|
||||
child: Text(data.initial,
|
||||
style: TextStyle(
|
||||
color: ring, fontSize: 104, fontWeight: FontWeight.w600)),
|
||||
);
|
||||
final c = client;
|
||||
final inner = (c != null && data.avatarUrl != null)
|
||||
? MxcAvatar(
|
||||
mxcUri: data.avatarUrl,
|
||||
client: c,
|
||||
size: 256,
|
||||
borderRadius: BorderRadius.zero,
|
||||
placeholder: letter,
|
||||
)
|
||||
: Builder(builder: letter);
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
padding: const EdgeInsets.all(3),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: ring, width: 2),
|
||||
),
|
||||
child: ClipOval(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.cover,
|
||||
child: SizedBox(width: 256, height: 256, child: inner),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PathClipper extends CustomClipper<Path> {
|
||||
final Path path;
|
||||
_PathClipper(this.path);
|
||||
|
||||
@override
|
||||
Path getClip(Size size) => path;
|
||||
|
||||
@override
|
||||
bool shouldReclip(_PathClipper old) => !identical(old.path, path);
|
||||
}
|
||||
|
||||
class _ContourPainter extends CustomPainter {
|
||||
final List<_CellShape> shapes;
|
||||
final double glow;
|
||||
final Color speakingColor;
|
||||
|
||||
_ContourPainter({
|
||||
required this.shapes,
|
||||
required this.glow,
|
||||
required this.speakingColor,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
for (final s in shapes) {
|
||||
final speaking = s.data.isSpeaking;
|
||||
final color = speaking ? speakingColor : s.data.avatarColor;
|
||||
final o = s.opacity;
|
||||
// Weiches Leuchten innen an der Kontur – wirkt „prall“, bleibt aber
|
||||
// ein Linien-Look.
|
||||
canvas.save();
|
||||
canvas.clipPath(s.path);
|
||||
canvas.drawPath(
|
||||
s.path,
|
||||
Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = speaking ? 30 : 22
|
||||
..color = color.withValues(
|
||||
alpha: o * (speaking ? 0.20 + 0.12 * glow : 0.10))
|
||||
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 12),
|
||||
);
|
||||
canvas.restore();
|
||||
if (speaking) {
|
||||
canvas.drawPath(
|
||||
s.path,
|
||||
Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 8
|
||||
..color = color.withValues(alpha: o * (0.25 + 0.25 * glow))
|
||||
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8),
|
||||
);
|
||||
}
|
||||
canvas.drawPath(
|
||||
s.path,
|
||||
Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = speaking ? 3 : 2
|
||||
..strokeJoin = StrokeJoin.round
|
||||
..color = color.withValues(alpha: o * (speaking ? 1.0 : 0.85)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_ContourPainter old) => true;
|
||||
}
|
||||
|
||||
/// Vorführ-Ansicht der Anruf-Formen mit erfundenen Teilnehmern – nur für
|
||||
/// Screenshots/Tests (UI-Tour), damit man die Ansicht mit mehreren Personen
|
||||
/// sieht, ohne dass so viele Testkonten anrufen müssen.
|
||||
@visibleForTesting
|
||||
class CallBubblesDemo extends StatelessWidget {
|
||||
final int count;
|
||||
/// Wer gerade spricht (Index, -1 = niemand).
|
||||
final int speaking;
|
||||
const CallBubblesDemo({super.key, this.count = 5, this.speaking = 2});
|
||||
|
||||
static const _names = ['Du', 'Anna', 'Ben', 'Clara', 'David', 'Ela', 'Finn'];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = PyramidTheme.of(context);
|
||||
final participants = [
|
||||
for (var i = 0; i < count.clamp(1, _names.length); i++)
|
||||
_ParticipantData(
|
||||
id: 'demo$i',
|
||||
name: _names[i],
|
||||
isLocal: i == 0,
|
||||
isSpeaking: i == speaking,
|
||||
isMuted: i == 3,
|
||||
avatarColor: VoiceChannelView._avatarColors[i % VoiceChannelView._avatarColors.length],
|
||||
),
|
||||
];
|
||||
return _UnifiedCallView(
|
||||
participants: participants,
|
||||
client: null,
|
||||
startedAt: DateTime.now().subtract(const Duration(minutes: 3, seconds: 12)),
|
||||
isLiveKit: true,
|
||||
displayName: 'Familie',
|
||||
isConnecting: false,
|
||||
onHangUp: () {},
|
||||
onMinimize: () {},
|
||||
onToggleMic: () {},
|
||||
onToggleDeafen: () {},
|
||||
onToggleCam: () {},
|
||||
onToggleScreen: () {},
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
isCameraOff: true,
|
||||
isScreenSharing: false,
|
||||
pt: pt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
part of '../voice_channel.dart';
|
||||
|
||||
/// Darstellung der Teilnehmer: Bubbles (Standard), Kacheln, oder ein großes
|
||||
/// Bild (Bildschirm/Kamera) mit Leiste.
|
||||
enum _CallLayout { bubbles, grid, hero }
|
||||
|
||||
class _UnifiedCallView extends StatefulWidget {
|
||||
final List<_ParticipantData> participants;
|
||||
final Client? client;
|
||||
final DateTime startedAt;
|
||||
final bool isLiveKit;
|
||||
final String displayName;
|
||||
final bool isConnecting;
|
||||
@@ -17,9 +23,13 @@ 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,
|
||||
required this.client,
|
||||
required this.startedAt,
|
||||
required this.isLiveKit,
|
||||
required this.displayName,
|
||||
required this.isConnecting,
|
||||
@@ -35,6 +45,8 @@ class _UnifiedCallView extends StatefulWidget {
|
||||
required this.isScreenSharing,
|
||||
this.onQualityMenu,
|
||||
required this.pt,
|
||||
this.isSpeakerOn = true,
|
||||
this.onToggleSpeaker,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -42,31 +54,52 @@ class _UnifiedCallView extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _UnifiedCallViewState extends State<_UnifiedCallView> {
|
||||
/// Zuletzt gewählte Ansicht (Bubbles/Kacheln) – bleibt beim Minimieren
|
||||
/// und für den nächsten Anruf erhalten.
|
||||
static _CallLayout _preferred = _CallLayout.bubbles;
|
||||
_CallLayout _mode = _preferred;
|
||||
String? _heroId;
|
||||
bool _isGridView = false;
|
||||
bool _stripCollapsed = false;
|
||||
Set<String> _knownScreens = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_autoSelectHero();
|
||||
_followScreenShares();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_UnifiedCallView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
_autoSelectHero();
|
||||
_followScreenShares();
|
||||
}
|
||||
|
||||
void _autoSelectHero() {
|
||||
if (_isGridView) return;
|
||||
final screenshare = widget.participants.firstWhereOrNull((p) => p.isScreenShare);
|
||||
if (screenshare != null && (_heroId == null || !widget.participants.any((p) => p.id == _heroId && p.hasVideo))) {
|
||||
_heroId = screenshare.id;
|
||||
} else if (_heroId == null && widget.participants.isNotEmpty) {
|
||||
final firstVideo = widget.participants.firstWhereOrNull((p) => p.hasVideo);
|
||||
if (firstVideo != null) _heroId = firstVideo.id;
|
||||
/// Neue Bildschirmübertragung → groß zeigen. Verschwindet das große Bild
|
||||
/// (Person weg, Kamera aus), zurück zu den Bubbles.
|
||||
void _followScreenShares() {
|
||||
final screens = widget.participants
|
||||
.where((p) => p.isScreenShare)
|
||||
.map((p) => p.id)
|
||||
.toSet();
|
||||
final fresh = screens.difference(_knownScreens);
|
||||
if (fresh.isNotEmpty) {
|
||||
_mode = _CallLayout.hero;
|
||||
_heroId = fresh.first;
|
||||
}
|
||||
_knownScreens = screens;
|
||||
if (_mode == _CallLayout.hero &&
|
||||
!widget.participants.any((p) => p.id == _heroId && p.hasVideo)) {
|
||||
_mode = _preferred;
|
||||
_heroId = null;
|
||||
}
|
||||
}
|
||||
|
||||
void _showLarge(_ParticipantData p) {
|
||||
if (!p.hasVideo) return;
|
||||
setState(() {
|
||||
_mode = _CallLayout.hero;
|
||||
_heroId = p.id;
|
||||
});
|
||||
}
|
||||
|
||||
List<_ParticipantData> _sorted(List<_ParticipantData> all) {
|
||||
@@ -83,8 +116,10 @@ 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);
|
||||
|
||||
final hero = _mode == _CallLayout.hero
|
||||
? widget.participants.firstWhereOrNull((p) => p.id == _heroId)
|
||||
: null;
|
||||
|
||||
if (widget.isConnecting) {
|
||||
return Scaffold(
|
||||
backgroundColor: pt.bg1,
|
||||
@@ -115,23 +150,45 @@ class _UnifiedCallViewState extends State<_UnifiedCallView> {
|
||||
children: [
|
||||
_CallHeader(
|
||||
displayName: widget.displayName,
|
||||
isGridView: _isGridView,
|
||||
onToggleView: () => setState(() => _isGridView = !_isGridView),
|
||||
startedAt: widget.startedAt,
|
||||
count: widget.participants.length,
|
||||
layout: _mode,
|
||||
onToggleView: () => setState(() {
|
||||
_mode = _mode == _CallLayout.bubbles
|
||||
? _CallLayout.grid
|
||||
: _CallLayout.bubbles;
|
||||
_preferred = _mode;
|
||||
_heroId = null;
|
||||
}),
|
||||
onMinimize: widget.onMinimize,
|
||||
pt: pt,
|
||||
),
|
||||
Expanded(
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: _isGridView || hero == null
|
||||
? _ParticipantGrid(participants: all, pt: pt, onSelect: (p) => setState(() { _heroId = p.id; _isGridView = false; }))
|
||||
duration: motionDuration(context, 300),
|
||||
child: hero == null
|
||||
? (_mode == _CallLayout.grid
|
||||
? _ParticipantGrid(
|
||||
key: const ValueKey('grid'),
|
||||
participants: all,
|
||||
pt: pt,
|
||||
onSelect: _showLarge)
|
||||
: _BubbleStage(
|
||||
key: const ValueKey('bubbles'),
|
||||
participants: widget.participants,
|
||||
client: widget.client,
|
||||
pt: pt,
|
||||
onSelect: _showLarge))
|
||||
: _HeroLayout(
|
||||
key: const ValueKey('hero'),
|
||||
hero: hero,
|
||||
others: all.where((p) => p.id != hero.id).toList(),
|
||||
stripCollapsed: _stripCollapsed,
|
||||
onToggleStrip: () => setState(() => _stripCollapsed = !_stripCollapsed),
|
||||
onSetStripCollapsed: (v) => setState(() => _stripCollapsed = v),
|
||||
onSelectOther: (p) => setState(() => _heroId = p.id),
|
||||
onSelectOther: (p) => p.hasVideo
|
||||
? setState(() => _heroId = p.id)
|
||||
: null,
|
||||
onQualityMenu: widget.onQualityMenu,
|
||||
pt: pt,
|
||||
),
|
||||
@@ -148,6 +205,8 @@ class _UnifiedCallViewState extends State<_UnifiedCallView> {
|
||||
onToggleScreen: widget.onToggleScreen,
|
||||
onHangUp: widget.onHangUp,
|
||||
pt: pt,
|
||||
isSpeakerOn: widget.isSpeakerOn,
|
||||
onToggleSpeaker: widget.onToggleSpeaker,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -157,30 +216,55 @@ class _UnifiedCallViewState extends State<_UnifiedCallView> {
|
||||
|
||||
class _CallHeader extends StatelessWidget {
|
||||
final String displayName;
|
||||
final bool isGridView;
|
||||
final DateTime startedAt;
|
||||
final int count;
|
||||
final _CallLayout layout;
|
||||
final VoidCallback onToggleView;
|
||||
final VoidCallback onMinimize;
|
||||
final PyramidTheme pt;
|
||||
|
||||
const _CallHeader({required this.displayName, required this.isGridView, required this.onToggleView, required this.onMinimize, required this.pt});
|
||||
const _CallHeader({
|
||||
required this.displayName,
|
||||
required this.startedAt,
|
||||
required this.count,
|
||||
required this.layout,
|
||||
required this.onToggleView,
|
||||
required this.onMinimize,
|
||||
required this.pt,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final toBubbles = layout != _CallLayout.bubbles;
|
||||
return Container(
|
||||
height: 52,
|
||||
height: 56,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: pt.border))),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.headphones_rounded, size: 16, color: pt.fgMuted),
|
||||
const SizedBox(width: 8),
|
||||
Text(displayName, style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(width: 8),
|
||||
_LiveBadge(pt: pt),
|
||||
const Spacer(),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(displayName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 2),
|
||||
Row(children: [
|
||||
_LiveBadge(pt: pt),
|
||||
const SizedBox(width: 8),
|
||||
_CallDuration(startedAt: startedAt, pt: pt),
|
||||
Text(count == 1 ? ' · nur du' : ' · $count Personen',
|
||||
style: TextStyle(color: pt.fgMuted, fontSize: 12)),
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
PyrIconBtn(
|
||||
icon: Icon(isGridView ? Icons.grid_view_rounded : Icons.featured_video_rounded, color: isGridView ? pt.accent : pt.fg),
|
||||
tooltip: isGridView ? 'Hero mode' : 'Grid mode',
|
||||
icon: Icon(toBubbles ? Icons.bubble_chart_rounded : Icons.grid_view_rounded, color: pt.fg),
|
||||
tooltip: toBubbles ? 'Bubble-Ansicht' : 'Kachelansicht',
|
||||
onPressed: onToggleView,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
@@ -195,6 +279,47 @@ class _CallHeader extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Gesprächsdauer „m:ss“ bzw. „h:mm:ss“, tickt jede Sekunde.
|
||||
class _CallDuration extends StatefulWidget {
|
||||
final DateTime startedAt;
|
||||
final PyramidTheme pt;
|
||||
const _CallDuration({required this.startedAt, required this.pt});
|
||||
|
||||
@override
|
||||
State<_CallDuration> createState() => _CallDurationState();
|
||||
}
|
||||
|
||||
class _CallDurationState extends State<_CallDuration> {
|
||||
late final Timer _timer =
|
||||
Timer.periodic(const Duration(seconds: 1), (_) => setState(() {}));
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_timer; // starten
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final d = DateTime.now().difference(widget.startedAt);
|
||||
String two(int n) => n.toString().padLeft(2, '0');
|
||||
final text = d.inHours > 0
|
||||
? '${d.inHours}:${two(d.inMinutes % 60)}:${two(d.inSeconds % 60)}'
|
||||
: '${d.inMinutes}:${two(d.inSeconds % 60)}';
|
||||
return Text(text,
|
||||
style: TextStyle(
|
||||
color: widget.pt.fgMuted,
|
||||
fontSize: 12,
|
||||
fontFeatures: const [FontFeature.tabularFigures()]));
|
||||
}
|
||||
}
|
||||
|
||||
class _LiveBadge extends StatelessWidget {
|
||||
final PyramidTheme pt;
|
||||
const _LiveBadge({required this.pt});
|
||||
|
||||
@@ -5,7 +5,7 @@ class _ParticipantGrid extends StatelessWidget {
|
||||
final PyramidTheme pt;
|
||||
final Function(_ParticipantData) onSelect;
|
||||
|
||||
const _ParticipantGrid({required this.participants, required this.pt, required this.onSelect});
|
||||
const _ParticipantGrid({super.key, required this.participants, required this.pt, required this.onSelect});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -41,6 +41,7 @@ class _HeroLayout extends StatelessWidget {
|
||||
final PyramidTheme pt;
|
||||
|
||||
const _HeroLayout({
|
||||
super.key,
|
||||
required this.hero,
|
||||
required this.others,
|
||||
required this.stripCollapsed,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -12,6 +12,19 @@ class _ParticipantData {
|
||||
final Color avatarColor;
|
||||
final dynamic originalParticipant;
|
||||
final double aspectRatio;
|
||||
/// Woraus der Anfangsbuchstabe im Avatar gebildet wird (Standard: [name]).
|
||||
final String? initialFrom;
|
||||
/// Profilbild (mxc://) der Person, falls bekannt.
|
||||
final Uri? avatarUrl;
|
||||
/// Bin das ich?
|
||||
final bool isLocal;
|
||||
/// Kamera-/Bildschirmbild formatfüllend (für die runden Bubbles).
|
||||
final Widget? coverVideoWidget;
|
||||
|
||||
String get initial {
|
||||
final src = (initialFrom ?? name).replaceFirst('@', '').trim();
|
||||
return src.isEmpty ? '?' : src.characters.first.toUpperCase();
|
||||
}
|
||||
|
||||
const _ParticipantData({
|
||||
required this.id,
|
||||
@@ -24,9 +37,22 @@ class _ParticipantData {
|
||||
this.avatarColor = Colors.grey,
|
||||
this.originalParticipant,
|
||||
this.aspectRatio = 16.0 / 9.0,
|
||||
this.initialFrom,
|
||||
this.avatarUrl,
|
||||
this.isLocal = false,
|
||||
this.coverVideoWidget,
|
||||
});
|
||||
}
|
||||
|
||||
/// Startzeit je Anruf (für die Dauer im Kopf). Schlüssel bleibt beim
|
||||
/// Minimieren gleich, damit die Uhr nicht neu anfängt.
|
||||
final _callStarts = <String, DateTime>{};
|
||||
|
||||
DateTime _callStartFor(String key) {
|
||||
if (_callStarts.length > 20) _callStarts.clear();
|
||||
return _callStarts.putIfAbsent(key, DateTime.now);
|
||||
}
|
||||
|
||||
class VoiceChannelView extends ConsumerWidget {
|
||||
const VoiceChannelView({super.key});
|
||||
|
||||
@@ -35,10 +61,13 @@ class VoiceChannelView extends ConsumerWidget {
|
||||
final pt = PyramidTheme.of(context);
|
||||
final call = ref.watch(voiceChannelProvider);
|
||||
final voip = ref.watch(callSignalingProvider);
|
||||
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||
|
||||
if (voip.currentCall != null && voip.currentCall!.state != CallState.kEnded) {
|
||||
return _UnifiedCallView(
|
||||
participants: _getVoipParticipants(voip),
|
||||
client: client,
|
||||
startedAt: _callStartFor('voip:${voip.currentCall!.callId}'),
|
||||
isLiveKit: false,
|
||||
displayName: voip.currentCall!.room.getLocalizedDisplayname(),
|
||||
isConnecting: false,
|
||||
@@ -58,6 +87,8 @@ class VoiceChannelView extends ConsumerWidget {
|
||||
isScreenSharing: voip.isScreensharing,
|
||||
onQualityMenu: (pos) => _showVoipQualityMenu(context, voip, pos),
|
||||
pt: pt,
|
||||
isSpeakerOn: voip.isSpeakerOn,
|
||||
onToggleSpeaker: voip.toggleSpeaker,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,7 +97,9 @@ class VoiceChannelView extends ConsumerWidget {
|
||||
}
|
||||
|
||||
return _UnifiedCallView(
|
||||
participants: _getLiveKitParticipants(call),
|
||||
participants: _getLiveKitParticipants(call, client),
|
||||
client: client,
|
||||
startedAt: _callStartFor('lk:${call.currentRoomId ?? call.displayName}'),
|
||||
isLiveKit: true,
|
||||
displayName: call.displayName,
|
||||
isConnecting: call.isConnecting,
|
||||
@@ -86,12 +119,31 @@ class VoiceChannelView extends ConsumerWidget {
|
||||
isScreenSharing: call.isScreenSharing,
|
||||
onQualityMenu: (pos) => _showLiveKitQualityMenu(context, call, pos),
|
||||
pt: pt,
|
||||
isSpeakerOn: call.isSpeakerOn,
|
||||
onToggleSpeaker: call.toggleSpeaker,
|
||||
);
|
||||
}
|
||||
|
||||
List<_ParticipantData> _getLiveKitParticipants(VoiceChannelService call) {
|
||||
List<_ParticipantData> _getLiveKitParticipants(
|
||||
VoiceChannelService call, Client? client) {
|
||||
final room = call.room;
|
||||
if (room == null) return [];
|
||||
// LiveKit-Identität = Matrix-ID (vom Token-Server gesetzt) → Name und
|
||||
// Profilbild aus dem Matrix-Raum des Anrufs.
|
||||
final matrixRoomId = call.currentRoomId;
|
||||
final matrixRoom =
|
||||
matrixRoomId == null ? null : client?.getRoomById(matrixRoomId);
|
||||
User? member(String id) {
|
||||
if (!id.startsWith('@')) return null;
|
||||
if (matrixRoom != null) {
|
||||
return matrixRoom.unsafeGetUserFromMemoryOrFallback(id);
|
||||
}
|
||||
for (final r in client?.rooms ?? const []) {
|
||||
final u = r.unsafeGetUserFromMemoryOrFallback(id);
|
||||
if (u.avatarUrl != null || u.displayName != null) return u;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
final all = <Participant>[
|
||||
if (room.localParticipant != null) room.localParticipant!,
|
||||
...room.remoteParticipants.values,
|
||||
@@ -115,10 +167,20 @@ class VoiceChannelView extends ConsumerWidget {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
final isLocal = p.identity == room.localParticipant?.identity;
|
||||
final user = member(p.identity);
|
||||
final name = isLocal
|
||||
? 'Du'
|
||||
: (user?.calcDisplayname() ??
|
||||
(p.name.isNotEmpty ? p.name : p.identity));
|
||||
return _ParticipantData(
|
||||
id: p.identity,
|
||||
name: p.identity == room.localParticipant?.identity ? 'Du' : p.identity,
|
||||
name: name,
|
||||
initialFrom: isLocal ? user?.calcDisplayname() ?? p.identity : null,
|
||||
avatarUrl: user?.avatarUrl,
|
||||
isLocal: isLocal,
|
||||
videoWidget: videoTrack != null ? VideoTrackRenderer(videoTrack, fit: VideoViewFit.contain) : null,
|
||||
coverVideoWidget: videoTrack != null ? VideoTrackRenderer(videoTrack, fit: VideoViewFit.cover) : null,
|
||||
isSpeaking: p.isSpeaking,
|
||||
isMuted: !p.isMicrophoneEnabled(),
|
||||
hasVideo: videoTrack != null,
|
||||
@@ -139,14 +201,23 @@ class VoiceChannelView extends ConsumerWidget {
|
||||
return (w > 0 && h > 0) ? w / h : 16.0 / 9.0;
|
||||
}
|
||||
|
||||
final mroom = voip.currentCall?.room;
|
||||
final partnerId = mroom?.directChatMatrixID;
|
||||
final myId = mroom?.client.userID;
|
||||
|
||||
// Remote
|
||||
final isRemoteScreen = voip.isRemoteScreensharing;
|
||||
final hasRemoteVideo = voip.remoteRenderer.srcObject != null && voip.remoteRenderer.srcObject!.getVideoTracks().isNotEmpty;
|
||||
participants.add(_ParticipantData(
|
||||
id: 'remote',
|
||||
name: voip.currentCall?.room.getLocalizedDisplayname() ?? 'Remote',
|
||||
name: voip.currentCall?.room.getLocalizedDisplayname() ?? 'Gegenseite',
|
||||
videoWidget: hasRemoteVideo ? rtc.RTCVideoView(voip.remoteRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null,
|
||||
coverVideoWidget: hasRemoteVideo ? rtc.RTCVideoView(voip.remoteRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitCover) : null,
|
||||
avatarUrl: partnerId == null
|
||||
? mroom?.avatar
|
||||
: mroom?.unsafeGetUserFromMemoryOrFallback(partnerId).avatarUrl,
|
||||
isMuted: false,
|
||||
isSpeaking: voip.isRemoteSpeaking,
|
||||
hasVideo: hasRemoteVideo,
|
||||
isScreenShare: isRemoteScreen,
|
||||
avatarColor: _avatarColors[1],
|
||||
@@ -159,8 +230,15 @@ class VoiceChannelView extends ConsumerWidget {
|
||||
participants.add(_ParticipantData(
|
||||
id: 'local',
|
||||
name: 'Du',
|
||||
initialFrom: voip.currentCall?.room.client.userID?.split(':').first,
|
||||
videoWidget: hasLocalVideo ? rtc.RTCVideoView(voip.localRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null,
|
||||
coverVideoWidget: hasLocalVideo ? rtc.RTCVideoView(voip.localRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitCover) : null,
|
||||
avatarUrl: myId == null
|
||||
? null
|
||||
: mroom?.unsafeGetUserFromMemoryOrFallback(myId).avatarUrl,
|
||||
isLocal: true,
|
||||
isMuted: voip.isMicMuted,
|
||||
isSpeaking: voip.isLocalSpeaking,
|
||||
hasVideo: hasLocalVideo,
|
||||
isScreenShare: isLocalScreen,
|
||||
avatarColor: _avatarColors[0],
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart' show Ticker;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:livekit_client/livekit_client.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:pyramid/core/app_state.dart';
|
||||
import 'package:pyramid/core/matrix_client.dart';
|
||||
import 'package:pyramid/features/call_ui/inflated_layout.dart';
|
||||
import 'package:pyramid/widgets/mxc_image.dart';
|
||||
import 'package:pyramid/widgets/spring_press.dart' show motionDuration;
|
||||
import 'package:pyramid/widgets/screen_share_picker.dart';
|
||||
import 'package:pyramid/core/theme.dart';
|
||||
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
||||
@@ -17,3 +25,4 @@ part 'view/call_view.dart';
|
||||
part 'view/participant_layout.dart';
|
||||
part 'view/avatars_controls.dart';
|
||||
part 'view/quality_menu.dart';
|
||||
part 'view/bubble_stage.dart';
|
||||
|
||||
@@ -10,6 +10,8 @@ import 'package:path_provider/path_provider.dart';
|
||||
import 'package:pyramid/core/settings_prefs.dart';
|
||||
import 'package:pyramid/core/appearance.dart';
|
||||
import 'package:pyramid/core/theme.dart';
|
||||
import 'package:pyramid/features/rooms/rooms_provider.dart'
|
||||
show spaceChannelIdsProvider;
|
||||
import 'package:pyramid/widgets/spring_press.dart';
|
||||
import 'package:pyramid/features/chat/attachment_dialog.dart';
|
||||
import 'package:pyramid/features/chat/emoji_picker.dart';
|
||||
@@ -319,9 +321,12 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
|
||||
final mobile = isMobileLayout(size);
|
||||
|
||||
final roomName = widget.room.getLocalizedDisplayname();
|
||||
// „#“ nur bei Server-Kanälen; Gruppen heißen wie die Gruppe.
|
||||
final placeholder = widget.room.isDirectChat
|
||||
? 'Nachricht…'
|
||||
: 'Nachricht an #$roomName';
|
||||
: ref.watch(spaceChannelIdsProvider).contains(widget.room.id)
|
||||
? 'Nachricht an #$roomName'
|
||||
: 'Nachricht an $roomName';
|
||||
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -19,10 +19,14 @@ import 'package:pyramid/features/chat/chat_composer.dart';
|
||||
import 'package:pyramid/features/chat/chat_provider.dart';
|
||||
import 'package:pyramid/features/chat/message_group.dart';
|
||||
import 'package:pyramid/features/chat/pinned_messages_panel.dart';
|
||||
import 'package:pyramid/features/group_call/group_call.dart';
|
||||
import 'package:pyramid/widgets/hover_region.dart';
|
||||
import 'package:pyramid/widgets/measure_size.dart';
|
||||
import 'package:pyramid/widgets/spring_press.dart';
|
||||
import 'package:pyramid/core/read_markers.dart';
|
||||
import 'package:pyramid/core/room_kinds.dart';
|
||||
import 'package:pyramid/features/rooms/rooms_provider.dart'
|
||||
show spaceChannelIdsProvider;
|
||||
import 'package:pyramid/widgets/mxc_image.dart';
|
||||
|
||||
import 'package:pyramid/core/notification_service.dart';
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,9 @@ 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;
|
||||
@@ -55,6 +58,8 @@ class MessageGroup extends StatefulWidget {
|
||||
this.showStatusIndicator = false,
|
||||
this.readEventIds = const {},
|
||||
this.fullyReadId,
|
||||
this.unreadFading = false,
|
||||
this.unreadDividerKey,
|
||||
this.jumpEventId,
|
||||
this.jumpKey,
|
||||
this.highlightEventId,
|
||||
@@ -155,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) ──
|
||||
@@ -174,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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -93,11 +93,19 @@ class _InitialBox extends StatelessWidget {
|
||||
// fullyRead event is in the middle of the group (same sender, <5 min window).
|
||||
class _InlineUnreadDivider extends StatelessWidget {
|
||||
final PyramidTheme pt;
|
||||
const _InlineUnreadDivider({required this.pt});
|
||||
final bool fading;
|
||||
const _InlineUnreadDivider({super.key, required this.pt, this.fading = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
// Blendet aus wie der Gruppen-Trenner (vorher blieb dieser „Neue
|
||||
// Nachrichten“-Trenner mitten in einer Gruppe bis zum erneuten Öffnen
|
||||
// stehen – Bernd 2026-10-08).
|
||||
return AnimatedOpacity(
|
||||
opacity: fading ? 0.0 : 1.0,
|
||||
duration: const Duration(milliseconds: 700),
|
||||
curve: Curves.easeOut,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(0, 8, 0, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -112,6 +120,7 @@ class _InlineUnreadDivider extends StatelessWidget {
|
||||
Expanded(child: Divider(color: pt.accent.withAlpha(160), height: 1)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,10 @@ class _MessageContent extends StatelessWidget {
|
||||
final displayEvent = _displayEvent;
|
||||
final isEdited = displayEvent.eventId != event.eventId;
|
||||
|
||||
if (GroupCallNotice.matches(displayEvent)) {
|
||||
return GroupCallNotice(event: displayEvent);
|
||||
}
|
||||
|
||||
// Still encrypted (decryption pending or failed)
|
||||
if (displayEvent.type == EventTypes.Encrypted) {
|
||||
return Padding(
|
||||
|
||||
@@ -20,6 +20,7 @@ import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:pyramid/features/chat/document_viewer.dart';
|
||||
import 'package:pyramid/features/chat/media_viewer.dart';
|
||||
import 'package:pyramid/features/chat/reaction_service.dart';
|
||||
import 'package:pyramid/features/group_call/group_call.dart';
|
||||
import 'package:pyramid/utils/gif_favorite_service.dart';
|
||||
import 'package:pyramid/widgets/mxc_image.dart';
|
||||
import 'package:pyramid/widgets/spring_press.dart';
|
||||
|
||||
@@ -26,10 +26,19 @@ class _ChatHeader extends ConsumerWidget {
|
||||
final name = room?.getLocalizedDisplayname() ?? roomId;
|
||||
final isDm = room?.isDirectChat ?? false;
|
||||
final isEncrypted = room?.encrypted ?? false;
|
||||
final topic = room?.topic ?? (isDm ? '' : 'Welcome to #$name');
|
||||
final topic = room?.topic ?? '';
|
||||
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
final isMobile = size.width < 900 || size.height < 500;
|
||||
// Gruppenchat (kein Server-Kanal, kein Sprachkanal): Kopf mit Avatar +
|
||||
// Mitgliederzahl und Gruppenanruf (UI-Audit S3, Bernd 2026-10-09).
|
||||
final isGroup = room != null &&
|
||||
isGroupChatRoom(room!) &&
|
||||
!ref.watch(spaceChannelIdsProvider).contains(room!.id);
|
||||
final groupCallRunning = isGroup &&
|
||||
otherCallParticipants(
|
||||
ref.watch(voiceParticipantsProvider(room!.id)), room!)
|
||||
.isNotEmpty;
|
||||
|
||||
return Container(
|
||||
height: 52,
|
||||
@@ -53,6 +62,8 @@ class _ChatHeader extends ConsumerWidget {
|
||||
// Room icon / avatar
|
||||
if (isDm && client != null) ...[
|
||||
_DmHeaderAvatar(room: room, name: name, client: client),
|
||||
] else if (isGroup && client != null) ...[
|
||||
_GroupHeaderAvatar(room: room!, client: client, pt: pt),
|
||||
] else if (!isDm) ...[
|
||||
Icon(
|
||||
isEncrypted ? Icons.lock_outline_rounded : Icons.tag_rounded,
|
||||
@@ -85,6 +96,28 @@ class _ChatHeader extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (isGroup)
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
style: TextStyle(
|
||||
color: pt.fg,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
_groupSubtitle(room!),
|
||||
style: TextStyle(color: pt.fgDim, fontSize: 12),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
name,
|
||||
@@ -92,7 +125,9 @@ class _ChatHeader extends ConsumerWidget {
|
||||
color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
if (!isDm && topic.isNotEmpty) ...[
|
||||
if (isGroup)
|
||||
const SizedBox.shrink()
|
||||
else if (!isDm && topic.isNotEmpty) ...[
|
||||
Container(width: 1, height: 18, color: pt.border),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
@@ -107,18 +142,25 @@ class _ChatHeader extends ConsumerWidget {
|
||||
// Right actions
|
||||
Row(
|
||||
children: [
|
||||
if (isDm) ...[
|
||||
if (isDm || isGroup) ...[
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.phone_rounded),
|
||||
tooltip: 'Voice call',
|
||||
onPressed: () => _startCall(ref, room, cam: false),
|
||||
icon: Icon(Icons.phone_rounded,
|
||||
color: groupCallRunning ? pt.online : null),
|
||||
tooltip: groupCallRunning
|
||||
? 'Gruppenanruf beitreten'
|
||||
: (isGroup ? 'Gruppenanruf' : 'Anrufen'),
|
||||
onPressed: () => isGroup
|
||||
? startOrJoinGroupCall(context, ref, room!)
|
||||
: _startCall(ref, room, cam: false),
|
||||
),
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.videocam_rounded),
|
||||
tooltip: 'Video call',
|
||||
onPressed: () => _startCall(ref, room, cam: true),
|
||||
tooltip: 'Videoanruf',
|
||||
onPressed: () => isGroup
|
||||
? startOrJoinGroupCall(context, ref, room!, video: true)
|
||||
: _startCall(ref, room, cam: true),
|
||||
),
|
||||
if (size.width > 700)
|
||||
Container(
|
||||
@@ -129,12 +171,6 @@ class _ChatHeader extends ConsumerWidget {
|
||||
),
|
||||
],
|
||||
if (size.width > 800) ...[
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.inbox_rounded),
|
||||
tooltip: 'Threads',
|
||||
onPressed: () {},
|
||||
),
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.push_pin_rounded),
|
||||
@@ -557,13 +593,13 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.phone_rounded),
|
||||
tooltip: 'Voice call',
|
||||
tooltip: 'Anrufen',
|
||||
onPressed: () => _startCall(cam: false),
|
||||
),
|
||||
PyrIconBtn(
|
||||
size: 28,
|
||||
icon: const Icon(Icons.videocam_rounded),
|
||||
tooltip: 'Video call',
|
||||
tooltip: 'Videoanruf',
|
||||
onPressed: () => _startCall(cam: true),
|
||||
),
|
||||
if (size.width > 700)
|
||||
@@ -641,6 +677,43 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Untertitel im Gruppen-Kopf: Mitgliederzahl, dahinter das Thema.
|
||||
String _groupSubtitle(Room room) {
|
||||
final count = room.summary.mJoinedMemberCount ?? room.getParticipants().length;
|
||||
final members = count == 1 ? '1 Mitglied' : '$count Mitglieder';
|
||||
final topic = room.topic.trim();
|
||||
return topic.isEmpty ? members : '$members · $topic';
|
||||
}
|
||||
|
||||
/// Runder Gruppen-Avatar (Raumbild oder Gruppen-Symbol).
|
||||
class _GroupHeaderAvatar extends StatelessWidget {
|
||||
final Room room;
|
||||
final Client client;
|
||||
final PyramidTheme pt;
|
||||
const _GroupHeaderAvatar(
|
||||
{required this.room, required this.client, required this.pt});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const size = 32.0;
|
||||
Widget fallback(BuildContext _) => Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration:
|
||||
BoxDecoration(color: pt.accentSoft, shape: BoxShape.circle),
|
||||
child: Icon(Icons.groups_rounded, size: 18, color: pt.accent),
|
||||
);
|
||||
if (room.avatar == null) return Builder(builder: fallback);
|
||||
return MxcAvatar(
|
||||
mxcUri: room.avatar,
|
||||
client: client,
|
||||
size: size,
|
||||
borderRadius: BorderRadius.circular(size / 2),
|
||||
placeholder: fallback,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DmHeaderAvatar extends StatelessWidget {
|
||||
final Room? room;
|
||||
final String name;
|
||||
|
||||
@@ -65,7 +65,7 @@ class _MessageList extends StatelessWidget {
|
||||
Icon(Icons.forum_outlined, size: 48, color: pt.fgDim),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No messages yet',
|
||||
'Noch keine Nachrichten – schreib die erste!',
|
||||
style: TextStyle(color: pt.fgMuted, fontSize: 14),
|
||||
),
|
||||
],
|
||||
@@ -216,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) {
|
||||
@@ -292,6 +293,9 @@ 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,
|
||||
|
||||
@@ -120,7 +120,7 @@ class NoChatSelected extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Choose from the sidebar on the left',
|
||||
'Links findest du alle Chats und Räume.',
|
||||
style: TextStyle(color: pt.fgDim, fontSize: 13),
|
||||
),
|
||||
],
|
||||
@@ -151,3 +151,210 @@ class _FloatingComposerArea extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,6 +28,11 @@ 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.
|
||||
@@ -162,6 +167,7 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
||||
_historyExhausted = false;
|
||||
_unreadFading = false;
|
||||
_unreadFadeTimer?.cancel();
|
||||
_unreadDwellTimer?.cancel();
|
||||
_lastMarkedEventId = null;
|
||||
_popInAfter = null;
|
||||
_jumpEventId = null;
|
||||
@@ -177,6 +183,7 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
||||
@override
|
||||
void dispose() {
|
||||
_unreadFadeTimer?.cancel();
|
||||
_unreadDwellTimer?.cancel();
|
||||
_highlightTimer?.cancel();
|
||||
_scrollCtrl.dispose();
|
||||
super.dispose();
|
||||
@@ -247,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) {
|
||||
@@ -275,7 +284,7 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
||||
cancelNotificationForRoom(widget.roomId);
|
||||
|
||||
final room = ref.read(roomProvider(widget.roomId));
|
||||
if (room == null) return;
|
||||
if (room == null || room.membership != Membership.join) return;
|
||||
if (room.markedUnread) room.markUnread(false).catchError((_) {});
|
||||
final timeline = tl ?? ref.read(timelineProvider(widget.roomId)).valueOrNull;
|
||||
final latest = timeline?.events
|
||||
@@ -311,6 +320,7 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
||||
.any((e) => _MessageList._isVisible(e) && e.senderId != me);
|
||||
if (hasNew) {
|
||||
_unreadFadeTimer?.cancel();
|
||||
_unreadDwellTimer?.cancel();
|
||||
setState(() {
|
||||
_sessionFullyReadId = since;
|
||||
_sessionHasUnread = true;
|
||||
@@ -318,26 +328,57 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
||||
});
|
||||
}
|
||||
}
|
||||
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 {
|
||||
if (!_scrollCtrl.hasClients) return;
|
||||
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);
|
||||
@@ -406,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: [
|
||||
@@ -476,8 +520,13 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
||||
setState(() => _pinnedOpen = !_pinnedOpen),
|
||||
),
|
||||
const _ConnectionBanner(),
|
||||
const _ActiveCallBar(),
|
||||
if (room != null && !isInvite)
|
||||
GroupCallBanner(room: room),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
child: isInvite
|
||||
? _InvitePanel(room: room!, pt: pt)
|
||||
: Stack(
|
||||
children: [
|
||||
timelineAsync.when(
|
||||
loading: () => Center(
|
||||
@@ -488,7 +537,7 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
||||
),
|
||||
error: (e, _) => Center(
|
||||
child: Text(
|
||||
'Could not load messages',
|
||||
'Nachrichten konnten nicht geladen werden.',
|
||||
style: TextStyle(color: pt.fgDim, fontSize: 14),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:pyramid/core/app_state.dart';
|
||||
import 'package:pyramid/core/theme.dart';
|
||||
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
||||
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
|
||||
import 'package:pyramid/widgets/mxc_image.dart';
|
||||
import 'package:pyramid/widgets/spring_press.dart';
|
||||
|
||||
/// Gruppenanrufe in Gruppenchats (Bernd 2026-10-09: „Anruf + Beitreten-Karte“).
|
||||
///
|
||||
/// - Technik wie bei Sprachkanälen: LiveKit-Raum = Matrix-Raum-ID, Anwesenheit
|
||||
/// über `io.pyramid.voice.presence`-State-Events (Herzschlag alle 30 s).
|
||||
/// - Wer einen Anruf eröffnet (sonst niemand drin), schickt die Nachricht
|
||||
/// „📞 Gruppenanruf gestartet“ mit [groupCallContentKey]. Sie löst bei den
|
||||
/// Mitgliedern eine normale Push-Benachrichtigung aus (eine m.notice würde
|
||||
/// von den Standard-Push-Regeln stumm geschaltet) und erscheint in Pyramid
|
||||
/// als Anruf-Karte; andere Apps zeigen den Text.
|
||||
/// - Damit alle Mitglieder ihre Anwesenheit schreiben dürfen, wird die nötige
|
||||
/// Berechtigung für dieses eine State-Event auf „alle“ gesenkt – nur wer
|
||||
/// Rechte ändern darf (neue Gruppen bekommen das schon beim Erstellen).
|
||||
const groupCallContentKey = 'io.pyramid.group_call';
|
||||
|
||||
/// Erlaubt allen Mitgliedern von [roomId], ihre Anruf-Anwesenheit zu
|
||||
/// schreiben (sonst sieht man im Anruf nur Admins). Ändert nur diesen einen
|
||||
/// Eintrag der Berechtigungen.
|
||||
Future<void> allowVoicePresenceForAll(Client client, String roomId) async {
|
||||
final plRaw =
|
||||
await client.getRoomStateWithKey(roomId, EventTypes.RoomPowerLevels, '');
|
||||
final pl = Map<String, dynamic>.from(plRaw);
|
||||
final events = Map<String, dynamic>.from(pl['events'] as Map? ?? {});
|
||||
final usersDefault = pl['users_default'] is int ? pl['users_default'] as int : 0;
|
||||
if (events[kVoicePresenceType] == usersDefault) return;
|
||||
events[kVoicePresenceType] = usersDefault;
|
||||
pl['events'] = events;
|
||||
await client.setRoomStateWithKey(roomId, EventTypes.RoomPowerLevels, '', pl);
|
||||
}
|
||||
|
||||
/// Wie [allowVoicePresenceForAll], aber nur wenn nötig UND erlaubt.
|
||||
Future<void> ensureVoicePresenceWritable(Room room) async {
|
||||
final pl = room.getState(EventTypes.RoomPowerLevels)?.content;
|
||||
if (pl == null) return; // keine Rechte-Tabelle: jeder darf
|
||||
final usersDefault = pl['users_default'] is int ? pl['users_default'] as int : 0;
|
||||
if (room.powerForChangingStateEvent(kVoicePresenceType) <= usersDefault) {
|
||||
return;
|
||||
}
|
||||
if (!room.canChangePowerLevel) return;
|
||||
await allowVoicePresenceForAll(room.client, room.id);
|
||||
}
|
||||
|
||||
/// Bin ich gerade (oder gleich) im Gruppenanruf von [roomId]?
|
||||
bool isInGroupCall(VoiceChannelService call, String roomId) =>
|
||||
(call.isActive || call.isConnecting) && call.currentRoomId == roomId;
|
||||
|
||||
/// Teilnehmer des Anrufs in [room] ohne mich selbst.
|
||||
List<Map<String, dynamic>> otherCallParticipants(
|
||||
List<Map<String, dynamic>> all, Room room) =>
|
||||
all.where((p) => p['userId'] != room.client.userID).toList();
|
||||
|
||||
/// Anruf-Knopf im Gruppenchat und „Beitreten“: läuft schon ein Anruf, tritt
|
||||
/// man bei; sonst startet man ihn und benachrichtigt die Mitglieder.
|
||||
Future<void> startOrJoinGroupCall(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
Room room, {
|
||||
bool video = false,
|
||||
}) async {
|
||||
final call = ref.read(voiceChannelProvider);
|
||||
if (isInGroupCall(call, room.id)) {
|
||||
ref.read(viewModeProvider.notifier).state = ViewMode.voice;
|
||||
return;
|
||||
}
|
||||
final voip = ref.read(callSignalingProvider).currentCall;
|
||||
if (voip != null && voip.state != CallState.kEnded) {
|
||||
ScaffoldMessenger.maybeOf(context)?.showSnackBar(const SnackBar(
|
||||
content: Text('Beende zuerst den laufenden Anruf.')));
|
||||
return;
|
||||
}
|
||||
final othersBefore = otherCallParticipants(
|
||||
ref.read(voiceParticipantsProvider(room.id)), room)
|
||||
.length;
|
||||
if (call.isActive || call.isConnecting) await call.hangUp();
|
||||
|
||||
ref.read(activeVoiceRoomIdProvider.notifier).state = room.id;
|
||||
ref.read(viewModeProvider.notifier).state = ViewMode.voice;
|
||||
try {
|
||||
await ensureVoicePresenceWritable(room);
|
||||
} catch (e) {
|
||||
debugPrint('[GroupCall] Berechtigung für Anwesenheit: $e');
|
||||
}
|
||||
await call.startCall(
|
||||
roomName: room.id,
|
||||
roomDisplayName: room.getLocalizedDisplayname(),
|
||||
identity: room.client.userID ?? '',
|
||||
audioOnly: !video,
|
||||
matrixClient: room.client,
|
||||
matrixRoomId: room.id,
|
||||
);
|
||||
if (othersBefore == 0 &&
|
||||
call.isActive &&
|
||||
call.error == null &&
|
||||
call.currentRoomId == room.id) {
|
||||
try {
|
||||
await room.sendEvent({
|
||||
'msgtype': MessageTypes.Text,
|
||||
'body': video ? '📹 Videoanruf gestartet' : '📞 Gruppenanruf gestartet',
|
||||
groupCallContentKey: {'action': 'start', 'video': video},
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('[GroupCall] Benachrichtigung: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Avatare der Teilnehmer, leicht überlappend (max. [max]).
|
||||
class CallAvatarStack extends StatelessWidget {
|
||||
final List<Map<String, dynamic>> participants;
|
||||
final Client client;
|
||||
final double size;
|
||||
final int max;
|
||||
final Color ringColor;
|
||||
|
||||
const CallAvatarStack({
|
||||
super.key,
|
||||
required this.participants,
|
||||
required this.client,
|
||||
required this.ringColor,
|
||||
this.size = 30,
|
||||
this.max = 3,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = PyramidTheme.of(context);
|
||||
final shown = participants.take(max).toList();
|
||||
final step = size * 0.62;
|
||||
return SizedBox(
|
||||
width: shown.isEmpty ? 0 : size + step * (shown.length - 1),
|
||||
height: size,
|
||||
child: Stack(
|
||||
children: [
|
||||
for (var i = shown.length - 1; i >= 0; i--)
|
||||
Positioned(
|
||||
left: step * i,
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: ringColor, width: 2),
|
||||
),
|
||||
child: ClipOval(child: _avatar(shown[i], pt)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _avatar(Map<String, dynamic> p, PyramidTheme pt) {
|
||||
final name = (p['displayName'] as String? ?? '?').replaceFirst('@', '');
|
||||
final initial = name.isEmpty ? '?' : name.characters.first.toUpperCase();
|
||||
Widget fallback(BuildContext _) => Container(
|
||||
color: pt.accentSoft,
|
||||
alignment: Alignment.center,
|
||||
child: Text(initial,
|
||||
style: TextStyle(
|
||||
color: pt.accent,
|
||||
fontSize: size * 0.42,
|
||||
fontWeight: FontWeight.w700)),
|
||||
);
|
||||
final url = p['avatarUrl'] as String?;
|
||||
if (url == null || url.isEmpty) return Builder(builder: fallback);
|
||||
return MxcAvatar(
|
||||
mxcUri: Uri.tryParse(url),
|
||||
client: client,
|
||||
size: size,
|
||||
borderRadius: BorderRadius.circular(size / 2),
|
||||
placeholder: fallback,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _participantSummary(List<Map<String, dynamic>> others) {
|
||||
final names = others
|
||||
.map((p) => (p['displayName'] as String? ?? '').split(' ').first)
|
||||
.where((n) => n.isNotEmpty)
|
||||
.toList();
|
||||
if (names.isEmpty) return '${others.length} dabei';
|
||||
if (names.length <= 2) return '${names.join(' und ')} ${names.length == 1 ? 'ist' : 'sind'} dabei';
|
||||
return '${names.take(2).join(', ')} und ${names.length - 2} weitere';
|
||||
}
|
||||
|
||||
/// Karte über dem Chatverlauf: „Gruppenanruf läuft · N dabei · Beitreten“.
|
||||
/// Unsichtbar, solange niemand im Anruf ist oder man selbst schon drin ist.
|
||||
class GroupCallBanner extends ConsumerWidget {
|
||||
final Room room;
|
||||
const GroupCallBanner({super.key, required this.room});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final pt = PyramidTheme.of(context);
|
||||
final others =
|
||||
otherCallParticipants(ref.watch(voiceParticipantsProvider(room.id)), room);
|
||||
final call = ref.watch(voiceChannelProvider);
|
||||
final show = !room.isDirectChat &&
|
||||
others.isNotEmpty &&
|
||||
!isInGroupCall(call, room.id);
|
||||
|
||||
return AnimatedSize(
|
||||
duration: motionDuration(context, 260),
|
||||
curve: springyCurve,
|
||||
alignment: Alignment.topCenter,
|
||||
child: !show
|
||||
? const SizedBox(width: double.infinity)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 4),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 10, 10),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(colors: [
|
||||
pt.online.withValues(alpha: 0.20),
|
||||
pt.online.withValues(alpha: 0.08),
|
||||
]),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: pt.online.withValues(alpha: 0.35)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
CallAvatarStack(
|
||||
participants: others,
|
||||
client: room.client,
|
||||
ringColor: pt.bg1,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Gruppenanruf läuft',
|
||||
style: TextStyle(
|
||||
color: pt.fg,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 1),
|
||||
Text(_participantSummary(others),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style:
|
||||
TextStyle(color: pt.fgMuted, fontSize: 12.5)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SpringPress(
|
||||
child: FilledButton.icon(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: pt.online,
|
||||
foregroundColor: Colors.black,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14, vertical: 10),
|
||||
shape: const StadiumBorder(),
|
||||
),
|
||||
icon: const Icon(Icons.call_rounded, size: 18),
|
||||
label: const Text('Beitreten',
|
||||
style: TextStyle(fontWeight: FontWeight.w700)),
|
||||
onPressed: () => startOrJoinGroupCall(context, ref, room),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Darstellung der „Gruppenanruf gestartet“-Nachricht im Verlauf.
|
||||
class GroupCallNotice extends ConsumerWidget {
|
||||
final Event event;
|
||||
const GroupCallNotice({super.key, required this.event});
|
||||
|
||||
/// Ist [event] eine Gruppenanruf-Nachricht von Pyramid?
|
||||
static bool matches(Event event) =>
|
||||
event.content[groupCallContentKey] is Map;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final pt = PyramidTheme.of(context);
|
||||
final room = event.room;
|
||||
final info = event.content[groupCallContentKey] as Map;
|
||||
final video = info['video'] == true;
|
||||
final all = ref.watch(voiceParticipantsProvider(room.id));
|
||||
final call = ref.watch(voiceChannelProvider);
|
||||
final inThis = isInGroupCall(call, room.id);
|
||||
// Alte Karten (gestern und früher) gehören zu längst beendeten Anrufen.
|
||||
final recent = DateTime.now().difference(event.originServerTs) <
|
||||
const Duration(hours: 12);
|
||||
final running = recent && (all.isNotEmpty || inThis);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||
padding: const EdgeInsets.fromLTRB(10, 8, 8, 8),
|
||||
constraints: const BoxConstraints(maxWidth: 340),
|
||||
decoration: BoxDecoration(
|
||||
color: running ? pt.online.withValues(alpha: 0.12) : pt.bg2,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: running ? pt.online.withValues(alpha: 0.35) : pt.border),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: running ? pt.online : pt.bg3,
|
||||
),
|
||||
child: Icon(video ? Icons.videocam_rounded : Icons.call_rounded,
|
||||
size: 18, color: running ? Colors.black : pt.fgMuted),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(video ? 'Videoanruf gestartet' : 'Gruppenanruf gestartet',
|
||||
style: TextStyle(
|
||||
color: pt.fg,
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w700)),
|
||||
Text(
|
||||
running
|
||||
? (inThis ? 'Du bist dabei' : 'Läuft · ${all.length} dabei')
|
||||
: 'Beendet',
|
||||
style: TextStyle(color: pt.fgMuted, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (running && !inThis) ...[
|
||||
const SizedBox(width: 10),
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: pt.online,
|
||||
textStyle: const TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
onPressed: () =>
|
||||
startOrJoinGroupCall(context, ref, room, video: video),
|
||||
child: const Text('Beitreten'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:pyramid/core/matrix_client.dart';
|
||||
import 'package:pyramid/core/theme.dart';
|
||||
import 'package:pyramid/widgets/mxc_image.dart';
|
||||
import 'package:pyramid/widgets/pyramid_logo.dart';
|
||||
|
||||
class MembersPanel extends ConsumerWidget {
|
||||
@@ -94,7 +95,7 @@ class MembersPanel extends ConsumerWidget {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 24),
|
||||
child: Text(
|
||||
'No members loaded',
|
||||
'Keine Mitglieder geladen',
|
||||
style: TextStyle(color: pt.fgDim, fontSize: 12),
|
||||
),
|
||||
),
|
||||
@@ -177,19 +178,22 @@ class _MemberItemState extends State<_MemberItem> {
|
||||
opacity: widget.isOffline ? 0.5 : 1,
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(pt.rSm),
|
||||
),
|
||||
child: Center(
|
||||
// Rund wie überall sonst, mit echtem Profilbild.
|
||||
MxcAvatar(
|
||||
mxcUri: member.avatarUrl,
|
||||
client: member.room.client,
|
||||
size: 32,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
placeholder: (_) => Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
initial,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
@@ -214,7 +218,7 @@ class _MemberItemState extends State<_MemberItem> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
member.id == member.room.client.userID ? '$name (Du)' : name,
|
||||
style: TextStyle(
|
||||
color: widget.isOffline ? pt.fgMuted : pt.fg,
|
||||
fontSize: 13,
|
||||
|
||||
@@ -15,6 +15,7 @@ class _Header extends ConsumerWidget {
|
||||
final isRealSpace = spaceId != null && spaceId != 'dms' && spaceId != 'rooms';
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
final isMobile = size.width < 900 || size.height < 500;
|
||||
final bottomNav = ref.watch(bottomNavProvider);
|
||||
|
||||
final spaces = ref.watch(spacesProvider).valueOrNull ?? [];
|
||||
Room? activeSpace;
|
||||
@@ -29,7 +30,7 @@ class _Header extends ConsumerWidget {
|
||||
|
||||
final name = isRealSpace
|
||||
? (activeSpace?.getLocalizedDisplayname() ?? 'Space')
|
||||
: (spaceId == 'dms' ? 'Chats' : 'Räume');
|
||||
: 'Chats';
|
||||
|
||||
void handleMenu(_SpaceMenuItem item) {
|
||||
switch (item) {
|
||||
@@ -101,7 +102,7 @@ class _Header extends ConsumerWidget {
|
||||
).then((confirmed) {
|
||||
if (confirmed == true) {
|
||||
space.leave().then((_) {
|
||||
ref.read(activeSpaceIdProvider.notifier).state = 'rooms';
|
||||
ref.read(activeSpaceIdProvider.notifier).state = 'dms';
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -110,7 +111,7 @@ class _Header extends ConsumerWidget {
|
||||
}
|
||||
|
||||
List<PopupMenuEntry<_SpaceMenuItem>> buildMenuItems() {
|
||||
if (spaceId == 'dms' || spaceId == null) {
|
||||
if (!isRealSpace) {
|
||||
return [
|
||||
PopupMenuItem(
|
||||
value: _SpaceMenuItem.newDm,
|
||||
@@ -119,15 +120,10 @@ class _Header extends ConsumerWidget {
|
||||
PopupMenuItem(
|
||||
value: _SpaceMenuItem.createGroup,
|
||||
child: _MenuRow(Icons.group_add_rounded, 'Gruppe erstellen', pt)),
|
||||
];
|
||||
} else if (spaceId == 'rooms') {
|
||||
return [
|
||||
PopupMenuItem(
|
||||
value: _SpaceMenuItem.createRoom,
|
||||
child: _MenuRow(Icons.add_rounded, 'Raum erstellen', pt)),
|
||||
const PopupMenuDivider(),
|
||||
PopupMenuItem(
|
||||
value: _SpaceMenuItem.joinRoom,
|
||||
child: _MenuRow(Icons.login_rounded, 'Beitreten', pt)),
|
||||
child: _MenuRow(Icons.login_rounded, 'Raum beitreten', pt)),
|
||||
PopupMenuItem(
|
||||
value: _SpaceMenuItem.discoverRooms,
|
||||
child: _MenuRow(Icons.explore_rounded, 'Entdecken', pt)),
|
||||
@@ -167,7 +163,18 @@ class _Header extends ConsumerWidget {
|
||||
|
||||
Widget controlsRow = Row(
|
||||
children: [
|
||||
if (isMobile)
|
||||
if (isMobile && bottomNav) ...[
|
||||
if (isRealSpace)
|
||||
PyrIconBtn(
|
||||
icon: Icon(Icons.arrow_back_rounded,
|
||||
color: hasBanner ? Colors.white : null),
|
||||
tooltip: 'Alle Server',
|
||||
onPressed: () =>
|
||||
ref.read(activeSpaceIdProvider.notifier).state = null,
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 2),
|
||||
] else if (isMobile)
|
||||
PyrIconBtn(
|
||||
icon: const Icon(Icons.menu_rounded),
|
||||
tooltip: 'Spaces',
|
||||
@@ -176,7 +183,7 @@ class _Header extends ConsumerWidget {
|
||||
else ...[
|
||||
PyrIconBtn(
|
||||
icon: const Icon(Icons.menu_rounded),
|
||||
tooltip: 'Toggle sidebar',
|
||||
tooltip: 'Seitenleiste ein/aus',
|
||||
onPressed: () =>
|
||||
ref.read(railCollapsedProvider.notifier).update((s) => !s),
|
||||
),
|
||||
@@ -316,7 +323,7 @@ class _Header extends ConsumerWidget {
|
||||
height: isRealSpace ? 56 : 52,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: isRealSpace ? pt.bg0 : pt.bg1,
|
||||
color: isRealSpace && !isMobile ? pt.bg0 : pt.bg1,
|
||||
border: Border(bottom: BorderSide(color: pt.border)),
|
||||
),
|
||||
child: controlsRow,
|
||||
@@ -458,9 +465,13 @@ class _NotificationsButton extends ConsumerWidget {
|
||||
class _FilterInput extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final PyramidTheme pt;
|
||||
final String hint;
|
||||
final ValueChanged<String> onChanged;
|
||||
const _FilterInput(
|
||||
{required this.controller, required this.pt, required this.onChanged});
|
||||
{required this.controller,
|
||||
required this.pt,
|
||||
required this.hint,
|
||||
required this.onChanged});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -469,18 +480,17 @@ class _FilterInput extends StatelessWidget {
|
||||
child: Stack(
|
||||
alignment: Alignment.centerLeft,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: Icon(Icons.search_rounded, size: 14, color: pt.fgDim),
|
||||
),
|
||||
TextField(
|
||||
controller: controller,
|
||||
onChanged: onChanged,
|
||||
style: TextStyle(color: pt.fg, fontSize: 13),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Chats durchsuchen',
|
||||
hintText: hint,
|
||||
hintStyle: TextStyle(color: pt.fgDim, fontSize: 13),
|
||||
contentPadding: const EdgeInsets.fromLTRB(28, 7, 10, 7),
|
||||
prefixIcon: Icon(Icons.search_rounded, size: 16, color: pt.fgDim),
|
||||
prefixIconConstraints:
|
||||
const BoxConstraints(minWidth: 32, minHeight: 20),
|
||||
contentPadding: const EdgeInsets.fromLTRB(0, 7, 10, 7),
|
||||
filled: true,
|
||||
fillColor: pt.bg2,
|
||||
border: OutlineInputBorder(
|
||||
|
||||
@@ -16,7 +16,7 @@ class _InviteItemState extends ConsumerState<_InviteItem> {
|
||||
Future<void> _accept() async {
|
||||
setState(() => _accepting = true);
|
||||
try {
|
||||
await widget.room.join();
|
||||
await acceptInvite(ref, widget.room);
|
||||
if (mounted) {
|
||||
ref.read(activeRoomIdProvider.notifier).state = widget.room.id;
|
||||
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||
|
||||
@@ -59,56 +59,66 @@ class _DraggableRoomItemState extends State<_DraggableRoomItem> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter der gemeinsamen Chatliste (Chips über der Liste).
|
||||
enum _ChatFilter { all, unread, people, groups }
|
||||
|
||||
final _chatFilterProvider =
|
||||
StateProvider<_ChatFilter>((ref) => _ChatFilter.all);
|
||||
|
||||
/// „Chats“: Direktnachrichten und Gruppen zusammen, nach letzter Aktivität
|
||||
/// (Bernd 2026-10-09). Kanäle eines Servers stehen nur in ihrem Server.
|
||||
class _RoomsList extends ConsumerWidget {
|
||||
final List<Room> rooms;
|
||||
final String filter;
|
||||
final String? spaceId;
|
||||
final Map<String, bool> collapsed;
|
||||
final PyramidTheme pt;
|
||||
final ValueChanged<String> onToggleSection;
|
||||
|
||||
const _RoomsList({
|
||||
required this.rooms,
|
||||
required this.filter,
|
||||
required this.spaceId,
|
||||
required this.collapsed,
|
||||
required this.pt,
|
||||
required this.onToggleSection,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final activeRoomId = ref.watch(activeRoomIdProvider);
|
||||
final isDms = spaceId == 'dms';
|
||||
final channels = ref.watch(spaceChannelIdsProvider);
|
||||
final chatFilter = ref.watch(_chatFilterProvider);
|
||||
final query = filter.toLowerCase();
|
||||
|
||||
final invites = rooms
|
||||
.where((r) =>
|
||||
r.membership == Membership.invite &&
|
||||
isDms == r.isDirectChat)
|
||||
.toList();
|
||||
final invites =
|
||||
rooms.where((r) => r.membership == Membership.invite).toList();
|
||||
|
||||
final filtered = rooms.where((r) {
|
||||
if (r.membership == Membership.invite) return false;
|
||||
if (isDms != r.isDirectChat) return false;
|
||||
if (filter.isEmpty) return true;
|
||||
return r.getLocalizedDisplayname()
|
||||
.toLowerCase()
|
||||
.contains(filter.toLowerCase());
|
||||
}).toList();
|
||||
|
||||
if (filtered.isEmpty && invites.isEmpty) {
|
||||
return Center(
|
||||
child:
|
||||
Text(isDms ? 'Noch keine Chats' : 'Keine Räume', style: TextStyle(color: pt.fgDim, fontSize: 13)));
|
||||
bool matches(Room r) {
|
||||
switch (chatFilter) {
|
||||
case _ChatFilter.all:
|
||||
break;
|
||||
case _ChatFilter.unread:
|
||||
if (!r.isUnread) return false;
|
||||
case _ChatFilter.people:
|
||||
if (!r.isDirectChat) return false;
|
||||
case _ChatFilter.groups:
|
||||
if (r.isDirectChat) return false;
|
||||
}
|
||||
if (query.isEmpty) return true;
|
||||
return r.getLocalizedDisplayname().toLowerCase().contains(query);
|
||||
}
|
||||
|
||||
final sectionTitle = isDms ? 'Direktnachrichten' : 'Räume';
|
||||
final sectionId = isDms ? 'dms' : 'rooms';
|
||||
final isCollapsed = collapsed[sectionId] ?? false;
|
||||
final chats = rooms
|
||||
.where((r) =>
|
||||
r.membership == Membership.join && isChatListRoom(r, channels))
|
||||
.toList();
|
||||
final filtered = chats.where(matches).toList();
|
||||
final unreadCount = chats.where((r) => r.isUnread).length;
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(8, 4, 8, 12),
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 12),
|
||||
children: [
|
||||
_ChatFilterChips(
|
||||
value: chatFilter,
|
||||
unreadCount: unreadCount,
|
||||
pt: pt,
|
||||
onChanged: (f) => ref.read(_chatFilterProvider.notifier).state = f,
|
||||
),
|
||||
if (invites.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 2),
|
||||
@@ -124,22 +134,18 @@ class _RoomsList extends ConsumerWidget {
|
||||
child: _InviteItem(room: r, pt: pt))),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
_SectionHeader(
|
||||
title: sectionTitle,
|
||||
count: filtered.length,
|
||||
isCollapsed: isCollapsed,
|
||||
pt: pt,
|
||||
onToggle: () => onToggleSection(sectionId),
|
||||
onAddTap: isDms
|
||||
? () => showDialog(
|
||||
context: context, builder: (_) => const NewDmDialog())
|
||||
: () => showDialog(
|
||||
context: context, builder: (_) => const CreateJoinDialog()),
|
||||
),
|
||||
if (!isCollapsed)
|
||||
if (filtered.isEmpty)
|
||||
_ChatListEmpty(
|
||||
filter: chatFilter,
|
||||
searching: query.isNotEmpty,
|
||||
hasInvites: invites.isNotEmpty,
|
||||
pt: pt,
|
||||
)
|
||||
else
|
||||
...filtered.map((room) {
|
||||
final isVoice = _isVoiceRoom(room);
|
||||
return _RoomItem(
|
||||
key: ValueKey('chat:${room.id}'),
|
||||
room: room,
|
||||
isActive: isVoice ? false : activeRoomId == room.id,
|
||||
isInVoice:
|
||||
@@ -163,6 +169,145 @@ class _RoomsList extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ChatFilterChips extends StatelessWidget {
|
||||
final _ChatFilter value;
|
||||
final int unreadCount;
|
||||
final PyramidTheme pt;
|
||||
final ValueChanged<_ChatFilter> onChanged;
|
||||
|
||||
const _ChatFilterChips({
|
||||
required this.value,
|
||||
required this.unreadCount,
|
||||
required this.pt,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget chip(_ChatFilter f, String label) {
|
||||
final selected = value == f;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 6),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
child: GestureDetector(
|
||||
onTap: () => onChanged(selected ? _ChatFilter.all : f),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOutCubic,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? pt.accentSoft : pt.bg2,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(
|
||||
color: selected ? pt.accent.withAlpha(120) : pt.border),
|
||||
),
|
||||
child: Text(label,
|
||||
style: TextStyle(
|
||||
color: selected ? pt.accent : pt.fgMuted,
|
||||
fontSize: 12.5,
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w500)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final chips = [
|
||||
chip(_ChatFilter.all, 'Alle'),
|
||||
chip(_ChatFilter.unread,
|
||||
unreadCount > 0 ? 'Ungelesen · $unreadCount' : 'Ungelesen'),
|
||||
chip(_ChatFilter.people, 'Personen'),
|
||||
chip(_ChatFilter.groups, 'Gruppen'),
|
||||
];
|
||||
// Handy: wischbare Zeile; PC (schmale Spalte): umbrechen statt
|
||||
// abschneiden – mit der Maus wischt niemand seitwärts.
|
||||
if (!isMobileLayout(MediaQuery.sizeOf(context))) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 2, 4, 6),
|
||||
child: Wrap(runSpacing: 6, children: chips),
|
||||
);
|
||||
}
|
||||
return SizedBox(
|
||||
height: 40,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.fromLTRB(4, 2, 4, 6),
|
||||
children: chips,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Leerzustand der Chatliste – mit Handlung statt nur „nichts da“.
|
||||
class _ChatListEmpty extends StatelessWidget {
|
||||
final _ChatFilter filter;
|
||||
final bool searching;
|
||||
final bool hasInvites;
|
||||
final PyramidTheme pt;
|
||||
|
||||
const _ChatListEmpty({
|
||||
required this.filter,
|
||||
required this.searching,
|
||||
required this.hasInvites,
|
||||
required this.pt,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (hasInvites && filter == _ChatFilter.all && !searching) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final (IconData icon, String text) = searching
|
||||
? (Icons.search_off_rounded, 'Nichts gefunden')
|
||||
: switch (filter) {
|
||||
_ChatFilter.unread => (Icons.done_all_rounded, 'Alles gelesen'),
|
||||
_ChatFilter.people => (
|
||||
Icons.person_outline_rounded,
|
||||
'Noch keine Unterhaltungen'
|
||||
),
|
||||
_ChatFilter.groups => (Icons.groups_outlined, 'Noch keine Gruppen'),
|
||||
_ChatFilter.all => (
|
||||
Icons.chat_bubble_outline_rounded,
|
||||
'Noch keine Chats'
|
||||
),
|
||||
};
|
||||
final showAction = !searching && filter != _ChatFilter.unread;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 40, 16, 16),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(icon, size: 34, color: pt.fgDim),
|
||||
const SizedBox(height: 10),
|
||||
Text(text, style: TextStyle(color: pt.fgMuted, fontSize: 14)),
|
||||
if (showAction) ...[
|
||||
const SizedBox(height: 14),
|
||||
FilledButton.tonalIcon(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: pt.accentSoft,
|
||||
foregroundColor: pt.accent,
|
||||
),
|
||||
icon: Icon(filter == _ChatFilter.groups
|
||||
? Icons.group_add_rounded
|
||||
: Icons.edit_rounded, size: 18),
|
||||
label: Text(filter == _ChatFilter.groups
|
||||
? 'Gruppe erstellen'
|
||||
: 'Neue Unterhaltung'),
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (_) => filter == _ChatFilter.groups
|
||||
? const CreateJoinDialog()
|
||||
: const NewDmDialog(),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Beitreten/Verlassen eines Voice-Channels aus der Raumliste – exakt das
|
||||
/// Verhalten von `joinVoiceChannel` in rooms_space_list.dart (Toggle beim
|
||||
/// aktiven Kanal, Wechsel legt vorher auf).
|
||||
@@ -187,84 +332,6 @@ Future<void> _toggleVoiceChannel(WidgetRef ref, Room room) async {
|
||||
);
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatefulWidget {
|
||||
final String title;
|
||||
final int count;
|
||||
final bool isCollapsed;
|
||||
final PyramidTheme pt;
|
||||
final VoidCallback onToggle;
|
||||
final VoidCallback? onAddTap;
|
||||
|
||||
const _SectionHeader({
|
||||
required this.title,
|
||||
required this.count,
|
||||
required this.isCollapsed,
|
||||
required this.pt,
|
||||
required this.onToggle,
|
||||
this.onAddTap,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_SectionHeader> createState() => _SectionHeaderState();
|
||||
}
|
||||
|
||||
class _SectionHeaderState extends State<_SectionHeader> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 4),
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: widget.onToggle,
|
||||
child: Row(children: [
|
||||
AnimatedRotation(
|
||||
turns: widget.isCollapsed ? -0.25 : 0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOutBack,
|
||||
child: Icon(Icons.keyboard_arrow_down_rounded,
|
||||
size: 14,
|
||||
color: _hovered ? pt.fgMuted : pt.fgDim),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(widget.title.toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: _hovered ? pt.fgMuted : pt.fgDim,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.04 * 11)),
|
||||
const Spacer(),
|
||||
Text('${widget.count}',
|
||||
style: TextStyle(color: pt.fgDim, fontSize: 11)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
if (_hovered && widget.onAddTap != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.onAddTap,
|
||||
child: SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: Center(
|
||||
child: Icon(Icons.add, size: 12, color: pt.fgMuted)),
|
||||
),
|
||||
),
|
||||
] else
|
||||
const SizedBox(width: 22),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RoomItem extends ConsumerStatefulWidget {
|
||||
final Room room;
|
||||
final bool isActive;
|
||||
@@ -513,6 +580,19 @@ class _RoomItemState extends ConsumerState<_RoomItem> {
|
||||
final voiceParticipants = isVoice ? ref.watch(voiceParticipantsProvider(room.id)) : <Map<String, dynamic>>[];
|
||||
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||
|
||||
// Handy: Sprachkanal als Kachel mit Teilnehmern (rooms_mobile_tile.dart).
|
||||
if (isVoice && isMobileLayout(MediaQuery.sizeOf(context))) {
|
||||
return _MobileVoiceTile(
|
||||
room: room,
|
||||
inVoice: inVoice,
|
||||
participants: voiceParticipants,
|
||||
client: client,
|
||||
pt: pt,
|
||||
onTap: widget.onTap,
|
||||
onLongPress: _showContextMenu,
|
||||
);
|
||||
}
|
||||
|
||||
// Handy: Messenger-Kachel mit Vorschau (rooms_mobile_tile.dart).
|
||||
if (!isVoice && isMobileLayout(MediaQuery.sizeOf(context))) {
|
||||
return _MobileRoomTile(
|
||||
@@ -574,6 +654,11 @@ class _RoomItemState extends ConsumerState<_RoomItem> {
|
||||
children: [
|
||||
if (isDm) ...[
|
||||
_DmAvatar(room: room, pt: pt),
|
||||
] else if (!isVoice &&
|
||||
!ref
|
||||
.watch(spaceChannelIdsProvider)
|
||||
.contains(room.id)) ...[
|
||||
_RoomTileAvatar(room: room, pt: pt, size: 22),
|
||||
] else ...[
|
||||
SizedBox(
|
||||
width: 16,
|
||||
|
||||
@@ -119,34 +119,41 @@ class _MobileRoomTile extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Avatar für Räume (kein DM): Raumbild oder abgerundetes Symbol.
|
||||
/// Avatar für Räume (kein DM): Raumbild oder Symbol. Gruppen (kein
|
||||
/// Server-Kanal) sind rund wie Personen, Server-Kanäle abgerundet mit „#“.
|
||||
class _RoomTileAvatar extends ConsumerWidget {
|
||||
final Room room;
|
||||
final PyramidTheme pt;
|
||||
const _RoomTileAvatar({required this.room, required this.pt});
|
||||
final double size;
|
||||
const _RoomTileAvatar(
|
||||
{required this.room, required this.pt, this.size = _kTileAvatar});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||
final isChannel = ref.watch(spaceChannelIdsProvider).contains(room.id);
|
||||
final radius = BorderRadius.circular(isChannel ? size * 0.3 : size / 2);
|
||||
final fallback = Container(
|
||||
width: _kTileAvatar,
|
||||
height: _kTileAvatar,
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg3,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
color: isChannel ? pt.bg3 : pt.accentSoft,
|
||||
borderRadius: radius,
|
||||
),
|
||||
child: Icon(
|
||||
room.encrypted ? Icons.lock_outline_rounded : Icons.tag_rounded,
|
||||
size: 22,
|
||||
color: pt.fgMuted,
|
||||
isChannel
|
||||
? (room.encrypted ? Icons.lock_outline_rounded : Icons.tag_rounded)
|
||||
: Icons.groups_rounded,
|
||||
size: size * 0.5,
|
||||
color: isChannel ? pt.fgMuted : pt.accent,
|
||||
),
|
||||
);
|
||||
if (client == null || room.avatar == null) return fallback;
|
||||
return MxcAvatar(
|
||||
mxcUri: room.avatar,
|
||||
client: client,
|
||||
size: _kTileAvatar,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
size: size,
|
||||
borderRadius: radius,
|
||||
placeholder: (_) => fallback,
|
||||
);
|
||||
}
|
||||
@@ -165,3 +172,103 @@ String? _roomPreview(Room room, String? myUserId) {
|
||||
if (room.isDirectChat) return body;
|
||||
return '${e.senderFromMemoryOrFallback.calcDisplayname()}: $body';
|
||||
}
|
||||
|
||||
/// Sprachkanal im Handy-Layout: Kachel wie ein Chat, darunter wer gerade drin
|
||||
/// ist (Avatare + Namen) bzw. „Antippen zum Beitreten“.
|
||||
class _MobileVoiceTile extends StatelessWidget {
|
||||
final Room room;
|
||||
final bool inVoice;
|
||||
final List<Map<String, dynamic>> participants;
|
||||
final Client? client;
|
||||
final PyramidTheme pt;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onLongPress;
|
||||
|
||||
const _MobileVoiceTile({
|
||||
required this.room,
|
||||
required this.inVoice,
|
||||
required this.participants,
|
||||
required this.client,
|
||||
required this.pt,
|
||||
required this.onTap,
|
||||
required this.onLongPress,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final green = pt.online;
|
||||
final names = participants
|
||||
.map((p) => (p['displayName'] as String? ?? '').split(' ').first)
|
||||
.where((n) => n.isNotEmpty)
|
||||
.toList();
|
||||
final subtitle = inVoice
|
||||
? 'Du bist drin · antippen zum Verlassen'
|
||||
: participants.isEmpty
|
||||
? 'Sprachkanal · antippen zum Beitreten'
|
||||
: names.length <= 2
|
||||
? '${names.join(' und ')} ${names.length == 1 ? 'ist' : 'sind'} drin'
|
||||
: '${names.take(2).join(', ')} und ${names.length - 2} weitere';
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
child: Container(
|
||||
color: inVoice ? green.withValues(alpha: 0.10) : Colors.transparent,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: _kTileAvatar,
|
||||
height: _kTileAvatar,
|
||||
decoration: BoxDecoration(
|
||||
color: green.withValues(alpha: inVoice ? 0.30 : 0.14),
|
||||
borderRadius: BorderRadius.circular(_kTileAvatar * 0.3),
|
||||
),
|
||||
child: Icon(
|
||||
participants.isEmpty
|
||||
? Icons.volume_up_rounded
|
||||
: Icons.graphic_eq_rounded,
|
||||
size: 22,
|
||||
color: green,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(room.getLocalizedDisplayname(),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: pt.fg,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 3),
|
||||
Text(subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: participants.isEmpty && !inVoice
|
||||
? pt.fgDim
|
||||
: green,
|
||||
fontSize: 13.5)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (participants.isNotEmpty && client != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
CallAvatarStack(
|
||||
participants: participants,
|
||||
client: client!,
|
||||
ringColor: pt.bg1,
|
||||
size: 26,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,8 +64,8 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
|
||||
});
|
||||
final roomsAsync = ref.watch(roomListProvider);
|
||||
final activeSpaceId = ref.watch(activeSpaceIdProvider);
|
||||
final call = ref.watch(voiceChannelProvider);
|
||||
final voip = ref.watch(callSignalingProvider);
|
||||
final bottomNav = ref.watch(bottomNavProvider) &&
|
||||
isMobileLayout(MediaQuery.sizeOf(context));
|
||||
|
||||
final isRealSpace = activeSpaceId != null &&
|
||||
activeSpaceId != 'dms' &&
|
||||
@@ -87,6 +87,7 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
|
||||
_FilterInput(
|
||||
controller: _filterCtrl,
|
||||
pt: pt,
|
||||
hint: isRealSpace ? 'Kanäle durchsuchen' : 'Chats durchsuchen',
|
||||
onChanged: (v) => setState(() => _filter = v),
|
||||
),
|
||||
Expanded(
|
||||
@@ -112,60 +113,90 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
|
||||
),
|
||||
),
|
||||
error: (e, _) => Center(
|
||||
child: Text('Error',
|
||||
child: Text('Fehler',
|
||||
style: TextStyle(color: pt.fgDim, fontSize: 12)),
|
||||
),
|
||||
data: (rooms) => _RoomsList(
|
||||
rooms: rooms,
|
||||
filter: _filter,
|
||||
spaceId: activeSpaceId,
|
||||
collapsed: _collapsed,
|
||||
pt: pt,
|
||||
onToggleSection: (id) => setState(() {
|
||||
_collapsed[id] = !(_collapsed[id] ?? false);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Call-Dock: immer ÜBER dem Konto-Balken, animiert ein-/ausblenden.
|
||||
// Im Mobil-Querformat (Höhe knapp) liegen Call-Balken und
|
||||
// Konto-Balken nebeneinander statt übereinander.
|
||||
Builder(builder: (context) {
|
||||
final hasVoipCall = voip.currentCall != null &&
|
||||
voip.currentCall!.state != CallState.kEnded;
|
||||
final hasCallDock = call.isActive || hasVoipCall;
|
||||
final isLandscapeCompact =
|
||||
MediaQuery.sizeOf(context).height < 500;
|
||||
|
||||
final callDock = AnimatedSize(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (call.isActive) MiniCallWidget(call: call),
|
||||
if (hasVoipCall) MiniCallWidget(call: voip),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (isLandscapeCompact && hasCallDock) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(child: callDock),
|
||||
const Expanded(child: UserPanel()),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [callDock, const UserPanel()],
|
||||
);
|
||||
}),
|
||||
// Call-Dock + Profilbalken. Mit der unteren Leiste (Handy) liegen
|
||||
// beide dort (layout/mobile_tabs.dart).
|
||||
if (!bottomNav) const _PanelFooter(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Laufender Anruf als kompakte Leiste, animiert ein-/ausgeblendet.
|
||||
class CallDock extends ConsumerWidget {
|
||||
const CallDock({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final call = ref.watch(voiceChannelProvider);
|
||||
final voip = ref.watch(callSignalingProvider);
|
||||
// Klingeln (noch nicht angenommen) zählt nicht – dafür gibt es das
|
||||
// Annehmen-Fenster (Bernd 2026-10-08).
|
||||
final hasVoipCall = voip.currentCall != null &&
|
||||
voip.currentCall!.state != CallState.kEnded &&
|
||||
voip.currentCall!.state != CallState.kRinging;
|
||||
// Am PC bei offener Anrufansicht nicht doppelt steuern (UI-Audit S6); am
|
||||
// Handy liegt die Anrufansicht ohnehin darüber.
|
||||
final callViewOpen = ref.watch(viewModeProvider) == ViewMode.voice &&
|
||||
!isMobileLayout(MediaQuery.sizeOf(context));
|
||||
return AnimatedSize(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (call.isActive && !callViewOpen) MiniCallWidget(call: call),
|
||||
if (hasVoipCall && !callViewOpen) MiniCallWidget(call: voip),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Ist gerade ein Anruf im Dock sichtbar?
|
||||
static bool visible(WidgetRef ref, BuildContext context) {
|
||||
final call = ref.watch(voiceChannelProvider);
|
||||
final voip = ref.watch(callSignalingProvider);
|
||||
final hasVoipCall = voip.currentCall != null &&
|
||||
voip.currentCall!.state != CallState.kEnded &&
|
||||
voip.currentCall!.state != CallState.kRinging;
|
||||
final callViewOpen = ref.watch(viewModeProvider) == ViewMode.voice &&
|
||||
!isMobileLayout(MediaQuery.sizeOf(context));
|
||||
return !callViewOpen && (call.isActive || hasVoipCall);
|
||||
}
|
||||
}
|
||||
|
||||
/// Call-Dock immer ÜBER dem Konto-Balken. Im Mobil-Querformat (Höhe knapp)
|
||||
/// liegen beide nebeneinander statt übereinander.
|
||||
class _PanelFooter extends ConsumerWidget {
|
||||
const _PanelFooter();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final hasCallDock = CallDock.visible(ref, context);
|
||||
final isLandscapeCompact = MediaQuery.sizeOf(context).height < 500;
|
||||
if (isLandscapeCompact && hasCallDock) {
|
||||
return const Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(child: CallDock()),
|
||||
Expanded(child: UserPanel()),
|
||||
],
|
||||
);
|
||||
}
|
||||
return const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [CallDock(), UserPanel()],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ class _SpaceInviteViewState extends ConsumerState<_SpaceInviteView> {
|
||||
try {
|
||||
await widget.space.leave();
|
||||
if (mounted) {
|
||||
ref.read(activeSpaceIdProvider.notifier).state = 'rooms';
|
||||
ref.read(activeSpaceIdProvider.notifier).state = 'dms';
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _declining = false);
|
||||
|
||||
@@ -10,10 +10,14 @@ import 'package:pyramid/core/app_state.dart';
|
||||
import 'package:pyramid/core/appearance.dart';
|
||||
import 'package:pyramid/core/matrix_client.dart';
|
||||
import 'package:pyramid/core/notification_content.dart' show notificationBody;
|
||||
import 'package:pyramid/core/room_kinds.dart';
|
||||
import 'package:pyramid/core/settings_prefs.dart' show bottomNavProvider;
|
||||
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/group_call/group_call.dart' show CallAvatarStack;
|
||||
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
|
||||
import 'package:pyramid/features/rooms/user_panel.dart';
|
||||
import 'package:pyramid/features/spaces/space_admin_dialog.dart';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:pyramid/core/matrix_client.dart';
|
||||
import 'package:pyramid/core/room_kinds.dart';
|
||||
|
||||
final roomListProvider = StreamProvider<List<Room>>((ref) async* {
|
||||
final client = await ref.watch(matrixClientProvider.future);
|
||||
@@ -12,19 +13,33 @@ final roomListProvider = StreamProvider<List<Room>>((ref) async* {
|
||||
}
|
||||
});
|
||||
|
||||
/// IDs aller Server-Kanäle (siehe [spaceChannelIds]); wird mit jeder
|
||||
/// Synchronisation neu berechnet.
|
||||
final spaceChannelIdsProvider = Provider<Set<String>>((ref) {
|
||||
ref.watch(roomListProvider);
|
||||
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||
return client == null ? const {} : spaceChannelIds(client);
|
||||
});
|
||||
|
||||
/// Ungelesene Unterhaltungen in „Chats“ (Direktnachrichten + Gruppen ohne
|
||||
/// Server) – für das Abzeichen am Chats-Symbol.
|
||||
final dmUnreadCountProvider = StreamProvider<int>((ref) async* {
|
||||
final client = await ref.watch(matrixClientProvider.future);
|
||||
|
||||
yield _countDmUnread(client);
|
||||
yield _countChatUnread(client);
|
||||
|
||||
await for (final _ in client.onSync.stream) {
|
||||
yield _countDmUnread(client);
|
||||
yield _countChatUnread(client);
|
||||
}
|
||||
});
|
||||
|
||||
int _countDmUnread(Client client) {
|
||||
int _countChatUnread(Client client) {
|
||||
final channels = spaceChannelIds(client);
|
||||
return client.rooms
|
||||
.where((r) => !r.isSpace && r.isDirectChat && r.notificationCount > 0)
|
||||
.where((r) =>
|
||||
r.membership == Membership.join &&
|
||||
isChatListRoom(r, channels) &&
|
||||
r.notificationCount > 0)
|
||||
.length;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@ 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/room_kinds.dart';
|
||||
import 'package:pyramid/core/theme.dart';
|
||||
import 'package:pyramid/features/rooms/rooms_provider.dart';
|
||||
import 'package:pyramid/widgets/create_join_dialog.dart';
|
||||
@@ -32,8 +34,25 @@ class SpacesRail extends ConsumerWidget {
|
||||
}
|
||||
// Switch space and restore last visited room for the new space
|
||||
ref.read(activeSpaceIdProvider.notifier).state = id;
|
||||
ref.read(activeRoomIdProvider.notifier).state =
|
||||
ref.read(lastRoomPerSpaceProvider)[id];
|
||||
var target = ref.read(lastRoomPerSpaceProvider)[id];
|
||||
// Am PC nicht auf „Wähle einen Chat aus“ landen: ersten Textkanal des
|
||||
// Spaces öffnen (UI-Audit S5). Am Handy bleibt die Kanalliste sichtbar.
|
||||
if (target == null &&
|
||||
id != null &&
|
||||
id != 'dms' &&
|
||||
id != 'rooms' &&
|
||||
!isMobileLayout(MediaQuery.sizeOf(context))) {
|
||||
final client = ref.read(matrixClientProvider).valueOrNull;
|
||||
final space = client?.getRoomById(id);
|
||||
for (final child in space?.spaceChildren ?? []) {
|
||||
final r = child.roomId == null ? null : client!.getRoomById(child.roomId!);
|
||||
if (r != null && r.membership == Membership.join && !isVoiceChannelRoom(r)) {
|
||||
target = r.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
ref.read(activeRoomIdProvider.notifier).state = target;
|
||||
final scaffold = Scaffold.maybeOf(context);
|
||||
if (scaffold != null && scaffold.isDrawerOpen) {
|
||||
scaffold.closeDrawer();
|
||||
@@ -48,7 +67,9 @@ class SpacesRail extends ConsumerWidget {
|
||||
const SizedBox(height: 12),
|
||||
_HomeButton(
|
||||
pt: pt,
|
||||
active: activeSpaceId == 'dms' || activeSpaceId == null,
|
||||
active: activeSpaceId == 'dms' ||
|
||||
activeSpaceId == 'rooms' ||
|
||||
activeSpaceId == null,
|
||||
unreadCount: dmUnread,
|
||||
onTap: () => selectSpace('dms'),
|
||||
),
|
||||
@@ -67,18 +88,8 @@ class SpacesRail extends ConsumerWidget {
|
||||
child: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
_VirtualSpaceItem(
|
||||
id: 'rooms',
|
||||
label: 'R',
|
||||
color: const Color(0xFFF59E0B),
|
||||
name: 'Räume',
|
||||
active: activeSpaceId == 'rooms',
|
||||
unread: 0,
|
||||
pt: pt,
|
||||
onTap: () => selectSpace('rooms'),
|
||||
),
|
||||
...spaces.map((space) => Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: _SpaceItem(
|
||||
space: space,
|
||||
active: activeSpaceId == space.id,
|
||||
@@ -238,99 +249,6 @@ class _HomeButtonState extends State<_HomeButton> {
|
||||
}
|
||||
}
|
||||
|
||||
class _VirtualSpaceItem extends StatefulWidget {
|
||||
final String id;
|
||||
final String label;
|
||||
final Color color;
|
||||
final String name;
|
||||
final bool active;
|
||||
final int unread;
|
||||
final PyramidTheme pt;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _VirtualSpaceItem({
|
||||
required this.id,
|
||||
required this.label,
|
||||
required this.color,
|
||||
required this.name,
|
||||
required this.active,
|
||||
required this.unread,
|
||||
required this.pt,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_VirtualSpaceItem> createState() => _VirtualSpaceItemState();
|
||||
}
|
||||
|
||||
class _VirtualSpaceItemState extends State<_VirtualSpaceItem> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
final active = widget.active;
|
||||
final radius = active
|
||||
? pt.rLg * 0.6
|
||||
: _hovered ? pt.rLg * 0.7 : pt.rLg;
|
||||
|
||||
return Center(
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
// Active indicator
|
||||
if (active)
|
||||
Positioned(
|
||||
left: -18,
|
||||
top: 10,
|
||||
child: Container(
|
||||
width: 4,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: pt.accent,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topRight: Radius.circular(3),
|
||||
bottomRight: Radius.circular(3),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
message: widget.name,
|
||||
waitDuration: const Duration(milliseconds: 400),
|
||||
preferBelow: false,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOutBack,
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: _hovered ? pt.bg3 : pt.bg2,
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
),
|
||||
child: Center(
|
||||
child: PyramidGlyph(
|
||||
size: 30,
|
||||
color: widget.color,
|
||||
label: widget.label,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SpaceItem extends ConsumerStatefulWidget {
|
||||
final Room space;
|
||||
final bool active;
|
||||
|
||||
@@ -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;
|
||||
@@ -254,8 +282,14 @@ 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) {
|
||||
// Sprachkanäle UND Gruppenanrufe: Anwesenheit im Matrix-Raum, damit
|
||||
// die anderen sehen, wer drin ist (Gruppen: „Beitreten“-Karte).
|
||||
if (matrixClient != null && matrixRoomId != null) {
|
||||
await _writePresence(true);
|
||||
_presenceHeartbeat = Timer.periodic(const Duration(seconds: 30), (_) {
|
||||
_writePresence(true).catchError((_) {});
|
||||
@@ -269,6 +303,7 @@ class LiveKitCallManager extends VoiceChannelService {
|
||||
isScreenSharing = false;
|
||||
_currentScreenShareSourceId = null;
|
||||
_screenShareTrack = null;
|
||||
CallForeground.screenCaptureStopped('livekit');
|
||||
}
|
||||
notifyListeners();
|
||||
})
|
||||
@@ -487,6 +522,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();
|
||||
@@ -587,6 +627,7 @@ class LiveKitCallManager extends VoiceChannelService {
|
||||
}
|
||||
}
|
||||
try { await _room?.localParticipant?.setScreenShareEnabled(false); } catch (_) {}
|
||||
await CallForeground.screenCaptureStopped('livekit');
|
||||
isScreenSharing = false;
|
||||
_currentScreenShareSourceId = null;
|
||||
_screenShareTrack = null;
|
||||
@@ -597,6 +638,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();
|
||||
|
||||
+90
-15
@@ -18,7 +18,10 @@ 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/layout/mobile_tabs.dart';
|
||||
import 'package:pyramid/core/settings_prefs.dart' show bottomNavProvider;
|
||||
import 'package:pyramid/widgets/spring_press.dart' show motionDuration;
|
||||
import 'package:pyramid/widgets/mxc_image.dart';
|
||||
import 'package:pyramid/widgets/search_modal.dart';
|
||||
import 'package:pyramid/widgets/settings_modal.dart';
|
||||
import 'package:pyramid/widgets/share_target_dialog.dart';
|
||||
@@ -151,6 +154,44 @@ class _AppShellState extends ConsumerState<AppShell>
|
||||
ref.read(activeModalProvider.notifier).state = ModalKind.none;
|
||||
}
|
||||
|
||||
bool _hadVoipCall = false;
|
||||
bool _hadLiveKit = false;
|
||||
|
||||
/// Anruf ist vorbei: Anrufansicht verlassen (sofern kein anderer Anruf
|
||||
/// mehr läuft) und kurz Bescheid geben.
|
||||
void _leaveCallView(String message) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
try {
|
||||
_leaveCallViewNow(message);
|
||||
} catch (e, s) {
|
||||
debugPrint('[AppShell] Anrufansicht verlassen: $e $s');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _leaveCallViewNow(String message) {
|
||||
{
|
||||
if (!mounted) return;
|
||||
final voip = ref.read(callSignalingProvider);
|
||||
final lk = ref.read(voiceChannelProvider);
|
||||
final stillInCall = (voip.currentCall != null &&
|
||||
voip.currentCall!.state != CallState.kEnded) ||
|
||||
lk.isActive ||
|
||||
lk.isConnecting;
|
||||
if (stillInCall) return;
|
||||
if (ref.read(viewModeProvider) == ViewMode.voice) {
|
||||
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||
}
|
||||
ScaffoldMessenger.maybeOf(context)
|
||||
?..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(
|
||||
content: Text(message),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: const Duration(seconds: 3),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = PyramidTheme.of(context);
|
||||
@@ -164,6 +205,12 @@ class _AppShellState extends ConsumerState<AppShell>
|
||||
// würde der Nutzer permanent zurück in die Call-UI gezwungen und könnte
|
||||
// sie nie minimieren.
|
||||
ref.listen(callSignalingProvider, (prev, next) {
|
||||
// Anruf vorbei (aufgelegt, abgelehnt, gescheitert) → nicht auf der
|
||||
// leeren Anrufansicht stehen lassen (UI-Audit S1, 2026-10-08).
|
||||
final hasCall = next.currentCall != null &&
|
||||
next.currentCall!.state != CallState.kEnded;
|
||||
if (_hadVoipCall && !hasCall) _leaveCallView('Anruf beendet');
|
||||
_hadVoipCall = hasCall;
|
||||
final wasConnected = _wasVoipConnected;
|
||||
final isConnected = next.currentCall != null &&
|
||||
next.currentCall!.state == CallState.kConnected;
|
||||
@@ -176,6 +223,12 @@ class _AppShellState extends ConsumerState<AppShell>
|
||||
});
|
||||
|
||||
ref.listen(voiceChannelProvider, (prev, next) {
|
||||
final lkActive = next.isActive || next.isConnecting;
|
||||
if (_hadLiveKit && !lkActive) {
|
||||
// Fehler (z. B. Verbindung verloren) sichtbar machen statt still.
|
||||
_leaveCallView(next.error ?? (next.isVoiceChannel ? 'Sprachkanal verlassen' : 'Anruf beendet'));
|
||||
}
|
||||
_hadLiveKit = lkActive;
|
||||
// Only auto-switch to voice view for video/audio calls, NOT voice channels
|
||||
// (voice channels should stay in background with a mini bar)
|
||||
final wasActive = _wasLiveKitActive;
|
||||
@@ -344,11 +397,17 @@ class _MobileShell extends ConsumerWidget {
|
||||
: activeRoomId != null
|
||||
? ChatView(roomId: activeRoomId!)
|
||||
: null;
|
||||
final bottomNav = ref.watch(bottomNavProvider);
|
||||
final tab = ref.watch(mobileTabProvider);
|
||||
final body = MobilePageStack(
|
||||
base: const RoomsPanel(),
|
||||
base: bottomNav ? const MobileTabsBase() : const RoomsPanel(),
|
||||
front: front,
|
||||
background: pt.bg1,
|
||||
);
|
||||
// Untere Leiste: Zurück führt erst über die Tabs (Server → Übersicht →
|
||||
// Chats), bevor die App geschlossen wird.
|
||||
final tabsAtRoot = !bottomNav ||
|
||||
(tab == MobileTab.chats);
|
||||
|
||||
// Symbole der Statusleiste passend zum Theme (hell auf dunkel und umgekehrt).
|
||||
final overlayStyle = (pt.isDark
|
||||
@@ -362,7 +421,10 @@ class _MobileShell extends ConsumerWidget {
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: overlayStyle,
|
||||
child: PopScope(
|
||||
canPop: activeRoomId == null && viewMode == ViewMode.chat && !membersOpen,
|
||||
canPop: activeRoomId == null &&
|
||||
viewMode == ViewMode.chat &&
|
||||
!membersOpen &&
|
||||
tabsAtRoot,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop) {
|
||||
if (membersOpen) {
|
||||
@@ -371,6 +433,8 @@ class _MobileShell extends ConsumerWidget {
|
||||
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||
} else if (activeRoomId != null) {
|
||||
ref.read(activeRoomIdProvider.notifier).state = null;
|
||||
} else if (bottomNav) {
|
||||
handleMobileTabBack(ref);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -378,11 +442,13 @@ class _MobileShell extends ConsumerWidget {
|
||||
// bg1 = Farbe der Kopfzeilen → der Streifen hinter der Statusleiste
|
||||
// geht nahtlos in sie über.
|
||||
backgroundColor: pt.bg1,
|
||||
drawer: Drawer(
|
||||
width: 80,
|
||||
backgroundColor: pt.bg0,
|
||||
child: const SafeArea(child: SpacesRail()),
|
||||
),
|
||||
drawer: bottomNav
|
||||
? null
|
||||
: Drawer(
|
||||
width: 80,
|
||||
backgroundColor: pt.bg0,
|
||||
child: const SafeArea(child: SpacesRail()),
|
||||
),
|
||||
// SafeArea: Abstand zu Statusleiste/Kamera-Aussparung oben und zur
|
||||
// Gesten-/Navigationsleiste unten (edge-to-edge, siehe main.dart).
|
||||
body: SafeArea(child: Stack(
|
||||
@@ -519,18 +585,27 @@ class _IncomingCallOverlay extends ConsumerWidget {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: pt.accent,
|
||||
shape: BoxShape.circle,
|
||||
MxcAvatar(
|
||||
mxcUri: call.room.avatar,
|
||||
client: call.room.client,
|
||||
size: 88,
|
||||
placeholder: (_) => Container(
|
||||
width: 88,
|
||||
height: 88,
|
||||
decoration: BoxDecoration(
|
||||
color: pt.accent,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
call.room.getLocalizedDisplayname().characters.firstOrNull?.toUpperCase() ?? '?',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 36, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.person_rounded, size: 40, color: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Eingehender Anruf',
|
||||
call.type == CallType.kVideo ? 'Eingehender Videoanruf' : 'Eingehender Anruf',
|
||||
style: TextStyle(color: pt.fgDim, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
@@ -0,0 +1,623 @@
|
||||
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/matrix_client.dart';
|
||||
import 'package:pyramid/core/room_kinds.dart';
|
||||
import 'package:pyramid/core/theme.dart';
|
||||
import 'package:pyramid/features/rooms/rooms_panel.dart';
|
||||
import 'package:pyramid/features/rooms/rooms_provider.dart';
|
||||
import 'package:pyramid/widgets/create_join_dialog.dart';
|
||||
import 'package:pyramid/widgets/mxc_image.dart';
|
||||
import 'package:pyramid/widgets/spring_press.dart';
|
||||
|
||||
/// Handy-Navigation mit unterer Leiste (Einstellung „Navigation: Untere
|
||||
/// Leiste“, Bernd 2026-10-09 zum Vergleich mit dem Seitenmenü):
|
||||
/// **Chats** (Direktnachrichten + Gruppen), **Server** (Übersicht → Kanäle),
|
||||
/// **Du** (Profil + Einstellungen). Chat und Anruf gleiten wie gewohnt
|
||||
/// darüber; die Leiste liegt darunter und verschwindet mit.
|
||||
enum MobileTab { chats, servers, me }
|
||||
|
||||
final mobileTabProvider = StateProvider<MobileTab>((ref) => MobileTab.chats);
|
||||
|
||||
/// Zuletzt geöffneter Server – „Server“ öffnet wieder dort.
|
||||
final _lastServerProvider = StateProvider<String?>((ref) => null);
|
||||
|
||||
bool _isRealSpace(String? id) => id != null && id != 'dms' && id != 'rooms';
|
||||
|
||||
/// Wechselt den Tab (und die passende Liste).
|
||||
void selectMobileTab(WidgetRef ref, MobileTab tab) {
|
||||
final current = ref.read(mobileTabProvider);
|
||||
final space = ref.read(activeSpaceIdProvider);
|
||||
if (_isRealSpace(space)) ref.read(_lastServerProvider.notifier).state = space;
|
||||
switch (tab) {
|
||||
case MobileTab.chats:
|
||||
ref.read(activeSpaceIdProvider.notifier).state = 'dms';
|
||||
case MobileTab.servers:
|
||||
// Nochmal auf „Server“ tippen = zurück zur Übersicht.
|
||||
ref.read(activeSpaceIdProvider.notifier).state =
|
||||
current == MobileTab.servers ? null : ref.read(_lastServerProvider);
|
||||
case MobileTab.me:
|
||||
break;
|
||||
}
|
||||
ref.read(mobileTabProvider.notifier).state = tab;
|
||||
}
|
||||
|
||||
/// Zurück-Taste mit unterer Leiste: Server → Übersicht → Chats.
|
||||
/// Liefert `true`, wenn sie etwas getan hat.
|
||||
bool handleMobileTabBack(WidgetRef ref) {
|
||||
final tab = ref.read(mobileTabProvider);
|
||||
if (tab == MobileTab.servers &&
|
||||
_isRealSpace(ref.read(activeSpaceIdProvider))) {
|
||||
ref.read(activeSpaceIdProvider.notifier).state = null;
|
||||
ref.read(_lastServerProvider.notifier).state = null;
|
||||
return true;
|
||||
}
|
||||
if (tab != MobileTab.chats) {
|
||||
selectMobileTab(ref, MobileTab.chats);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Grundseite unter Chat/Anruf: Inhalt des Tabs, Anruf-Leiste, Tab-Leiste.
|
||||
class MobileTabsBase extends ConsumerWidget {
|
||||
const MobileTabsBase({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final pt = PyramidTheme.of(context);
|
||||
final tab = ref.watch(mobileTabProvider);
|
||||
final space = ref.watch(activeSpaceIdProvider);
|
||||
final Widget content = switch (tab) {
|
||||
MobileTab.chats => const RoomsPanel(key: ValueKey('tab-chats')),
|
||||
MobileTab.servers => _isRealSpace(space)
|
||||
? RoomsPanel(key: ValueKey('tab-server-$space'))
|
||||
: const ServerOverview(key: ValueKey('tab-servers')),
|
||||
MobileTab.me => const MeTab(key: ValueKey('tab-me')),
|
||||
};
|
||||
return ColoredBox(
|
||||
color: pt.bg1,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: AnimatedSwitcher(
|
||||
duration: motionDuration(context, 220),
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
transitionBuilder: (child, anim) => FadeTransition(
|
||||
opacity: anim,
|
||||
child: SlideTransition(
|
||||
position: Tween(
|
||||
begin: const Offset(0, 0.015),
|
||||
end: Offset.zero,
|
||||
).animate(anim),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: content,
|
||||
),
|
||||
),
|
||||
const CallDock(),
|
||||
const _TabBar(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TabBar extends ConsumerWidget {
|
||||
const _TabBar();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final pt = PyramidTheme.of(context);
|
||||
final tab = ref.watch(mobileTabProvider);
|
||||
final chatUnread = ref.watch(dmUnreadCountProvider).valueOrNull ?? 0;
|
||||
final serverUnread = ref.watch(_serverUnreadProvider);
|
||||
|
||||
Widget item(MobileTab t, IconData icon, IconData activeIcon, String label,
|
||||
int badge) {
|
||||
final selected = tab == t;
|
||||
return Expanded(
|
||||
child: Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
label: label,
|
||||
child: SpringPress(
|
||||
child: InkResponse(
|
||||
onTap: () => selectMobileTab(ref, t),
|
||||
radius: 40,
|
||||
highlightShape: BoxShape.rectangle,
|
||||
containedInkWell: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: motionDuration(context, 240),
|
||||
curve: springyCurve,
|
||||
width: selected ? 60 : 44,
|
||||
height: 30,
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? pt.accentSoft : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Icon(selected ? activeIcon : icon,
|
||||
size: 22,
|
||||
color: selected ? pt.accent : pt.fgMuted),
|
||||
),
|
||||
if (badge > 0)
|
||||
Positioned(
|
||||
right: selected ? 6 : 0,
|
||||
top: -3,
|
||||
child: SpringBump(
|
||||
value: badge,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minWidth: 18),
|
||||
height: 18,
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 5),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: pt.danger,
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
border: Border.all(color: pt.bg1, width: 2),
|
||||
),
|
||||
child: Text(badge > 99 ? '99+' : '$badge',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(label,
|
||||
style: TextStyle(
|
||||
color: selected ? pt.fg : pt.fgMuted,
|
||||
fontSize: 12,
|
||||
fontWeight:
|
||||
selected ? FontWeight.w700 : FontWeight.w500)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg1,
|
||||
border: Border(top: BorderSide(color: pt.border)),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(8, 4, 8, 6),
|
||||
child: Row(
|
||||
children: [
|
||||
item(MobileTab.chats, Icons.chat_bubble_outline_rounded,
|
||||
Icons.chat_bubble_rounded, 'Chats', chatUnread),
|
||||
item(MobileTab.servers, Icons.dns_outlined, Icons.dns_rounded,
|
||||
'Server', serverUnread),
|
||||
item(MobileTab.me, Icons.person_outline_rounded,
|
||||
Icons.person_rounded, 'Du', 0),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ungelesene Kanäle über alle Server (für das Abzeichen am „Server“-Tab).
|
||||
final _serverUnreadProvider = Provider<int>((ref) {
|
||||
ref.watch(roomListProvider);
|
||||
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||
if (client == null) return 0;
|
||||
final channels = spaceChannelIds(client);
|
||||
return client.rooms
|
||||
.where((r) =>
|
||||
channels.contains(r.id) &&
|
||||
r.membership == Membership.join &&
|
||||
r.notificationCount > 0)
|
||||
.length;
|
||||
});
|
||||
|
||||
/// Übersicht aller Server als Karten (Tab „Server“).
|
||||
class ServerOverview extends ConsumerWidget {
|
||||
const ServerOverview({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final pt = PyramidTheme.of(context);
|
||||
ref.watch(roomListProvider); // bei jeder Synchronisation neu
|
||||
final spaces = ref.watch(spacesProvider).valueOrNull ?? [];
|
||||
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_TabHeader(
|
||||
title: 'Server',
|
||||
pt: pt,
|
||||
action: IconButton(
|
||||
tooltip: 'Server erstellen oder beitreten',
|
||||
icon: Icon(Icons.add_rounded, color: pt.fgMuted),
|
||||
onPressed: () => showDialog(
|
||||
context: context, builder: (_) => const CreateJoinDialog()),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: spaces.isEmpty || client == null
|
||||
? _EmptyServers(pt: pt)
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 16),
|
||||
itemCount: spaces.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
||||
itemBuilder: (context, i) =>
|
||||
_ServerCard(space: spaces[i], client: client, pt: pt),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ServerCard extends ConsumerWidget {
|
||||
final Room space;
|
||||
final Client client;
|
||||
final PyramidTheme pt;
|
||||
const _ServerCard(
|
||||
{required this.space, required this.client, required this.pt});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final name = space.getLocalizedDisplayname();
|
||||
final invited = space.membership == Membership.invite;
|
||||
var channels = 0, unread = 0, inVoice = 0;
|
||||
for (final child in space.spaceChildren) {
|
||||
final id = child.roomId;
|
||||
if (id == null) continue;
|
||||
final r = client.getRoomById(id);
|
||||
if (r == null || r.isSpace) continue;
|
||||
channels++;
|
||||
if (r.membership == Membership.join && r.notificationCount > 0) unread++;
|
||||
if (isVoiceChannelRoom(r)) {
|
||||
inVoice += ref.watch(voiceParticipantsProvider(r.id)).length;
|
||||
}
|
||||
}
|
||||
final subtitle = invited
|
||||
? 'Einladung – antippen zum Ansehen'
|
||||
: [
|
||||
channels == 1 ? '1 Kanal' : '$channels Kanäle',
|
||||
if (unread > 0) '$unread ungelesen',
|
||||
].join(' · ');
|
||||
|
||||
final initial = name.isEmpty ? '?' : name.characters.first.toUpperCase();
|
||||
Widget fallback(BuildContext _) => Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: pt.accentSoft,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(initial,
|
||||
style: TextStyle(
|
||||
color: pt.accent, fontSize: 22, fontWeight: FontWeight.w700)),
|
||||
);
|
||||
|
||||
return SpringPress(
|
||||
pressedScale: 0.97,
|
||||
child: Material(
|
||||
color: pt.bg2,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
onTap: () {
|
||||
ref.read(activeSpaceIdProvider.notifier).state = space.id;
|
||||
ref.read(activeRoomIdProvider.notifier).state = null;
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(
|
||||
color: invited ? pt.accent.withAlpha(120) : pt.border),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
space.avatar == null
|
||||
? Builder(builder: fallback)
|
||||
: MxcAvatar(
|
||||
mxcUri: space.avatar,
|
||||
client: client,
|
||||
size: 52,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
placeholder: fallback,
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: pt.fg,
|
||||
fontSize: 16,
|
||||
fontWeight: unread > 0
|
||||
? FontWeight.w700
|
||||
: FontWeight.w600)),
|
||||
const SizedBox(height: 2),
|
||||
Text(subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: invited ? pt.accent : pt.fgMuted,
|
||||
fontSize: 13)),
|
||||
if (inVoice > 0) ...[
|
||||
const SizedBox(height: 4),
|
||||
Row(children: [
|
||||
Icon(Icons.graphic_eq_rounded,
|
||||
size: 14, color: pt.online),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
inVoice == 1
|
||||
? '1 Person im Sprachkanal'
|
||||
: '$inVoice Personen im Sprachkanal',
|
||||
style: TextStyle(
|
||||
color: pt.online,
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w600)),
|
||||
]),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(Icons.chevron_right_rounded, color: pt.fgDim),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyServers extends StatelessWidget {
|
||||
final PyramidTheme pt;
|
||||
const _EmptyServers({required this.pt});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.dns_outlined, size: 40, color: pt.fgDim),
|
||||
const SizedBox(height: 10),
|
||||
Text('Noch keine Server',
|
||||
style: TextStyle(color: pt.fgMuted, fontSize: 15)),
|
||||
const SizedBox(height: 4),
|
||||
Text('Server sind wie Discord: Kanäle für Text und Sprache.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: pt.fgDim, fontSize: 13)),
|
||||
const SizedBox(height: 14),
|
||||
FilledButton.tonalIcon(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: pt.accentSoft,
|
||||
foregroundColor: pt.accent,
|
||||
),
|
||||
icon: const Icon(Icons.add_rounded, size: 18),
|
||||
label: const Text('Server erstellen oder beitreten'),
|
||||
onPressed: () => showDialog(
|
||||
context: context, builder: (_) => const CreateJoinDialog()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TabHeader extends StatelessWidget {
|
||||
final String title;
|
||||
final PyramidTheme pt;
|
||||
final Widget? action;
|
||||
const _TabHeader({required this.title, required this.pt, this.action});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 52,
|
||||
padding: const EdgeInsets.only(left: 18, right: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg1,
|
||||
border: Border(bottom: BorderSide(color: pt.border)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(title,
|
||||
style: TextStyle(
|
||||
color: pt.fg, fontSize: 17, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
?action,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final _myProfileProvider = FutureProvider.autoDispose<Profile?>((ref) async {
|
||||
final client = await ref.watch(matrixClientProvider.future);
|
||||
final id = client.userID;
|
||||
if (id == null) return null;
|
||||
try {
|
||||
return await client.getProfileFromUserId(id);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
/// Tab „Du“: eigenes Profil und Einstellungen.
|
||||
class MeTab extends ConsumerWidget {
|
||||
const MeTab({super.key});
|
||||
|
||||
static const _entries = <(String, String, IconData)>[
|
||||
('profile', 'Mein Profil', Icons.person_outline_rounded),
|
||||
('notifications', 'Benachrichtigungen', Icons.notifications_none_rounded),
|
||||
('appearance', 'Erscheinungsbild', Icons.palette_outlined),
|
||||
('voice', 'Sprache & Video', Icons.headphones_rounded),
|
||||
('privacy', 'Datenschutz', Icons.lock_outline_rounded),
|
||||
('sessions', 'Sitzungen', Icons.devices_rounded),
|
||||
('encryption', 'Verschlüsselung', Icons.shield_outlined),
|
||||
('about', 'Über Pyramid', Icons.info_outline_rounded),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final pt = PyramidTheme.of(context);
|
||||
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||
final profile = ref.watch(_myProfileProvider).valueOrNull;
|
||||
final userId = client?.userID ?? '';
|
||||
final name = profile?.displayName ??
|
||||
userId.split(':').first.replaceFirst('@', '');
|
||||
final initial = name.isEmpty ? '?' : name.characters.first.toUpperCase();
|
||||
|
||||
void open(String section) {
|
||||
ref.read(settingsSectionRequestProvider.notifier).state = section;
|
||||
ref.read(activeModalProvider.notifier).state = ModalKind.settings;
|
||||
}
|
||||
|
||||
Widget fallback(BuildContext _) => Container(
|
||||
width: 76,
|
||||
height: 76,
|
||||
decoration: BoxDecoration(color: pt.accentSoft, shape: BoxShape.circle),
|
||||
alignment: Alignment.center,
|
||||
child: Text(initial,
|
||||
style: TextStyle(
|
||||
color: pt.accent, fontSize: 32, fontWeight: FontWeight.w700)),
|
||||
);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_TabHeader(title: 'Du', pt: pt),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(12, 16, 12, 16),
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg2,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: pt.border),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
profile?.avatarUrl == null || client == null
|
||||
? Builder(builder: fallback)
|
||||
: MxcAvatar(
|
||||
mxcUri: profile!.avatarUrl,
|
||||
client: client,
|
||||
size: 76,
|
||||
placeholder: fallback,
|
||||
),
|
||||
Positioned(
|
||||
right: 2,
|
||||
bottom: 2,
|
||||
child: Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: pt.online,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: pt.bg2, width: 3),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: pt.fg,
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 2),
|
||||
Text(userId,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style:
|
||||
TextStyle(color: pt.fgMuted, fontSize: 13)),
|
||||
const SizedBox(height: 10),
|
||||
OutlinedButton.icon(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: pt.fg,
|
||||
side: BorderSide(color: pt.border),
|
||||
shape: const StadiumBorder(),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
icon: const Icon(Icons.edit_rounded, size: 16),
|
||||
label: const Text('Profil bearbeiten'),
|
||||
onPressed: () => open('profile'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg2,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: pt.border),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 1; i < _entries.length; i++) ...[
|
||||
if (i > 1)
|
||||
Divider(height: 1, indent: 56, color: pt.border),
|
||||
ListTile(
|
||||
leading: Icon(_entries[i].$3, color: pt.fgMuted),
|
||||
title: Text(_entries[i].$2,
|
||||
style: TextStyle(color: pt.fg, fontSize: 15)),
|
||||
trailing: Icon(Icons.chevron_right_rounded,
|
||||
color: pt.fgDim),
|
||||
onTap: () => open(_entries[i].$1),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import 'package:matrix/matrix.dart';
|
||||
import 'package:pyramid/core/app_state.dart';
|
||||
import 'package:pyramid/core/matrix_client.dart';
|
||||
import 'package:pyramid/core/theme.dart';
|
||||
import 'package:pyramid/features/group_call/group_call.dart'
|
||||
show allowVoicePresenceForAll;
|
||||
import 'package:pyramid/widgets/mxc_image.dart';
|
||||
|
||||
enum CreateJoinTab { create, join, discover }
|
||||
@@ -100,15 +102,7 @@ class _CreateJoinDialogState extends ConsumerState<CreateJoinDialog> {
|
||||
// weil Continuwuity power_level_content_override beim createRoom
|
||||
// ignoriert.
|
||||
try {
|
||||
final plRaw = await client.getRoomStateWithKey(
|
||||
roomId, EventTypes.RoomPowerLevels, '');
|
||||
final pl = Map<String, dynamic>.from(plRaw);
|
||||
final events =
|
||||
Map<String, dynamic>.from(pl['events'] as Map? ?? {});
|
||||
events['io.pyramid.voice.presence'] = 0;
|
||||
pl['events'] = events;
|
||||
await client.setRoomStateWithKey(
|
||||
roomId, EventTypes.RoomPowerLevels, '', pl);
|
||||
await allowVoicePresenceForAll(client, roomId);
|
||||
} catch (_) {
|
||||
// Nicht kritisch — Presence-Anzeige funktioniert dann nur für Admins.
|
||||
}
|
||||
@@ -123,6 +117,10 @@ class _CreateJoinDialogState extends ConsumerState<CreateJoinDialog> {
|
||||
? [StateEvent(content: {'algorithm': 'm.megolm.v1.aes-sha2'}, type: EventTypes.Encryption)]
|
||||
: null,
|
||||
);
|
||||
// Gruppenanrufe: alle Mitglieder dürfen ihre Anwesenheit zeigen.
|
||||
try {
|
||||
await allowVoicePresenceForAll(client, roomId);
|
||||
} catch (_) {}
|
||||
}
|
||||
if (mounted) {
|
||||
ref.read(activeRoomIdProvider.notifier).state = roomId;
|
||||
@@ -581,7 +579,7 @@ class _SpaceEditDialogState extends ConsumerState<SpaceEditDialog> {
|
||||
try {
|
||||
await widget.space.leave();
|
||||
if (mounted) {
|
||||
ref.read(activeSpaceIdProvider.notifier).state = 'rooms';
|
||||
ref.read(activeSpaceIdProvider.notifier).state = 'dms';
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -169,6 +169,26 @@ class _AppearanceSection extends StatelessWidget {
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
if (isMobileLayout(MediaQuery.sizeOf(context))) ...[
|
||||
_SettingsGroup(title: 'Navigation', pt: pt, children: [
|
||||
Consumer(builder: (context, ref, _) {
|
||||
final bottom = ref.watch(bottomNavProvider);
|
||||
return _AdaptiveSettingsRow(
|
||||
title: 'Hauptmenü',
|
||||
desc: 'Seitenmenü (☰) oder Leiste unten mit Chats · Server · Du',
|
||||
pt: pt,
|
||||
control: _SegControl(
|
||||
options: const ['Seitenmenü', 'Untere Leiste'],
|
||||
selected: bottom ? 1 : 0,
|
||||
pt: pt,
|
||||
onSelect: (i) =>
|
||||
ref.read(bottomNavProvider.notifier).set(i == 1),
|
||||
),
|
||||
);
|
||||
}),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
_SettingsGroup(title: 'Form & Bewegung', pt: pt, children: [
|
||||
_SettingsRow(
|
||||
title: 'Eckenrundung',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -81,6 +82,20 @@ class _SettingsModalState extends ConsumerState<SettingsModal> {
|
||||
('about', 'Über Pyramid', Icons.info_outline_rounded),
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final requested = ref.read(settingsSectionRequestProvider);
|
||||
if (requested != null) {
|
||||
_section = requested;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
ref.read(settingsSectionRequestProvider.notifier).state = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
String _titleFor(String id) =>
|
||||
_categories.firstWhere((c) => c.$1 == id, orElse: () => (id, id, Icons.settings)).$2;
|
||||
|
||||
@@ -213,7 +228,7 @@ class _SettingsModalState extends ConsumerState<SettingsModal> {
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
children: [
|
||||
for (final c in _categories)
|
||||
for (final c in _categories.where((c) => c.$1 != 'keys'))
|
||||
_MobileCategoryTile(
|
||||
label: c.$2,
|
||||
icon: c.$3,
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: pyramid
|
||||
description: A flexible Matrix client with resizable panel layout.
|
||||
publish_to: none
|
||||
version: 0.1.38+39
|
||||
version: 0.1.43+44
|
||||
environment:
|
||||
sdk: ">=3.11.1 <4.0.0"
|
||||
|
||||
|
||||
+24
-3
@@ -109,17 +109,38 @@ 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
|
||||
# Integrationstests lassen die Test-App manchmal weiterlaufen – die
|
||||
# hielte die Testprofil-DB offen (zwei Clients auf einer DB beim
|
||||
# nächsten Lauf). Nur Prozesse aus DIESEM Build-Ordner beenden.
|
||||
Get-Process pyramid -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Path -like "$PWDuild\windows\*" } |
|
||||
Stop-Process -Force -Confirm:$false -ErrorAction SilentlyContinue
|
||||
}
|
||||
Write-Host "Screenshots: build/ui_screens/ (Testinhalte ggf. mit test/live_ui_fixture_test.dart anlegen)"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pyramid/features/call_ui/inflated_layout.dart';
|
||||
|
||||
/// [seconds] lang simulieren (60 Schritte pro Sekunde).
|
||||
bool _run(InflatedLayout l, double seconds) {
|
||||
var moving = true;
|
||||
for (var i = 0; i < (seconds * 60).round(); i++) {
|
||||
moving = l.step(1 / 60);
|
||||
}
|
||||
return moving;
|
||||
}
|
||||
|
||||
List<String> _ids(int n) => [for (var i = 0; i < n; i++) 'p$i'];
|
||||
|
||||
void main() {
|
||||
const phone = Size(390, 620);
|
||||
const desktop = Size(1200, 700);
|
||||
|
||||
test('eine Person füllt den ganzen Bereich bis an die Ränder', () {
|
||||
final l = InflatedLayout()..sync(['solo'], {}, phone);
|
||||
_run(l, 1);
|
||||
final r = l.cellBounds('solo');
|
||||
expect(r.left, closeTo(l.margin, 0.5));
|
||||
expect(r.top, closeTo(l.margin, 0.5));
|
||||
expect(r.right, closeTo(phone.width - l.margin, 0.5));
|
||||
expect(r.bottom, closeTo(phone.height - l.margin, 0.5));
|
||||
// Gerundete Ecken: der Umriss enthält die Ecke des Rechtecks nicht mehr.
|
||||
final path = l.outline('solo');
|
||||
expect(path.contains(r.topLeft + const Offset(2, 2)), isFalse);
|
||||
expect(path.contains(r.center), isTrue);
|
||||
});
|
||||
|
||||
test('vier Personen: gleich große Zellen, zusammen fast die ganze Fläche', () {
|
||||
final l = InflatedLayout()..sync(_ids(4), {}, desktop);
|
||||
final moving = _run(l, 6);
|
||||
expect(moving, isFalse, reason: 'kommt nicht zur Ruhe');
|
||||
final areas = l.cells.values.map((c) => c.area).toList();
|
||||
final avg = areas.reduce((a, b) => a + b) / areas.length;
|
||||
for (final a in areas) {
|
||||
expect(a, closeTo(avg, avg * 0.08));
|
||||
}
|
||||
final total = areas.reduce((a, b) => a + b);
|
||||
expect(total, greaterThan(l.bounds.width * l.bounds.height * 0.9));
|
||||
});
|
||||
|
||||
test('wer spricht, bekommt deutlich mehr Fläche', () {
|
||||
final l = InflatedLayout()..sync(_ids(3), {}, phone);
|
||||
_run(l, 4);
|
||||
l.sync(_ids(3), {'p1'}, phone);
|
||||
_run(l, 5);
|
||||
final speaker = l.cells['p1']!.area;
|
||||
final other = l.cells['p0']!.area;
|
||||
expect(speaker / other,
|
||||
closeTo(InflatedLayout.speakingShare, InflatedLayout.speakingShare * 0.15));
|
||||
});
|
||||
|
||||
test('je mehr Personen, desto kleiner die Zellen', () {
|
||||
double avgArea(int n) {
|
||||
final l = InflatedLayout()..sync(_ids(n), {}, phone);
|
||||
_run(l, 5);
|
||||
return l.cells.values.fold<double>(0, (s, c) => s + c.area) / n;
|
||||
}
|
||||
|
||||
final a2 = avgArea(2), a5 = avgArea(5), a9 = avgArea(9);
|
||||
expect(a5, lessThan(a2));
|
||||
expect(a9, lessThan(a5));
|
||||
});
|
||||
|
||||
test('Neue pumpen sich auf, Gehende verschwinden', () {
|
||||
final l = InflatedLayout()..sync(_ids(2), {}, phone);
|
||||
_run(l, 3);
|
||||
l.sync(_ids(3), {}, phone);
|
||||
expect(l.cells['p2']!.area, lessThan(l.cells['p0']!.area));
|
||||
_run(l, 5);
|
||||
expect(l.cells['p2']!.area, closeTo(l.cells['p0']!.area, l.cells['p0']!.area * 0.1));
|
||||
l.sync(['p0', 'p2'], {}, phone);
|
||||
_run(l, 3);
|
||||
expect(l.cells.keys.toSet(), {'p0', 'p2'});
|
||||
});
|
||||
|
||||
test('Zellen überlappen nicht und jeder Mittelpunkt liegt in seiner Zelle', () {
|
||||
final l = InflatedLayout()..sync(_ids(7), {'p3'}, desktop);
|
||||
_run(l, 6);
|
||||
final paths = {for (final id in l.cells.keys) id: l.outline(id)};
|
||||
for (final c in l.cells.values) {
|
||||
expect(paths[c.id]!.contains(c.centroid), isTrue, reason: '${c.id} Schwerpunkt');
|
||||
for (final other in l.cells.values) {
|
||||
if (other.id == c.id) continue;
|
||||
expect(paths[other.id]!.contains(c.centroid), isFalse,
|
||||
reason: '${c.id} liegt in ${other.id}');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Drehen (hochkant → quer): zwei Personen nebeneinander', () {
|
||||
final l = InflatedLayout()..sync(_ids(2), {}, phone);
|
||||
_run(l, 3);
|
||||
final a = l.cells['p0']!.centroid, b = l.cells['p1']!.centroid;
|
||||
expect((a.dy - b.dy).abs(), greaterThan((a.dx - b.dx).abs()), reason: 'hochkant: übereinander');
|
||||
l.sync(_ids(2), {}, desktop);
|
||||
_run(l, 3);
|
||||
final c = l.cells['p0']!.centroid, d = l.cells['p1']!.centroid;
|
||||
expect((c.dx - d.dx).abs(), greaterThan((c.dy - d.dy).abs()), reason: 'quer: nebeneinander');
|
||||
});
|
||||
}
|
||||
@@ -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