feat: federnde Übergänge und Animationen, Entwürfe je Raum

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Bernd Steckmeister
2026-10-07 22:07:17 +02:00
co-authored by Claude Opus 5.5
parent 1d0ccfde8b
commit ae6f651f4c
14 changed files with 704 additions and 41 deletions
@@ -60,6 +60,9 @@ 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');
@@ -96,6 +99,24 @@ void main() {
await tester.tapAt(Offset(size.width / 2, size.height / 2)); await tester.tapAt(Offset(size.width / 2, size.height / 2));
await _pumpFor(tester, const Duration(seconds: 1)); 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');
@@ -210,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)),
+57 -10
View File
@@ -35,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;
@@ -47,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);
} }
@@ -121,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();
@@ -315,12 +327,28 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (widget.replyTo != null) // Antwort-Leiste federt auf und klappt beim Schließen weich zu.
_ReplyBar( AnimatedSwitcher(
event: widget.replyTo!, duration: motionDuration(context, 300),
onClear: widget.onClearReply ?? () {}, reverseDuration: motionDuration(context, 160),
pt: pt, switchInCurve: Curves.easeOutBack,
switchOutCurve: Curves.easeIn,
transitionBuilder: (child, anim) => SizeTransition(
sizeFactor: anim,
alignment: Alignment.bottomCenter,
child: FadeTransition(opacity: anim, child: child),
), ),
child: widget.replyTo != null
? KeyedSubtree(
key: ValueKey('reply:${widget.replyTo!.eventId}'),
child: _ReplyBar(
event: widget.replyTo!,
onClear: widget.onClearReply ?? () {},
pt: pt,
),
)
: const SizedBox(key: ValueKey('reply:none'), width: double.infinity),
),
Padding( Padding(
// Bottom inset matches the profile block in the rooms list (8px) so // Bottom inset matches the profile block in the rooms list (8px) so
// the composer sits flush with it. // the composer sits flush with it.
@@ -574,10 +602,18 @@ class _SendButtonState extends State<_SendButton> {
: Matrix4.identity(), : Matrix4.identity(),
transformAlignment: Alignment.center, transformAlignment: Alignment.center,
child: Center( child: Center(
child: Icon( // Sobald gesendet werden kann, ploppt der Pfeil federnd auf.
Icons.send_rounded, child: AnimatedScale(
size: widget.size * 0.45, scale: active ? 1 : 0.8,
color: active ? pt.accentFg : pt.fgDim, duration: active
? motionDuration(context, 520)
: const Duration(milliseconds: 140),
curve: active ? Curves.elasticOut : Curves.easeOut,
child: Icon(
Icons.send_rounded,
size: widget.size * 0.45,
color: active ? pt.accentFg : pt.fgDim,
),
), ),
), ),
), ),
@@ -627,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,
),
),
),
), ),
], ],
), ),
+2
View File
@@ -10,6 +10,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:pyramid/core/app_state.dart'; import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/appearance.dart' show isMobileLayout;
import 'package:pyramid/core/matrix_client.dart'; import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/settings_prefs.dart'; import 'package:pyramid/core/settings_prefs.dart';
import 'package:pyramid/core/theme.dart'; import 'package:pyramid/core/theme.dart';
@@ -20,6 +21,7 @@ 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/measure_size.dart';
import 'package:pyramid/widgets/spring_press.dart';
import 'package:pyramid/core/read_markers.dart'; import 'package:pyramid/core/read_markers.dart';
import 'package:pyramid/widgets/mxc_image.dart'; import 'package:pyramid/widgets/mxc_image.dart';
+13 -2
View File
@@ -6,6 +6,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:pyramid/core/theme.dart'; import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/chat/media_player.dart'; import 'package:pyramid/features/chat/media_player.dart';
import 'package:pyramid/utils/gif_favorite_service.dart'; import 'package:pyramid/utils/gif_favorite_service.dart';
import 'package:pyramid/widgets/spring_press.dart' show motionDuration;
/// Overlay viewer for images, GIFs and videos. /// Overlay viewer for images, GIFs and videos.
/// ///
@@ -50,9 +51,19 @@ class MediaViewer extends StatefulWidget {
// Den Hintergrund zeichnet der Viewer selbst (damit er beim Wischen // Den Hintergrund zeichnet der Viewer selbst (damit er beim Wischen
// ausblenden kann). // ausblenden kann).
barrierColor: Colors.transparent, barrierColor: Colors.transparent,
transitionDuration: const Duration(milliseconds: 180), transitionDuration: motionDuration(context, 300),
transitionBuilder: (context, animation, secondaryAnimation, child) => transitionBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child), FadeTransition(
opacity: animation,
child: ScaleTransition(
scale: Tween(begin: 0.9, end: 1.0).animate(CurvedAnimation(
parent: animation,
curve: Curves.easeOutBack,
reverseCurve: Curves.easeIn,
)),
child: child,
),
),
pageBuilder: (context, animation, secondaryAnimation) => MediaViewer( pageBuilder: (context, animation, secondaryAnimation) => MediaViewer(
senderName: senderName, senderName: senderName,
filename: filename, filename: filename,
@@ -1,5 +1,15 @@
part of '../message_group.dart'; part of '../message_group.dart';
/// Bereits federnd eingeblendete Nachrichten (begrenzt, älteste fallen raus).
final _poppedMessageIds = <String>{};
void _rememberPopped(String id) {
_poppedMessageIds.add(id);
if (_poppedMessageIds.length > 300) {
_poppedMessageIds.remove(_poppedMessageIds.first);
}
}
String _formatMessageTime(DateTime t) { String _formatMessageTime(DateTime t) {
final h = t.hour.toString().padLeft(2, '0'); final h = t.hour.toString().padLeft(2, '0');
final m = t.minute.toString().padLeft(2, '0'); final m = t.minute.toString().padLeft(2, '0');
@@ -28,6 +38,10 @@ class MessageGroup extends StatefulWidget {
final GlobalKey? jumpKey; final GlobalKey? jumpKey;
final String? highlightEventId; final String? highlightEventId;
final bool highlightOn; final bool highlightOn;
/// Nachrichten, die nach diesem Zeitpunkt eintreffen (oder gerade
/// gesendet werden), ploppen beim ersten Erscheinen federnd auf. null = aus
/// (z. B. solange der Chat gerade erst geöffnet wird).
final DateTime? popInAfter;
const MessageGroup({ const MessageGroup({
super.key, super.key,
@@ -45,6 +59,7 @@ class MessageGroup extends StatefulWidget {
this.jumpKey, this.jumpKey,
this.highlightEventId, this.highlightEventId,
this.highlightOn = false, this.highlightOn = false,
this.popInAfter,
}); });
@override @override
@@ -301,15 +316,39 @@ class _MessageGroupState extends State<MessageGroup> {
), ),
); );
if (_isMobile) return row; if (_isMobile) return _popIn(event, row);
// Desktop: per-row hover highlight + actions. // Desktop: per-row hover highlight + actions.
return MouseRegion( return _popIn(event, MouseRegion(
onEnter: (_) => setState(() => _hoveredEventId = event.eventId), onEnter: (_) => setState(() => _hoveredEventId = event.eventId),
onExit: (_) => setState(() { onExit: (_) => setState(() {
if (_hoveredEventId == event.eventId) _hoveredEventId = null; if (_hoveredEventId == event.eventId) _hoveredEventId = null;
}), }),
child: row, child: row,
));
}
// Neu angekommene oder gerade gesendete Nachricht: federnd einblenden.
// Der Schlüssel (Transaktions-ID bleibt beim Senden gleich) hält den
// Zustand der Zeile, die Merkliste verhindert ein zweites Abspielen, falls
// die Gruppe neu aufgebaut wird (z. B. lokale Kopie → Server-Event).
Widget _popIn(Event event, Widget row) {
final id = event.transactionId ?? event.eventId;
final after = widget.popInAfter;
final fresh = after != null &&
!_poppedMessageIds.contains(id) &&
(event.status.isSending ||
(event.originServerTs.isAfter(after) &&
DateTime.now().difference(event.originServerTs) <
const Duration(minutes: 2)));
if (fresh) _rememberPopped(id);
return PopIn(
key: ValueKey('row:$id'),
play: fresh,
alignment: event.senderId == widget.currentUserId
? Alignment.bottomRight
: Alignment.bottomLeft,
child: row,
); );
} }
@@ -22,11 +22,15 @@ class _ReactionsRow extends StatelessWidget {
// Group by emoji key → list of senders // Group by emoji key → list of senders
final Map<String, List<String>> grouped = {}; final Map<String, List<String>> grouped = {};
final newest = <String, DateTime>{};
for (final r in reactionEvents) { for (final r in reactionEvents) {
final key = r.content.tryGetMap<String, dynamic>('m.relates_to')?['key'] as String?; final key = r.content.tryGetMap<String, dynamic>('m.relates_to')?['key'] as String?;
if (key == null || key.isEmpty) continue; if (key == null || key.isEmpty) continue;
(grouped[key] ??= []).add(r.senderId); (grouped[key] ??= []).add(r.senderId);
final ts = r.originServerTs;
if (newest[key] == null || ts.isAfter(newest[key]!)) newest[key] = ts;
} }
final now = DateTime.now();
if (grouped.isEmpty) return const SizedBox.shrink(); if (grouped.isEmpty) return const SizedBox.shrink();
return Padding( return Padding(
@@ -37,6 +41,9 @@ class _ReactionsRow extends StatelessWidget {
children: grouped.entries.map((e) { children: grouped.entries.map((e) {
final myReaction = e.value.contains(currentUserId); final myReaction = e.value.contains(currentUserId);
return _ReactionChip( return _ReactionChip(
key: ValueKey(e.key),
// Gerade erst vergeben → federnd aufploppen.
fresh: now.difference(newest[e.key]!) < const Duration(seconds: 4),
emoji: e.key, emoji: e.key,
count: e.value.length, count: e.value.length,
mine: myReaction, mine: myReaction,
@@ -69,8 +76,11 @@ class _ReactionChip extends StatefulWidget {
final bool mine; final bool mine;
final PyramidTheme pt; final PyramidTheme pt;
final VoidCallback onTap; final VoidCallback onTap;
final bool fresh;
const _ReactionChip({ const _ReactionChip({
super.key,
this.fresh = false,
required this.emoji, required this.emoji,
required this.count, required this.count,
required this.mine, required this.mine,
@@ -88,12 +98,22 @@ class _ReactionChipState extends State<_ReactionChip> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final pt = widget.pt; final pt = widget.pt;
return MouseRegion( return PopIn(
play: widget.fresh,
fromScale: 0.3,
offsetY: 0,
alignment: Alignment.center,
milliseconds: 520,
child: SpringBump(
value: widget.count,
child: MouseRegion(
cursor: SystemMouseCursors.click, cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true), onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false), onExit: (_) => setState(() => _hovered = false),
child: GestureDetector( child: GestureDetector(
onTap: widget.onTap, onTap: widget.onTap,
child: SpringPress(
pressedScale: 0.85,
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 120), duration: const Duration(milliseconds: 120),
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
@@ -122,6 +142,9 @@ class _ReactionChipState extends State<_ReactionChip> {
], ],
), ),
), ),
),
),
),
), ),
); );
} }
@@ -23,6 +23,9 @@ class _MessageList extends StatelessWidget {
/// frei, damit die neueste Nachricht ganz über die Leiste scrollen kann. /// frei, damit die neueste Nachricht ganz über die Leiste scrollen kann.
final double bottomInset; 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,
@@ -40,6 +43,7 @@ class _MessageList extends StatelessWidget {
this.highlightEventId, this.highlightEventId,
this.highlightOn = false, this.highlightOn = false,
this.bottomInset = 0, this.bottomInset = 0,
this.popInAfter,
}); });
@override @override
@@ -292,6 +296,7 @@ class _MessageList extends StatelessWidget {
jumpKey: jumpKey, jumpKey: jumpKey,
highlightEventId: highlightEventId, highlightEventId: highlightEventId,
highlightOn: highlightOn, highlightOn: highlightOn,
popInAfter: popInAfter,
); );
}, },
); );
+35 -9
View File
@@ -32,6 +32,9 @@ class _ChatViewState extends ConsumerState<ChatView> {
// Neuaufbau erneut eine Lesebestätigung schickt, und markiert nach dem // Neuaufbau erneut eine Lesebestätigung schickt, und markiert nach dem
// Zurückkehren in die App, ab wo „Neue Nachrichten“ beginnen. // Zurückkehren in die App, ab wo „Neue Nachrichten“ beginnen.
String? _lastMarkedEventId; 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();
@@ -115,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)) {
@@ -142,6 +148,11 @@ class _ChatViewState extends ConsumerState<ChatView> {
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;
@@ -152,6 +163,7 @@ class _ChatViewState extends ConsumerState<ChatView> {
_unreadFading = false; _unreadFading = false;
_unreadFadeTimer?.cancel(); _unreadFadeTimer?.cancel();
_lastMarkedEventId = null; _lastMarkedEventId = null;
_popInAfter = null;
_jumpEventId = null; _jumpEventId = null;
_highlightEventId = null; _highlightEventId = null;
_highlightOn = false; _highlightOn = false;
@@ -202,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));
@@ -499,28 +512,35 @@ class _ChatViewState extends ConsumerState<ChatView> {
highlightEventId: _highlightEventId, highlightEventId: _highlightEventId,
highlightOn: _highlightOn, highlightOn: _highlightOn,
bottomInset: room != null ? _composerHeight : 0, bottomInset: room != null ? _composerHeight : 0,
popInAfter: _popInAfter,
); );
}, },
), ),
if (_showScrollDown) Positioned(
Positioned(
right: 16, right: 16,
bottom: (room != null ? _composerHeight : 0) + 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(
@@ -530,10 +550,13 @@ 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): // Schwebende Eingabeleiste (Bernds Wunsch 2026-10-07):
// der Chat läuft optisch darunter weiter // der Chat läuft optisch darunter weiter
@@ -561,6 +584,9 @@ class _ChatViewState extends ConsumerState<ChatView> {
children: [ children: [
_TypingIndicator(room: room, pt: pt), _TypingIndicator(room: room, pt: pt),
ChatComposer( ChatComposer(
// Je Raum ein eigenes Eingabefeld
// (Text, „tippt…“, Entwurf).
key: ValueKey('composer:${room.id}'),
room: room, room: room,
replyTo: _replyTo, replyTo: _replyTo,
onClearReply: () => onClearReply: () =>
@@ -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(),
),
], ],
), ),
], ],
+1
View File
@@ -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 ──────────────────────────────────────────────────────────────────
+34 -13
View File
@@ -17,6 +17,8 @@ import 'package:pyramid/features/chat/chat_view.dart';
import 'package:pyramid/features/members/members_panel.dart'; import 'package:pyramid/features/members/members_panel.dart';
import 'package:pyramid/features/rooms/rooms_panel.dart'; import 'package:pyramid/features/rooms/rooms_panel.dart';
import 'package:pyramid/features/spaces/spaces_rail.dart'; import 'package:pyramid/features/spaces/spaces_rail.dart';
import 'package:pyramid/layout/mobile_page_stack.dart';
import 'package:pyramid/widgets/spring_press.dart' show motionDuration;
import 'package:pyramid/widgets/search_modal.dart'; import 'package:pyramid/widgets/search_modal.dart';
import 'package:pyramid/widgets/settings_modal.dart'; import 'package:pyramid/widgets/settings_modal.dart';
import 'package:pyramid/widgets/share_target_dialog.dart'; import 'package:pyramid/widgets/share_target_dialog.dart';
@@ -335,14 +337,18 @@ class _MobileShell extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final voip = ref.watch(callSignalingProvider); final voip = ref.watch(callSignalingProvider);
Widget body; // Die Chatliste bleibt aufgebaut; Chat bzw. Sprachkanal gleiten federnd
if (viewMode == ViewMode.voice) { // darüber (Bernds Wunsch 2026-10-07: mehr Übergänge/Elastizität).
body = const VoiceChannelView(); final Widget? front = viewMode == ViewMode.voice
} else if (activeRoomId != null) { ? const VoiceChannelView()
body = ChatView(roomId: activeRoomId!); : activeRoomId != null
} else { ? ChatView(roomId: activeRoomId!)
body = const RoomsPanel(); : null;
} final body = MobilePageStack(
base: const RoomsPanel(),
front: front,
background: pt.bg1,
);
// Symbole der Statusleiste passend zum Theme (hell auf dunkel und umgekehrt). // Symbole der Statusleiste passend zum Theme (hell auf dunkel und umgekehrt).
final overlayStyle = (pt.isDark final overlayStyle = (pt.isDark
@@ -387,7 +393,12 @@ class _MobileShell extends ConsumerWidget {
Positioned.fill( Positioned.fill(
child: GestureDetector( child: GestureDetector(
onTap: () => ref.read(membersPanelOpenProvider.notifier).state = false, onTap: () => ref.read(membersPanelOpenProvider.notifier).state = false,
child: Container(color: Colors.black.withAlpha(110)), child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: 1),
duration: motionDuration(context, 220),
builder: (_, v, _) =>
ColoredBox(color: Colors.black.withAlpha((110 * v).round())),
),
), ),
), ),
Positioned( Positioned(
@@ -396,10 +407,20 @@ class _MobileShell extends ConsumerWidget {
bottom: 0, bottom: 0,
child: Builder(builder: (ctx) { child: Builder(builder: (ctx) {
final w = MediaQuery.sizeOf(ctx).width; final w = MediaQuery.sizeOf(ctx).width;
return MembersPanel( // Gleitet von rechts herein.
roomId: activeRoomId!, return TweenAnimationBuilder<double>(
width: (w * 0.82).clamp(240.0, 320.0), tween: Tween(begin: 1, end: 0),
onClose: () => ref.read(membersPanelOpenProvider.notifier).state = false, duration: motionDuration(ctx, 340),
curve: Curves.easeOutCubic,
builder: (_, v, child) => FractionalTranslation(
translation: Offset(v, 0),
child: child,
),
child: MembersPanel(
roomId: activeRoomId!,
width: (w * 0.82).clamp(240.0, 320.0),
onClose: () => ref.read(membersPanelOpenProvider.notifier).state = false,
),
); );
}), }),
), ),
+166
View File
@@ -0,0 +1,166 @@
import 'package:flutter/physics.dart';
import 'package:flutter/widgets.dart';
import 'package:pyramid/core/theme.dart';
/// Handy-Navigation zwischen Chatliste und geöffnetem Chat.
///
/// Die Chatliste ([base]) bleibt immer aufgebaut – Scrollposition und Zustand
/// bleiben beim Zurückkehren erhalten. Der Vordergrund ([front], z. B. der
/// Chat) gleitet federnd von rechts darüber, die Liste weicht dabei leicht
/// nach links aus und dunkelt ab; beim Zurück läuft es umgekehrt. Wechselt
/// der Vordergrund direkt (anderer Chat), wird ohne Schieben getauscht.
/// Mit „Animationen: 0 %“ wird sofort umgeschaltet.
class MobilePageStack extends StatefulWidget {
const MobilePageStack({
super.key,
required this.base,
required this.front,
required this.background,
});
final Widget base;
final Widget? front;
/// Hintergrund hinter dem Vordergrund, damit die Liste beim Schieben nicht
/// durch durchsichtige Stellen scheint.
final Color background;
@override
State<MobilePageStack> createState() => _MobilePageStackState();
}
class _MobilePageStackState extends State<MobilePageStack>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl = AnimationController.unbounded(
vsync: this,
value: widget.front != null ? 1.0 : 0.0,
);
/// Angezeigter Vordergrund – bleibt beim Hinausgleiten noch stehen.
late Widget? _front = widget.front;
@override
void didUpdateWidget(MobilePageStack oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.front != null) {
_front = widget.front;
if (oldWidget.front == null) _animateTo(1);
} else if (oldWidget.front != null) {
_animateTo(0);
}
}
void _animateTo(double target) {
final motion = PyramidTheme.of(context).motion;
if (motion < 0.05) {
_ctrl.value = target;
_settle();
return;
}
// Feder knapp unter kritischer Dämpfung: zügig, weich auslaufend.
// Kleinere „Animationen“-Werte = steifere Feder = schneller.
final spring = SpringDescription.withDampingRatio(
mass: 1,
stiffness: 520 / (motion * motion),
ratio: 0.86,
);
_ctrl
.animateWith(
SpringSimulation(spring, _ctrl.value, target, _ctrl.velocity),
)
.then((_) {
_ctrl.value = target;
_settle();
});
}
void _settle() {
if (mounted && widget.front == null && _front != null) {
setState(() => _front = null);
}
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
return AnimatedBuilder(
animation: _ctrl,
builder: (context, _) {
final front = _front;
final shown = _ctrl.value.clamp(0.0, 1.0);
// Vollständig verdeckt: Liste nicht mehr zeichnen/animieren
// (bleibt aber aufgebaut).
final covered = front != null && shown >= 0.999;
return Stack(
fit: StackFit.expand,
children: [
KeyedSubtree(
key: const ValueKey('base'),
child: IgnorePointer(
// Nur gesperrt, solange der Vordergrund offen ist bzw.
// hereinkommt – beim Hinausgleiten ist die Liste schon
// wieder bedienbar (schneller Tipp auf den nächsten Chat).
ignoring: widget.front != null,
child: TickerMode(
enabled: !covered,
child: Offstage(
offstage: covered,
child: Transform.translate(
offset: Offset(-0.25 * width * shown, 0),
child: widget.base,
),
),
),
),
),
if (front != null && !covered && shown > 0)
Positioned.fill(
key: const ValueKey('scrim'),
child: IgnorePointer(
child: ColoredBox(
color: const Color(0xFF000000)
.withValues(alpha: 0.30 * shown),
),
),
),
if (front != null)
KeyedSubtree(
key: const ValueKey('front'),
child: Transform.translate(
offset: Offset(width * (1 - shown), 0),
child: DecoratedBox(
decoration: BoxDecoration(
color: widget.background,
boxShadow: covered
? null
: const [
BoxShadow(
color: Color(0x40000000),
blurRadius: 18,
),
],
),
// Beim Hinausgleiten nicht mehr bedienbar.
child: IgnorePointer(
ignoring: widget.front == null,
child: front,
),
),
),
),
],
);
},
);
},
);
}
}
+135
View File
@@ -69,3 +69,138 @@ Duration motionDuration(BuildContext context, int milliseconds) {
final motion = PyramidTheme.of(context).motion; final motion = PyramidTheme.of(context).motion;
return Duration(milliseconds: (milliseconds * motion).round()); 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,
),
);
}
}
+150
View File
@@ -0,0 +1,150 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/layout/mobile_page_stack.dart';
import 'package:pyramid/widgets/spring_press.dart';
/// Handy-Übergang Chatliste ↔ Chat und die federnden Einblendungen.
void main() {
Widget host({required Widget? front, double motion = 1}) {
return PyramidTheme(
motion: motion,
child: Directionality(
textDirection: TextDirection.ltr,
child: MobilePageStack(
base: const _Counter(key: ValueKey('liste')),
front: front,
background: const Color(0xFF101010),
),
),
);
}
testWidgets('Chatliste behält ihren Zustand, Chat gleitet herein und hinaus',
(tester) async {
await tester.pumpWidget(host(front: null));
await tester.tap(find.byKey(const ValueKey('liste')));
await tester.pump();
expect(find.text('Zähler 1'), findsOneWidget);
// Chat öffnen: gleitet von rechts herein.
await tester.pumpWidget(host(front: const Text('Chat')));
await tester.pump(const Duration(milliseconds: 40));
final midX = tester.getTopLeft(find.text('Chat')).dx;
expect(midX, greaterThan(0), reason: 'Chat sollte noch hereingleiten');
await tester.pumpAndSettle();
expect(tester.getTopLeft(find.text('Chat')).dx, 0);
// Verdeckte Liste ist nicht mehr bedienbar, aber noch aufgebaut.
expect(find.text('Zähler 1', skipOffstage: false), findsOneWidget);
// Zurück: Chat gleitet hinaus und wird danach entfernt.
await tester.pumpWidget(host(front: null));
await tester.pump(const Duration(milliseconds: 40));
expect(find.text('Chat'), findsOneWidget, reason: 'noch beim Hinausgleiten');
// Schon während des Hinausgleitens ist die Liste wieder bedienbar.
await tester.tapAt(const Offset(20, 300));
await tester.pumpAndSettle();
expect(find.text('Zähler 2'), findsOneWidget);
await tester.pumpAndSettle();
expect(find.text('Chat'), findsNothing);
// Zustand der Liste ist erhalten geblieben.
await tester.tap(find.byKey(const ValueKey('liste')));
await tester.pump();
expect(find.text('Zähler 3'), findsOneWidget);
});
testWidgets('Animationen 0 %: sofortiger Wechsel ohne Gleiten', (
tester,
) async {
await tester.pumpWidget(host(front: null, motion: 0));
await tester.pumpWidget(host(front: const Text('Chat'), motion: 0));
await tester.pump();
expect(tester.getTopLeft(find.text('Chat')).dx, 0);
await tester.pumpWidget(host(front: null, motion: 0));
await tester.pump();
expect(find.text('Chat'), findsNothing);
});
testWidgets('PopIn spielt nur, wenn verlangt, und endet unverändert', (
tester,
) async {
Widget pop(bool play) => PyramidTheme(
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: PopIn(play: play, child: const Text('Neu')),
),
),
);
await tester.pumpWidget(pop(true));
await tester.pump(const Duration(milliseconds: 30));
final opacity = tester.widget<Opacity>(
find.ancestor(of: find.text('Neu'), matching: find.byType(Opacity)),
);
expect(opacity.opacity, lessThan(1));
await tester.pumpAndSettle();
expect(
tester
.widget<Opacity>(
find.ancestor(of: find.text('Neu'), matching: find.byType(Opacity)),
)
.opacity,
1,
);
await tester.pumpWidget(const SizedBox());
await tester.pumpWidget(pop(false));
expect(
find.ancestor(of: find.text('Neu'), matching: find.byType(Opacity)),
findsNothing,
);
});
testWidgets('SpringBump hüpft nur beim Hochzählen', (tester) async {
Widget bump(int v) => PyramidTheme(
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(child: SpringBump(value: v, child: Text('$v'))),
),
);
double scale() => tester
.widget<Transform>(
find.ancestor(
of: find.byType(Text),
matching: find.byType(Transform),
),
)
.transform
.storage[0]; // x-Skalierung (z bleibt bei Transform.scale immer 1)
await tester.pumpWidget(bump(1));
expect(scale(), 1);
await tester.pumpWidget(bump(2));
await tester.pump(const Duration(milliseconds: 16));
expect(scale(), lessThan(1), reason: 'Hochzählen soll hüpfen');
await tester.pumpAndSettle();
expect(scale(), closeTo(1, 0.001));
await tester.pumpWidget(bump(0));
await tester.pump(const Duration(milliseconds: 16));
expect(scale(), closeTo(1, 0.001), reason: 'Herunterzählen hüpft nicht');
});
}
class _Counter extends StatefulWidget {
const _Counter({super.key});
@override
State<_Counter> createState() => _CounterState();
}
class _CounterState extends State<_Counter> {
int _n = 0;
@override
Widget build(BuildContext context) => GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => setState(() => _n++),
child: Center(child: Text('Zähler $_n')),
);
}