// Author: fruit.com.cn import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; const int _heartDurationMs = 1900; const int _lastHeartDelayMs = 260; const double _heartPopPortion = 0.12; const double _heartStartScale = 0.7; const double _heartHoldPortion = 0.45; const List<_HeartParticle> _hearts = [ _HeartParticle( delay: 0, travel: 120, drift: 0, size: 44, color: AppColors.doubleTapHeartRed, ), _HeartParticle( delay: 140, travel: 120, drift: -32, size: 34, color: AppColors.doubleTapHeartPink, ), _HeartParticle( delay: _lastHeartDelayMs, travel: 120, drift: 30, size: 38, color: AppColors.doubleTapHeartRed, ), ]; /// 双击点赞时,在点击位置飘出一小簇心。 class PlayerDoubleTapHeartBurst extends StatefulWidget { const PlayerDoubleTapHeartBurst({ super.key, required this.burstId, required this.onCompleted, }); static const int particleCount = 3; static const int singleHeartDurationMs = _heartDurationMs; static const Duration animationDuration = Duration( milliseconds: _lastHeartDelayMs + _heartDurationMs, ); final int burstId; final VoidCallback onCompleted; @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(); if (kDebugMode) { debugPrint('[player] phase=heart-burst-init id=${widget.burstId}'); debugPrint('[player] phase=heart-burst-start id=${widget.burstId}'); } final total = _hearts .map((heart) => heart.delay + _heartDurationMs) .reduce((left, right) => left > right ? left : right); _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) { final elapsedMs = _controller.duration?.inMilliseconds ?? 0; debugPrint( '[player] phase=heart-burst-complete ' 'id=${widget.burstId} elapsedMs=$elapsedMs', ); } widget.onCompleted(); } @override void dispose() { if (kDebugMode) { debugPrint( '[player] phase=heart-burst-dispose ' 'id=${widget.burstId} elapsedMs=${_lifetime.elapsedMilliseconds} ' 'completed=${_completed ? 1 : 0}', ); } _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( clipBehavior: Clip.none, children: [ for (var index = 0; index < _hearts.length; index++) _heart(_hearts[index], elapsed, index), ], ); }, ), ), ); } 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.easeOutSine.transform(progress); final scale = progress < _heartPopPortion ? _heartStartScale + (1 - _heartStartScale) * (progress / _heartPopPortion) : 1.0; final opacity = progress < _heartHoldPortion ? 1.0 : 1 - Curves.easeInOutSine.transform( (progress - _heartHoldPortion) / (1 - _heartHoldPortion), ); return Positioned( left: heart.drift * move - heart.size / 2, top: -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; }