- ExchangeRateService에 JPY, CNY 환율 지원 추가 - 구독 서비스별 다국어 표시 이름 지원 - 분석 화면 차트 및 UI/UX 개선 - 설정 화면 전면 리팩토링 - SMS 스캔 기능 사용성 개선 - 전체 앱 다국어 번역 확대 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
286 lines
7.6 KiB
Dart
286 lines
7.6 KiB
Dart
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<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 {
|
|
// 상수 정의
|
|
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;
|
|
|
|
// 제스처 추적
|
|
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<double>(
|
|
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<double>(
|
|
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(),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|