// Author: fruit.com.cn import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; const int _heartDurationMs = 1500; const double _heartPopPortion = 0.18; const double _heartStartScale = 0.7; const double _heartHoldPortion = 0.55; const List<_HeartParticle> _hearts = [ _HeartParticle( delay: 0, travel: 108, drift: 0, size: 48, color: AppColors.doubleTapHeartRed, ), _HeartParticle( delay: 50, travel: 96, drift: -28, size: 36, color: AppColors.doubleTapHeartPink, ), _HeartParticle( delay: 90, travel: 118, drift: 26, size: 40, color: AppColors.doubleTapHeartRed, ), ]; /// 双击点赞时,在点击位置飘出一小簇心。 class PlayerDoubleTapHeartBurst extends StatefulWidget { const PlayerDoubleTapHeartBurst({ super.key, required this.onCompleted, this.origin = Offset.zero, }); final VoidCallback onCompleted; final Offset origin; @override State createState() => _PlayerDoubleTapHeartBurstState(); } class _PlayerDoubleTapHeartBurstState extends State with SingleTickerProviderStateMixin { late final AnimationController _controller; final Stopwatch _lifetime = Stopwatch(); bool _completed = false; @override void initState() { super.initState(); _lifetime.start(); final total = _hearts .map((heart) => heart.delay + _heartDurationMs) .reduce((left, right) => left > right ? left : right); if (kDebugMode) { debugPrint( '[player] phase=heart-burst-start ' 'id=${identityHashCode(this)} count=${_hearts.length} ' 'configuredMs=$total at=${widget.origin.dx.toStringAsFixed(1)},' '${widget.origin.dy.toStringAsFixed(1)}', ); } _controller = AnimationController( vsync: this, duration: Duration(milliseconds: total), )..addStatusListener(_onStatus); _controller.forward(); } void _onStatus(AnimationStatus status) { if (status != AnimationStatus.completed || _completed) return; _completed = true; if (kDebugMode) { debugPrint( '[player] phase=heart-burst-complete ' 'id=${identityHashCode(this)} ' 'configuredMs=${_controller.duration?.inMilliseconds ?? 0}', ); } widget.onCompleted(); } @override void dispose() { if (kDebugMode) { final elapsedMs = _completed ? _controller.duration?.inMilliseconds ?? 0 : _lifetime.elapsedMilliseconds; debugPrint( '[player] phase=heart-burst-dispose ' 'id=${identityHashCode(this)} elapsedMs=$elapsedMs ' 'completed=${_completed ? 1 : 0}', ); if (!_completed) { debugPrintStack( maxFrames: 30, label: '[player] heart-burst-premature-dispose ' 'id=${identityHashCode(this)} mount=tap-gesture-region', ); } } _lifetime.stop(); _controller ..removeStatusListener(_onStatus) ..dispose(); super.dispose(); } @override Widget build(BuildContext context) { return IgnorePointer( child: SizedBox.shrink( child: AnimatedBuilder( animation: _controller, builder: (context, _) { final elapsed = _controller.value * _controller.duration!.inMilliseconds; return Stack( fit: StackFit.expand, clipBehavior: Clip.none, children: [ for (var index = 0; index < _hearts.length; index++) _heart(_hearts[index], elapsed, index), if (kDebugMode) Positioned( left: widget.origin.dx + 8, top: widget.origin.dy, child: IgnorePointer( child: Text( '${elapsed.round()}ms', key: const Key('heart-burst-elapsed-debug'), style: const TextStyle( color: Colors.white60, fontSize: 11, ), ), ), ), ], ); }, ), ), ); } Widget _heart(_HeartParticle heart, double elapsed, int index) { final progress = ((elapsed - heart.delay) / _heartDurationMs).clamp( 0.0, 1.0, ); if (progress <= 0) return const SizedBox.shrink(); final move = Curves.easeOutCubic.transform(progress); final scale = progress < _heartPopPortion ? _heartStartScale + (1 - _heartStartScale) * (progress / _heartPopPortion) : 1.0; final opacity = progress < _heartHoldPortion ? 1.0 : 1 - ((progress - _heartHoldPortion) / (1 - _heartHoldPortion)); return Positioned( left: widget.origin.dx + heart.drift * move - heart.size / 2, top: widget.origin.dy - heart.size / 2 - heart.travel * move, child: Opacity( opacity: opacity.clamp(0.0, 1.0), child: Transform.scale( scale: scale, child: Icon( key: Key('double-tap-heart-$index'), Icons.favorite_rounded, color: heart.color, size: heart.size, ), ), ), ); } } class _HeartParticle { const _HeartParticle({ required this.delay, required this.travel, required this.drift, required this.size, required this.color, }); final int delay; final double travel; final double drift; final double size; final Color color; }