diff --git a/lib/pages/player/widgets/player_double_tap_hearts.dart b/lib/pages/player/widgets/player_double_tap_hearts.dart index 76d0d57..baf482b 100644 --- a/lib/pages/player/widgets/player_double_tap_hearts.dart +++ b/lib/pages/player/widgets/player_double_tap_hearts.dart @@ -14,6 +14,7 @@ class PlayerDoubleTapHeartBurst extends StatefulWidget { required this.burstId, required this.onCompleted, this.random, + this.origin = Offset.zero, }); static const int minParticleCount = 4; @@ -23,6 +24,7 @@ class PlayerDoubleTapHeartBurst extends StatefulWidget { final int burstId; final VoidCallback onCompleted; final Random? random; + final Offset origin; @visibleForTesting static double opacityForProgress(double progress) => @@ -94,7 +96,9 @@ class _PlayerDoubleTapHeartBurstState extends State debugPrint('[player] phase=heart-burst-init id=${widget.burstId}'); debugPrint( '[player] phase=heart-burst-start ' - 'id=${widget.burstId} count=${_particles.length}', + 'id=${widget.burstId} count=${_particles.length} ' + 'at=${widget.origin.dx.toStringAsFixed(1)},' + '${widget.origin.dy.toStringAsFixed(1)}', ); } _controller = AnimationController( @@ -168,7 +172,7 @@ class _PlayerDoubleTapHeartBurstState extends State final scale = PlayerDoubleTapHeartBurst.scaleForProgress(progress); return Positioned( left: particle.drift * move - particle.size / 2, - top: -particle.travel * move, + top: -particle.size / 2 - particle.travel * move, child: Opacity( opacity: PlayerDoubleTapHeartBurst.opacityForProgress(progress), child: Transform.scale( diff --git a/lib/pages/player/widgets/player_tap_gesture_region.dart b/lib/pages/player/widgets/player_tap_gesture_region.dart index 59e1cbd..8d36c2b 100644 --- a/lib/pages/player/widgets/player_tap_gesture_region.dart +++ b/lib/pages/player/widgets/player_tap_gesture_region.dart @@ -50,12 +50,18 @@ class _PlayerTapGestureRegionState extends State { int _downSerial = 0; int _tapSeq = 0; int _nextHeartBurstId = 0; - final List<({int id, Offset position})> _heartBursts = []; + final Map _heartOverlayEntries = {}; @override void dispose() { _tapSeq++; _singleTapTimer?.cancel(); + for (final entry in _heartOverlayEntries.values.toList()) { + entry + ..remove() + ..dispose(); + } + _heartOverlayEntries.clear(); super.dispose(); } @@ -81,26 +87,6 @@ class _PlayerTapGestureRegionState extends State { onLongPressEnd: (_) => widget.onInteractionEnd(), onLongPressCancel: widget.onInteractionEnd, ), - if (_heartBursts.isNotEmpty) - Positioned.fill( - child: IgnorePointer( - child: Stack( - clipBehavior: Clip.none, - children: [ - for (final burst in _heartBursts) - Positioned( - left: burst.position.dx, - top: burst.position.dy - 10, - child: PlayerDoubleTapHeartBurst( - key: ValueKey(burst.id), - burstId: burst.id, - onCompleted: () => _removeHeartBurst(burst.id), - ), - ), - ], - ), - ), - ), ], ), ), @@ -142,7 +128,7 @@ class _PlayerTapGestureRegionState extends State { void _handleTapUp(TapUpDetails details) { final now = _now(); if (_doubleTapDown || _singleTapBlocked(now)) { - _handleDoubleTapLike(details.localPosition); + _handleDoubleTapLike(details.globalPosition); return; } final serial = _downSerial; @@ -160,24 +146,56 @@ class _PlayerTapGestureRegionState extends State { _singleTapTimer = pending; } - void _handleDoubleTapLike(Offset position) { + void _handleDoubleTapLike(Offset globalPosition) { _blockSingleTapUntil = _now().add(_singleTapDelay); _cancelPendingSingleTap(); if (!widget.doubleTapEnabled || _likedThisPair) return; _likedThisPair = true; - final id = _nextHeartBurstId++; - setState(() => _heartBursts.add((id: id, position: position))); + _showHeartBurst(globalPosition); if (kDebugMode) { debugPrint( '[player] phase=double-tap-like episodeId=${widget.episodeId}', ); } - widget.onDoubleTapLike?.call(position); + widget.onDoubleTapLike?.call(globalPosition); } - void _removeHeartBurst(int id) { - if (!mounted) return; - setState(() => _heartBursts.removeWhere((burst) => burst.id == id)); + void _showHeartBurst(Offset globalPosition) { + final overlay = Overlay.maybeOf(context, rootOverlay: true); + if (overlay == null) return; + final overlayBox = overlay.context.findRenderObject(); + final origin = overlayBox is RenderBox + ? overlayBox.globalToLocal(globalPosition) + : globalPosition; + final id = _nextHeartBurstId++; + late final OverlayEntry entry; + entry = OverlayEntry( + builder: (context) => Positioned( + left: origin.dx, + top: origin.dy, + child: IgnorePointer( + child: PlayerDoubleTapHeartBurst( + key: ValueKey('heart-overlay-$id'), + burstId: id, + origin: origin, + onCompleted: () => _removeHeartOverlay(id), + ), + ), + ), + ); + _heartOverlayEntries[id] = entry; + overlay.insert(entry); + } + + void _removeHeartOverlay(int id) { + final entry = _heartOverlayEntries.remove(id); + if (entry == null) return; + entry + ..remove() + ..dispose(); + if (kDebugMode) { + debugPrint('[player] phase=heart-burst-entry-removed id=$id'); + } } void _handleLongPressStart(LongPressStartDetails details, double width) { diff --git a/test/player/double_tap_like_test.dart b/test/player/double_tap_like_test.dart index 9463851..65c37c9 100644 --- a/test/player/double_tap_like_test.dart +++ b/test/player/double_tap_like_test.dart @@ -143,6 +143,75 @@ void main() { expect(tester.state(burstFinder), same(burstState)); expect(find.byKey(const Key('double-tap-heart-0')), findsOneWidget); }); + + testWidgets('heart overlay starts at global tap position and is cleaned up', ( + tester, + ) async { + final key = GlobalKey<_HarnessState>(); + final clock = _TestClock(); + await _pumpHarness( + tester, + FakeInteractionRepository(), + FakeHaptics(), + key, + clock, + ); + const tapPosition = Offset(120, 220); + + await tester.tapAt(tapPosition); + clock.advance(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tapAt(tapPosition); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1)); + + final firstHeart = find.byKey(const Key('double-tap-heart-0')); + expect(firstHeart, findsOneWidget); + expect( + (tester.getCenter(firstHeart) - tapPosition).distance, + lessThanOrEqualTo(1), + ); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + expect(find.byType(PlayerDoubleTapHeartBurst), findsNothing); + }); + + testWidgets('multiple heart overlays run independently', (tester) async { + final key = GlobalKey<_HarnessState>(); + final clock = _TestClock(); + await _pumpHarness( + tester, + FakeInteractionRepository(), + FakeHaptics(), + key, + clock, + ); + + await tester.tapAt(const Offset(100, 180)); + clock.advance(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tapAt(const Offset(100, 180)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + clock.advance(const Duration(milliseconds: 400)); + await tester.pump(const Duration(milliseconds: 400)); + await tester.tapAt(const Offset(260, 360)); + clock.advance(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tapAt(const Offset(260, 360)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1)); + + expect(find.byType(PlayerDoubleTapHeartBurst), findsNWidgets(2)); + expect(find.byKey(const ValueKey('heart-overlay-0')), findsOneWidget); + expect(find.byKey(const ValueKey('heart-overlay-1')), findsOneWidget); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + expect(find.byType(PlayerDoubleTapHeartBurst), findsNothing); + }); } Future _pumpHarness(