// Author: fruit.com.cn import 'dart:async'; 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.burstId, required this.onCompleted, this.origin = Offset.zero, this.now, }); final int burstId; final VoidCallback onCompleted; final Offset origin; final DateTime Function()? now; @override State createState() => _PlayerDoubleTapHeartBurstState(); } class _PlayerDoubleTapHeartBurstState extends State { Timer? _timer; late final DateTime _startAt; late final int _totalMs; final Stopwatch _lifetime = Stopwatch(); int _elapsedMs = 0; int _tickCount = 0; bool _completed = false; @override void initState() { super.initState(); _lifetime.start(); _startAt = _now(); _totalMs = _hearts .map((heart) => heart.delay + _heartDurationMs) .reduce((left, right) => left > right ? left : right); if (kDebugMode) { debugPrint( '[player] phase=heart-burst-start ' 'id=${widget.burstId} count=${_hearts.length} ' 'configuredMs=$_totalMs at=${widget.origin.dx.toStringAsFixed(1)},' '${widget.origin.dy.toStringAsFixed(1)}', ); } _timer = Timer.periodic( const Duration(milliseconds: 16), (_) => _handleTick(), ); } DateTime _now() => widget.now?.call() ?? DateTime.now(); void _handleTick() { if (!mounted || _completed) return; final rawElapsedMs = _now().difference(_startAt).inMilliseconds; final elapsedMs = rawElapsedMs < 0 ? 0 : rawElapsedMs > _totalMs ? _totalMs : rawElapsedMs; _elapsedMs = elapsedMs; _tickCount++; if (kDebugMode && _tickCount % 10 == 0) { debugPrint( '[player] phase=heart-tick id=${widget.burstId} ' 'elapsedMs=$_elapsedMs', ); } if (_elapsedMs >= _totalMs) { _complete(); return; } setState(() {}); } void _complete() { if (_completed) return; _completed = true; _timer?.cancel(); if (kDebugMode) { debugPrint( '[player] phase=heart-burst-complete ' 'id=${widget.burstId} ' 'configuredMs=$_totalMs', ); } widget.onCompleted(); } @override void dispose() { if (kDebugMode) { final elapsedMs = _completed ? _totalMs : _elapsedMs; debugPrint( '[player] phase=heart-burst-dispose ' 'id=${widget.burstId} elapsedMs=$elapsedMs ' 'completed=${_completed ? 1 : 0}', ); if (!_completed) { debugPrintStack( maxFrames: 30, label: '[player] heart-burst-premature-dispose ' 'id=${widget.burstId} mount=root-overlay', ); } } _lifetime.stop(); _timer?.cancel(); _timer = null; super.dispose(); } @override Widget build(BuildContext context) { return IgnorePointer( child: Stack( fit: StackFit.expand, clipBehavior: Clip.none, children: [ for (var index = 0; index < _hearts.length; index++) _heart(_hearts[index], _elapsedMs.toDouble(), index), ], ), ); } Widget _heart(_HeartParticle heart, double elapsed, int index) { final rawProgress = (elapsed - heart.delay) / _heartDurationMs; if (rawProgress < 0) return const SizedBox.shrink(); final progress = rawProgress.clamp(0.0, 1.0); 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; }