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