diff --git a/integration_test/mobile_ui_screens_test.dart b/integration_test/mobile_ui_screens_test.dart index 01fb1bd..31b02ec 100644 --- a/integration_test/mobile_ui_screens_test.dart +++ b/integration_test/mobile_ui_screens_test.dart @@ -60,6 +60,9 @@ void main() { // ── Direktnachricht: Sprechblasen (Handy-Standard) und Liste ── 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 _scrollToNewest(tester); await _shot(tester, '02_chat'); @@ -96,6 +99,24 @@ void main() { 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; await _pumpFor(tester, const Duration(seconds: 2)); await _shot(tester, '02b_chat_liste'); @@ -210,6 +231,14 @@ void main() { await _shot(tester, '10_einstellungen_text_chat'); container.read(activeModalProvider.notifier).state = ModalKind.none; 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, timeout: const Timeout(Duration(minutes: 6)), diff --git a/lib/features/chat/chat_composer.dart b/lib/features/chat/chat_composer.dart index 5c46b54..58f6ade 100644 --- a/lib/features/chat/chat_composer.dart +++ b/lib/features/chat/chat_composer.dart @@ -35,6 +35,10 @@ class ChatComposer extends ConsumerStatefulWidget { } class _ChatComposerState extends ConsumerState { + /// 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 = {}; + final _ctrl = TextEditingController(); late final FocusNode _focus; bool _canSend = false; @@ -47,6 +51,12 @@ class _ChatComposerState extends ConsumerState { 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 { @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 { 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, + ), + ), + ), ), ], ), diff --git a/lib/features/chat/chat_view.dart b/lib/features/chat/chat_view.dart index ebbec62..8feba17 100644 --- a/lib/features/chat/chat_view.dart +++ b/lib/features/chat/chat_view.dart @@ -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'; diff --git a/lib/features/chat/media_viewer.dart b/lib/features/chat/media_viewer.dart index 153d92c..a923158 100644 --- a/lib/features/chat/media_viewer.dart +++ b/lib/features/chat/media_viewer.dart @@ -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, diff --git a/lib/features/chat/message/message_group_core.dart b/lib/features/chat/message/message_group_core.dart index 0639602..39a40ba 100644 --- a/lib/features/chat/message/message_group_core.dart +++ b/lib/features/chat/message/message_group_core.dart @@ -1,5 +1,15 @@ part of '../message_group.dart'; +/// Bereits federnd eingeblendete Nachrichten (begrenzt, älteste fallen raus). +final _poppedMessageIds = {}; + +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 { ), ); - 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, ); } diff --git a/lib/features/chat/message/message_reactions.dart b/lib/features/chat/message/message_reactions.dart index 1ba9b8b..82cfdec 100644 --- a/lib/features/chat/message/message_reactions.dart +++ b/lib/features/chat/message/message_reactions.dart @@ -22,11 +22,15 @@ class _ReactionsRow extends StatelessWidget { // Group by emoji key → list of senders final Map> grouped = {}; + final newest = {}; for (final r in reactionEvents) { final key = r.content.tryGetMap('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> { ], ), ), + ), + ), + ), ), ); } diff --git a/lib/features/chat/view/chat_message_list.dart b/lib/features/chat/view/chat_message_list.dart index 87c1ebc..13a1a09 100644 --- a/lib/features/chat/view/chat_message_list.dart +++ b/lib/features/chat/view/chat_message_list.dart @@ -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, ); }, ); diff --git a/lib/features/chat/view/chat_view_core.dart b/lib/features/chat/view/chat_view_core.dart index ef48cde..71fb408 100644 --- a/lib/features/chat/view/chat_view_core.dart +++ b/lib/features/chat/view/chat_view_core.dart @@ -32,6 +32,9 @@ class _ChatViewState extends ConsumerState { // 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 { 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 { 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 { _unreadFading = false; _unreadFadeTimer?.cancel(); _lastMarkedEventId = null; + _popInAfter = null; _jumpEventId = null; _highlightEventId = null; _highlightOn = false; @@ -202,6 +214,7 @@ class _ChatViewState extends ConsumerState { 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 { 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 { ), ], ), - 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 { 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: () => diff --git a/lib/features/rooms/panel/rooms_mobile_tile.dart b/lib/features/rooms/panel/rooms_mobile_tile.dart index 11add10..2b6477a 100644 --- a/lib/features/rooms/panel/rooms_mobile_tile.dart +++ b/lib/features/rooms/panel/rooms_mobile_tile.dart @@ -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(), + ), ], ), ], diff --git a/lib/features/rooms/rooms_panel.dart b/lib/features/rooms/rooms_panel.dart index a6f3464..d058f9b 100644 --- a/lib/features/rooms/rooms_panel.dart +++ b/lib/features/rooms/rooms_panel.dart @@ -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 ────────────────────────────────────────────────────────────────── diff --git a/lib/layout/app_shell.dart b/lib/layout/app_shell.dart index 4fd7837..ee97a70 100644 --- a/lib/layout/app_shell.dart +++ b/lib/layout/app_shell.dart @@ -17,6 +17,8 @@ import 'package:pyramid/features/chat/chat_view.dart'; import 'package:pyramid/features/members/members_panel.dart'; import 'package:pyramid/features/rooms/rooms_panel.dart'; import 'package:pyramid/features/spaces/spaces_rail.dart'; +import 'package:pyramid/layout/mobile_page_stack.dart'; +import 'package:pyramid/widgets/spring_press.dart' show motionDuration; import 'package:pyramid/widgets/search_modal.dart'; import 'package:pyramid/widgets/settings_modal.dart'; import 'package:pyramid/widgets/share_target_dialog.dart'; @@ -335,14 +337,18 @@ class _MobileShell extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final voip = ref.watch(callSignalingProvider); - Widget body; - if (viewMode == ViewMode.voice) { - body = const VoiceChannelView(); - } else if (activeRoomId != null) { - body = ChatView(roomId: activeRoomId!); - } else { - body = const RoomsPanel(); - } + // Die Chatliste bleibt aufgebaut; Chat bzw. Sprachkanal gleiten federnd + // darüber (Bernds Wunsch 2026-10-07: mehr Übergänge/Elastizität). + final Widget? front = viewMode == ViewMode.voice + ? const VoiceChannelView() + : activeRoomId != null + ? ChatView(roomId: activeRoomId!) + : null; + final body = MobilePageStack( + base: const RoomsPanel(), + front: front, + background: pt.bg1, + ); // Symbole der Statusleiste passend zum Theme (hell auf dunkel und umgekehrt). final overlayStyle = (pt.isDark @@ -387,7 +393,12 @@ class _MobileShell extends ConsumerWidget { Positioned.fill( child: GestureDetector( onTap: () => ref.read(membersPanelOpenProvider.notifier).state = false, - child: Container(color: Colors.black.withAlpha(110)), + child: TweenAnimationBuilder( + tween: Tween(begin: 0, end: 1), + duration: motionDuration(context, 220), + builder: (_, v, _) => + ColoredBox(color: Colors.black.withAlpha((110 * v).round())), + ), ), ), Positioned( @@ -396,10 +407,20 @@ class _MobileShell extends ConsumerWidget { bottom: 0, child: Builder(builder: (ctx) { final w = MediaQuery.sizeOf(ctx).width; - return MembersPanel( - roomId: activeRoomId!, - width: (w * 0.82).clamp(240.0, 320.0), - onClose: () => ref.read(membersPanelOpenProvider.notifier).state = false, + // Gleitet von rechts herein. + return TweenAnimationBuilder( + 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!, + width: (w * 0.82).clamp(240.0, 320.0), + onClose: () => ref.read(membersPanelOpenProvider.notifier).state = false, + ), ); }), ), diff --git a/lib/layout/mobile_page_stack.dart b/lib/layout/mobile_page_stack.dart new file mode 100644 index 0000000..077f25e --- /dev/null +++ b/lib/layout/mobile_page_stack.dart @@ -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 createState() => _MobilePageStackState(); +} + +class _MobilePageStackState extends State + 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, + ), + ), + ), + ), + ], + ); + }, + ); + }, + ); + } +} diff --git a/lib/widgets/spring_press.dart b/lib/widgets/spring_press.dart index e65d4c7..5e3678b 100644 --- a/lib/widgets/spring_press.dart +++ b/lib/widgets/spring_press.dart @@ -69,3 +69,138 @@ 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 createState() => _PopInState(); +} + +class _PopInState extends State 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 createState() => _SpringBumpState(); +} + +class _SpringBumpState extends State + 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, + ), + ); + } +} diff --git a/test/spring_motion_test.dart b/test/spring_motion_test.dart new file mode 100644 index 0000000..14737ea --- /dev/null +++ b/test/spring_motion_test.dart @@ -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( + find.ancestor(of: find.text('Neu'), matching: find.byType(Opacity)), + ); + expect(opacity.opacity, lessThan(1)); + await tester.pumpAndSettle(); + expect( + tester + .widget( + 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( + 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')), + ); +}