feat: 폼 필드 컴포넌트 분리 및 구독 카드 인터랙션 개선
- billing_cycle_selector, category_selector, currency_selector 컴포넌트 분리 - 구독 카드 클릭 이슈 해결을 위한 리팩토링 - SMS 스캔 화면 UI/UX 개선 및 기능 강화 - 상세 화면 컨트롤러 로직 개선 - 알림 서비스 및 구독 URL 매칭 기능 추가 - CLAUDE.md 프로젝트 가이드라인 대폭 확장 - 전반적인 코드 구조 개선 및 타입 안정성 강화 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
@@ -8,7 +9,7 @@ class SwipeableSubscriptionCard extends StatefulWidget {
|
||||
final VoidCallback? onEdit;
|
||||
final Future<void> Function()? onDelete;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
|
||||
const SwipeableSubscriptionCard({
|
||||
super.key,
|
||||
required this.subscription,
|
||||
@@ -18,23 +19,34 @@ class SwipeableSubscriptionCard extends StatefulWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
State<SwipeableSubscriptionCard> createState() => _SwipeableSubscriptionCardState();
|
||||
State<SwipeableSubscriptionCard> createState() =>
|
||||
_SwipeableSubscriptionCardState();
|
||||
}
|
||||
|
||||
class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
|
||||
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<double> _animation;
|
||||
double _dragStartX = 0;
|
||||
double _currentOffset = 0; // 현재 카드의 실제 위치
|
||||
bool _isDragging = false; // 드래그 중인지 여부
|
||||
|
||||
// 제스처 추적
|
||||
Offset? _startPosition;
|
||||
DateTime? _startTime;
|
||||
bool _isValidTap = true;
|
||||
|
||||
// 상태 관리
|
||||
double _currentOffset = 0;
|
||||
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%에서 삭제/편집 실행
|
||||
double _cardWidth = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -50,128 +62,128 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
|
||||
parent: _controller,
|
||||
curve: Curves.easeOutExpo,
|
||||
));
|
||||
|
||||
// 애니메이션 상태 리스너 추가
|
||||
_controller.addStatusListener(_onAnimationStatusChanged);
|
||||
|
||||
// 애니메이션 리스너 추가
|
||||
_controller.addListener(_onAnimationUpdate);
|
||||
|
||||
_controller.addListener(() {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentOffset = _animation.value;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_screenWidth = MediaQuery.of(context).size.width;
|
||||
_cardWidth = _screenWidth - 32; // 좌우 margin 16px씩 제외
|
||||
_cardWidth = MediaQuery.of(context).size.width - 32;
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
void didUpdateWidget(SwipeableSubscriptionCard oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// 위젯이 업데이트될 때 카드를 원위치로 복귀
|
||||
if (oldWidget.subscription.id != widget.subscription.id) {
|
||||
_controller.stop();
|
||||
setState(() {
|
||||
_currentOffset = 0;
|
||||
_isDragging = false;
|
||||
});
|
||||
_resetCard();
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
// 제스처 핸들러
|
||||
void _handlePanStart(DragStartDetails details) {
|
||||
_startPosition = details.localPosition;
|
||||
_startTime = DateTime.now();
|
||||
_isValidTap = true;
|
||||
_hapticTriggered = false;
|
||||
_isDragging = true;
|
||||
_controller.stop(); // 진행 중인 애니메이션 중지
|
||||
_controller.stop();
|
||||
}
|
||||
|
||||
void _handleDragUpdate(DragUpdateDetails details) {
|
||||
final delta = details.localPosition.dx - _dragStartX;
|
||||
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;
|
||||
});
|
||||
|
||||
// 햅틱 피드백 트리거 (카드 너비의 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; // 반복 방지
|
||||
}
|
||||
|
||||
// 햅틱 피드백
|
||||
_triggerHapticFeedback();
|
||||
}
|
||||
|
||||
void _handleDragEnd(DragEndDetails details) async {
|
||||
_isDragging = false;
|
||||
void _handlePanEnd(DragEndDetails details) {
|
||||
final duration = DateTime.now().difference(_startTime!);
|
||||
final velocity = details.velocity.pixelsPerSecond.dx;
|
||||
|
||||
// 탭/스와이프 처리 분기
|
||||
|
||||
// 탭 처리 - 짧은 시간 내에 작은 움직임만 있었다면 탭으로 처리
|
||||
if (_isValidTap &&
|
||||
duration.inMilliseconds < _tapDurationMs &&
|
||||
_currentOffset.abs() < _tapTolerance) {
|
||||
_processTap();
|
||||
return;
|
||||
}
|
||||
|
||||
// 스와이프 처리
|
||||
_processSwipe(velocity);
|
||||
}
|
||||
|
||||
// 헬퍼 메서드
|
||||
void _processTap() {
|
||||
if (widget.onTap != null) {
|
||||
widget.onTap!();
|
||||
}
|
||||
_animateToOffset(0);
|
||||
}
|
||||
|
||||
void _processSwipe(double velocity) {
|
||||
final extent = _currentOffset.abs();
|
||||
|
||||
// 카드 너비의 40% 계산
|
||||
final deleteThreshold = _cardWidth * _deleteThresholdPercent;
|
||||
|
||||
if (extent > deleteThreshold || velocity.abs() > 800) {
|
||||
// 40% 이상 스와이프 시 삭제/편집 액션
|
||||
if (_isSwipingLeft && widget.onDelete != null) {
|
||||
|
||||
if (extent > deleteThreshold || velocity.abs() > _velocityThreshold) {
|
||||
// 삭제 실행
|
||||
if (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!();
|
||||
widget.onDelete!().then((_) {
|
||||
if (mounted) {
|
||||
_animateToOffset(0);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 액션이 없는 경우 원위치로 복귀
|
||||
_animateToOffset(0);
|
||||
}
|
||||
} else {
|
||||
// 40% 미만: 모두 원위치로 복귀
|
||||
// 원위치 복귀
|
||||
_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) {
|
||||
// 애니메이션 컨트롤러 리셋
|
||||
_controller.stop();
|
||||
_controller.value = 0;
|
||||
|
||||
_animation = Tween<double>(
|
||||
begin: _currentOffset,
|
||||
end: offset,
|
||||
@@ -179,94 +191,97 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
|
||||
parent: _controller,
|
||||
curve: Curves.easeOutExpo,
|
||||
));
|
||||
|
||||
_controller.forward();
|
||||
|
||||
_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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 스와이프 가능한 카드
|
||||
_buildActionButtons(),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPanStart: _handlePanStart,
|
||||
onPanUpdate: _handlePanUpdate,
|
||||
onPanEnd: _handlePanEnd,
|
||||
child: _buildCard(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user