// Author: fruit.com.cn import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; /// 双击点赞时,在点击位置飘出一小簇心。 class PlayerDoubleTapHeartBurst extends StatefulWidget { const PlayerDoubleTapHeartBurst({ super.key, required this.burstId, required this.onCompleted, this.random, }); static const int minParticleCount = 4; static const int maxParticleCount = 5; static const int minimumBurstDurationMs = 2000; final int burstId; final VoidCallback onCompleted; final Random? random; @visibleForTesting static double opacityForProgress(double progress) => 1 - Curves.easeIn.transform(progress); @visibleForTesting static double scaleForProgress(double progress) { return progress < 0.3 ? 0.55 + (1.15 - 0.55) * (progress / 0.3) : 1.15 - (1.15 - 0.2) * ((progress - 0.3) / 0.7); } @override State createState() => _PlayerDoubleTapHeartBurstState(); } class _PlayerDoubleTapHeartBurstState extends State with SingleTickerProviderStateMixin { late final AnimationController _controller; late final List<_HeartParticle> _particles; final Stopwatch _lifetime = Stopwatch(); bool _completed = false; @override void initState() { super.initState(); _lifetime.start(); final random = widget.random ?? Random(); var delay = 0; _particles = List.generate( PlayerDoubleTapHeartBurst.minParticleCount + random.nextInt( PlayerDoubleTapHeartBurst.maxParticleCount - PlayerDoubleTapHeartBurst.minParticleCount + 1, ), (index) { if (index > 0) delay += 80 + random.nextInt(41); return _HeartParticle( delay: delay, duration: 1500 + random.nextInt(301), travel: 100 + random.nextInt(31), drift: -30 + random.nextInt(61), size: (22 + random.nextInt(13)).toDouble(), color: random.nextBool() ? AppColors.doubleTapHeartRed : AppColors.doubleTapHeartPink, ); }, ); var total = _particles .map((particle) => particle.delay + particle.duration) .reduce(max); if (total < PlayerDoubleTapHeartBurst.minimumBurstDurationMs) { final last = _particles.last; final requiredDuration = PlayerDoubleTapHeartBurst.minimumBurstDurationMs - last.delay; final adjustedDuration = max( last.duration, requiredDuration, ).clamp(1500, 1800).toInt(); _particles[_particles.length - 1] = last.withDuration(adjustedDuration); total = _particles .map((particle) => particle.delay + particle.duration) .reduce(max); } if (kDebugMode) { debugPrint('[player] phase=heart-burst-init id=${widget.burstId}'); debugPrint( '[player] phase=heart-burst-start ' 'id=${widget.burstId} count=${_particles.length}', ); } _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} configuredMs=$elapsedMs', ); } widget.onCompleted(); } @override void dispose() { if (kDebugMode) { final elapsedMs = _completed ? _controller.duration?.inMilliseconds ?? 0 : _lifetime.elapsedMilliseconds; debugPrint( '[player] phase=heart-burst-dispose ' 'id=${widget.burstId} elapsedMs=$elapsedMs ' '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 < _particles.length; index++) _particle(_particles[index], elapsed, index), ], ); }, ), ), ); } Widget _particle(_HeartParticle particle, double elapsed, int index) { final progress = ((elapsed - particle.delay) / particle.duration).clamp( 0.0, 1.0, ); if (progress <= 0) return const SizedBox.shrink(); final move = Curves.easeOutCubic.transform(progress); final scale = PlayerDoubleTapHeartBurst.scaleForProgress(progress); return Positioned( left: particle.drift * move - particle.size / 2, top: -particle.travel * move, child: Opacity( opacity: PlayerDoubleTapHeartBurst.opacityForProgress(progress), child: Transform.scale( scale: scale, child: Icon( key: Key('double-tap-heart-$index'), Icons.favorite_rounded, color: particle.color, size: particle.size, ), ), ), ); } } class _HeartParticle { const _HeartParticle({ required this.delay, required this.duration, required this.travel, required this.drift, required this.size, required this.color, }); final int delay; final int duration; final int travel; final int drift; final double size; final Color color; _HeartParticle withDuration(int nextDuration) { return _HeartParticle( delay: delay, duration: nextDuration, travel: travel, drift: drift, size: size, color: color, ); } }