part of '../message_actions.dart'; const _reactionTrayMaxWidth = (52.0 * 6) + (Grid.twelve * 5) + (Grid.xxs * 2); const _reactionTrayMaxHeight = 52.0 + (Grid.xxs * 2); const _reactionTraySpringAllowance = 16.0; const _reactionPopoverDuration = Duration(milliseconds: 320); final _reactionSpringCurve = _SpringEaseOutCurve( duration: const Duration(milliseconds: 260), bounce: 0.22, ); /// Presents the frequently-used reaction tray over a long-pressed message. /// /// The conversation is frosted around [anchorRect] so the selected message /// remains visually connected to the tray. void _showMessageReactionPopover({ required BuildContext context, required WidgetRef ref, required TimelineMessage message, required Rect anchorRect, required EdgeInsets spotlightPadding, }) { unawaited(HapticFeedback.mediumImpact()); final reduceMotion = MediaQuery.disableAnimationsOf(context); showGeneralDialog( context: context, barrierDismissible: true, barrierLabel: 'Dismiss reaction picker', barrierColor: Colors.transparent, transitionDuration: reduceMotion ? Duration.zero : _reactionPopoverDuration, transitionBuilder: (context, animation, secondaryAnimation, child) => child, pageBuilder: (dialogContext, animation, secondaryAnimation) => _MessageReactionPopover( anchorRect: anchorRect, spotlightPadding: spotlightPadding, animation: animation, message: message, pageContext: context, pageRef: ref, ), ); } class _MessageReactionPopover extends StatelessWidget { final Rect anchorRect; final EdgeInsets spotlightPadding; final Animation animation; final TimelineMessage message; final BuildContext pageContext; final WidgetRef pageRef; const _MessageReactionPopover({ required this.anchorRect, required this.spotlightPadding, required this.animation, required this.message, required this.pageContext, required this.pageRef, }); @override Widget build(BuildContext context) { final mediaQuery = MediaQuery.of(context); return LayoutBuilder( builder: (context, constraints) { final safeTop = mediaQuery.padding.top + mediaQuery.viewInsets.top; final safeBottom = constraints.maxHeight - mediaQuery.padding.bottom - mediaQuery.viewInsets.bottom; final availableAbove = anchorRect.top - Grid.xxs - safeTop; final availableBelow = safeBottom - anchorRect.bottom - Grid.xxs; final showAbove = availableAbove >= _reactionTrayMaxHeight || (availableBelow < _reactionTrayMaxHeight && availableAbove >= availableBelow); final proposedTop = showAbove ? anchorRect.top - _reactionTrayMaxHeight - Grid.xxs : anchorRect.bottom + Grid.xxs; final maxTop = math.max(safeTop, safeBottom - _reactionTrayMaxHeight); final top = proposedTop.clamp(safeTop, maxTop).toDouble(); final trayScaleAlignment = showAbove ? Alignment.bottomLeft : Alignment.topLeft; final trayWidth = math.min( _reactionTrayMaxWidth, constraints.maxWidth - (Grid.xxs * 2), ); final maxLeft = constraints.maxWidth - trayWidth - Grid.xxs; final left = (anchorRect.center.dx - (trayWidth / 2)) .clamp(Grid.xxs, maxLeft) .toDouble(); return Stack( children: [ Positioned.fill( child: AnimatedBuilder( animation: animation, builder: (context, child) { final blurProgress = const Interval( 0, 0.30, curve: Curves.easeOutCubic, ).transform(animation.value); final sigma = 20 * blurProgress; return ClipPath( key: const ValueKey('reaction-popover-background'), clipper: _OutsideAnchorClipper( anchorRect, spotlightPadding, ), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma), child: ColoredBox( color: context.colors.inverseSurface.withValues( alpha: 0.10 * blurProgress, ), ), ), ); }, ), ), Positioned.fill( child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => Navigator.of(context).pop(), ), ), Positioned( top: top, left: left, width: trayWidth + _reactionTraySpringAllowance, height: _reactionTrayMaxHeight, child: AnimatedBuilder( animation: animation, child: SizedBox( width: trayWidth, height: _reactionTrayMaxHeight, child: Padding( padding: const EdgeInsets.all(Grid.xxs), child: _QuickReactionRow( message: message, sheetContext: context, pageContext: pageContext, pageRef: pageRef, presentationAnimation: animation, ), ), ), builder: (context, child) { final appearance = const Interval( 0.04, 0.23, curve: Curves.easeOutCubic, ).transform(animation.value); final expansion = const Interval( 0.16, 0.92, ).transform(animation.value); final springExpansion = _reactionSpringCurve.transform( expansion, ); final width = lerpDouble( _reactionTrayMaxHeight, trayWidth, springExpansion, )!; return Opacity( opacity: appearance, child: Transform.scale( alignment: trayScaleAlignment, scale: lerpDouble(0.95, 1, appearance)!, child: Align( alignment: Alignment.centerLeft, child: SizedBox( key: const ValueKey('reaction-popover-tray'), width: width, height: _reactionTrayMaxHeight, child: Material( color: context.colors.surface, surfaceTintColor: Colors.transparent, elevation: 8, shadowColor: Colors.black.withValues(alpha: 0.2), shape: const StadiumBorder(), clipBehavior: Clip.antiAlias, child: OverflowBox( alignment: Alignment.centerLeft, minWidth: trayWidth, maxWidth: trayWidth, minHeight: _reactionTrayMaxHeight, maxHeight: _reactionTrayMaxHeight, child: child, ), ), ), ), ), ); }, ), ), ], ); }, ); } } class _OutsideAnchorClipper extends CustomClipper { final Rect anchorRect; final EdgeInsets spotlightPadding; const _OutsideAnchorClipper(this.anchorRect, this.spotlightPadding); @override Path getClip(Size size) { final spotlightRect = Rect.fromLTRB( anchorRect.left - spotlightPadding.left, anchorRect.top - spotlightPadding.top, anchorRect.right + spotlightPadding.right, anchorRect.bottom + spotlightPadding.bottom, ); return Path() ..fillType = PathFillType.evenOdd ..addRect(Offset.zero & size) ..addRRect( RRect.fromRectAndRadius(spotlightRect, const Radius.circular(Radii.lg)), ); } @override bool shouldReclip(_OutsideAnchorClipper oldClipper) => oldClipper.anchorRect != anchorRect || oldClipper.spotlightPadding != spotlightPadding; } class _SpringEaseOutCurve extends Curve { final double _durationSeconds; final SpringSimulation _simulation; _SpringEaseOutCurve({required Duration duration, required double bounce}) : _durationSeconds = duration.inMicroseconds / Duration.microsecondsPerSecond, _simulation = SpringSimulation( SpringDescription.withDurationAndBounce( duration: duration, bounce: bounce, ), 0, 1, 0, snapToEnd: true, ); @override double transformInternal(double t) => _simulation.x(t * _durationSeconds); }