Files
submanager/lib/widgets/swipeable_subscription_card.dart
JiWoong Sul d37f66d526 feat(settings): SMS 읽기 권한 상태/요청 위젯 추가 (Android)
- 설정 화면에 SMS 권한 카드 추가: 상태 표시(허용/미허용/영구 거부), 권한 요청/설정 이동 지원\n- 기존 알림 권한 카드 스타일과 일관성 유지

feat(permissions): 최초 실행 시 SMS 권한 온보딩 화면 추가 및 Splash에서 라우팅 (Android)

- 권한 필요 이유/수집 범위 현지화 문구 추가\n- 거부/영구거부 케이스 처리 및 설정 이동

chore(codex): AGENTS.md/체크 스크립트/CI/프롬프트 템플릿 추가

- AGENTS.md, scripts/check.sh, scripts/fix.sh, .github/workflows/flutter_ci.yml, .claude/agents/codex.md, 문서 템플릿 추가

refactor(logging): 경로별 print 제거 후 경량 로거(Log) 도입

- SMS 스캐너/컨트롤러, URL 매처, 데이터 리포지토리, 내비게이션, 메모리/성능 유틸 등 핵심 경로 치환

feat(exchange): 환율 API URL을 --dart-define로 오버라이드 가능 + 폴백 로깅 강화

test: URL 매처/환율 스모크 테스트 추가

chore(android): RECEIVE_SMS 권한 제거 (READ_SMS만 유지)

fix(lints): dart fix + 수동 정리로 경고 대폭 감소, 비동기 context(mounted) 보강

fix(deprecations):\n- flutter_local_notifications의 androidAllowWhileIdle → androidScheduleMode 전환\n- WillPopScope → PopScope 교체

i18n: SMS 권한 온보딩/설정 문구 현지화 키 추가
2025-09-07 21:32:16 +09:00

270 lines
7.1 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 {
// 상수 정의
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<double> _animation;
// 제스처 추적
Offset? _startPosition;
// 제스처 관련 보조 변수(간소화)
// 상태 관리
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;
_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<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(),
),
],
);
}
}