import 'package:flutter/material.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 { late AnimationController _controller; late Animation _animation; double _dragStartX = 0; double _currentOffset = 0; // 현재 카드의 실제 위치 bool _isDragging = false; // 드래그 중인지 여부 bool _isSwipingLeft = false; bool _hapticTriggered = false; double _screenWidth = 0; double _cardWidth = 0; // 카드의 실제 너비 (margin 제외) static const double _actionThresholdPercent = 0.15; // 15%에서 액션 버튼 표시 static const double _deleteThresholdPercent = 0.40; // 40%에서 삭제/편집 실행 @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.addStatusListener(_onAnimationStatusChanged); // 애니메이션 리스너 추가 _controller.addListener(_onAnimationUpdate); } @override void didChangeDependencies() { super.didChangeDependencies(); _screenWidth = MediaQuery.of(context).size.width; _cardWidth = _screenWidth - 32; // 좌우 margin 16px씩 제외 } @override void didUpdateWidget(SwipeableSubscriptionCard oldWidget) { super.didUpdateWidget(oldWidget); // 위젯이 업데이트될 때 카드를 원위치로 복귀 if (oldWidget.subscription.id != widget.subscription.id) { _controller.stop(); setState(() { _currentOffset = 0; _isDragging = false; }); } } @override void dispose() { _controller.removeListener(_onAnimationUpdate); _controller.removeStatusListener(_onAnimationStatusChanged); _controller.stop(); _controller.dispose(); super.dispose(); } void _onAnimationUpdate() { if (!_isDragging) { setState(() { _currentOffset = _animation.value; }); } } void _onAnimationStatusChanged(AnimationStatus status) { if (status == AnimationStatus.completed && !_isDragging) { setState(() { _currentOffset = _animation.value; }); } } void _handleDragStart(DragStartDetails details) { _dragStartX = details.localPosition.dx; _hapticTriggered = false; _isDragging = true; _controller.stop(); // 진행 중인 애니메이션 중지 } void _handleDragUpdate(DragUpdateDetails details) { final delta = details.localPosition.dx - _dragStartX; setState(() { _currentOffset = delta; _isSwipingLeft = delta < 0; }); // 햅틱 피드백 트리거 (카드 너비의 15%) final actionThreshold = _cardWidth * _actionThresholdPercent; if (!_hapticTriggered && _currentOffset.abs() > actionThreshold) { _hapticTriggered = true; HapticFeedbackHelper.mediumImpact(); } // 삭제 임계값에 도달했을 때 강한 햅틱 (카드 너비의 40%) final deleteThreshold = _cardWidth * _deleteThresholdPercent; if (_currentOffset.abs() > deleteThreshold && _hapticTriggered) { HapticFeedbackHelper.heavyImpact(); _hapticTriggered = false; // 반복 방지 } } void _handleDragEnd(DragEndDetails details) async { _isDragging = false; final velocity = details.velocity.pixelsPerSecond.dx; final extent = _currentOffset.abs(); // 카드 너비의 40% 계산 final deleteThreshold = _cardWidth * _deleteThresholdPercent; if (extent > deleteThreshold || velocity.abs() > 800) { // 40% 이상 스와이프 시 삭제/편집 액션 if (_isSwipingLeft && widget.onDelete != null) { HapticFeedbackHelper.success(); // 삭제 확인 다이얼로그 표시 await widget.onDelete!(); // 다이얼로그가 닫힌 후 원위치로 복귀 if (mounted) { _animateToOffset(0); } } else if (!_isSwipingLeft && widget.onEdit != null) { HapticFeedbackHelper.success(); // 편집 화면으로 이동 전 원위치로 복귀 _animateToOffset(0); Future.delayed(const Duration(milliseconds: 300), () { widget.onEdit!(); }); } else { // 액션이 없는 경우 원위치로 복귀 _animateToOffset(0); } } else { // 40% 미만: 모두 원위치로 복귀 _animateToOffset(0); } } void _animateToOffset(double offset) { // 애니메이션 컨트롤러 리셋 _controller.stop(); _controller.value = 0; _animation = Tween( begin: _currentOffset, end: offset, ).animate(CurvedAnimation( parent: _controller, curve: Curves.easeOutExpo, )); _controller.forward(); } @override Widget build(BuildContext context) { return Stack( children: [ // 배경 액션 버튼들 Positioned.fill( 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) Padding( padding: const EdgeInsets.only(left: 24), child: AnimatedOpacity( duration: const Duration(milliseconds: 200), opacity: _currentOffset > (_cardWidth * 0.10) ? 1.0 : 0.0, child: AnimatedScale( duration: const Duration(milliseconds: 200), scale: _currentOffset > (_cardWidth * 0.10) ? 1.0 : 0.5, child: const Icon( Icons.edit_rounded, color: Colors.white, size: 28, ), ), ), ), // 삭제 버튼 (왼쪽 스와이프) if (_isSwipingLeft) Padding( padding: const EdgeInsets.only(right: 24), child: AnimatedOpacity( duration: const Duration(milliseconds: 200), opacity: _currentOffset.abs() > (_cardWidth * 0.10) ? 1.0 : 0.0, child: AnimatedScale( duration: const Duration(milliseconds: 200), scale: _currentOffset.abs() > (_cardWidth * 0.10) ? 1.0 : 0.5, child: Icon( _currentOffset.abs() > (_cardWidth * _deleteThresholdPercent) ? Icons.delete_forever_rounded : Icons.delete_rounded, color: Colors.white, size: 28, ), ), ), ), ], ), ), ), // 스와이프 가능한 카드 GestureDetector( onHorizontalDragStart: _handleDragStart, onHorizontalDragUpdate: _handleDragUpdate, onHorizontalDragEnd: _handleDragEnd, child: Transform.translate( offset: Offset(_currentOffset, 0), child: Transform.scale( scale: 1.0 - (_currentOffset.abs() / 2000), child: Transform.rotate( angle: _currentOffset / 2000, child: GestureDetector( onTap: () { if (_currentOffset.abs() < 10) { widget.onTap?.call(); } }, child: SubscriptionCard( subscription: widget.subscription, ), ), ), ), ), ), ], ); } }