Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,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) {
|
||||||
|
|||||||
+83
-31
@@ -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,
|
|
||||||
},
|
/// Liefert Token und LiveKit-Adresse für [room]. Wirft
|
||||||
'metadata': displayName ?? identity,
|
/// [LiveKitTokenException] mit einer verständlichen Meldung.
|
||||||
},
|
static Future<({String token, String url})> fetch({
|
||||||
issuer: apiKey,
|
required String room,
|
||||||
subject: identity,
|
required String matrixToken,
|
||||||
|
http.Client? httpClient,
|
||||||
|
}) async {
|
||||||
|
final client = httpClient ?? http.Client();
|
||||||
|
try {
|
||||||
|
final res = await client
|
||||||
|
.post(
|
||||||
|
Uri.parse(endpoint),
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({'room': room, 'matrix_token': matrixToken}),
|
||||||
|
)
|
||||||
|
.timeout(_timeout);
|
||||||
|
if (res.statusCode == 401) {
|
||||||
|
throw const LiveKitTokenException(
|
||||||
|
'Anmeldung vom Anrufserver abgelehnt – bitte neu anmelden.',
|
||||||
);
|
);
|
||||||
|
}
|
||||||
// Sign the token with your secret
|
if (res.statusCode != 200) {
|
||||||
final token = jwt.sign(
|
throw LiveKitTokenException(
|
||||||
SecretKey(apiSecret),
|
'Anrufserver antwortet nicht richtig (HTTP ${res.statusCode}).',
|
||||||
expiresIn: Duration(seconds: ttlSeconds),
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
return token;
|
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));
|
||||||
|
|||||||
@@ -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(
|
||||||
|
duration: motionDuration(context, 300),
|
||||||
|
reverseDuration: motionDuration(context, 160),
|
||||||
|
switchInCurve: Curves.easeOutBack,
|
||||||
|
switchOutCurve: Curves.easeIn,
|
||||||
|
transitionBuilder: (child, anim) => SizeTransition(
|
||||||
|
sizeFactor: anim,
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: FadeTransition(opacity: anim, child: child),
|
||||||
|
),
|
||||||
|
child: widget.replyTo != null
|
||||||
|
? KeyedSubtree(
|
||||||
|
key: ValueKey('reply:${widget.replyTo!.eventId}'),
|
||||||
|
child: _ReplyBar(
|
||||||
event: widget.replyTo!,
|
event: widget.replyTo!,
|
||||||
onClear: widget.onClearReply ?? () {},
|
onClear: widget.onClearReply ?? () {},
|
||||||
pt: pt,
|
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,6 +372,12 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
|
|||||||
? [BoxShadow(color: pt.accentSoft, blurRadius: 0, spreadRadius: 3)]
|
? [BoxShadow(color: pt.accentSoft, blurRadius: 0, spreadRadius: 3)]
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
|
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(
|
child: Focus(
|
||||||
onFocusChange: (_) => setState(() {}),
|
onFocusChange: (_) => setState(() {}),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -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,6 +465,7 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
|
|||||||
canSend: _canSend,
|
canSend: _canSend,
|
||||||
pt: pt,
|
pt: pt,
|
||||||
onTap: _send,
|
onTap: _send,
|
||||||
|
size: mobile ? 42 : 36,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -431,6 +473,9 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -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,6 +518,8 @@ 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: SpringPress(
|
||||||
|
enabled: widget.onTap != null,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: touchSize,
|
width: touchSize,
|
||||||
height: touchSize,
|
height: touchSize,
|
||||||
@@ -499,6 +544,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: SpringPress(
|
||||||
|
enabled: active,
|
||||||
|
pressedScale: 0.82,
|
||||||
child: AnimatedContainer(
|
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,14 +602,23 @@ class _SendButtonState extends State<_SendButton> {
|
|||||||
: Matrix4.identity(),
|
: Matrix4.identity(),
|
||||||
transformAlignment: Alignment.center,
|
transformAlignment: Alignment.center,
|
||||||
child: Center(
|
child: Center(
|
||||||
|
// Sobald gesendet werden kann, ploppt der Pfeil federnd auf.
|
||||||
|
child: AnimatedScale(
|
||||||
|
scale: active ? 1 : 0.8,
|
||||||
|
duration: active
|
||||||
|
? motionDuration(context, 520)
|
||||||
|
: const Duration(milliseconds: 140),
|
||||||
|
curve: active ? Curves.elasticOut : Curves.easeOut,
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.send_rounded,
|
Icons.send_rounded,
|
||||||
size: 16,
|
size: widget.size * 0.45,
|
||||||
color: active ? pt.accentFg : pt.fgDim,
|
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,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),
|
||||||
@@ -123,6 +143,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,6 +143,13 @@ class _ChatHeader extends ConsumerWidget {
|
|||||||
onPressed: onTogglePinned,
|
onPressed: onTogglePinned,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
if (isMobile)
|
||||||
|
_ChatOverflowMenu(
|
||||||
|
pt: pt,
|
||||||
|
onSearch: onOpenSearch,
|
||||||
|
onMembers: onToggleMembers,
|
||||||
|
)
|
||||||
|
else ...[
|
||||||
PyrIconBtn(
|
PyrIconBtn(
|
||||||
size: 28,
|
size: 28,
|
||||||
icon: const Icon(Icons.search_rounded),
|
icon: const Icon(Icons.search_rounded),
|
||||||
@@ -155,6 +164,7 @@ class _ChatHeader extends ConsumerWidget {
|
|||||||
onPressed: onToggleMembers,
|
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,21 +388,16 @@ 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,
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
],
|
|
||||||
// Klickbarer Profilbereich: Avatar + Name + Details
|
|
||||||
Expanded(
|
|
||||||
child: MouseRegion(
|
child: MouseRegion(
|
||||||
cursor: SystemMouseCursors.click,
|
cursor: SystemMouseCursors.click,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
@@ -399,9 +421,10 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
|
|||||||
mxcUri: avatarUrl.toString(),
|
mxcUri: avatarUrl.toString(),
|
||||||
client: client,
|
client: client,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
placeholder: _initialBox(initial, pt),
|
placeholder:
|
||||||
|
_initialBox(initial, pt, avatarSize),
|
||||||
)
|
)
|
||||||
: _initialBox(initial, pt),
|
: _initialBox(initial, pt, avatarSize),
|
||||||
),
|
),
|
||||||
AnimatedContainer(
|
AnimatedContainer(
|
||||||
duration: _animDuration,
|
duration: _animDuration,
|
||||||
@@ -506,9 +529,30 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
// Zurück-Pfeil und Aktionen: fest in der obersten 52-px-Zeile –
|
||||||
// Aktionen — bleiben immer sichtbar
|
// sie rutschen beim Aufklappen nicht mehr in die Mitte.
|
||||||
Row(
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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,
|
||||||
@@ -537,6 +581,13 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
|
|||||||
active: widget.pinnedOpen,
|
active: widget.pinnedOpen,
|
||||||
onPressed: widget.onTogglePinned,
|
onPressed: widget.onTogglePinned,
|
||||||
),
|
),
|
||||||
|
if (isMobile)
|
||||||
|
_ChatOverflowMenu(
|
||||||
|
pt: pt,
|
||||||
|
onSearch: widget.onOpenSearch,
|
||||||
|
onMembers: widget.onToggleMembers,
|
||||||
|
)
|
||||||
|
else ...[
|
||||||
PyrIconBtn(
|
PyrIconBtn(
|
||||||
size: 28,
|
size: 28,
|
||||||
icon: const Icon(Icons.search_rounded),
|
icon: const Icon(Icons.search_rounded),
|
||||||
@@ -551,24 +602,34 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
|
|||||||
onPressed: widget.onToggleMembers,
|
onPressed: widget.onToggleMembers,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
||||||
|
duration: _animDuration,
|
||||||
|
curve: _animCurve,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.fg,
|
||||||
|
fontSize: avatarSize * 0.42,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
height: 1.0,
|
||||||
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
initial,
|
initial,
|
||||||
style: TextStyle(
|
textHeightBehavior: const TextHeightBehavior(
|
||||||
color: pt.fg, fontSize: 28, fontWeight: FontWeight.w700),
|
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,25 @@ class NoChatSelected extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fläche der schwebenden Eingabeleiste: um das Eingabefeld herum völlig
|
||||||
|
/// klar – der Chat ist dort normal zu sehen –, fängt aber alle Berührungen
|
||||||
|
/// ab (ColoredBox ist auch transparent „opak“ für Treffer), damit dort nur
|
||||||
|
/// die Eingabe reagiert und nicht der Chat dahinter. Die Unschärfe sitzt nur
|
||||||
|
/// hinter dem Eingabefeld selbst (chat_composer.dart).
|
||||||
|
class _FloatingComposerArea extends StatelessWidget {
|
||||||
|
final PyramidTheme pt;
|
||||||
|
final Widget child;
|
||||||
|
const _FloatingComposerArea({required this.pt, required this.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ColoredBox(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,9 +28,20 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
// Fade-out of the "Neue Nachrichten" divider once the user reaches the bottom.
|
// Fade-out of the "Neue Nachrichten" divider once the user reaches the bottom.
|
||||||
bool _unreadFading = false;
|
bool _unreadFading = false;
|
||||||
Timer? _unreadFadeTimer;
|
Timer? _unreadFadeTimer;
|
||||||
|
// Zuletzt von hier als gelesen gemeldetes Event. Verhindert, dass jeder
|
||||||
|
// Neuaufbau erneut eine Lesebestätigung schickt, und markiert nach dem
|
||||||
|
// Zurückkehren in die App, ab wo „Neue Nachrichten“ beginnen.
|
||||||
|
String? _lastMarkedEventId;
|
||||||
|
// Was danach eintrifft, ploppt federnd auf (beim Öffnen gesetzt, damit die
|
||||||
|
// schon vorhandenen Nachrichten ruhig erscheinen).
|
||||||
|
DateTime? _popInAfter;
|
||||||
|
|
||||||
// Jump-to-message (from search): scroll to + briefly highlight a target event.
|
// 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 +88,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 +118,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 +137,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 +162,8 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
_historyExhausted = false;
|
_historyExhausted = false;
|
||||||
_unreadFading = false;
|
_unreadFading = false;
|
||||||
_unreadFadeTimer?.cancel();
|
_unreadFadeTimer?.cancel();
|
||||||
|
_lastMarkedEventId = null;
|
||||||
|
_popInAfter = null;
|
||||||
_jumpEventId = null;
|
_jumpEventId = null;
|
||||||
_highlightEventId = null;
|
_highlightEventId = null;
|
||||||
_highlightOn = false;
|
_highlightOn = false;
|
||||||
@@ -174,6 +214,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));
|
||||||
@@ -224,21 +265,60 @@ 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) 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();
|
||||||
|
setState(() {
|
||||||
|
_sessionFullyReadId = since;
|
||||||
|
_sessionHasUnread = true;
|
||||||
|
_unreadFading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_markRead(timeline);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onScroll() async {
|
Future<void> _onScroll() async {
|
||||||
@@ -313,7 +393,9 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
// listen) so it also fires when this ChatView is freshly mounted for a
|
// 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 &&
|
||||||
@@ -429,28 +511,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 +550,49 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: Icon(Icons.keyboard_arrow_down_rounded, size: 20, color: pt.fgMuted),
|
child: Icon(Icons.keyboard_arrow_down_rounded,
|
||||||
|
size: _scrollBtnSize * 0.56, color: pt.fgMuted),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Schwebende Eingabeleiste (Bernds Wunsch 2026-10-07):
|
||||||
|
// der Chat läuft optisch darunter weiter
|
||||||
|
// (durchscheinend + Unschärfe), die ganze Fläche
|
||||||
|
// fängt aber Berührungen ab – dort reagiert nur
|
||||||
|
// die Eingabe, nicht der Chat dahinter.
|
||||||
|
if (room != null)
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: MeasureSize(
|
||||||
|
onChange: (size) {
|
||||||
|
if (mounted &&
|
||||||
|
(size.height - _composerHeight).abs() >
|
||||||
|
0.5) {
|
||||||
|
setState(
|
||||||
|
() => _composerHeight = size.height);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: _FloatingComposerArea(
|
||||||
|
pt: pt,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_TypingIndicator(room: room, pt: pt),
|
||||||
|
ChatComposer(
|
||||||
|
// Je Raum ein eigenes Eingabefeld
|
||||||
|
// (Text, „tippt…“, Entwurf).
|
||||||
|
key: ValueKey('composer:${room.id}'),
|
||||||
|
room: room,
|
||||||
|
replyTo: _replyTo,
|
||||||
|
onClearReply: () =>
|
||||||
|
setState(() => _replyTo = null),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -468,13 +600,6 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (room != null) _TypingIndicator(room: room, pt: pt),
|
|
||||||
if (room != null)
|
|
||||||
ChatComposer(
|
|
||||||
room: room,
|
|
||||||
replyTo: _replyTo,
|
|
||||||
onClearReply: () => setState(() => _replyTo = null),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -22,6 +22,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 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -173,8 +173,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 +189,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(
|
||||||
|
|||||||
+31
-10
@@ -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.
|
||||||
|
return TweenAnimationBuilder<double>(
|
||||||
|
tween: Tween(begin: 1, end: 0),
|
||||||
|
duration: motionDuration(ctx, 340),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
builder: (_, v, child) => FractionalTranslation(
|
||||||
|
translation: Offset(v, 0),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
child: MembersPanel(
|
||||||
roomId: activeRoomId!,
|
roomId: activeRoomId!,
|
||||||
width: (w * 0.82).clamp(240.0, 320.0),
|
width: (w * 0.82).clamp(240.0, 320.0),
|
||||||
onClose: () => ref.read(membersPanelOpenProvider.notifier).state = false,
|
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,6 +88,8 @@ class _PyrIconBtnState extends State<PyrIconBtn> {
|
|||||||
onExit: (_) => setState(() => _hovered = false),
|
onExit: (_) => setState(() => _hovered = false),
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: widget.onPressed,
|
onTap: widget.onPressed,
|
||||||
|
child: SpringPress(
|
||||||
|
enabled: widget.onPressed != null,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: touchSize,
|
width: touchSize,
|
||||||
height: touchSize,
|
height: touchSize,
|
||||||
@@ -87,15 +97,15 @@ class _PyrIconBtnState extends State<PyrIconBtn> {
|
|||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 150),
|
duration: const Duration(milliseconds: 150),
|
||||||
curve: Curves.easeOutCubic,
|
curve: Curves.easeOutCubic,
|
||||||
width: widget.size,
|
width: boxSize,
|
||||||
height: widget.size,
|
height: boxSize,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _hovered ? bgHover : Colors.transparent,
|
color: _hovered ? pt.bgHover : Colors.transparent,
|
||||||
borderRadius: BorderRadius.circular(7),
|
borderRadius: BorderRadius.circular(mobile ? boxSize / 2 : 7),
|
||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: IconTheme(
|
child: IconTheme(
|
||||||
data: IconThemeData(color: color, size: 16),
|
data: IconThemeData(color: color, size: iconSize),
|
||||||
child: widget.icon,
|
child: widget.icon,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -103,6 +113,7 @@ class _PyrIconBtnState extends State<PyrIconBtn> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (widget.tooltip != null) {
|
if (widget.tooltip != null) {
|
||||||
|
|||||||
@@ -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,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.38+39
|
||||||
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
|
||||||
|
|||||||
@@ -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')),
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user