From 687a23093611a60a79b22f217452f2674ab69864 Mon Sep 17 00:00:00 2001 From: avashilling <165050625+avashilling@users.noreply.github.com> Date: Wed, 30 Jul 2025 16:16:49 -0400 Subject: [PATCH] feat: add bouncy reaction animations Also changes reaction wrap alignment to line up with own message better, and adds a burst animation when reactions are removed. --- lib/pages/chat/chat_event_list.dart | 1 + lib/pages/chat/events/message.dart | 8 +- lib/pages/chat/events/message_reactions.dart | 500 ++++++++++++++++--- lib/pages/chat/events/reaction_burst.dart | 55 ++ 4 files changed, 480 insertions(+), 84 deletions(-) create mode 100644 lib/pages/chat/events/reaction_burst.dart diff --git a/lib/pages/chat/chat_event_list.dart b/lib/pages/chat/chat_event_list.dart index 7e11588a4..7ad6bf72a 100644 --- a/lib/pages/chat/chat_event_list.dart +++ b/lib/pages/chat/chat_event_list.dart @@ -163,6 +163,7 @@ class ChatEventList extends StatelessWidget { previousEvent: previousEvent, wallpaperMode: hasWallpaper, scrollController: controller.scrollController, + controller: controller, colors: colors, isCollapsed: isCollapsed, onExpand: canExpand diff --git a/lib/pages/chat/events/message.dart b/lib/pages/chat/events/message.dart index ced1908de..66c4895fd 100644 --- a/lib/pages/chat/events/message.dart +++ b/lib/pages/chat/events/message.dart @@ -9,6 +9,7 @@ import 'package:swipe_to_action/swipe_to_action.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pages/chat/events/room_creation_state_event.dart'; import 'package:fluffychat/utils/adaptive_bottom_sheet.dart'; import 'package:fluffychat/utils/date_time_extension.dart'; @@ -43,6 +44,7 @@ class Message extends StatelessWidget { final void Function()? resetAnimateIn; final bool wallpaperMode; final ScrollController scrollController; + final ChatController controller; final List colors; final void Function()? onExpand; final bool isCollapsed; @@ -67,6 +69,7 @@ class Message extends StatelessWidget { this.wallpaperMode = false, required this.onMention, required this.scrollController, + required this.controller, required this.colors, this.onExpand, this.isCollapsed = false, @@ -852,7 +855,8 @@ class Message extends StatelessWidget { AnimatedSize( duration: FluffyThemes.animationDuration, curve: FluffyThemes.animationCurve, - alignment: Alignment.bottomCenter, + alignment: + ownMessage ? Alignment.bottomRight : Alignment.bottomLeft, child: !showReceiptsRow ? const SizedBox.shrink() : Padding( @@ -861,7 +865,7 @@ class Message extends StatelessWidget { left: (ownMessage ? 0 : Avatar.defaultSize) + 12.0, right: ownMessage ? 0 : 12.0, ), - child: MessageReactions(event, timeline), + child: MessageReactions(event, timeline, controller), ), ), if (displayReadMarker) diff --git a/lib/pages/chat/events/message_reactions.dart b/lib/pages/chat/events/message_reactions.dart index dede458aa..c2f0eef0c 100644 --- a/lib/pages/chat/events/message_reactions.dart +++ b/lib/pages/chat/events/message_reactions.dart @@ -1,131 +1,374 @@ +import 'dart:async'; +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:collection/collection.dart' show IterableExtension; import 'package:matrix/matrix.dart'; import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/pages/chat/chat.dart'; +import 'package:fluffychat/pages/chat/events/reaction_burst.dart'; +import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:fluffychat/widgets/mxc_image.dart'; -class MessageReactions extends StatelessWidget { +class MessageReactions extends StatefulWidget { final Event event; final Timeline timeline; + final ChatController controller; - const MessageReactions(this.event, this.timeline, {super.key}); + const MessageReactions(this.event, this.timeline, this.controller, + {super.key}); @override - Widget build(BuildContext context) { - final allReactionEvents = - event.aggregatedEvents(timeline, RelationshipTypes.reaction); - final reactionMap = {}; - final client = Matrix.of(context).client; + State createState() => _MessageReactionsState(); +} + +class _MessageReactionsState extends State { + StreamSubscription? _reactionSubscription; + Map _reactionMap = {}; + Set _newlyAddedReactions = {}; + late Client client; + + @override + void initState() { + super.initState(); + client = Matrix.of(context).client; + _updateReactionMap(); + _setupReactionStream(); + } + + void _setupReactionStream() { + //listen for new reaction events to know when to trigger reaction + _reactionSubscription = widget.controller.room.client.onSync.stream.where( + (update) { + final room = widget.controller.room; + final timelineEvents = update.rooms?.join?[room.id]?.timeline?.events; + if (timelineEvents == null) return false; + + final eventID = widget.event.eventId; + return timelineEvents.any( + (e) => + e.type == EventTypes.Redaction || + (e.type == EventTypes.Reaction && + Event.fromMatrixEvent(e, room).relationshipEventId == + eventID), + ); + }, + ).listen(_onReactionUpdate); + } + + void _onReactionUpdate(SyncUpdate update) { + //Identifies newly added reactions so they can be animated on arrival + final previousReactions = Set.from(_reactionMap.keys); + _updateReactionMap(); + final currentReactions = Set.from(_reactionMap.keys); + _newlyAddedReactions = currentReactions.difference(previousReactions); + + if (mounted) { + setState(() {}); + } + } + void _updateReactionMap() { + final allReactionEvents = widget.event + .aggregatedEvents(widget.timeline, RelationshipTypes.reaction); + final newReactionMap = {}; for (final e in allReactionEvents) { final key = e.content .tryGetMap('m.relates_to') ?.tryGet('key'); if (key != null) { - if (!reactionMap.containsKey(key)) { - reactionMap[key] = _ReactionEntry( + if (!newReactionMap.containsKey(key)) { + newReactionMap[key] = _ReactionEntry( key: key, count: 0, reacted: false, reactors: [], ); } - reactionMap[key]!.count++; - reactionMap[key]!.reactors!.add(e.senderFromMemoryOrFallback); - reactionMap[key]!.reacted |= e.senderId == e.room.client.userID; + newReactionMap[key]!.count++; + newReactionMap[key]!.reactors!.add(e.senderFromMemoryOrFallback); + newReactionMap[key]!.reacted |= e.senderId == client.userID; } } - final reactionList = reactionMap.values.toList(); + _reactionMap = newReactionMap; + } + + @override + void dispose() { + _reactionSubscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final reactionList = _reactionMap.values.toList(); reactionList.sort((a, b) => b.count - a.count > 0 ? 1 : -1); - final ownMessage = event.senderId == event.room.client.userID; - return Wrap( - spacing: 4.0, - runSpacing: 4.0, - alignment: ownMessage ? WrapAlignment.end : WrapAlignment.start, - children: [ - ...reactionList.map( - (r) => _Reaction( - reactionKey: r.key, - count: r.count, - reacted: r.reacted, - onTap: () { - if (r.reacted) { - final evt = allReactionEvents.firstWhereOrNull( - (e) => - e.senderId == e.room.client.userID && - e.content.tryGetMap('m.relates_to')?['key'] == r.key, - ); - if (evt != null) { - showFutureLoadingDialog( - context: context, - future: () => evt.redactEvent(), - ); - } - } else { - event.room.sendReaction(event.eventId, r.key); - } - }, - onLongPress: () async => await _AdaptableReactorsDialog( - client: client, - reactionEntry: r, - ).show(context), - ), - ), - if (allReactionEvents.any((e) => e.status.isSending)) - const SizedBox( - width: 24, - height: 24, - child: Padding( - padding: EdgeInsets.all(4.0), - child: CircularProgressIndicator.adaptive(strokeWidth: 1), + final ownMessage = widget.event.senderId == widget.event.room.client.userID; + final allReactionEvents = widget.event + .aggregatedEvents(widget.timeline, RelationshipTypes.reaction) + .toList(); + + //directionality handles wrap and loading position of own and other messages + return Directionality( + textDirection: ownMessage ? TextDirection.rtl : TextDirection.ltr, + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + runSpacing: 4.0, + alignment: WrapAlignment.start, + children: [ + ...reactionList.map( + (r) => _Reaction( + key: ValueKey(r.key), + firstReact: _newlyAddedReactions.contains(r.key), + reactionKey: r.key, + count: r.count, + reacted: r.reacted, + onTap: () => _handleReactionTap(r, allReactionEvents), + onLongPress: () async => await _AdaptableReactorsDialog( + client: client, + reactionEntry: r, + ).show(context), ), ), - ], + if (allReactionEvents.any((e) => e.status.isSending)) + const SizedBox( + width: 24, + height: 24, + child: Padding( + padding: EdgeInsets.all(4.0), + child: CircularProgressIndicator.adaptive(strokeWidth: 1), + ), + ), + ], + ), ); } + + void _handleReactionTap( + _ReactionEntry reaction, + List allReactionEvents, + ) { + if (reaction.reacted) { + final evt = allReactionEvents.firstWhereOrNull( + (e) => + e.senderId == e.room.client.userID && + e.content.tryGetMap('m.relates_to')?['key'] == reaction.key, + ); + if (evt != null) { + showFutureLoadingDialog( + context: context, + future: () => evt.redactEvent(), + ); + } + } else { + widget.event.room.sendReaction(widget.event.eventId, reaction.key); + } + } } -class _Reaction extends StatelessWidget { +class _Reaction extends StatefulWidget { final String reactionKey; final int count; final bool? reacted; - final void Function()? onTap; + final bool firstReact; + final Function()? onTap; final void Function()? onLongPress; const _Reaction({ + required super.key, required this.reactionKey, required this.count, required this.reacted, + required this.firstReact, required this.onTap, required this.onLongPress, }); + @override + State<_Reaction> createState() => _ReactionState(); +} + +class _ReactionState extends State<_Reaction> with TickerProviderStateMixin { + late AnimationController _bounceOutController; + late Animation _bounceOutAnimation; + late AnimationController _burstController; + late Animation _burstAnimation; + late AnimationController _growController; + late Animation _growScale; + late Animation _growOffset; + late AnimationController _feedbackBounceController; + late Animation _feedbackBounceAnimation; + + final List _burstParticles = []; + bool _isBusy = false; + + @override + void initState() { + super.initState(); + _bounceOutController = AnimationController( + duration: const Duration(milliseconds: 500), + vsync: this, + ); + _bounceOutAnimation = Tween( + begin: 1.0, + end: 0, + ).animate( + CurvedAnimation( + parent: _bounceOutController, + curve: Curves.easeInBack, + ), + ); + + _burstController = AnimationController( + duration: const Duration(milliseconds: 400), + vsync: this, + ); + _burstAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate( + CurvedAnimation( + parent: _burstController, + curve: Curves.easeOutQuint, + ), + ); + + _growController = AnimationController( + duration: const Duration(milliseconds: 500), + vsync: this, + ); + _growScale = TweenSequence([ + TweenSequenceItem( + tween: Tween(begin: 0.6, end: 1.18) + .chain(CurveTween(curve: Curves.easeOutBack)), + weight: 60, + ), + TweenSequenceItem( + tween: Tween(begin: 1.18, end: 1.0) + .chain(CurveTween(curve: Curves.easeIn)), + weight: 40, + ), + ]).animate(_growController); + _growOffset = TweenSequence([ + TweenSequenceItem( + tween: Tween(begin: 0.0, end: -10.0) + .chain(CurveTween(curve: Curves.easeOut)), + weight: 60, + ), + TweenSequenceItem( + tween: Tween(begin: -10.0, end: 0.0) + .chain(CurveTween(curve: Curves.easeIn)), + weight: 40, + ), + ]).animate(_growController); + + _feedbackBounceController = AnimationController( + duration: const Duration(milliseconds: 500), + vsync: this, + ); + _feedbackBounceAnimation = TweenSequence([ + TweenSequenceItem( + tween: Tween(begin: 1.0, end: 0.65) + .chain(CurveTween(curve: Curves.easeOut)), + weight: 30, + ), + TweenSequenceItem( + tween: Tween(begin: 0.65, end: 1.0) + .chain(CurveTween(curve: Curves.elasticOut)), + weight: 70, + ), + ]).animate(_feedbackBounceController); + + if (widget.firstReact) { + _growController.forward(); + } + } + + @override + void dispose() { + _bounceOutController.dispose(); + _burstController.dispose(); + _growController.dispose(); + _feedbackBounceController.dispose(); + super.dispose(); + } + + void resetState() { + _bounceOutController.reset(); + _burstController.reset(); + _growController.reset(); + _feedbackBounceController.reset(); + } + + _animateAndReact() async { + resetState(); + final wasReacted = widget.reacted; + final wasSingle = (widget.count == 1); + + if (wasReacted == true) { + if (wasSingle) { + await _bounceOutController.forward(); + await _triggerBurstAnimation(); + } else { + _triggerBurstAnimation(); + _feedbackBounceController.forward(); + } + } + + //execute actual reaction event and wait to finish + if (widget.onTap != null) { + if (!wasReacted!) { + await _growController.forward(); + } + widget.onTap!(); + } + } + + Future _triggerBurstAnimation() async { + _burstParticles.clear(); + final random = Random(); + for (int i = 0; i < 8; i++) { + _burstParticles.add( + BurstParticle( + angle: (i * 45.0) + random.nextDouble() * 30 - 15, + distance: 20 + random.nextDouble() * 30, + scale: 0.6 + random.nextDouble() * 0.4, + rotation: random.nextDouble() * 360, + ), + ); + } + + _burstController.reset(); + await _burstController.forward(); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); Widget content; - if (reactionKey.startsWith('mxc://')) { + if (widget.reactionKey.startsWith('mxc://')) { content = Row( mainAxisSize: MainAxisSize.min, children: [ MxcImage( - uri: Uri.parse(reactionKey), + uri: Uri.parse(widget.reactionKey), width: 20, height: 20, animated: false, isThumbnail: false, ), - if (count > 1) ...[ + if (widget.count > 1) ...[ const SizedBox(width: 4), Text( - count.toString(), + widget.count.toString(), textAlign: TextAlign.center, style: TextStyle( color: theme.colorScheme.onSurface, @@ -136,38 +379,131 @@ class _Reaction extends StatelessWidget { ], ); } else { - var renderKey = Characters(reactionKey); + var renderKey = Characters(widget.reactionKey); if (renderKey.length > 10) { renderKey = renderKey.getRange(0, 9) + Characters('…'); } content = Text( - renderKey.toString() + (count > 1 ? ' $count' : ''), + renderKey.toString() + (widget.count > 1 ? ' ${widget.count}' : ''), style: TextStyle( color: theme.colorScheme.onSurface, fontSize: DefaultTextStyle.of(context).style.fontSize, ), ); } - return InkWell( - onTap: () => onTap != null ? onTap!() : null, - onLongPress: () => onLongPress != null ? onLongPress!() : null, - borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2), - child: Container( - decoration: BoxDecoration( - color: reacted == true - ? theme.colorScheme.primaryContainer - : theme.colorScheme.surfaceContainerHigh, - border: Border.all( - color: reacted == true - ? theme.colorScheme.primary - : theme.colorScheme.surfaceContainerHigh, - width: 1, - ), - borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2), + //Burst should continue/overflow after emoji shrinks away + return Stack( + clipBehavior: Clip.none, + children: [ + AnimatedBuilder( + animation: Listenable.merge([ + _bounceOutAnimation, + _growController, + _feedbackBounceController + ]), + builder: (context, child) { + final isGrowing = _growController.isAnimating || + (_growController.value > 0 && _growController.value < 1.0); + final isBouncing = _bounceOutController.isAnimating; + final isFeedbackBouncing = _feedbackBounceController.isAnimating; + + double scale; + if (isGrowing) { + scale = _growScale.value; + } else if (isFeedbackBouncing) { + scale = _feedbackBounceAnimation.value; + } else { + scale = _bounceOutAnimation.value; + } + + final offsetY = isGrowing ? _growOffset.value : 0.0; + + return AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + alignment: Alignment.center, + clipBehavior: Clip.none, + child: Opacity( + opacity: scale.clamp(0.0, 1.0), + child: Transform.translate( + offset: Offset(0, offsetY), + child: Transform.scale( + scale: scale, + alignment: Alignment.center, + child: scale > 0.01 + ? Padding( + padding: const EdgeInsets.only(left: 2, right: 2), + child: InkWell( + onTap: () async { + if (_isBusy || + isBouncing || + isGrowing || + isFeedbackBouncing) { + return; + } + _isBusy = true; + try { + await _animateAndReact(); + } finally { + if (mounted) setState(() => _isBusy = false); + } + }, + onLongPress: () => widget.onLongPress != null + ? widget.onLongPress!() + : null, + borderRadius: BorderRadius.circular( + AppConfig.borderRadius / 2, + ), + child: Container( + decoration: BoxDecoration( + color: widget.reacted == true + ? theme.colorScheme.primaryContainer + : theme + .colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular( + AppConfig.borderRadius / 2, + ), + border: Border.all( + color: widget.reacted == true + ? theme.colorScheme.primary + : theme + .colorScheme.surfaceContainerHigh, + width: 1, + )), + padding: PlatformInfos.isIOS + ? const EdgeInsets.fromLTRB(5.5, 1, 3, 2.5) + : const EdgeInsets.symmetric( + horizontal: 4, + vertical: 2, + ), + child: content, + ), + ), + ) + : const SizedBox.shrink(), + ), + ), + ), + ); + }, ), - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), - child: content, - ), + AnimatedBuilder( + animation: _burstAnimation, + builder: (context, child) { + if (_burstAnimation.value == 0.0) return const SizedBox.shrink(); + return Positioned.fill( + child: IgnorePointer( + child: CustomPaint( + painter: BurstPainter( + particles: _burstParticles, + progress: _burstAnimation.value, + particleColor: theme.colorScheme.primary), + ), + ), + ); + }, + ), + ], ); } } diff --git a/lib/pages/chat/events/reaction_burst.dart b/lib/pages/chat/events/reaction_burst.dart new file mode 100644 index 000000000..41cdd1d5b --- /dev/null +++ b/lib/pages/chat/events/reaction_burst.dart @@ -0,0 +1,55 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; + +class BurstParticle { + final double angle; + final double distance; + final double scale; + final double rotation; + + BurstParticle({ + required this.angle, + required this.distance, + required this.scale, + required this.rotation, + }); +} + +class BurstPainter extends CustomPainter { + final List particles; + final double progress; + final Color particleColor; + final double baseRadius; + + BurstPainter({ + required this.particles, + required this.progress, + required this.particleColor, + this.baseRadius = 5.0, + }); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + + for (final particle in particles) { + final radians = particle.angle * (pi / 180); + final currentDistance = particle.distance * progress * 0.8; + final x = center.dx + cos(radians) * currentDistance; + final y = center.dy + sin(radians) * currentDistance; + final opacity = (1.0 - progress).clamp(0.0, 1.0); + final animatedRadius = + baseRadius * particle.scale * (1.0 - progress * 0.3); + + final paint = Paint() + ..color = particleColor.withValues(alpha: opacity) + ..style = PaintingStyle.fill; + + canvas.drawCircle(Offset(x, y), animatedRadius, paint); + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => true; +}