import 'dart:async'; import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/views/camera/share_image_editor/layer_data.dart'; import 'package:twonly/src/views/chats/media_viewer_components/emoji_reactions_row.component.dart'; import 'package:twonly/src/views/components/animate_icon.dart'; import 'package:twonly/src/views/components/emoji_picker.bottom.dart'; class ReactionButtons extends StatefulWidget { const ReactionButtons({ required this.show, required this.textInputFocused, required this.mediaViewerDistanceFromBottom, required this.messageId, required this.groupId, required this.emojiKey, required this.hide, super.key, }); final double mediaViewerDistanceFromBottom; final bool show; final bool textInputFocused; final GlobalKey emojiKey; final String messageId; final String groupId; final void Function() hide; @override State createState() => _ReactionButtonsState(); } class _ReactionButtonsState extends State { int selectedShortReaction = -1; final GlobalKey _keyEmojiPicker = GlobalKey(); List selectedEmojis = EmojiAnimation.animatedIcons.keys .toList() .sublist(0, 6); @override void initState() { super.initState(); initAsync(); } Future initAsync() async { if (gUser.preSelectedEmojies != null) { selectedEmojis = gUser.preSelectedEmojies!; } setState(() {}); } @override Widget build(BuildContext context) { final firstRowEmojis = selectedEmojis.take(6).toList(); final secondRowEmojis = selectedEmojis.length > 6 ? selectedEmojis.skip(6).toList() : []; return AnimatedPositioned( duration: const Duration(milliseconds: 200), // Animation duration bottom: widget.show ? (widget.textInputFocused ? 50 : widget.mediaViewerDistanceFromBottom) : widget.mediaViewerDistanceFromBottom - 20, left: widget.show ? 0 : MediaQuery.sizeOf(context).width / 2, right: widget.show ? 0 : MediaQuery.sizeOf(context).width / 2, curve: Curves.linearToEaseOut, child: AnimatedOpacity( opacity: widget.show ? 1.0 : 0.0, // Fade in/out duration: const Duration(milliseconds: 150), child: Container( color: widget.show ? Colors.black.withAlpha(0) : Colors.transparent, padding: widget.show ? const EdgeInsets.symmetric(vertical: 32) : null, child: Column( children: [ if (secondRowEmojis.isNotEmpty) Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, crossAxisAlignment: CrossAxisAlignment.end, children: secondRowEmojis .map( (emoji) => EmojiReactionWidget( messageId: widget.messageId, groupId: widget.groupId, hide: widget.hide, show: widget.show, emoji: emoji as String, emojiKey: widget.emojiKey, ), ) .toList(), ), if (secondRowEmojis.isNotEmpty) const SizedBox(height: 15), Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, crossAxisAlignment: CrossAxisAlignment.end, children: [ ...firstRowEmojis.map( (emoji) => EmojiReactionWidget( messageId: widget.messageId, groupId: widget.groupId, hide: widget.hide, show: widget.show, emoji: emoji, emojiKey: widget.emojiKey, ), ), GestureDetector( key: _keyEmojiPicker, onTap: () async { final layer = // ignore: inference_failure_on_function_invocation await showModalBottomSheet( context: context, backgroundColor: context.color.surface, builder: (context) { return const EmojiPickerBottom(); }, ) as EmojiLayerData?; if (layer == null) return; await sendReaction( widget.groupId, widget.messageId, layer.text, ); widget.emojiKey.currentState?.spawn( getGlobalOffset(_keyEmojiPicker), layer.text, ); widget.hide(); }, child: Container( decoration: BoxDecoration( color: context.color.surfaceContainer.withAlpha(100), borderRadius: BorderRadius.circular(12), ), padding: const EdgeInsets.all(8), child: const FaIcon( FontAwesomeIcons.ellipsisVertical, size: 24, ), ), ), ], ), ], ), ), ), ); } } class EmojiFloatWidget extends StatefulWidget { const EmojiFloatWidget({ super.key, }); @override EmojiFloatWidgetState createState() => EmojiFloatWidgetState(); } class EmojiFloatWidgetState extends State with SingleTickerProviderStateMixin { final List<_Particle> _particles = []; late final Ticker _ticker; final Random _rnd = Random(); Duration _lastTick = Duration.zero; @override void initState() { super.initState(); _ticker = createTicker(_tick)..start(); } void _tick(Duration elapsed) { final dt = (_lastTick == Duration.zero) ? 0.016 : (elapsed - _lastTick).inMicroseconds / 1e6; _lastTick = elapsed; for (final p in List<_Particle>.from(_particles)) { p.update(dt); if (p.isDead) _particles.remove(p); } if (mounted) setState(() {}); } @override void dispose() { _ticker.dispose(); super.dispose(); } /// Call this to spawn the emoji animation from a global screen position. void spawn(Offset globalPosition, String emoji) { final box = context.findRenderObject() as RenderBox?; if (box == null) return; final local = box.globalToLocal(globalPosition); const spawnCount = 10; final life = const Duration(milliseconds: 2000).inMilliseconds / 1000.0; for (var i = 0; i < spawnCount; i++) { final dx = (_rnd.nextDouble() - 0.5) * 220; final vx = dx; final vy = -(100 + _rnd.nextDouble() * 80); final rot = (_rnd.nextDouble() - 0.5) * 2; final scale = 0.9 + _rnd.nextDouble() * 0.6; _particles.add( _Particle( emoji: emoji, x: local.dx, y: local.dy, vx: vx, vy: vy, rotation: rot, lifetime: life, scale: scale, ), ); } setState(() {}); } @override Widget build(BuildContext context) { return IgnorePointer( child: CustomPaint( painter: _ParticlePainter(List<_Particle>.from(_particles)), size: Size.infinite, ), ); } } class _Particle { _Particle({ required this.emoji, required this.x, required this.y, required this.vx, required this.vy, required this.rotation, required this.lifetime, required this.scale, }); final String emoji; double x; double y; double vx; double vy; double rotation; double age = 0; final double lifetime; final double scale; bool get isDead => age >= lifetime; void update(double dt) { age += dt; // vertical-only motion emphasis: mild gravity slows ascent then gently pulls down vy += 100 * dt; // gravity (positive = down) // slight horizontal drag to reduce sideways drift vx *= 1 - 3.0 * dt; // integrate position x += vx * dt; y += vy * dt; // slow rotation decay rotation *= 1 - 1.5 * dt; } double get progress => (age / lifetime).clamp(0.0, 1.0); // opacity falls from 1 -> 0 as particle ages double get opacity => (1.0 - progress).clamp(0.0, 1.0); // scale can gently grow then shrink; here we slightly increase early double get currentScale { final p = progress; if (p < 0.5) return scale * (1.0 + 0.3 * (p / 0.5)); return scale * (1.3 - 0.3 * ((p - 0.5) / 0.5)); } } class _ParticlePainter extends CustomPainter { _ParticlePainter(this.particles); final List<_Particle> particles; @override void paint(Canvas canvas, Size size) { final textPainter = TextPainter(textDirection: TextDirection.ltr); for (final p in particles) { final tp = TextSpan( text: p.emoji, style: TextStyle( fontSize: 24 * p.currentScale, color: Colors.black.withValues(alpha: p.opacity), ), ); textPainter ..text = tp ..layout(); canvas ..save() ..translate(p.x - textPainter.width / 2, p.y - textPainter.height / 2) ..rotate(p.rotation); textPainter.paint(canvas, Offset.zero); canvas.restore(); } } @override bool shouldRepaint(covariant _ParticlePainter old) => true; }