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:
JiWoong Sul
2025-07-14 15:47:46 +09:00
parent 2f60ef585a
commit 111c519883
39 changed files with 2376 additions and 1231 deletions

View File

@@ -40,6 +40,14 @@ class AddSubscriptionAppBar extends StatelessWidget implements PreferredSizeWidg
),
child: SafeArea(
child: AppBar(
leading: IconButton(
icon: const Icon(
Icons.chevron_left,
size: 28,
color: Color(0xFF1E293B),
),
onPressed: () => Navigator.of(context).pop(),
),
title: Text(
'구독 추가',
style: TextStyle(

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../../controllers/add_subscription_controller.dart';
import '../common/form_fields/currency_input_field.dart';
import '../common/form_fields/date_picker_field.dart';
import '../../theme/app_colors.dart';
/// 구독 추가 화면의 이벤트/할인 섹션
class AddSubscriptionEventSection extends StatelessWidget {
@@ -37,56 +38,67 @@ class AddSubscriptionEventSection extends StatelessWidget {
)),
child: Container(
margin: const EdgeInsets.only(bottom: 20),
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
color: AppColors.glassCard,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: controller.isEventActive
? const Color(0xFF3B82F6)
: Colors.grey.withValues(alpha: 0.2),
width: controller.isEventActive ? 2 : 1,
color: AppColors.glassBorder.withValues(alpha: 0.1),
width: 1,
),
boxShadow: [
BoxShadow(
color: AppColors.shadowBlack,
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Checkbox(
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: controller.gradientColors[0].withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.local_offer_rounded,
color: controller.gradientColors[0],
size: 24,
),
),
const SizedBox(width: 12),
const Text(
'이벤트 가격',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.darkNavy,
),
),
],
),
Switch.adaptive(
value: controller.isEventActive,
onChanged: (value) {
setState(() {
controller.isEventActive = value ?? false;
controller.isEventActive = value;
if (!controller.isEventActive) {
// 이벤트 비활성화 시 관련 데이터 초기화
controller.eventStartDate = DateTime.now();
controller.eventEndDate = DateTime.now().add(const Duration(days: 30));
controller.eventStartDate = null;
controller.eventEndDate = null;
controller.eventPriceController.clear();
} else {
// 이벤트 활성화 시 날짜가 null이면 기본값 설정
controller.eventStartDate ??= DateTime.now();
controller.eventEndDate ??= DateTime.now().add(const Duration(days: 30));
}
});
},
activeColor: const Color(0xFF3B82F6),
),
const Text(
'이벤트/할인 설정',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF1E293B),
),
),
const SizedBox(width: 8),
Icon(
Icons.local_offer,
size: 20,
color: controller.isEventActive
? const Color(0xFF3B82F6)
: Colors.grey,
activeColor: controller.gradientColors[0],
),
],
),
@@ -101,6 +113,39 @@ class AddSubscriptionEventSection extends StatelessWidget {
child: Column(
children: [
const SizedBox(height: 20),
// 이벤트 설명
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.infoColor.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.infoColor.withValues(alpha: 0.3),
width: 1,
),
),
child: Row(
children: [
Icon(
Icons.info_outline_rounded,
color: AppColors.infoColor,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'할인 또는 프로모션 가격을 설정하세요',
style: TextStyle(
fontSize: 14,
color: AppColors.darkNavy,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
const SizedBox(height: 20),
// 이벤트 기간
DateRangePickerField(
@@ -109,6 +154,10 @@ class AddSubscriptionEventSection extends StatelessWidget {
onStartDateSelected: (date) {
setState(() {
controller.eventStartDate = date;
// 시작일 설정 시 종료일이 없으면 자동으로 1달 후로 설정
if (date != null && controller.eventEndDate == null) {
controller.eventEndDate = date.add(const Duration(days: 30));
}
});
},
onEndDateSelected: (date) {
@@ -118,7 +167,7 @@ class AddSubscriptionEventSection extends StatelessWidget {
},
startLabel: '시작일',
endLabel: '종료일',
primaryColor: const Color(0xFF3B82F6),
primaryColor: controller.gradientColors[0],
),
const SizedBox(height: 20),

View File

@@ -6,6 +6,11 @@ import '../../providers/category_provider.dart';
import '../common/form_fields/base_text_field.dart';
import '../common/form_fields/currency_input_field.dart';
import '../common/form_fields/date_picker_field.dart';
import '../common/form_fields/currency_selector.dart';
import '../common/form_fields/billing_cycle_selector.dart';
import '../common/form_fields/category_selector.dart';
import '../glassmorphism_card.dart';
import '../../theme/app_colors.dart';
/// 구독 추가 화면의 폼 섹션
class AddSubscriptionForm extends StatelessWidget {
@@ -39,12 +44,8 @@ class AddSubscriptionForm extends StatelessWidget {
parent: controller.animationController!,
curve: const Interval(0.2, 1.0, curve: Curves.easeOutCubic),
)),
child: Card(
elevation: 1,
shadowColor: Colors.black12,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: GlassmorphismCard(
backgroundColor: AppColors.glassCard,
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
@@ -134,8 +135,9 @@ class AddSubscriptionForm extends StatelessWidget {
),
),
const SizedBox(height: 8),
_CurrencySelector(
CurrencySelector(
currency: controller.currency,
isGlassmorphism: true,
onChanged: (value) {
setState(() {
controller.currency = value;
@@ -161,9 +163,10 @@ class AddSubscriptionForm extends StatelessWidget {
),
),
const SizedBox(height: 8),
_BillingCycleSelector(
BillingCycleSelector(
billingCycle: controller.billingCycle,
gradientColors: controller.gradientColors,
baseColor: controller.gradientColors[0],
isGlassmorphism: true,
onChanged: (value) {
setState(() {
controller.billingCycle = value;
@@ -217,10 +220,11 @@ class AddSubscriptionForm extends StatelessWidget {
),
),
const SizedBox(height: 8),
_CategorySelector(
CategorySelector(
categories: categoryProvider.categories,
selectedCategoryId: controller.selectedCategoryId,
gradientColors: controller.gradientColors,
baseColor: controller.gradientColors[0],
isGlassmorphism: true,
onChanged: (categoryId) {
setState(() {
controller.selectedCategoryId = categoryId;
@@ -240,192 +244,3 @@ class AddSubscriptionForm extends StatelessWidget {
}
}
/// 통화 선택기
class _CurrencySelector extends StatelessWidget {
final String currency;
final ValueChanged<String> onChanged;
const _CurrencySelector({
required this.currency,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
_CurrencyOption(
label: '',
value: 'KRW',
isSelected: currency == 'KRW',
onTap: () => onChanged('KRW'),
),
const SizedBox(width: 8),
_CurrencyOption(
label: '\$',
value: 'USD',
isSelected: currency == 'USD',
onTap: () => onChanged('USD'),
),
],
);
}
}
/// 통화 옵션
class _CurrencyOption extends StatelessWidget {
final String label;
final String value;
final bool isSelected;
final VoidCallback onTap;
const _CurrencyOption({
required this.label,
required this.value,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Expanded(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: isSelected
? const Color(0xFF3B82F6)
: Colors.grey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: Text(
label,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: isSelected ? Colors.white : Colors.grey[600],
),
),
),
),
),
);
}
}
/// 결제 주기 선택기
class _BillingCycleSelector extends StatelessWidget {
final String billingCycle;
final List<Color> gradientColors;
final ValueChanged<String> onChanged;
const _BillingCycleSelector({
required this.billingCycle,
required this.gradientColors,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final cycles = ['월간', '분기별', '반기별', '연간'];
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: cycles.map((cycle) {
final isSelected = billingCycle == cycle;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: InkWell(
onTap: () => onChanged(cycle),
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
),
decoration: BoxDecoration(
color: isSelected
? gradientColors[0]
: Colors.grey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
cycle,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: isSelected ? Colors.white : Colors.grey[700],
),
),
),
),
);
}).toList(),
),
);
}
}
/// 카테고리 선택기
class _CategorySelector extends StatelessWidget {
final List<dynamic> categories;
final String? selectedCategoryId;
final List<Color> gradientColors;
final ValueChanged<String?> onChanged;
const _CategorySelector({
required this.categories,
required this.selectedCategoryId,
required this.gradientColors,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 8,
runSpacing: 8,
children: categories.map((category) {
final isSelected = selectedCategoryId == category.id;
return InkWell(
onTap: () => onChanged(category.id),
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
decoration: BoxDecoration(
color: isSelected
? gradientColors[0]
: Colors.grey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
category.icon,
style: const TextStyle(fontSize: 16),
),
const SizedBox(width: 6),
Text(
category.name,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: isSelected ? Colors.white : Colors.grey[700],
),
),
],
),
),
);
}).toList(),
);
}
}

View File

@@ -52,7 +52,7 @@ class AnalysisBadge extends StatelessWidget {
color: AppColors.darkNavy,
),
),
const SizedBox(height: 2),
const SizedBox(height: 0),
FutureBuilder<String>(
future: CurrencyUtil.formatAmount(
subscription.monthlyCost,

View File

@@ -42,12 +42,18 @@ class AppNavigator {
/// 구독 상세 화면으로 네비게이션
static Future<void> toDetail(BuildContext context, SubscriptionModel subscription) async {
print('AppNavigator.toDetail 호출됨: ${subscription.serviceName}');
HapticFeedback.lightImpact();
await Navigator.of(context).pushNamed(
AppRoutes.subscriptionDetail,
arguments: subscription,
);
try {
await Navigator.of(context).pushNamed(
AppRoutes.subscriptionDetail,
arguments: subscription,
);
print('DetailScreen 네비게이션 성공');
} catch (e) {
print('DetailScreen 네비게이션 오류: $e');
}
}
/// SMS 스캔 화면으로 네비게이션

View File

@@ -4,17 +4,19 @@ import 'package:intl/intl.dart';
/// 카테고리별 구독 그룹의 헤더 위젯
///
/// 카테고리 이름, 구독 개수, 총 비용을 표시합니다.
/// 참고: 여러 통화 단위가된 경우 간단히 원화 표시 형식을 사용합니다.
/// 통화별로 구분하여 표시하며,된 경우 각각 표시합니다.
class CategoryHeaderWidget extends StatelessWidget {
final String categoryName;
final int subscriptionCount;
final double totalCost;
final double totalCostUSD;
final double totalCostKRW;
const CategoryHeaderWidget({
Key? key,
required this.categoryName,
required this.subscriptionCount,
required this.totalCost,
required this.totalCostUSD,
required this.totalCostKRW,
}) : super(key: key);
@override
@@ -36,7 +38,7 @@ class CategoryHeaderWidget extends StatelessWidget {
),
),
Text(
'${subscriptionCount}개 · ${NumberFormat.currency(locale: 'ko_KR', symbol: '', decimalDigits: 0).format(totalCost)}',
_buildCostDisplay(),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
@@ -55,4 +57,44 @@ class CategoryHeaderWidget extends StatelessWidget {
),
);
}
/// 통화별 합계를 표시하는 문자열을 생성합니다.
String _buildCostDisplay() {
final parts = <String>[];
// 개수는 항상 표시
parts.add('$subscriptionCount개');
// 통화 부분을 별도로 처리
final currencyParts = <String>[];
// 달러가 있는 경우
if (totalCostUSD > 0) {
final formatter = NumberFormat.currency(
locale: 'en_US',
symbol: '\$',
decimalDigits: 2,
);
currencyParts.add(formatter.format(totalCostUSD));
}
// 원화가 있는 경우
if (totalCostKRW > 0) {
final formatter = NumberFormat.currency(
locale: 'ko_KR',
symbol: '',
decimalDigits: 0,
);
currencyParts.add(formatter.format(totalCostKRW));
}
// 통화가 하나 이상 있는 경우
if (currencyParts.isNotEmpty) {
// 통화가 여러 개인 경우 + 로 연결, 하나인 경우 그대로
final currencyDisplay = currencyParts.join(' + ');
parts.add(currencyDisplay);
}
return parts.join(' · ');
}
}

View File

@@ -103,7 +103,7 @@ class BaseTextField extends StatelessWidget {
prefixText: prefixText,
suffixIcon: suffixIcon,
filled: true,
fillColor: fillColor ?? AppColors.glassBackground,
fillColor: fillColor ?? AppColors.surfaceColorAlt,
contentPadding: contentPadding ?? const EdgeInsets.all(16),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
@@ -119,8 +119,8 @@ class BaseTextField extends StatelessWidget {
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: AppColors.textSecondary,
width: 1,
color: AppColors.borderColor.withValues(alpha: 0.7),
width: 1.5,
),
),
disabledBorder: OutlineInputBorder(

View File

@@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import '../../../theme/app_colors.dart';
/// 결제 주기 선택 위젯
/// 월간, 분기별, 반기별, 연간 중 선택할 수 있습니다.
class BillingCycleSelector extends StatelessWidget {
final String billingCycle;
final ValueChanged<String> onChanged;
final Color? baseColor;
final List<Color>? gradientColors;
final bool isGlassmorphism;
const BillingCycleSelector({
super.key,
required this.billingCycle,
required this.onChanged,
this.baseColor,
this.gradientColors,
this.isGlassmorphism = false,
});
@override
Widget build(BuildContext context) {
// 상세 화면에서는 '매월', 추가 화면에서는 '월간'으로 표시
final cycles = isGlassmorphism
? ['매월', '분기별', '반기별', '매년']
: ['월간', '분기별', '반기별', '연간'];
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: cycles.map((cycle) {
final isSelected = billingCycle == cycle;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: InkWell(
onTap: () => onChanged(cycle),
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
),
decoration: BoxDecoration(
color: _getBackgroundColor(isSelected),
borderRadius: BorderRadius.circular(12),
border: _getBorder(isSelected),
),
child: Text(
cycle,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: _getTextColor(isSelected),
),
),
),
),
);
}).toList(),
),
);
}
Color _getBackgroundColor(bool isSelected) {
if (!isSelected) {
return isGlassmorphism
? AppColors.backgroundColor
: Colors.grey.withValues(alpha: 0.1);
}
if (baseColor != null) {
return baseColor!;
}
if (gradientColors != null && gradientColors!.isNotEmpty) {
return gradientColors![0];
}
return const Color(0xFF3B82F6);
}
Border? _getBorder(bool isSelected) {
if (isSelected || !isGlassmorphism) {
return null;
}
return Border.all(
color: AppColors.borderColor.withValues(alpha: 0.5),
width: 1.5,
);
}
Color _getTextColor(bool isSelected) {
if (isSelected) {
return Colors.white;
}
return isGlassmorphism
? AppColors.darkNavy
: Colors.grey[700]!;
}
}

View File

@@ -0,0 +1,132 @@
import 'package:flutter/material.dart';
import '../../../theme/app_colors.dart';
/// 카테고리 선택 위젯
/// 구독 서비스의 카테고리를 선택할 수 있습니다.
class CategorySelector extends StatelessWidget {
final List<dynamic> categories;
final String? selectedCategoryId;
final ValueChanged<String?> onChanged;
final Color? baseColor;
final List<Color>? gradientColors;
final bool isGlassmorphism;
const CategorySelector({
super.key,
required this.categories,
required this.selectedCategoryId,
required this.onChanged,
this.baseColor,
this.gradientColors,
this.isGlassmorphism = false,
});
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 8,
runSpacing: 8,
children: categories.map((category) {
final isSelected = selectedCategoryId == category.id;
return InkWell(
onTap: () => onChanged(category.id),
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
decoration: BoxDecoration(
color: _getBackgroundColor(isSelected),
borderRadius: BorderRadius.circular(12),
border: _getBorder(isSelected),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_getCategoryIcon(category),
size: 18,
color: _getTextColor(isSelected),
),
const SizedBox(width: 6),
Text(
category.name,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: _getTextColor(isSelected),
),
),
],
),
),
);
}).toList(),
);
}
IconData _getCategoryIcon(dynamic category) {
// 카테고리명에 따른 아이콘 반환
switch (category.name) {
case '음악':
return Icons.music_note_rounded;
case 'OTT(동영상)':
return Icons.movie_filter_rounded;
case '저장/클라우드':
return Icons.cloud_outlined;
case '통신 · 인터넷 · TV':
return Icons.wifi_rounded;
case '생활/라이프스타일':
return Icons.home_outlined;
case '쇼핑/이커머스':
return Icons.shopping_cart_outlined;
case '프로그래밍':
return Icons.code_rounded;
case '협업/오피스':
return Icons.business_center_outlined;
case 'AI 서비스':
return Icons.smart_toy_outlined;
case '기타':
default:
return Icons.category_outlined;
}
}
Color _getBackgroundColor(bool isSelected) {
if (!isSelected) {
return isGlassmorphism
? AppColors.backgroundColor
: Colors.grey.withValues(alpha: 0.1);
}
if (baseColor != null) {
return baseColor!;
}
if (gradientColors != null && gradientColors!.isNotEmpty) {
return gradientColors![0];
}
return const Color(0xFF3B82F6);
}
Border? _getBorder(bool isSelected) {
if (isSelected || !isGlassmorphism) {
return null;
}
return Border.all(
color: AppColors.borderColor.withValues(alpha: 0.5),
width: 1.5,
);
}
Color _getTextColor(bool isSelected) {
if (isSelected) {
return Colors.white;
}
return isGlassmorphism
? AppColors.darkNavy
: Colors.grey[700]!;
}
}

View File

@@ -36,33 +36,62 @@ class CurrencyInputField extends StatefulWidget {
}
class _CurrencyInputFieldState extends State<CurrencyInputField> {
late TextEditingController _formattedController;
late FocusNode _focusNode;
bool _isFormatted = false;
@override
void initState() {
super.initState();
_formattedController = TextEditingController();
_updateFormattedValue();
widget.controller.addListener(_onControllerChanged);
_focusNode = widget.focusNode ?? FocusNode();
_focusNode.addListener(_onFocusChanged);
// 초기값이 있으면 포맷팅 적용
if (widget.controller.text.isNotEmpty) {
final value = double.tryParse(widget.controller.text.replaceAll(',', ''));
if (value != null) {
widget.controller.text = _formatCurrency(value);
_isFormatted = true;
}
}
}
@override
void dispose() {
widget.controller.removeListener(_onControllerChanged);
_formattedController.dispose();
if (widget.focusNode == null) {
_focusNode.dispose();
} else {
_focusNode.removeListener(_onFocusChanged);
}
super.dispose();
}
void _onControllerChanged() {
_updateFormattedValue();
}
void _updateFormattedValue() {
final value = double.tryParse(widget.controller.text.replaceAll(',', ''));
if (value != null) {
_formattedController.text = _formatCurrency(value);
} else {
_formattedController.text = '';
void _onFocusChanged() {
if (!_focusNode.hasFocus && widget.controller.text.isNotEmpty) {
// 포커스를 잃었을 때 포맷팅 적용
final value = _parseValue(widget.controller.text);
if (value != null) {
setState(() {
widget.controller.text = _formatCurrency(value);
_isFormatted = true;
});
}
} else if (_focusNode.hasFocus && _isFormatted) {
// 포커스를 받았을 때 포맷팅 제거
final value = _parseValue(widget.controller.text);
if (value != null) {
setState(() {
if (widget.currency == 'KRW') {
widget.controller.text = value.toInt().toString();
} else {
widget.controller.text = value.toString();
}
_isFormatted = false;
});
// 커서를 끝으로 이동
widget.controller.selection = TextSelection.fromPosition(
TextPosition(offset: widget.controller.text.length),
);
}
}
}
@@ -90,38 +119,42 @@ class _CurrencyInputFieldState extends State<CurrencyInputField> {
@override
Widget build(BuildContext context) {
return BaseTextField(
controller: _formattedController,
focusNode: widget.focusNode,
controller: widget.controller,
focusNode: _focusNode,
label: widget.label,
hintText: widget.hintText ?? _defaultHintText,
textInputAction: widget.textInputAction,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[\d,.]')),
FilteringTextInputFormatter.allow(
widget.currency == 'KRW'
? RegExp(r'[0-9]')
: RegExp(r'[0-9.]')
),
if (widget.currency == 'USD')
// USD의 경우 소수점 이하 2자리까지만 허용
TextInputFormatter.withFunction((oldValue, newValue) {
final text = newValue.text;
if (text.isEmpty) return newValue;
final parts = text.split('.');
if (parts.length > 2) {
// 소수점이 2개 이상인 경우 거부
return oldValue;
}
if (parts.length == 2 && parts[1].length > 2) {
// 소수점 이하가 2자리를 초과하는 경우 거부
return oldValue;
}
return newValue;
}),
],
prefixText: _prefixText,
onEditingComplete: widget.onEditingComplete,
enabled: widget.enabled,
onChanged: (value) {
final parsedValue = _parseValue(value);
if (parsedValue != null) {
widget.controller.text = parsedValue.toString();
widget.onChanged?.call(parsedValue);
} else {
widget.controller.text = '';
widget.onChanged?.call(null);
}
// 포맷팅 업데이트
if (parsedValue != null) {
final formattedText = _formatCurrency(parsedValue);
if (formattedText != value) {
_formattedController.value = TextEditingValue(
text: formattedText,
selection: TextSelection.collapsed(offset: formattedText.length),
);
}
}
widget.onChanged?.call(parsedValue);
},
validator: widget.validator ?? (value) {
if (value == null || value.isEmpty) {

View File

@@ -0,0 +1,117 @@
import 'package:flutter/material.dart';
import '../../../theme/app_colors.dart';
/// 통화 선택 위젯
/// KRW(원화)와 USD(달러) 중 선택할 수 있습니다.
class CurrencySelector extends StatelessWidget {
final String currency;
final ValueChanged<String> onChanged;
final bool isGlassmorphism;
const CurrencySelector({
super.key,
required this.currency,
required this.onChanged,
this.isGlassmorphism = false,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
_CurrencyOption(
label: '',
value: 'KRW',
isSelected: currency == 'KRW',
onTap: () => onChanged('KRW'),
isGlassmorphism: isGlassmorphism,
),
const SizedBox(width: 8),
_CurrencyOption(
label: '\$',
value: 'USD',
isSelected: currency == 'USD',
onTap: () => onChanged('USD'),
isGlassmorphism: isGlassmorphism,
),
],
);
}
}
/// 통화 옵션 버튼
class _CurrencyOption extends StatelessWidget {
final String label;
final String value;
final bool isSelected;
final VoidCallback onTap;
final bool isGlassmorphism;
const _CurrencyOption({
required this.label,
required this.value,
required this.isSelected,
required this.onTap,
required this.isGlassmorphism,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Expanded(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: _getBackgroundColor(theme),
borderRadius: BorderRadius.circular(12),
border: _getBorder(),
),
child: Center(
child: Text(
label,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: _getTextColor(),
),
),
),
),
),
);
}
Color _getBackgroundColor(ThemeData theme) {
if (isSelected) {
return isGlassmorphism
? theme.primaryColor
: const Color(0xFF3B82F6);
}
return isGlassmorphism
? AppColors.surfaceColorAlt
: Colors.grey.withValues(alpha: 0.1);
}
Border? _getBorder() {
if (isSelected || !isGlassmorphism) {
return null;
}
return Border.all(
color: AppColors.borderColor,
width: 1.5,
);
}
Color _getTextColor() {
if (isSelected) {
return Colors.white;
}
return isGlassmorphism
? AppColors.navyGray
: Colors.grey[600]!;
}
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../theme/app_colors.dart';
/// 날짜 선택 필드 위젯
/// 탭하면 날짜 선택기가 표시되며, 선택된 날짜를 보기 좋은 형식으로 표시합니다.
@@ -47,7 +48,7 @@ class DatePickerField extends StatelessWidget {
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
color: AppColors.darkNavy,
),
),
const SizedBox(height: 8),
@@ -82,10 +83,11 @@ class DatePickerField extends StatelessWidget {
child: Container(
padding: contentPadding ?? const EdgeInsets.all(16),
decoration: BoxDecoration(
color: backgroundColor ?? Colors.white,
color: backgroundColor ?? AppColors.surfaceColorAlt,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Colors.transparent,
color: AppColors.borderColor.withValues(alpha: 0.7),
width: 1.5,
),
),
child: Row(
@@ -96,8 +98,8 @@ class DatePickerField extends StatelessWidget {
style: TextStyle(
fontSize: 16,
color: enabled
? theme.colorScheme.onSurface
: theme.colorScheme.onSurface.withValues(alpha: 0.5),
? AppColors.textPrimary
: AppColors.textMuted,
),
),
),
@@ -105,8 +107,8 @@ class DatePickerField extends StatelessWidget {
Icons.calendar_today,
size: 20,
color: enabled
? theme.colorScheme.onSurface.withValues(alpha: 0.6)
: theme.colorScheme.onSurface.withValues(alpha: 0.3),
? AppColors.navyGray
: AppColors.textMuted,
),
],
),
@@ -227,8 +229,12 @@ class _DateRangeItem extends StatelessWidget {
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
color: AppColors.surfaceColorAlt,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppColors.borderColor.withValues(alpha: 0.7),
width: 1.5,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -237,7 +243,7 @@ class _DateRangeItem extends StatelessWidget {
label,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
color: AppColors.textSecondary,
),
),
const SizedBox(height: 4),
@@ -249,8 +255,8 @@ class _DateRangeItem extends StatelessWidget {
fontSize: 16,
fontWeight: FontWeight.w500,
color: date != null
? theme.colorScheme.onSurface
: theme.colorScheme.onSurface.withValues(alpha: 0.4),
? AppColors.textPrimary
: AppColors.textMuted,
),
),
],

View File

@@ -161,10 +161,10 @@ class AppSnackBar {
behavior: SnackBarBehavior.floating,
margin: showAtTop
? EdgeInsets.only(
top: MediaQuery.of(context).padding.top + 16,
top: MediaQuery.of(context).padding.top,
left: 16,
right: 16,
bottom: MediaQuery.of(context).size.height - 120,
bottom: MediaQuery.of(context).size.height - 100,
)
: const EdgeInsets.all(16),
padding: const EdgeInsets.symmetric(
@@ -222,10 +222,10 @@ class AppSnackBar {
behavior: SnackBarBehavior.floating,
margin: showAtTop
? EdgeInsets.only(
top: MediaQuery.of(context).padding.top + 16,
top: MediaQuery.of(context).padding.top,
left: 16,
right: 16,
bottom: MediaQuery.of(context).size.height - 120,
bottom: MediaQuery.of(context).size.height - 100,
)
: const EdgeInsets.all(16),
padding: const EdgeInsets.symmetric(

View File

@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../controllers/detail_screen_controller.dart';
import '../../theme/app_colors.dart';
import '../common/form_fields/currency_input_field.dart';
import '../common/form_fields/date_picker_field.dart';
@@ -19,9 +21,11 @@ class DetailEventSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
final baseColor = controller.getCardColor();
return Consumer<DetailScreenController>(
builder: (context, controller, child) {
final baseColor = controller.getCardColor();
return FadeTransition(
return FadeTransition(
opacity: fadeAnimation,
child: SlideTransition(
position: Tween<Offset>(
@@ -31,11 +35,21 @@ class DetailEventSection extends StatelessWidget {
parent: controller.animationController!,
curve: const Interval(0.4, 1.0, curve: Curves.easeOutCubic),
)),
child: Card(
elevation: 1,
shadowColor: Colors.black12,
shape: RoundedRectangleBorder(
child: Container(
decoration: BoxDecoration(
color: AppColors.glassCard,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppColors.glassBorder.withValues(alpha: 0.1),
width: 1,
),
boxShadow: [
BoxShadow(
color: AppColors.shadowBlack,
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Padding(
padding: const EdgeInsets.all(24),
@@ -66,6 +80,7 @@ class DetailEventSection extends StatelessWidget {
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.darkNavy,
),
),
],
@@ -93,14 +108,18 @@ class DetailEventSection extends StatelessWidget {
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
color: AppColors.infoColor.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.infoColor.withValues(alpha: 0.3),
width: 1,
),
),
child: Row(
children: [
Icon(
Icons.info_outline_rounded,
color: Colors.blue[700],
color: AppColors.infoColor,
size: 20,
),
const SizedBox(width: 8),
@@ -109,7 +128,8 @@ class DetailEventSection extends StatelessWidget {
'할인 또는 프로모션 가격을 설정하세요',
style: TextStyle(
fontSize: 14,
color: Colors.blue[700],
color: AppColors.darkNavy,
fontWeight: FontWeight.w500,
),
),
),
@@ -123,6 +143,10 @@ class DetailEventSection extends StatelessWidget {
endDate: controller.eventEndDate,
onStartDateSelected: (date) {
controller.eventStartDate = date;
// 시작일 설정 시 종료일이 없으면 자동으로 1달 후로 설정
if (date != null && controller.eventEndDate == null) {
controller.eventEndDate = date.add(const Duration(days: 30));
}
},
onEndDateSelected: (date) {
controller.eventEndDate = date;
@@ -138,6 +162,18 @@ class DetailEventSection extends StatelessWidget {
currency: controller.currency,
label: '이벤트 가격',
hintText: '할인된 가격을 입력하세요',
validator: controller.isEventActive
? (value) {
if (value == null || value.isEmpty) {
return '이벤트 가격을 입력해주세요';
}
final price = double.tryParse(value.replaceAll(',', ''));
if (price == null || price <= 0) {
return '올바른 가격을 입력해주세요';
}
return null;
}
: null,
),
const SizedBox(height: 16),
// 할인율 표시
@@ -156,6 +192,8 @@ class DetailEventSection extends StatelessWidget {
),
),
);
},
);
}
}
@@ -209,7 +247,7 @@ class _DiscountBadge extends StatelessWidget {
? '${discountAmount.toInt().toString()}원 절약'
: '\$${discountAmount.toStringAsFixed(2)} 절약',
style: TextStyle(
color: Colors.green[700],
color: const Color(0xFF15803D),
fontSize: 14,
fontWeight: FontWeight.w500,
),

View File

@@ -2,9 +2,13 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../controllers/detail_screen_controller.dart';
import '../../providers/category_provider.dart';
import '../../theme/app_colors.dart';
import '../common/form_fields/base_text_field.dart';
import '../common/form_fields/currency_input_field.dart';
import '../common/form_fields/date_picker_field.dart';
import '../common/form_fields/currency_selector.dart';
import '../common/form_fields/billing_cycle_selector.dart';
import '../common/form_fields/category_selector.dart';
/// 상세 화면 폼 섹션
/// 구독 정보를 편집할 수 있는 폼 필드들을 포함합니다.
@@ -22,9 +26,11 @@ class DetailFormSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
final baseColor = controller.getCardColor();
return Consumer<DetailScreenController>(
builder: (context, controller, child) {
final baseColor = controller.getCardColor();
return FadeTransition(
return FadeTransition(
opacity: fadeAnimation,
child: SlideTransition(
position: Tween<Offset>(
@@ -34,11 +40,21 @@ class DetailFormSection extends StatelessWidget {
parent: controller.animationController!,
curve: const Interval(0.3, 1.0, curve: Curves.easeOutCubic),
)),
child: Card(
elevation: 1,
shadowColor: Colors.black12,
shape: RoundedRectangleBorder(
child: Container(
decoration: BoxDecoration(
color: AppColors.glassCard,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppColors.glassBorder.withValues(alpha: 0.1),
width: 1,
),
boxShadow: [
BoxShadow(
color: AppColors.shadowBlack,
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Padding(
padding: const EdgeInsets.all(24),
@@ -86,11 +102,13 @@ class DetailFormSection extends StatelessWidget {
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.darkNavy,
),
),
const SizedBox(height: 8),
_CurrencySelector(
CurrencySelector(
currency: controller.currency,
isGlassmorphism: true,
onChanged: (value) {
controller.currency = value;
// 통화 변경시 금액 포맷 업데이트
@@ -121,12 +139,14 @@ class DetailFormSection extends StatelessWidget {
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.darkNavy,
),
),
const SizedBox(height: 8),
_BillingCycleSelector(
BillingCycleSelector(
billingCycle: controller.billingCycle,
baseColor: baseColor,
isGlassmorphism: true,
onChanged: (value) {
controller.billingCycle = value;
},
@@ -159,13 +179,15 @@ class DetailFormSection extends StatelessWidget {
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.darkNavy,
),
),
const SizedBox(height: 8),
_CategorySelector(
CategorySelector(
categories: categoryProvider.categories,
selectedCategoryId: controller.selectedCategoryId,
baseColor: baseColor,
isGlassmorphism: true,
onChanged: (categoryId) {
controller.selectedCategoryId = categoryId;
},
@@ -180,191 +202,8 @@ class DetailFormSection extends StatelessWidget {
),
),
);
}
}
/// 통화 선택기
class _CurrencySelector extends StatelessWidget {
final String currency;
final ValueChanged<String> onChanged;
const _CurrencySelector({
required this.currency,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
_CurrencyOption(
label: '',
value: 'KRW',
isSelected: currency == 'KRW',
onTap: () => onChanged('KRW'),
),
const SizedBox(width: 8),
_CurrencyOption(
label: '\$',
value: 'USD',
isSelected: currency == 'USD',
onTap: () => onChanged('USD'),
),
],
},
);
}
}
/// 통화 옵션
class _CurrencyOption extends StatelessWidget {
final String label;
final String value;
final bool isSelected;
final VoidCallback onTap;
const _CurrencyOption({
required this.label,
required this.value,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Expanded(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).primaryColor
: Colors.grey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: Text(
label,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: isSelected ? Colors.white : Colors.grey[600],
),
),
),
),
),
);
}
}
/// 결제 주기 선택기
class _BillingCycleSelector extends StatelessWidget {
final String billingCycle;
final Color baseColor;
final ValueChanged<String> onChanged;
const _BillingCycleSelector({
required this.billingCycle,
required this.baseColor,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final cycles = ['매월', '분기별', '반기별', '매년'];
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: cycles.map((cycle) {
final isSelected = billingCycle == cycle;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: InkWell(
onTap: () => onChanged(cycle),
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
),
decoration: BoxDecoration(
color: isSelected ? baseColor : Colors.grey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
cycle,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: isSelected ? Colors.white : Colors.grey[700],
),
),
),
),
);
}).toList(),
),
);
}
}
/// 카테고리 선택기
class _CategorySelector extends StatelessWidget {
final List<dynamic> categories;
final String? selectedCategoryId;
final Color baseColor;
final ValueChanged<String?> onChanged;
const _CategorySelector({
required this.categories,
required this.selectedCategoryId,
required this.baseColor,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 8,
runSpacing: 8,
children: categories.map((category) {
final isSelected = selectedCategoryId == category.id;
return InkWell(
onTap: () => onChanged(category.id),
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
decoration: BoxDecoration(
color: isSelected ? baseColor : Colors.grey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
category.icon,
style: const TextStyle(fontSize: 16),
),
const SizedBox(width: 6),
Text(
category.name,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: isSelected ? Colors.white : Colors.grey[700],
),
),
],
),
),
);
}).toList(),
);
}
}

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../../models/subscription_model.dart';
import '../../controllers/detail_screen_controller.dart';
@@ -24,10 +25,12 @@ class DetailHeaderSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
final baseColor = controller.getCardColor();
final gradient = controller.getGradient(baseColor);
return Consumer<DetailScreenController>(
builder: (context, controller, child) {
final baseColor = controller.getCardColor();
final gradient = controller.getGradient(baseColor);
return Container(
return Container(
height: 320,
decoration: BoxDecoration(gradient: gradient),
child: Stack(
@@ -118,8 +121,8 @@ class DetailHeaderSection extends StatelessWidget {
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: WebsiteIcon(
url: subscription.websiteUrl,
serviceName: subscription.serviceName,
url: controller.websiteUrlController.text,
serviceName: controller.serviceNameController.text,
size: 48,
),
),
@@ -131,7 +134,7 @@ class DetailHeaderSection extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
subscription.serviceName,
controller.serviceNameController.text,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w800,
@@ -148,7 +151,7 @@ class DetailHeaderSection extends StatelessWidget {
),
const SizedBox(height: 4),
Text(
'${subscription.billingCycle} 결제',
'${controller.billingCycle} 결제',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
@@ -174,20 +177,22 @@ class DetailHeaderSection extends StatelessWidget {
_InfoColumn(
label: '다음 결제일',
value: DateFormat('yyyy년 MM월 dd일')
.format(subscription.nextBillingDate),
.format(controller.nextBillingDate),
),
_InfoColumn(
label: '월 지출',
value: NumberFormat.currency(
locale: subscription.currency == 'KRW'
locale: controller.currency == 'KRW'
? 'ko_KR'
: 'en_US',
symbol: subscription.currency == 'KRW'
symbol: controller.currency == 'KRW'
? ''
: '\$',
decimalDigits:
subscription.currency == 'KRW' ? 0 : 2,
).format(subscription.monthlyCost),
controller.currency == 'KRW' ? 0 : 2,
).format(double.tryParse(
controller.monthlyCostController.text.replaceAll(',', '')
) ?? 0),
alignment: CrossAxisAlignment.end,
),
],
@@ -204,6 +209,8 @@ class DetailHeaderSection extends StatelessWidget {
],
),
);
},
);
}
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../../controllers/detail_screen_controller.dart';
import '../../theme/app_colors.dart';
import '../common/form_fields/base_text_field.dart';
import '../common/buttons/secondary_button.dart';
@@ -31,11 +32,21 @@ class DetailUrlSection extends StatelessWidget {
parent: controller.animationController!,
curve: const Interval(0.4, 1.0, curve: Curves.easeOutCubic),
)),
child: Card(
elevation: 1,
shadowColor: Colors.black12,
shape: RoundedRectangleBorder(
child: Container(
decoration: BoxDecoration(
color: AppColors.glassCard,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppColors.glassBorder.withValues(alpha: 0.1),
width: 1,
),
boxShadow: [
BoxShadow(
color: AppColors.shadowBlack,
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Padding(
padding: const EdgeInsets.all(24),
@@ -63,6 +74,7 @@ class DetailUrlSection extends StatelessWidget {
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.darkNavy,
),
),
],
@@ -78,7 +90,7 @@ class DetailUrlSection extends StatelessWidget {
keyboardType: TextInputType.url,
prefixIcon: Icon(
Icons.link_rounded,
color: Colors.grey[600],
color: AppColors.navyGray,
),
),
@@ -89,10 +101,10 @@ class DetailUrlSection extends StatelessWidget {
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.1),
color: AppColors.warningColor.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Colors.orange.withValues(alpha: 0.3),
color: AppColors.warningColor.withValues(alpha: 0.4),
width: 1,
),
),
@@ -103,7 +115,7 @@ class DetailUrlSection extends StatelessWidget {
children: [
Icon(
Icons.info_outline_rounded,
color: Colors.orange[700],
color: AppColors.warningColor,
size: 20,
),
const SizedBox(width: 8),
@@ -112,7 +124,7 @@ class DetailUrlSection extends StatelessWidget {
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.orange[700],
color: AppColors.darkNavy,
),
),
],
@@ -122,7 +134,8 @@ class DetailUrlSection extends StatelessWidget {
'이 서비스를 해지하려면 아래 링크를 통해 해지 페이지로 이동하세요.',
style: TextStyle(
fontSize: 14,
color: Colors.grey[700],
color: AppColors.darkNavy,
fontWeight: FontWeight.w500,
height: 1.5,
),
),
@@ -131,7 +144,7 @@ class DetailUrlSection extends StatelessWidget {
text: '해지 페이지로 이동',
icon: Icons.open_in_new_rounded,
onPressed: controller.openCancellationPage,
color: Colors.orange[700],
color: AppColors.warningColor,
),
],
),
@@ -144,14 +157,18 @@ class DetailUrlSection extends StatelessWidget {
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
color: AppColors.infoColor.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.infoColor.withValues(alpha: 0.3),
width: 1,
),
),
child: Row(
children: [
Icon(
Icons.auto_fix_high_rounded,
color: Colors.blue[700],
color: AppColors.infoColor,
size: 20,
),
const SizedBox(width: 8),
@@ -160,7 +177,8 @@ class DetailUrlSection extends StatelessWidget {
'URL이 비어있으면 서비스명을 기반으로 자동 매칭됩니다',
style: TextStyle(
fontSize: 14,
color: Colors.blue[700],
color: AppColors.darkNavy,
fontWeight: FontWeight.w500,
),
),
),

View File

@@ -68,23 +68,12 @@ class _FloatingNavigationBarState extends State<FloatingNavigationBar>
bottom: 20,
left: 20,
right: 20,
height: 88,
child: Transform.translate(
offset: Offset(0, 100 * (1 - _animation.value)),
child: Opacity(
opacity: _animation.value,
child: Stack(
children: [
// 차단 레이어 - 크기 명시
Positioned.fill(
child: Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(24),
),
),
),
// 글래스모피즘 레이어
GlassmorphismCard(
child: GlassmorphismCard(
padding:
const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
borderRadius: 24,
@@ -123,8 +112,6 @@ class _FloatingNavigationBarState extends State<FloatingNavigationBar>
],
),
),
],
),
),
),
);

View File

@@ -174,10 +174,17 @@ class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque, // translucent에서 opaque로 변경하여 이벤트 충돌 방지
onTapDown: _handleTapDown,
onTapUp: _handleTapUp,
onTapUp: (details) {
_handleTapUp(details);
// onTap 콜백 실행
if (widget.onTap != null) {
print('[AnimatedGlassmorphismCard] onTap 콜백 실행');
widget.onTap!();
}
},
onTapCancel: _handleTapCancel,
onTap: widget.onTap,
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
@@ -191,6 +198,7 @@ class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
borderRadius: widget.borderRadius,
blur: _blurAnimation.value,
opacity: widget.opacity,
onTap: null, // GlassmorphismCard의 onTap은 사용하지 않음
child: widget.child,
),
);

View File

@@ -7,7 +7,6 @@ import '../widgets/native_ad_widget.dart';
import '../widgets/main_summary_card.dart';
import '../widgets/subscription_list_widget.dart';
import '../widgets/empty_state_widget.dart';
import '../widgets/glassmorphic_app_bar.dart';
import '../theme/app_colors.dart';
class HomeContent extends StatelessWidget {
@@ -67,10 +66,10 @@ class HomeContent extends StatelessWidget {
controller: scrollController,
physics: const BouncingScrollPhysics(),
slivers: [
const GlassmorphicSliverAppBar(
title: '',
pinned: true,
expandedHeight: kToolbarHeight,
SliverToBoxAdapter(
child: SizedBox(
height: kToolbarHeight + MediaQuery.of(context).padding.top,
),
),
const SliverToBoxAdapter(
child: NativeAdWidget(key: ValueKey('home_ad')),
@@ -105,7 +104,9 @@ class HomeContent extends StatelessWidget {
parent: slideController, curve: Curves.easeOutCubic)),
child: Text(
'나의 구독 서비스',
style: Theme.of(context).textTheme.titleLarge,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: AppColors.darkNavy,
),
),
),
SlideTransition(
@@ -143,7 +144,7 @@ class HomeContent extends StatelessWidget {
),
SliverToBoxAdapter(
child: SizedBox(
height: 100 + MediaQuery.of(context).padding.bottom,
height: 120 + MediaQuery.of(context).padding.bottom,
),
),
],

View File

@@ -37,185 +37,196 @@ class MainScreenSummaryCard extends StatelessWidget {
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 4),
child: GlassmorphismCard(
borderRadius: 24,
blur: 15,
backgroundColor: AppColors.glassCard,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: AppColors.mainGradient.map((color) => color.withValues(alpha: 0.2)).toList(),
borderRadius: 24,
blur: 15,
backgroundColor: AppColors.glassCard,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: AppColors.mainGradient
.map((color) => color.withValues(alpha: 0.2))
.toList(),
),
border: Border.all(
color: AppColors.glassBorder,
width: 1,
),
child: Container(
width: double.infinity,
constraints: BoxConstraints(
minHeight: 180,
maxHeight: activeEvents > 0 ? 300 : 240,
),
border: Border.all(
color: AppColors.glassBorder,
width: 1,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
color: Colors.transparent,
),
child: Container(
width: double.infinity,
constraints: BoxConstraints(
minHeight: 180,
maxHeight: activeEvents > 0 ? 300 : 240,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
color: Colors.transparent,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(24),
child: Stack(
children: [
// 애니메이션 웨이브 배경
Positioned.fill(
child: AnimatedWaveBackground(
controller: waveController,
pulseController: pulseController,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(24),
child: Stack(
children: [
// 애니메이션 웨이브 배경
Positioned.fill(
child: AnimatedWaveBackground(
controller: waveController,
pulseController: pulseController,
),
Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'이번 달 총 구독 비용',
style: TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
fontSize: 15,
fontWeight: FontWeight.w500,
),
Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'이번 달 총 구독 비용',
style: TextStyle(
color: AppColors
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
NumberFormat.currency(
locale: 'ko_KR',
symbol: '',
decimalDigits: 0,
).format(monthlyCost),
style: const TextStyle(
color: AppColors
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
fontSize: 32,
fontWeight: FontWeight.bold,
letterSpacing: -1,
),
),
),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
NumberFormat.currency(
locale: 'ko_KR',
symbol: '',
decimalDigits: 0,
).format(monthlyCost),
style: const TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
fontSize: 32,
fontWeight: FontWeight.bold,
letterSpacing: -1,
),
),
const SizedBox(width: 4),
Text(
'',
style: TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
_buildInfoBox(
context,
title: '연간 구독 비용',
value: '${NumberFormat.currency(
locale: 'ko_KR',
symbol: '',
decimalDigits: 0,
).format(yearlyCost)}',
),
const SizedBox(width: 16),
_buildInfoBox(
context,
title: '총 구독 서비스',
value: '$totalSubscriptions개',
),
],
),
// 이벤트 절약액 표시
if (activeEvents > 0) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 14),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.white.withValues(alpha: 0.2),
Colors.white.withValues(alpha: 0.15),
],
),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.primaryColor.withValues(alpha: 0.3),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.25),
shape: BoxShape.circle,
),
child: const Icon(
Icons.local_offer_rounded,
size: 14,
color: AppColors.primaryColor, // color.md 가이드: 밝은 배경 위 딥 블루 아이콘
),
),
const SizedBox(width: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'이벤트 할인 중',
style: TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Row(
children: [
Text(
NumberFormat.currency(
locale: 'ko_KR',
symbol: '',
decimalDigits: 0,
).format(eventSavings),
style: const TextStyle(
color: AppColors.primaryColor, // color.md 가이드: 밝은 배경 위 딥 블루 강조
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
Text(
' 절약 ($activeEvents개)',
style: TextStyle(
color: AppColors.navyGray, // color.md 가이드: 밝은 배경 위 서브 텍스트
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
],
),
],
const SizedBox(width: 4),
Text(
'',
style: TextStyle(
color: AppColors
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
_buildInfoBox(
context,
title: '예상 연간 구독 비용',
value: '${NumberFormat.currency(
locale: 'ko_KR',
symbol: '',
decimalDigits: 0,
).format(yearlyCost)}',
),
const SizedBox(width: 16),
_buildInfoBox(
context,
title: '총 구독 서비스',
value: '$totalSubscriptions개',
),
],
),
// 이벤트 절약액 표시
if (activeEvents > 0) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(
vertical: 10, horizontal: 14),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.white.withValues(alpha: 0.2),
Colors.white.withValues(alpha: 0.15),
],
),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.primaryColor
.withValues(alpha: 0.3),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.25),
shape: BoxShape.circle,
),
child: const Icon(
Icons.local_offer_rounded,
size: 14,
color: AppColors
.primaryColor, // color.md 가이드: 밝은 배경 위 딥 블루 아이콘
),
),
const SizedBox(width: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'이벤트 할인 중',
style: TextStyle(
color: AppColors
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Row(
children: [
Text(
NumberFormat.currency(
locale: 'ko_KR',
symbol: '',
decimalDigits: 0,
).format(eventSavings),
style: const TextStyle(
color: AppColors
.primaryColor, // color.md 가이드: 밝은 배경 위 딥 블루 강조
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
Text(
' 절약 ($activeEvents개)',
style: TextStyle(
color: AppColors
.navyGray, // color.md 가이드: 밝은 배경 위 서브 텍스트
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
],
),
],
),
),
],
),
],
),
],
),
),
],
),
),
),
),
),
);
@@ -236,7 +247,7 @@ class MainScreenSummaryCard extends StatelessWidget {
Text(
title,
style: TextStyle(
color: AppColors.navyGray, // color.md 가이드: 밝은 배경 위 서브 텍스트
color: AppColors.navyGray, // color.md 가이드: 밝은 배경 위 서브 텍스트
fontSize: 12,
fontWeight: FontWeight.w500,
),
@@ -245,7 +256,7 @@ class MainScreenSummaryCard extends StatelessWidget {
Text(
value,
style: const TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
color: AppColors.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
fontSize: 14,
fontWeight: FontWeight.bold,
),

View File

@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../models/subscription_model.dart';
import '../providers/category_provider.dart';
import 'website_icon.dart';
import 'app_navigator.dart';
import '../theme/app_colors.dart';
@@ -8,10 +10,12 @@ import 'glassmorphism_card.dart';
class SubscriptionCard extends StatefulWidget {
final SubscriptionModel subscription;
final VoidCallback? onTap;
const SubscriptionCard({
super.key,
required this.subscription,
this.onTap,
});
@override
@@ -190,6 +194,34 @@ class _SubscriptionCardState extends State<SubscriptionCard>
return false;
}
// 카테고리별 그라데이션 색상 생성
List<Color> _getCategoryGradientColors(BuildContext context) {
try {
if (widget.subscription.categoryId == null) {
return AppColors.blueGradient;
}
final categoryProvider = context.watch<CategoryProvider>();
final category = categoryProvider.getCategoryById(widget.subscription.categoryId!);
if (category == null) {
return AppColors.blueGradient;
}
final categoryColor = Color(
int.parse(category.color.replaceAll('#', '0xFF'))
);
return [
categoryColor,
categoryColor.withValues(alpha: 0.8),
];
} catch (e) {
// 색상 파싱 실패 시 기본 파란색 그라데이션 반환
return AppColors.blueGradient;
}
}
@override
Widget build(BuildContext context) {
@@ -200,53 +232,39 @@ class _SubscriptionCardState extends State<SubscriptionCard>
child: MouseRegion(
onEnter: (_) => _onHover(true),
onExit: (_) => _onHover(false),
child: AnimatedBuilder(
animation: _hoverController,
builder: (context, child) {
final scale = 1.0 + (0.02 * _hoverController.value);
child: AnimatedGlassmorphismCard(
padding: EdgeInsets.zero,
borderRadius: 16,
blur: _isHovering ? 15 : 10,
width: double.infinity, // 전체 너비를 차지하도록 설정
onTap: widget.onTap ?? () async {
await AppNavigator.toDetail(context, widget.subscription);
},
child: Column(
children: [
// 그라데이션 상단 바 효과
AnimatedContainer(
duration: const Duration(milliseconds: 200),
height: 4,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: widget.subscription.isCurrentlyInEvent
? [
const Color(0xFFFF6B6B),
const Color(0xFFFF8787),
]
: isNearBilling
? AppColors.amberGradient
: _getCategoryGradientColors(context),
begin: Alignment.centerLeft,
end: Alignment.centerRight,
),
),
),
return Transform.scale(
scale: scale,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () async {
await AppNavigator.toDetail(context, widget.subscription);
},
splashColor: AppColors.primaryColor.withValues(alpha: 0.1),
highlightColor: AppColors.primaryColor.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(16),
child: AnimatedGlassmorphismCard(
onTap: () {}, // onTap은 이미 InkWell에서 처리됨
padding: EdgeInsets.zero,
borderRadius: 16,
blur: _isHovering ? 15 : 10,
width: double.infinity, // 전체 너비를 차지하도록 설정
child: Column(
children: [
// 그라데이션 상단 바 효과
AnimatedContainer(
duration: const Duration(milliseconds: 200),
height: 4,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: widget.subscription.isCurrentlyInEvent
? [
const Color(0xFFFF6B6B),
const Color(0xFFFF8787),
]
: isNearBilling
? AppColors.amberGradient
: AppColors.blueGradient,
begin: Alignment.centerLeft,
end: Alignment.centerRight,
),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 서비스 아이콘
@@ -304,7 +322,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
borderRadius:
BorderRadius.circular(12),
),
child: Row(
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
@@ -526,15 +544,10 @@ class _SubscriptionCardState extends State<SubscriptionCard>
),
),
],
),
),
],
),
),
),
),
);
},
],
),
),
),
);

View File

@@ -42,10 +42,8 @@ class SubscriptionListWidget extends StatelessWidget {
child: CategoryHeaderWidget(
categoryName: category,
subscriptionCount: subscriptions.length,
totalCost: subscriptions.fold(
0.0,
(sum, sub) => sum + sub.monthlyCost,
),
totalCostUSD: _calculateTotalByCurrency(subscriptions, 'USD'),
totalCostKRW: _calculateTotalByCurrency(subscriptions, 'KRW'),
),
),
// 카테고리별 구독 목록
@@ -87,10 +85,7 @@ class SubscriptionListWidget extends StatelessWidget {
child: SwipeableSubscriptionCard(
subscription: subscriptions[subIndex],
onTap: () {
AppNavigator.toDetail(context, subscriptions[subIndex]);
},
onEdit: () {
// 편집 화면으로 이동
print('[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨');
AppNavigator.toDetail(context, subscriptions[subIndex]);
},
onDelete: () async {
@@ -111,7 +106,7 @@ class SubscriptionListWidget extends StatelessWidget {
);
if (context.mounted) {
AppSnackBar.showSuccess(
AppSnackBar.showError(
context: context,
message: '${subscriptions[subIndex].serviceName} 구독이 삭제되었습니다.',
icon: Icons.delete_forever_rounded,
@@ -134,6 +129,13 @@ class SubscriptionListWidget extends StatelessWidget {
),
);
}
/// 특정 통화의 총 합계를 계산합니다.
double _calculateTotalByCurrency(List<SubscriptionModel> subscriptions, String currency) {
return subscriptions
.where((sub) => sub.currency == currency)
.fold(0.0, (sum, sub) => sum + sub.monthlyCost);
}
}
/// 여러 Sliver 위젯을 하나의 위젯으로 감싸는 도우미 위젯

View File

@@ -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(),
),
],
);
}
}
}