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
+57 -10
View File
@@ -35,6 +35,10 @@ class ChatComposer extends ConsumerStatefulWidget {
}
class _ChatComposerState extends ConsumerState<ChatComposer> {
/// Angefangene Nachrichten je Raum (nur im Speicher): Beim Raumwechsel
/// bleibt der Text beim richtigen Raum, statt im nächsten Chat zu landen.
static final _drafts = <String, String>{};
final _ctrl = TextEditingController();
late final FocusNode _focus;
bool _canSend = false;
@@ -47,6 +51,12 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
void initState() {
super.initState();
_focus = FocusNode(onKeyEvent: _handleKeyEvent);
// Entwurf vor dem Listener setzen – sonst ginge sofort „tippt…“ raus.
final draft = _drafts.remove(widget.room.id);
if (draft != null) {
_ctrl.text = draft;
_canSend = draft.trim().isNotEmpty;
}
_ctrl.addListener(_onTextChanged);
}
@@ -121,6 +131,8 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
@override
void dispose() {
final text = _ctrl.text;
if (text.trim().isNotEmpty) _drafts[widget.room.id] = text;
_typingTimer?.cancel();
if (_isTyping) widget.room.setTyping(false).catchError((_) {});
_gifOverlay?.remove();
@@ -315,12 +327,28 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.replyTo != null)
_ReplyBar(
event: widget.replyTo!,
onClear: widget.onClearReply ?? () {},
pt: pt,
// Antwort-Leiste federt auf und klappt beim Schließen weich zu.
AnimatedSwitcher(
duration: motionDuration(context, 300),
reverseDuration: motionDuration(context, 160),
switchInCurve: Curves.easeOutBack,
switchOutCurve: Curves.easeIn,
transitionBuilder: (child, anim) => SizeTransition(
sizeFactor: anim,
alignment: Alignment.bottomCenter,
child: FadeTransition(opacity: anim, child: child),
),
child: widget.replyTo != null
? KeyedSubtree(
key: ValueKey('reply:${widget.replyTo!.eventId}'),
child: _ReplyBar(
event: widget.replyTo!,
onClear: widget.onClearReply ?? () {},
pt: pt,
),
)
: const SizedBox(key: ValueKey('reply:none'), width: double.infinity),
),
Padding(
// Bottom inset matches the profile block in the rooms list (8px) so
// the composer sits flush with it.
@@ -574,10 +602,18 @@ class _SendButtonState extends State<_SendButton> {
: Matrix4.identity(),
transformAlignment: Alignment.center,
child: Center(
child: Icon(
Icons.send_rounded,
size: widget.size * 0.45,
color: active ? pt.accentFg : pt.fgDim,
// Sobald gesendet werden kann, ploppt der Pfeil federnd auf.
child: AnimatedScale(
scale: active ? 1 : 0.8,
duration: active
? motionDuration(context, 520)
: const Duration(milliseconds: 140),
curve: active ? Curves.elasticOut : Curves.easeOut,
child: Icon(
Icons.send_rounded,
size: widget.size * 0.45,
color: active ? pt.accentFg : pt.fgDim,
),
),
),
),
@@ -627,9 +663,20 @@ class _ReplyBar extends StatelessWidget {
],
),
),
// Handy: größeres X mit fingergerechter Trefferfläche.
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onClear,
child: Icon(Icons.close_rounded, size: 16, color: pt.fgDim),
child: SpringPress(
child: Padding(
padding: const EdgeInsets.all(6),
child: Icon(
Icons.close_rounded,
size: isMobileLayout(MediaQuery.sizeOf(context)) ? 22 : 16,
color: pt.fgDim,
),
),
),
),
],
),
+2
View File
@@ -10,6 +10,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/appearance.dart' show isMobileLayout;
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/settings_prefs.dart';
import 'package:pyramid/core/theme.dart';
@@ -20,6 +21,7 @@ import 'package:pyramid/features/chat/message_group.dart';
import 'package:pyramid/features/chat/pinned_messages_panel.dart';
import 'package:pyramid/widgets/hover_region.dart';
import 'package:pyramid/widgets/measure_size.dart';
import 'package:pyramid/widgets/spring_press.dart';
import 'package:pyramid/core/read_markers.dart';
import 'package:pyramid/widgets/mxc_image.dart';
+13 -2
View File
@@ -6,6 +6,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/chat/media_player.dart';
import 'package:pyramid/utils/gif_favorite_service.dart';
import 'package:pyramid/widgets/spring_press.dart' show motionDuration;
/// Overlay viewer for images, GIFs and videos.
///
@@ -50,9 +51,19 @@ class MediaViewer extends StatefulWidget {
// Den Hintergrund zeichnet der Viewer selbst (damit er beim Wischen
// ausblenden kann).
barrierColor: Colors.transparent,
transitionDuration: const Duration(milliseconds: 180),
transitionDuration: motionDuration(context, 300),
transitionBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
FadeTransition(
opacity: animation,
child: ScaleTransition(
scale: Tween(begin: 0.9, end: 1.0).animate(CurvedAnimation(
parent: animation,
curve: Curves.easeOutBack,
reverseCurve: Curves.easeIn,
)),
child: child,
),
),
pageBuilder: (context, animation, secondaryAnimation) => MediaViewer(
senderName: senderName,
filename: filename,
@@ -1,5 +1,15 @@
part of '../message_group.dart';
/// Bereits federnd eingeblendete Nachrichten (begrenzt, älteste fallen raus).
final _poppedMessageIds = <String>{};
void _rememberPopped(String id) {
_poppedMessageIds.add(id);
if (_poppedMessageIds.length > 300) {
_poppedMessageIds.remove(_poppedMessageIds.first);
}
}
String _formatMessageTime(DateTime t) {
final h = t.hour.toString().padLeft(2, '0');
final m = t.minute.toString().padLeft(2, '0');
@@ -28,6 +38,10 @@ class MessageGroup extends StatefulWidget {
final GlobalKey? jumpKey;
final String? highlightEventId;
final bool highlightOn;
/// Nachrichten, die nach diesem Zeitpunkt eintreffen (oder gerade
/// gesendet werden), ploppen beim ersten Erscheinen federnd auf. null = aus
/// (z. B. solange der Chat gerade erst geöffnet wird).
final DateTime? popInAfter;
const MessageGroup({
super.key,
@@ -45,6 +59,7 @@ class MessageGroup extends StatefulWidget {
this.jumpKey,
this.highlightEventId,
this.highlightOn = false,
this.popInAfter,
});
@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.
return MouseRegion(
return _popIn(event, MouseRegion(
onEnter: (_) => setState(() => _hoveredEventId = event.eventId),
onExit: (_) => setState(() {
if (_hoveredEventId == event.eventId) _hoveredEventId = null;
}),
child: row,
));
}
// Neu angekommene oder gerade gesendete Nachricht: federnd einblenden.
// Der Schlüssel (Transaktions-ID bleibt beim Senden gleich) hält den
// Zustand der Zeile, die Merkliste verhindert ein zweites Abspielen, falls
// die Gruppe neu aufgebaut wird (z. B. lokale Kopie → Server-Event).
Widget _popIn(Event event, Widget row) {
final id = event.transactionId ?? event.eventId;
final after = widget.popInAfter;
final fresh = after != null &&
!_poppedMessageIds.contains(id) &&
(event.status.isSending ||
(event.originServerTs.isAfter(after) &&
DateTime.now().difference(event.originServerTs) <
const Duration(minutes: 2)));
if (fresh) _rememberPopped(id);
return PopIn(
key: ValueKey('row:$id'),
play: fresh,
alignment: event.senderId == widget.currentUserId
? Alignment.bottomRight
: Alignment.bottomLeft,
child: row,
);
}
@@ -22,11 +22,15 @@ class _ReactionsRow extends StatelessWidget {
// Group by emoji key → list of senders
final Map<String, List<String>> grouped = {};
final newest = <String, DateTime>{};
for (final r in reactionEvents) {
final key = r.content.tryGetMap<String, dynamic>('m.relates_to')?['key'] as String?;
if (key == null || key.isEmpty) continue;
(grouped[key] ??= []).add(r.senderId);
final ts = r.originServerTs;
if (newest[key] == null || ts.isAfter(newest[key]!)) newest[key] = ts;
}
final now = DateTime.now();
if (grouped.isEmpty) return const SizedBox.shrink();
return Padding(
@@ -37,6 +41,9 @@ class _ReactionsRow extends StatelessWidget {
children: grouped.entries.map((e) {
final myReaction = e.value.contains(currentUserId);
return _ReactionChip(
key: ValueKey(e.key),
// Gerade erst vergeben → federnd aufploppen.
fresh: now.difference(newest[e.key]!) < const Duration(seconds: 4),
emoji: e.key,
count: e.value.length,
mine: myReaction,
@@ -69,8 +76,11 @@ class _ReactionChip extends StatefulWidget {
final bool mine;
final PyramidTheme pt;
final VoidCallback onTap;
final bool fresh;
const _ReactionChip({
super.key,
this.fresh = false,
required this.emoji,
required this.count,
required this.mine,
@@ -88,12 +98,22 @@ class _ReactionChipState extends State<_ReactionChip> {
@override
Widget build(BuildContext context) {
final pt = widget.pt;
return MouseRegion(
return PopIn(
play: widget.fresh,
fromScale: 0.3,
offsetY: 0,
alignment: Alignment.center,
milliseconds: 520,
child: SpringBump(
value: widget.count,
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: SpringPress(
pressedScale: 0.85,
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
@@ -122,6 +142,9 @@ class _ReactionChipState extends State<_ReactionChip> {
],
),
),
),
),
),
),
);
}
@@ -23,6 +23,9 @@ class _MessageList extends StatelessWidget {
/// frei, damit die neueste Nachricht ganz über die Leiste scrollen kann.
final double bottomInset;
/// Siehe MessageGroup.popInAfter.
final DateTime? popInAfter;
const _MessageList({
required this.timeline,
required this.scrollCtrl,
@@ -40,6 +43,7 @@ class _MessageList extends StatelessWidget {
this.highlightEventId,
this.highlightOn = false,
this.bottomInset = 0,
this.popInAfter,
});
@override
@@ -292,6 +296,7 @@ class _MessageList extends StatelessWidget {
jumpKey: jumpKey,
highlightEventId: highlightEventId,
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
// Zurückkehren in die App, ab wo „Neue Nachrichten“ beginnen.
String? _lastMarkedEventId;
// Was danach eintrifft, ploppt federnd auf (beim Öffnen gesetzt, damit die
// schon vorhandenen Nachrichten ruhig erscheinen).
DateTime? _popInAfter;
// Jump-to-message (from search): scroll to + briefly highlight a target event.
final _jumpKey = GlobalKey();
@@ -115,6 +118,9 @@ class _ChatViewState extends ConsumerState<ChatView> {
bool get _inSelectionMode => _selectedIds.isNotEmpty;
double get _scrollBtnSize =>
isMobileLayout(MediaQuery.sizeOf(context)) ? 44 : 36;
void _toggleSelect(String eventId) {
setState(() {
if (_selectedIds.contains(eventId)) {
@@ -142,6 +148,11 @@ class _ChatViewState extends ConsumerState<ChatView> {
void didUpdateWidget(ChatView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.roomId != widget.roomId) {
// Antwort/Auswahl gehören zum alten Raum – sonst ginge eine Antwort
// auf eine Nachricht aus Raum A in Raum B raus.
_replyTo = null;
_selectedIds.clear();
_showScrollDown = false;
_profileExpanded = false;
_sessionFullyReadId = null;
_sessionHasUnread = false;
@@ -152,6 +163,7 @@ class _ChatViewState extends ConsumerState<ChatView> {
_unreadFading = false;
_unreadFadeTimer?.cancel();
_lastMarkedEventId = null;
_popInAfter = null;
_jumpEventId = null;
_highlightEventId = null;
_highlightOn = false;
@@ -202,6 +214,7 @@ class _ChatViewState extends ConsumerState<ChatView> {
setState(() {
_sessionFullyReadId = newFullyReadId;
_sessionHasUnread = newHasUnread;
_popInAfter = events.firstOrNull?.originServerTs ?? DateTime.now();
});
if (newHasUnread) {
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToUnread(timeline));
@@ -499,28 +512,35 @@ class _ChatViewState extends ConsumerState<ChatView> {
highlightEventId: _highlightEventId,
highlightOn: _highlightOn,
bottomInset: room != null ? _composerHeight : 0,
popInAfter: _popInAfter,
);
},
),
if (_showScrollDown)
Positioned(
Positioned(
right: 16,
bottom: (room != null ? _composerHeight : 0) + 12,
child: AnimatedOpacity(
opacity: _showScrollDown ? 1 : 0,
duration: const Duration(milliseconds: 150),
child: IgnorePointer(
ignoring: !_showScrollDown,
child: AnimatedScale(
scale: _showScrollDown ? 1 : 0,
duration: motionDuration(
context, _showScrollDown ? 520 : 180),
curve: _showScrollDown
? Curves.elasticOut
: Curves.easeInBack,
child: GestureDetector(
onTap: () => _scrollCtrl.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
),
child: SpringPress(
child: Container(
width: 36,
height: 36,
width: _scrollBtnSize,
height: _scrollBtnSize,
decoration: BoxDecoration(
color: pt.bg0,
borderRadius: BorderRadius.circular(18),
borderRadius: BorderRadius.circular(_scrollBtnSize / 2),
border: Border.all(color: pt.border),
boxShadow: [
BoxShadow(
@@ -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):
// der Chat läuft optisch darunter weiter
@@ -561,6 +584,9 @@ class _ChatViewState extends ConsumerState<ChatView> {
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: () =>
@@ -95,10 +95,18 @@ class _MobileRoomTile extends StatelessWidget {
),
),
),
if (unread > 0) ...[
const SizedBox(width: 8),
_UnreadBadge(count: unread, isMention: mention, pt: pt),
],
// Immer im Baum, damit der Zähler beim Erscheinen und
// Hochzählen federnd aufploppen kann.
SpringBump(
value: unread,
child: unread > 0
? Padding(
padding: const EdgeInsets.only(left: 8),
child: _UnreadBadge(
count: unread, isMention: mention, pt: pt),
)
: const SizedBox.shrink(),
),
],
),
],
+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/mxc_image.dart';
import 'package:pyramid/widgets/new_dm_dialog.dart';
import 'package:pyramid/widgets/spring_press.dart' show SpringBump;
// ─── Helpers ──────────────────────────────────────────────────────────────────