Files
submanager/lib/widgets/swipeable_subscription_card.dart
JiWoong Sul 2f60ef585a feat: 글래스모피즘 디자인 시스템 및 색상 가이드 전면 적용
- @doc/color.md 가이드라인에 따른 색상 시스템 전면 개편
- 딥 블루(#2563EB), 스카이 블루(#60A5FA) 메인 컬러로 변경
- 모든 화면과 위젯에 글래스모피즘 효과 일관성 있게 적용
- darkNavy, navyGray 등 새로운 텍스트 색상 체계 도입
- 공통 스낵바 및 다이얼로그 컴포넌트 추가
- Claude AI 프로젝트 컨텍스트 파일(CLAUDE.md) 추가

영향받은 컴포넌트:
- 10개 스크린 (main, settings, detail, splash 등)
- 30개 이상 위젯 (buttons, cards, forms 등)
- 테마 시스템 (AppColors, AppTheme)

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-11 18:41:05 +09:00

272 lines
8.7 KiB
Dart

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<void> Function()? onDelete;
final VoidCallback? onTap;
const SwipeableSubscriptionCard({
super.key,
required this.subscription,
this.onEdit,
this.onDelete,
this.onTap,
});
@override
State<SwipeableSubscriptionCard> createState() => _SwipeableSubscriptionCardState();
}
class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _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<double>(
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<double>(
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,
),
),
),
),
),
),
],
);
}
}