import 'package:flutter/material.dart'; import '../models/subscription_model.dart'; import '../utils/haptic_feedback_helper.dart'; import 'subscription_card.dart'; import '../utils/reduce_motion.dart'; class SwipeableSubscriptionCard extends StatefulWidget { final SubscriptionModel subscription; final VoidCallback? onEdit; final Future Function()? onDelete; final VoidCallback? onTap; final bool keepAlive; const SwipeableSubscriptionCard({ super.key, required this.subscription, this.onEdit, this.onDelete, this.onTap, this.keepAlive = false, }); @override State createState() => _SwipeableSubscriptionCardState(); } class _SwipeableSubscriptionCardState extends State with SingleTickerProviderStateMixin, AutomaticKeepAliveClientMixin { // 상수 정의 static const double _tapTolerance = 20.0; // 탭 허용 범위 static const double _actionThresholdPercent = 0.15; static const double _deleteThresholdPercent = 0.40; static const double _velocityThreshold = 800.0; // static const double _animationDuration = 300.0; // 애니메이션 관련 late AnimationController _controller; late Animation _animation; // 제스처 추적 Offset? _startPosition; // 제스처 관련 보조 변수(간소화) // 상태 관리 double _currentOffset = 0; bool _isSwipingLeft = false; bool _hapticTriggered = false; double _cardWidth = 0; @override void initState() { super.initState(); _controller = AnimationController( duration: ReduceMotion.platform() ? const Duration(milliseconds: 0) : 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; _hapticTriggered = false; _controller.stop(); } void _handlePanUpdate(DragUpdateDetails details) { final currentPosition = details.localPosition; final delta = currentPosition.dx - _startPosition!.dx; // 탭/스와이프 판별 거리는 외부에서 사용하지 않아 제거 // 카드 이동 setState(() { _currentOffset = delta; _isSwipingLeft = delta < 0; }); // 햅틱 피드백 _triggerHapticFeedback(); } void _handlePanEnd(DragEndDetails details) { final velocity = details.velocity.pixelsPerSecond.dx; // 스와이프 처리만 수행 (탭은 SubscriptionCard에서 처리) _processSwipe(velocity); } // 헬퍼 메서드 // 탭 처리는 SubscriptionCard에서 수행 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: ReduceMotion.platform() ? const Duration(milliseconds: 0) : const Duration(milliseconds: 200), opacity: showIcon ? 1.0 : 0.0, child: AnimatedScale( duration: ReduceMotion.platform() ? const Duration(milliseconds: 0) : 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: ReduceMotion.platform() ? 1.0 : 1.0 - (_currentOffset.abs() / 2000), child: Transform.rotate( angle: ReduceMotion.platform() ? 0.0 : _currentOffset / 2000, child: SubscriptionCard( subscription: widget.subscription, onTap: widget .onTap, // onTap 콜백을 전달하여 SubscriptionCard 내부에서도 탭 처리 가능하도록 함 ), ), ), ); } @override Widget build(BuildContext context) { super.build(context); // 웹과 모바일 모두 동일한 스와이프 기능 제공 return Stack( children: [ _buildActionButtons(), GestureDetector( behavior: HitTestBehavior.opaque, onPanStart: _handlePanStart, onPanUpdate: _handlePanUpdate, onPanEnd: _handlePanEnd, // onTap 제거 - SubscriptionCard의 AnimatedGlassmorphismCard에서 처리하도록 함 child: _buildCard(), ), ], ); } @override bool get wantKeepAlive => widget.keepAlive; }