import 'package:flutter/material.dart'; import 'package:flutter/gestures.dart'; import '../models/subscription_model.dart'; import '../utils/haptic_feedback_helper.dart'; import 'subscription_card.dart'; class SwipeableSubscriptionCard extends StatefulWidget { final SubscriptionModel subscription; final VoidCallback? onEdit; final Future Function()? onDelete; final VoidCallback? onTap; const SwipeableSubscriptionCard({ super.key, required this.subscription, this.onEdit, this.onDelete, this.onTap, }); @override State createState() => _SwipeableSubscriptionCardState(); } class _SwipeableSubscriptionCardState extends State with SingleTickerProviderStateMixin { // 상수 정의 static const double _tapTolerance = 20.0; // 탭 허용 범위 static const double _actionThresholdPercent = 0.15; static const double _deleteThresholdPercent = 0.40; static const int _tapDurationMs = 500; static const double _velocityThreshold = 800.0; // static const double _animationDuration = 300.0; // 애니메이션 관련 late AnimationController _controller; late Animation _animation; // 제스처 추적 Offset? _startPosition; DateTime? _startTime; bool _isValidTap = true; // 상태 관리 double _currentOffset = 0; bool _isSwipingLeft = false; bool _hapticTriggered = false; double _cardWidth = 0; @override void initState() { super.initState(); _controller = AnimationController( duration: const Duration(milliseconds: 300), vsync: this, ); _animation = Tween( begin: 0.0, end: 0.0, ).animate(CurvedAnimation( parent: _controller, curve: Curves.easeOutExpo, )); _controller.addListener(() { if (mounted) { setState(() { _currentOffset = _animation.value; }); } }); } @override void didChangeDependencies() { super.didChangeDependencies(); _cardWidth = MediaQuery.of(context).size.width - 32; } @override void didUpdateWidget(SwipeableSubscriptionCard oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.subscription.id != widget.subscription.id) { _resetCard(); } } @override void dispose() { _controller.dispose(); super.dispose(); } // 제스처 핸들러 void _handlePanStart(DragStartDetails details) { _startPosition = details.localPosition; _startTime = DateTime.now(); _isValidTap = true; _hapticTriggered = false; _controller.stop(); } void _handlePanUpdate(DragUpdateDetails details) { final currentPosition = details.localPosition; final delta = currentPosition.dx - _startPosition!.dx; final distance = (currentPosition - _startPosition!).distance; // 탭 유효성 검사 - 거리가 허용 범위를 벗어나면 스와이프로 간주 if (distance > _tapTolerance) { _isValidTap = false; } // 카드 이동 setState(() { _currentOffset = delta; _isSwipingLeft = delta < 0; }); // 햅틱 피드백 _triggerHapticFeedback(); } void _handlePanEnd(DragEndDetails details) { final velocity = details.velocity.pixelsPerSecond.dx; // 스와이프 처리만 수행 (탭은 SubscriptionCard에서 처리) _processSwipe(velocity); } // 헬퍼 메서드 void _processTap() { print('[SwipeableSubscriptionCard] _processTap 호출됨'); if (widget.onTap != null) { print('[SwipeableSubscriptionCard] onTap 콜백 실행'); widget.onTap!(); } _animateToOffset(0); } void _processSwipe(double velocity) { final extent = _currentOffset.abs(); final deleteThreshold = _cardWidth * _deleteThresholdPercent; // 아주 작은 움직임은 무시하고 원위치로 복귀 if (extent < _tapTolerance) { _animateToOffset(0); return; } if (extent > deleteThreshold || velocity.abs() > _velocityThreshold) { // 삭제 실행 if (widget.onDelete != null) { HapticFeedbackHelper.success(); widget.onDelete!().then((_) { if (mounted) { _animateToOffset(0); } }); } else { _animateToOffset(0); } } else { // 원위치 복귀 _animateToOffset(0); } } void _triggerHapticFeedback() { final actionThreshold = _cardWidth * _actionThresholdPercent; final deleteThreshold = _cardWidth * _deleteThresholdPercent; final absOffset = _currentOffset.abs(); if (!_hapticTriggered && absOffset > actionThreshold) { _hapticTriggered = true; HapticFeedbackHelper.mediumImpact(); } else if (_hapticTriggered && absOffset > deleteThreshold) { HapticFeedbackHelper.heavyImpact(); _hapticTriggered = false; } } void _animateToOffset(double offset) { _animation = Tween( begin: _currentOffset, end: offset, ).animate(CurvedAnimation( parent: _controller, curve: Curves.easeOutExpo, )); _controller.forward(from: 0); } void _resetCard() { _controller.stop(); setState(() { _currentOffset = 0; }); } // 빌드 메서드 Widget _buildActionButtons() { return Positioned.fill( child: IgnorePointer( child: Container( margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), color: Colors.transparent, ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ if (!_isSwipingLeft) _buildDeleteIcon(true), if (_isSwipingLeft) _buildDeleteIcon(false), ], ), ), ), ); } Widget _buildDeleteIcon(bool isLeft) { final showIcon = _currentOffset.abs() > (_cardWidth * 0.10); final isDeleteThreshold = _currentOffset.abs() > (_cardWidth * _deleteThresholdPercent); return Padding( padding: EdgeInsets.only( left: isLeft ? 24 : 0, right: isLeft ? 0 : 24, ), child: AnimatedOpacity( duration: const Duration(milliseconds: 200), opacity: showIcon ? 1.0 : 0.0, child: AnimatedScale( duration: const Duration(milliseconds: 200), scale: showIcon ? 1.0 : 0.5, child: Icon( isDeleteThreshold ? Icons.delete_forever_rounded : Icons.delete_rounded, color: Colors.white, size: 28, ), ), ), ); } Widget _buildCard() { return Transform.translate( offset: Offset(_currentOffset, 0), child: Transform.scale( scale: 1.0 - (_currentOffset.abs() / 2000), child: Transform.rotate( angle: _currentOffset / 2000, child: SubscriptionCard( subscription: widget.subscription, onTap: widget.onTap, // onTap 콜백을 전달하여 SubscriptionCard 내부에서도 탭 처리 가능하도록 함 ), ), ), ); } @override Widget build(BuildContext context) { // 웹과 모바일 모두 동일한 스와이프 기능 제공 return Stack( children: [ _buildActionButtons(), GestureDetector( behavior: HitTestBehavior.opaque, onPanStart: _handlePanStart, onPanUpdate: _handlePanUpdate, onPanEnd: _handlePanEnd, // onTap 제거 - SubscriptionCard의 AnimatedGlassmorphismCard에서 처리하도록 함 child: _buildCard(), ), ], ); } }