diff --git a/doc/color.md b/doc/color.md index b358945..174753e 100644 --- a/doc/color.md +++ b/doc/color.md @@ -1,58 +1,79 @@ -## 구독관리 앱 글래스모피어즘 색상 가이드 -**신뢰성, 편안함, 트렌드함**을 모두 잡는 컬러 조합 추천 +# 구독관리 앱 글래스모피어즘 컬러 & 텍스트 컬러 가이드 -### 1. 컬러 선정 원칙 +구독관리 앱에 글래스모피어즘을 적용할 때, **신뢰성, 편안함, 트렌드함**을 모두 잡으면서도 **텍스트 가독성**을 최우선으로 고려한 컬러 팔레트와 활용법을 안내합니다. -- **신뢰성:** 블루 계열, 그레이, 화이트 등 안정적이고 전문적인 느낌의 색상 -- **편안함:** 저채도 파스텔, 연한 블루·민트, 따뜻한 베이지 등 눈에 부담 없는 색상 -- **트렌드함:** 그라디언트, 반투명 레이어, 약간의 네온 포인트 등 현대적 감각 +## 1. 컬러 팔레트 제안 -### 2. 추천 컬러 팔레트 +| 용도 | 컬러명 | Hex 코드 | 설명/느낌 | +|--------------|--------------|--------------|--------------------------| +| 메인 | Deep Blue | #2563eb | 신뢰, 포인트 | +| 서브 | Sky Blue | #60a5fa | 트렌디, 그라디언트 | +| 포인트 | Soft Mint | #38bdf8 | 상쾌함, 포인트 | +| 배경 | Light Gray | #f1f5f9 | 편안함, 밝은 배경 | +| 글래스 효과 | White Glass | #ffffff(투명)| 반투명 글래스 효과 | +| 포인트 | Pink Accent | #f472b6 | 트렌디, 액센트 | +| 그림자 | Shadow Black | rgba(0,0,0,0.08) | 깊이감 부여 | -| 용도 | 추천 색상 예시 (Hex) | 설명 | -|--------------|-------------------------------|---------------------------------------| -| 메인 | #2563eb, #60a5fa, #e0e7ef | 신뢰감 주는 블루 계열 그라디언트 | -| 서브 | #f9fafb, #f1f5f9, #f3f4f6 | 밝은 화이트·그레이, 편안한 배경 | -| 포인트 | #38bdf8, #7dd3fc, #f472b6 | 트렌디한 민트, 연핑크, 밝은 블루 | -| 테두리/블러 | rgba(255,255,255,0.3) | 글래스 효과용 반투명 화이트 | -| 그림자 | rgba(0,0,0,0.08) | 부드러운 깊이감 부여 | +## 2. 텍스트 색상 가이드 -### 3. 실전 적용 예시 +밝은 배경(예: #f1f5f9, #ffffff(투명)) 위에는 **어두운 텍스트**를, +진한 컬러(예: #2563eb, #38bdf8) 위에는 **밝은 텍스트**를 사용해야 가독성이 좋습니다. -- **배경:** - 연한 블루(#e0e7ef) 또는 밝은 그레이(#f9fafb) -- **글래스 카드:** - 반투명 화이트(예: rgba(255,255,255,0.2)), 블루 그라디언트 테두리 -- **포인트 버튼:** - 밝은 민트(#38bdf8) 또는 연핑크(#f472b6) -- **아이콘/텍스트:** - 진한 블루(#2563eb), 다크 그레이(#334155) -- **그라디언트 예시:** - LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFF2563eb), Color(0xFF60a5fa), Color(0xFFe0e7ef)], - ) +| 배경 컬러 | 추천 텍스트 컬러 | 용도/설명 | +|------------------|----------------------|-----------------------------------| +| Light Gray (#f1f5f9) | Dark Navy (#1e293b) | 메인 텍스트, 타이틀, 버튼 | +| White Glass (투명) | Deep Blue (#2563eb) | 강조 텍스트, 버튼 | +| Deep Blue (#2563eb) | Pure White (#ffffff) | 버튼, 반전 텍스트 | +| Sky Blue (#60a5fa) | Navy Gray (#334155) | 서브 텍스트, 부가 설명 | +| Soft Mint (#38bdf8) | Navy Gray (#334155) | 포인트 텍스트 | +| Pink Accent (#f472b6)| Deep Blue (#2563eb) | 강조, 포인트 텍스트 | -### 4. 참고 팁 +## 3. 실전 적용 예시 -- 글래스모피어즘은 **투명도·블러**와 함께 **밝고 깨끗한 색상**을 조합하면 신뢰감과 트렌디함을 동시에 줄 수 있습니다. -- 포인트 컬러를 너무 강하게 쓰기보다는, 전체적으로 **밝고 부드러운 톤**에 약간의 컬러만 더하는 것이 편안함을 극대화합니다. -- 실제 인기 앱(Reflect, T.RICKS, Coffee 등)도 블루·화이트·민트 계열을 주로 활용합니다. +- **배경**: Light Gray (#f1f5f9) +- **글래스 카드**: White Glass (rgba(255,255,255,0.2)), 테두리 Deep Blue (#2563eb) +- **메인 텍스트**: Dark Navy (#1e293b) +- **서브/설명 텍스트**: Navy Gray (#334155) +- **버튼 배경**: Deep Blue (#2563eb) +- **버튼 텍스트**: Pure White (#ffffff) +- **포인트/액센트**: Soft Mint (#38bdf8), Pink Accent (#f472b6) -### 5. 컬러 팔레트 예시 +## 4. 그라디언트 및 글래스 효과 예시 -| 이름 | Hex 코드 | 용도/느낌 | -|-------------|------------|-------------------| -| Deep Blue | #2563eb | 신뢰, 메인 | -| Sky Blue | #60a5fa | 트렌드, 그라디언트| -| Soft Mint | #38bdf8 | 포인트, 상쾌함 | -| Light Gray | #f1f5f9 | 배경, 편안함 | -| White Glass | #ffffff(투명도) | 글래스 효과 | -| Pink Accent | #f472b6 | 포인트, 트렌디 | +```dart +// Flutter 예시 (Dart) +LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Color(0xFF2563eb), + Color(0xFF60a5fa), + Color(0xFFe0e7ef), + ], +) +``` +- 글래스 카드 배경: rgba(255,255,255,0.2) + blur + border(Deep Blue) +- 텍스트: #1e293b(진한 네이비) 또는 #2563eb(딥블루) 사용 -### 6. 마무리 +## 5. 디자인 팁 -- **블루+화이트+민트** 조합은 신뢰성, 편안함, 트렌드함을 모두 만족시킵니다. -- 글래스모피어즘 효과와 함께라면, 위 팔레트로 세련되고 현대적인 구독관리 앱 UI를 완성할 수 있습니다. -- 실제 적용 시, 밝은 배경과 부드러운 그라디언트, 포인트 컬러를 적절히 조합해보세요. \ No newline at end of file +- **텍스트 대비**를 항상 체크하세요. + 밝은 배경에는 어두운 텍스트, 진한 배경에는 밝은 텍스트! +- **포인트 컬러**는 버튼, 아이콘, 강조 텍스트에만 제한적으로 사용하면 세련됨이 살아납니다. +- **글래스 효과**는 투명도와 블러, 그리고 경계선 컬러(예: #2563eb, #60a5fa)로 깊이감을 더하세요. + +## 6. 컬러/텍스트 조합 요약표 + +| 배경색 | 텍스트색 | 용도 예시 | +|------------------|------------------|--------------------| +| #f1f5f9 | #1e293b | 메인 타이틀, 내용 | +| #ffffff(투명) | #2563eb | 카드 내 강조 | +| #2563eb | #ffffff | 버튼, 반전 강조 | +| #60a5fa | #334155 | 서브, 설명 | +| #38bdf8 | #334155 | 포인트, 서브텍스트 | + +## 결론 + +- **블루+화이트+민트** 조합과, **밝은 배경+어두운 텍스트** 원칙으로 신뢰성, 편안함, 트렌드함, 가독성 모두 챙길 수 있습니다. +- 실제 앱에 적용할 때는 위 표를 참고해 각 상황별로 텍스트 컬러를 꼭 맞춰주세요. +- 글래스모피어즘 효과와 대비 높은 텍스트 조합으로, 세련되고 사용성 좋은 구독관리 앱을 완성할 수 있습니다. \ No newline at end of file diff --git a/lib/controllers/add_subscription_controller.dart b/lib/controllers/add_subscription_controller.dart new file mode 100644 index 0000000..86da619 --- /dev/null +++ b/lib/controllers/add_subscription_controller.dart @@ -0,0 +1,418 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:provider/provider.dart'; +import 'package:intl/intl.dart'; +import '../providers/subscription_provider.dart'; +import '../providers/category_provider.dart'; +import '../services/sms_service.dart'; +import '../services/subscription_url_matcher.dart'; + +/// AddSubscriptionScreen의 비즈니스 로직을 관리하는 Controller +class AddSubscriptionController { + final BuildContext context; + + // Form Key + final formKey = GlobalKey(); + + // Text Controllers + final serviceNameController = TextEditingController(); + final monthlyCostController = TextEditingController(); + final nextBillingDateController = TextEditingController(); + final websiteUrlController = TextEditingController(); + final eventPriceController = TextEditingController(); + + // Form State + String billingCycle = '월간'; + String currency = 'KRW'; + DateTime? nextBillingDate; + bool isLoading = false; + String? selectedCategoryId; + + // Event State + bool isEventActive = false; + DateTime? eventStartDate = DateTime.now(); + DateTime? eventEndDate = DateTime.now().add(const Duration(days: 30)); + + // Focus Nodes + final serviceNameFocus = FocusNode(); + final monthlyCostFocus = FocusNode(); + final billingCycleFocus = FocusNode(); + final nextBillingDateFocus = FocusNode(); + final websiteUrlFocus = FocusNode(); + final categoryFocus = FocusNode(); + final currencyFocus = FocusNode(); + + // Animation Controller + AnimationController? animationController; + Animation? fadeAnimation; + Animation? slideAnimation; + + // Scroll Controller + final ScrollController scrollController = ScrollController(); + double scrollOffset = 0; + + // UI State + int currentEditingField = -1; + bool isSaveHovered = false; + + // Gradient Colors + final List gradientColors = [ + const Color(0xFF3B82F6), + const Color(0xFF0EA5E9), + const Color(0xFF06B6D4), + ]; + + AddSubscriptionController({required this.context}); + + /// 초기화 + void initialize({required TickerProvider vsync}) { + // 결제일 기본값을 오늘 날짜로 설정 + nextBillingDate = DateTime.now(); + + // 서비스명 컨트롤러에 리스너 추가 + serviceNameController.addListener(onServiceNameChanged); + + // 애니메이션 컨트롤러 초기화 + animationController = AnimationController( + vsync: vsync, + duration: const Duration(milliseconds: 800), + ); + + fadeAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: animationController!, + curve: Curves.easeIn, + )); + + slideAnimation = Tween( + begin: const Offset(0.0, 0.2), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: animationController!, + curve: Curves.easeOut, + )); + + // 스크롤 리스너 + scrollController.addListener(() { + scrollOffset = scrollController.offset; + }); + + // 애니메이션 시작 + animationController!.forward(); + } + + /// 리소스 정리 + void dispose() { + // Controllers + serviceNameController.dispose(); + monthlyCostController.dispose(); + nextBillingDateController.dispose(); + websiteUrlController.dispose(); + eventPriceController.dispose(); + + // Focus Nodes + serviceNameFocus.dispose(); + monthlyCostFocus.dispose(); + billingCycleFocus.dispose(); + nextBillingDateFocus.dispose(); + websiteUrlFocus.dispose(); + categoryFocus.dispose(); + currencyFocus.dispose(); + + // Animation + animationController?.dispose(); + + // Scroll + scrollController.dispose(); + } + + /// 서비스명 변경시 호출 + void onServiceNameChanged() { + autoSelectCategory(); + } + + /// 카테고리 자동 선택 + void autoSelectCategory() { + final categoryProvider = Provider.of(context, listen: false); + final categories = categoryProvider.categories; + + final serviceName = serviceNameController.text.toLowerCase(); + + // 서비스명에 기반한 카테고리 매칭 로직 + dynamic matchedCategory; + + // 엔터테인먼트 관련 키워드 + if (serviceName.contains('netflix') || + serviceName.contains('youtube') || + serviceName.contains('disney') || + serviceName.contains('왓챠') || + serviceName.contains('티빙') || + serviceName.contains('웨이브') || + serviceName.contains('coupang play') || + serviceName.contains('쿠팡플레이')) { + matchedCategory = categories.firstWhere( + (cat) => cat.name == '엔터테인먼트', + orElse: () => categories.first, + ); + } + // 음악 관련 키워드 + else if (serviceName.contains('spotify') || + serviceName.contains('apple music') || + serviceName.contains('멜론') || + serviceName.contains('지니') || + serviceName.contains('플로') || + serviceName.contains('벅스')) { + matchedCategory = categories.firstWhere( + (cat) => cat.name == '음악', + orElse: () => categories.first, + ); + } + // 생산성 관련 키워드 + else if (serviceName.contains('notion') || + serviceName.contains('microsoft') || + serviceName.contains('office') || + serviceName.contains('google') || + serviceName.contains('dropbox') || + serviceName.contains('icloud') || + serviceName.contains('adobe')) { + matchedCategory = categories.firstWhere( + (cat) => cat.name == '생산성', + orElse: () => categories.first, + ); + } + // 게임 관련 키워드 + else if (serviceName.contains('xbox') || + serviceName.contains('playstation') || + serviceName.contains('nintendo') || + serviceName.contains('steam') || + serviceName.contains('게임')) { + matchedCategory = categories.firstWhere( + (cat) => cat.name == '게임', + orElse: () => categories.first, + ); + } + // 교육 관련 키워드 + else if (serviceName.contains('coursera') || + serviceName.contains('udemy') || + serviceName.contains('인프런') || + serviceName.contains('패스트캠퍼스') || + serviceName.contains('클래스101')) { + matchedCategory = categories.firstWhere( + (cat) => cat.name == '교육', + orElse: () => categories.first, + ); + } + // 쇼핑 관련 키워드 + else if (serviceName.contains('쿠팡') || + serviceName.contains('coupang') || + serviceName.contains('amazon') || + serviceName.contains('네이버') || + serviceName.contains('11번가')) { + matchedCategory = categories.firstWhere( + (cat) => cat.name == '쇼핑', + orElse: () => categories.first, + ); + } + + if (matchedCategory != null) { + selectedCategoryId = matchedCategory.id; + } + } + + /// SMS 스캔 + Future scanSMS({required Function setState}) async { + if (kIsWeb) return; + + setState(() => isLoading = true); + + try { + if (!await SMSService.hasSMSPermission()) { + final granted = await SMSService.requestSMSPermission(); + if (!granted) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Row( + children: [ + Icon(Icons.error_outline, color: Colors.white), + SizedBox(width: 12), + Expanded(child: Text('SMS 권한이 필요합니다.')), + ], + ), + behavior: SnackBarBehavior.floating, + backgroundColor: Colors.red, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + ); + } + return; + } + } + + final subscriptions = await SMSService.scanSubscriptions(); + if (subscriptions.isEmpty) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Row( + children: [ + Icon(Icons.info_outline, color: Colors.white), + SizedBox(width: 12), + Expanded(child: Text('구독 관련 SMS를 찾을 수 없습니다.')), + ], + ), + behavior: SnackBarBehavior.floating, + backgroundColor: Colors.orange, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + ); + } + return; + } + + final subscription = subscriptions.first; + setState(() { + serviceNameController.text = subscription['serviceName'] ?? ''; + + // 비용 처리 및 통화 단위 자동 감지 + final costValue = subscription['monthlyCost']?.toString() ?? ''; + + if (costValue.isNotEmpty) { + // 달러 표시가 있거나 소수점이 있으면 달러로 판단 + if (costValue.contains('\$') || costValue.contains('.')) { + currency = 'USD'; + String numericValue = costValue.replaceAll('\$', '').trim(); + if (!numericValue.contains('.')) { + numericValue = '$numericValue.00'; + } + final double parsedValue = + double.tryParse(numericValue.replaceAll(',', '')) ?? 0.0; + monthlyCostController.text = + NumberFormat('#,##0.00').format(parsedValue); + } else { + currency = 'KRW'; + String numericValue = + costValue.replaceAll('₩', '').replaceAll(',', '').trim(); + final int parsedValue = int.tryParse(numericValue) ?? 0; + monthlyCostController.text = + NumberFormat.decimalPattern().format(parsedValue); + } + } else { + monthlyCostController.text = ''; + } + + billingCycle = subscription['billingCycle'] ?? '월간'; + nextBillingDate = subscription['nextBillingDate'] != null + ? DateTime.parse(subscription['nextBillingDate']) + : DateTime.now(); + + // 서비스명이 있으면 URL 자동 매칭 시도 + if (subscription['serviceName'] != null && + subscription['serviceName'].isNotEmpty) { + final suggestedUrl = + SubscriptionUrlMatcher.suggestUrl(subscription['serviceName']); + if (suggestedUrl != null) { + websiteUrlController.text = suggestedUrl; + } + + // 서비스명 기반으로 카테고리 자동 선택 + autoSelectCategory(); + } + + // 애니메이션 재생 + animationController!.reset(); + animationController!.forward(); + }); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.error_outline, color: Colors.white), + const SizedBox(width: 12), + Expanded(child: Text('SMS 스캔 중 오류 발생: $e')), + ], + ), + behavior: SnackBarBehavior.floating, + backgroundColor: Colors.red, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + ); + } + } finally { + if (context.mounted) { + setState(() => isLoading = false); + } + } + } + + /// 구독 저장 + Future saveSubscription({required Function setState}) async { + if (formKey.currentState!.validate() && nextBillingDate != null) { + setState(() { + isLoading = true; + }); + + try { + // 콤마 제거하고 숫자만 추출 + final monthlyCost = + double.parse(monthlyCostController.text.replaceAll(',', '')); + + // 이벤트 가격 파싱 + double? eventPrice; + if (isEventActive && eventPriceController.text.isNotEmpty) { + eventPrice = double.tryParse( + eventPriceController.text.replaceAll(',', '') + ); + } + + await Provider.of(context, listen: false) + .addSubscription( + serviceName: serviceNameController.text.trim(), + monthlyCost: monthlyCost, + billingCycle: billingCycle, + nextBillingDate: nextBillingDate!, + websiteUrl: websiteUrlController.text.trim(), + categoryId: selectedCategoryId, + currency: currency, + isEventActive: isEventActive, + eventStartDate: eventStartDate, + eventEndDate: eventEndDate, + eventPrice: eventPrice, + ); + + if (context.mounted) { + Navigator.pop(context, true); // 성공 여부 반환 + } + } catch (e) { + setState(() { + isLoading = false; + }); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('저장 중 오류가 발생했습니다: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } else { + scrollController.animateTo( + 0.0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + } +} \ No newline at end of file diff --git a/lib/controllers/detail_screen_controller.dart b/lib/controllers/detail_screen_controller.dart new file mode 100644 index 0000000..1813514 --- /dev/null +++ b/lib/controllers/detail_screen_controller.dart @@ -0,0 +1,422 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../models/subscription_model.dart'; +import '../models/category_model.dart'; +import '../providers/subscription_provider.dart'; +import '../providers/category_provider.dart'; +import '../services/subscription_url_matcher.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:intl/intl.dart'; + +/// DetailScreen의 비즈니스 로직을 관리하는 Controller +class DetailScreenController { + final BuildContext context; + final SubscriptionModel subscription; + + // Text Controllers + late TextEditingController serviceNameController; + late TextEditingController monthlyCostController; + late TextEditingController websiteUrlController; + late TextEditingController eventPriceController; + + // Form State + late String billingCycle; + late DateTime nextBillingDate; + String? selectedCategoryId; + late String currency; + bool isLoading = false; + + // Event State + late bool isEventActive; + DateTime? eventStartDate; + DateTime? eventEndDate; + + // Focus Nodes + final serviceNameFocus = FocusNode(); + final monthlyCostFocus = FocusNode(); + final billingCycleFocus = FocusNode(); + final nextBillingDateFocus = FocusNode(); + final websiteUrlFocus = FocusNode(); + final categoryFocus = FocusNode(); + final currencyFocus = FocusNode(); + + // UI State + final ScrollController scrollController = ScrollController(); + double scrollOffset = 0; + int currentEditingField = -1; + bool isDeleteHovered = false; + bool isSaveHovered = false; + bool isCancelHovered = false; + + // Animation Controller + AnimationController? animationController; + Animation? fadeAnimation; + Animation? slideAnimation; + Animation? rotateAnimation; + + DetailScreenController({ + required this.context, + required this.subscription, + }); + + /// 초기화 + void initialize({required TickerProvider vsync}) { + // Text Controllers 초기화 + serviceNameController = TextEditingController(text: subscription.serviceName); + monthlyCostController = TextEditingController(text: subscription.monthlyCost.toString()); + websiteUrlController = TextEditingController(text: subscription.websiteUrl ?? ''); + eventPriceController = TextEditingController(); + + // Form State 초기화 + billingCycle = subscription.billingCycle; + nextBillingDate = subscription.nextBillingDate; + selectedCategoryId = subscription.categoryId; + currency = subscription.currency; + + // Event State 초기화 + isEventActive = subscription.isEventActive; + eventStartDate = subscription.eventStartDate; + eventEndDate = subscription.eventEndDate; + + // 이벤트 가격 초기화 + if (subscription.eventPrice != null) { + if (currency == 'KRW') { + eventPriceController.text = NumberFormat.decimalPattern() + .format(subscription.eventPrice!.toInt()); + } else { + eventPriceController.text = + NumberFormat('#,##0.00').format(subscription.eventPrice!); + } + } + + // 통화 단위에 따른 금액 표시 형식 조정 + _updateMonthlyCostFormat(); + + // 애니메이션 초기화 + animationController = AnimationController( + duration: const Duration(milliseconds: 800), + vsync: vsync, + ); + + fadeAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: animationController!, + curve: Curves.easeInOut, + )); + + slideAnimation = Tween( + begin: const Offset(0.0, 0.3), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: animationController!, + curve: Curves.easeOutCubic, + )); + + rotateAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: animationController!, + curve: Curves.easeOutCubic, + )); + + // 애니메이션 시작 + animationController!.forward(); + + // 서비스명 변경 감지 리스너 + serviceNameController.addListener(onServiceNameChanged); + + // 스크롤 리스너 + scrollController.addListener(() { + scrollOffset = scrollController.offset; + }); + } + + /// 리소스 정리 + void dispose() { + // Controllers + serviceNameController.dispose(); + monthlyCostController.dispose(); + websiteUrlController.dispose(); + eventPriceController.dispose(); + + // Focus Nodes + serviceNameFocus.dispose(); + monthlyCostFocus.dispose(); + billingCycleFocus.dispose(); + nextBillingDateFocus.dispose(); + websiteUrlFocus.dispose(); + categoryFocus.dispose(); + currencyFocus.dispose(); + + // Animation + animationController?.dispose(); + + // Scroll + scrollController.dispose(); + } + + /// 통화 단위에 따른 금액 표시 형식 업데이트 + void _updateMonthlyCostFormat() { + if (currency == 'KRW') { + // 원화는 소수점 없이 표시 + final intValue = subscription.monthlyCost.toInt(); + monthlyCostController.text = NumberFormat.decimalPattern().format(intValue); + } else { + // 달러는 소수점 2자리까지 표시 + monthlyCostController.text = NumberFormat('#,##0.00').format(subscription.monthlyCost); + } + } + + /// 서비스명 변경시 카테고리 자동 선택 + void onServiceNameChanged() { + autoSelectCategory(); + } + + /// 카테고리 자동 선택 + void autoSelectCategory() { + final categoryProvider = Provider.of(context, listen: false); + final categories = categoryProvider.categories; + + final serviceName = serviceNameController.text.toLowerCase(); + + // 서비스명에 기반한 카테고리 매칭 로직 + CategoryModel? matchedCategory; + + // 엔터테인먼트 관련 키워드 + if (serviceName.contains('netflix') || + serviceName.contains('youtube') || + serviceName.contains('disney') || + serviceName.contains('왓챠') || + serviceName.contains('티빙') || + serviceName.contains('웨이브') || + serviceName.contains('coupang play') || + serviceName.contains('쿠팡플레이')) { + matchedCategory = categories.firstWhere( + (cat) => cat.name == '엔터테인먼트', + orElse: () => categories.first, + ); + } + // 음악 관련 키워드 + else if (serviceName.contains('spotify') || + serviceName.contains('apple music') || + serviceName.contains('멜론') || + serviceName.contains('지니') || + serviceName.contains('플로') || + serviceName.contains('벅스')) { + matchedCategory = categories.firstWhere( + (cat) => cat.name == '음악', + orElse: () => categories.first, + ); + } + // 생산성 관련 키워드 + else if (serviceName.contains('notion') || + serviceName.contains('microsoft') || + serviceName.contains('office') || + serviceName.contains('google') || + serviceName.contains('dropbox') || + serviceName.contains('icloud') || + serviceName.contains('adobe')) { + matchedCategory = categories.firstWhere( + (cat) => cat.name == '생산성', + orElse: () => categories.first, + ); + } + // 게임 관련 키워드 + else if (serviceName.contains('xbox') || + serviceName.contains('playstation') || + serviceName.contains('nintendo') || + serviceName.contains('steam') || + serviceName.contains('게임')) { + matchedCategory = categories.firstWhere( + (cat) => cat.name == '게임', + orElse: () => categories.first, + ); + } + // 교육 관련 키워드 + else if (serviceName.contains('coursera') || + serviceName.contains('udemy') || + serviceName.contains('인프런') || + serviceName.contains('패스트캠퍼스') || + serviceName.contains('클래스101')) { + matchedCategory = categories.firstWhere( + (cat) => cat.name == '교육', + orElse: () => categories.first, + ); + } + // 쇼핑 관련 키워드 + else if (serviceName.contains('쿠팡') || + serviceName.contains('coupang') || + serviceName.contains('amazon') || + serviceName.contains('네이버') || + serviceName.contains('11번가')) { + matchedCategory = categories.firstWhere( + (cat) => cat.name == '쇼핑', + orElse: () => categories.first, + ); + } + + if (matchedCategory != null) { + selectedCategoryId = matchedCategory.id; + } + } + + /// 구독 정보 업데이트 + Future updateSubscription() async { + final provider = Provider.of(context, listen: false); + + // 웹사이트 URL이 비어있는 경우 자동 매칭 다시 시도 + String? websiteUrl = websiteUrlController.text; + if (websiteUrl.isEmpty) { + websiteUrl = SubscriptionUrlMatcher.suggestUrl(serviceNameController.text); + } + + // 구독 정보 업데이트 + + // 콤마 제거하고 숫자만 추출 + double monthlyCost = 0.0; + try { + monthlyCost = double.parse(monthlyCostController.text.replaceAll(',', '')); + } catch (e) { + // 파싱 오류 발생 시 기본값 사용 + monthlyCost = subscription.monthlyCost; + } + + subscription.serviceName = serviceNameController.text; + subscription.monthlyCost = monthlyCost; + subscription.websiteUrl = websiteUrl; + subscription.billingCycle = billingCycle; + subscription.nextBillingDate = nextBillingDate; + subscription.categoryId = selectedCategoryId; + subscription.currency = currency; + + // 이벤트 정보 업데이트 + subscription.isEventActive = isEventActive; + subscription.eventStartDate = eventStartDate; + subscription.eventEndDate = eventEndDate; + + // 이벤트 가격 파싱 + if (isEventActive && eventPriceController.text.isNotEmpty) { + try { + subscription.eventPrice = + double.parse(eventPriceController.text.replaceAll(',', '')); + } catch (e) { + subscription.eventPrice = null; + } + } else { + subscription.eventPrice = null; + } + + // 구독 업데이트 + await provider.updateSubscription(subscription); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Row( + children: [ + Icon(Icons.check_circle_rounded, color: Colors.white), + SizedBox(width: 12), + Text('구독 정보가 업데이트되었습니다.'), + ], + ), + behavior: SnackBarBehavior.floating, + backgroundColor: const Color(0xFF10B981), + duration: const Duration(seconds: 2), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ); + + // 변경 사항이 반영될 시간을 주기 위해 짧게 지연 후 결과 반환 + await Future.delayed(const Duration(milliseconds: 100)); + if (context.mounted) { + Navigator.of(context).pop(true); + } + } + } + + /// 구독 삭제 + Future deleteSubscription() async { + if (context.mounted) { + final provider = Provider.of(context, listen: false); + await provider.deleteSubscription(subscription.id); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Row( + children: [ + Icon(Icons.delete_forever_rounded, color: Colors.white), + SizedBox(width: 12), + Text('구독이 삭제되었습니다.'), + ], + ), + behavior: SnackBarBehavior.floating, + backgroundColor: const Color(0xFFDC2626), + duration: const Duration(seconds: 2), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ); + Navigator.of(context).pop(); + } + } + } + + /// 해지 페이지 열기 + Future openCancellationPage() async { + if (subscription.websiteUrl != null && + subscription.websiteUrl!.isNotEmpty) { + final Uri url = Uri.parse(subscription.websiteUrl!); + if (!await launchUrl(url, mode: LaunchMode.externalApplication)) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('웹사이트를 열 수 없습니다.'), + backgroundColor: Colors.red, + ), + ); + } + } + } else { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('웹사이트 정보가 없습니다. 해지는 웹사이트에서 진행해주세요.'), + backgroundColor: Colors.orange, + ), + ); + } + } + } + + /// 카드 색상 가져오기 + Color getCardColor() { + // 서비스 이름에 따라 일관된 색상 생성 + final int hash = subscription.serviceName.hashCode.abs(); + final List colors = [ + const Color(0xFF3B82F6), // 파랑 + const Color(0xFF10B981), // 초록 + const Color(0xFF8B5CF6), // 보라 + const Color(0xFFF59E0B), // 노랑 + const Color(0xFFEF4444), // 빨강 + const Color(0xFF0EA5E9), // 하늘 + const Color(0xFFEC4899), // 분홍 + ]; + + return colors[hash % colors.length]; + } + + /// 그라데이션 가져오기 + LinearGradient getGradient(Color baseColor) { + return LinearGradient( + colors: [ + baseColor, + baseColor.withValues(alpha: 0.8), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ); + } +} \ No newline at end of file diff --git a/lib/providers/notification_provider.dart b/lib/providers/notification_provider.dart index 81b424f..ff5c2b1 100644 --- a/lib/providers/notification_provider.dart +++ b/lib/providers/notification_provider.dart @@ -1,4 +1,3 @@ -import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter/foundation.dart'; import '../services/notification_service.dart'; diff --git a/lib/providers/subscription_provider.dart b/lib/providers/subscription_provider.dart index 82364ab..57804de 100644 --- a/lib/providers/subscription_provider.dart +++ b/lib/providers/subscription_provider.dart @@ -4,9 +4,6 @@ import 'package:hive/hive.dart'; import 'package:uuid/uuid.dart'; import '../models/subscription_model.dart'; import '../services/notification_service.dart'; -import 'package:provider/provider.dart'; -import 'notification_provider.dart'; -import '../navigator_key.dart'; class SubscriptionProvider extends ChangeNotifier { late Box _subscriptionBox; @@ -156,35 +153,6 @@ class SubscriptionProvider extends ChangeNotifier { } } - Future _scheduleNotifications() async { - final BuildContext? context = navigatorKey.currentContext; - if (context == null) return; - - final notificationProvider = Provider.of( - context, - listen: false, - ); - - if (!notificationProvider.isEnabled || - !notificationProvider.isPaymentEnabled) { - return; - } - - for (final subscription in _subscriptions) { - final notificationDate = subscription.nextBillingDate.subtract( - const Duration(days: 3), - ); - - if (notificationDate.isAfter(DateTime.now())) { - await NotificationService.scheduleNotification( - id: subscription.id.hashCode, - title: '구독 결제 예정 알림', - body: '${subscription.serviceName}의 결제가 3일 후 예정되어 있습니다.', - scheduledDate: notificationDate, - ); - } - } - } Future clearAllSubscriptions() async { _isLoading = true; diff --git a/lib/screens/add_subscription_screen.dart b/lib/screens/add_subscription_screen.dart index 894f96e..1ab6662 100644 --- a/lib/screens/add_subscription_screen.dart +++ b/lib/screens/add_subscription_screen.dart @@ -1,16 +1,12 @@ import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:intl/intl.dart'; -import 'package:flutter/foundation.dart' show kIsWeb; -import 'package:flutter/services.dart'; -import 'dart:math' as math; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import '../providers/subscription_provider.dart'; -import '../providers/category_provider.dart'; -import '../services/sms_service.dart'; -import '../services/subscription_url_matcher.dart'; -import '../services/exchange_rate_service.dart'; +import '../controllers/add_subscription_controller.dart'; +import '../widgets/add_subscription/add_subscription_app_bar.dart'; +import '../widgets/add_subscription/add_subscription_header.dart'; +import '../widgets/add_subscription/add_subscription_form.dart'; +import '../widgets/add_subscription/add_subscription_event_section.dart'; +import '../widgets/add_subscription/add_subscription_save_button.dart'; +/// 새로운 구독을 추가하는 화면 class AddSubscriptionScreen extends StatefulWidget { const AddSubscriptionScreen({Key? key}) : super(key: key); @@ -20,2002 +16,88 @@ class AddSubscriptionScreen extends StatefulWidget { class _AddSubscriptionScreenState extends State with SingleTickerProviderStateMixin { - final _formKey = GlobalKey(); - final _serviceNameController = TextEditingController(); - final _monthlyCostController = TextEditingController(); - final _nextBillingDateController = TextEditingController(); - final _websiteUrlController = TextEditingController(); - String _billingCycle = '월간'; - String _currency = 'KRW'; - DateTime? _nextBillingDate; - bool _isLoading = false; - String? _selectedCategoryId; - - // 이벤트 관련 상태 변수 - bool _isEventActive = false; - DateTime? _eventStartDate = DateTime.now(); // 오늘로 초기화 - DateTime? _eventEndDate = DateTime.now().add(const Duration(days: 30)); // 30일 후로 초기화 - final _eventPriceController = TextEditingController(); - - // 포커스 노드 추가 - final _serviceNameFocus = FocusNode(); - final _monthlyCostFocus = FocusNode(); - final _billingCycleFocus = FocusNode(); - final _nextBillingDateFocus = FocusNode(); - final _websiteUrlFocus = FocusNode(); - final _categoryFocus = FocusNode(); - final _currencyFocus = FocusNode(); - - // 애니메이션 컨트롤러 - late AnimationController _animationController; - late Animation _fadeAnimation; - late Animation _slideAnimation; - - // 스크롤 컨트롤러 - final ScrollController _scrollController = ScrollController(); - double _scrollOffset = 0; - - // 현재 편집 중인 필드 - int _currentEditingField = -1; - - // 호버 상태 - bool _isSaveHovered = false; - - final List _gradientColors = [ - const Color(0xFF3B82F6), - const Color(0xFF0EA5E9), - const Color(0xFF06B6D4), - ]; + late AddSubscriptionController _controller; @override void initState() { super.initState(); - - // 결제일 기본값을 오늘 날짜로 설정 - _nextBillingDate = DateTime.now(); - - // 디버깅 정보 출력 - print('환경 정보: kIsWeb = $kIsWeb'); - print('초기 통화 단위: $_currency'); - - // 서비스명 컨트롤러에 리스너 추가 - _serviceNameController.addListener(_onServiceNameChanged); - - // 애니메이션 컨트롤러 초기화 - _animationController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 800), - ); - - _fadeAnimation = Tween( - begin: 0.0, - end: 1.0, - ).animate(CurvedAnimation( - parent: _animationController, - curve: Curves.easeIn, - )); - - _slideAnimation = Tween( - begin: const Offset(0.0, 0.2), - end: Offset.zero, - ).animate(CurvedAnimation( - parent: _animationController, - curve: Curves.easeOutCubic, - )); - - _scrollController.addListener(() { - setState(() { - _scrollOffset = _scrollController.offset; - }); - }); - - _animationController.forward(); + _controller = AddSubscriptionController(context: context); + _controller.initialize(vsync: this); } @override void dispose() { - _serviceNameController.removeListener(_onServiceNameChanged); - _serviceNameController.dispose(); - _monthlyCostController.dispose(); - _nextBillingDateController.dispose(); - _websiteUrlController.dispose(); - _eventPriceController.dispose(); - _animationController.dispose(); - _scrollController.dispose(); - - // 포커스 노드 해제 - _serviceNameFocus.dispose(); - _monthlyCostFocus.dispose(); - _billingCycleFocus.dispose(); - _nextBillingDateFocus.dispose(); - _websiteUrlFocus.dispose(); - _categoryFocus.dispose(); - _currencyFocus.dispose(); - + _controller.dispose(); super.dispose(); } - // 서비스명이 변경될 때 호출되는 콜백 함수 - void _onServiceNameChanged() { - if (_serviceNameController.text.isNotEmpty && - _websiteUrlController.text.isEmpty) { - // 자동 URL 매칭 시도 - final suggestedUrl = - SubscriptionUrlMatcher.suggestUrl(_serviceNameController.text); - - // 매칭된 URL이 있으면 텍스트 컨트롤러에 설정 - if (suggestedUrl != null && suggestedUrl.isNotEmpty) { - setState(() { - _websiteUrlController.text = suggestedUrl; - }); - } - } - - // 서비스명이 변경될 때 카테고리 자동 선택 시도 - if (_serviceNameController.text.isNotEmpty && _selectedCategoryId == null) { - _autoSelectCategory(); - } - } - - // 서비스명을 기반으로 카테고리 자동 선택 함수 - void _autoSelectCategory() { - if (_serviceNameController.text.isEmpty) return; - - final serviceName = _serviceNameController.text.toLowerCase(); - final categoryProvider = - Provider.of(context, listen: false); - - // 카테고리가 없으면 리턴 - if (categoryProvider.categories.isEmpty) return; - - // OTT 서비스 확인 - if (SubscriptionUrlMatcher.ottServices.keys.any((key) => - serviceName.contains(key.toLowerCase()) || - key.toLowerCase().contains(serviceName))) { - // OTT 관련 카테고리 찾기 - try { - final ottCategory = categoryProvider.categories.firstWhere( - (cat) => - cat.name.contains('OTT') || - cat.name.contains('미디어') || - cat.name.contains('영상'), - ); - - setState(() { - _selectedCategoryId = ottCategory.id; - }); - return; - } catch (_) { - // OTT 카테고리가 없으면 첫 번째 카테고리 사용 - if (categoryProvider.categories.isNotEmpty) { - setState(() { - _selectedCategoryId = categoryProvider.categories.first.id; - }); - } - } - } - - // 음악 서비스 확인 - if (SubscriptionUrlMatcher.musicServices.keys.any((key) => - serviceName.contains(key.toLowerCase()) || - key.toLowerCase().contains(serviceName))) { - // 음악 관련 카테고리 찾기 - try { - final musicCategory = categoryProvider.categories.firstWhere( - (cat) => cat.name.contains('음악') || cat.name.contains('스트리밍'), - ); - - setState(() { - _selectedCategoryId = musicCategory.id; - }); - return; - } catch (_) { - // 음악 카테고리가 없으면 첫 번째 카테고리 사용 - if (categoryProvider.categories.isNotEmpty) { - setState(() { - _selectedCategoryId = categoryProvider.categories.first.id; - }); - } - } - } - - // AI 서비스 확인 - if (SubscriptionUrlMatcher.aiServices.keys.any((key) => - serviceName.contains(key.toLowerCase()) || - key.toLowerCase().contains(serviceName))) { - // AI 관련 카테고리 찾기 - try { - final aiCategory = categoryProvider.categories.firstWhere( - (cat) => cat.name.contains('AI') || cat.name.contains('인공지능'), - ); - - setState(() { - _selectedCategoryId = aiCategory.id; - }); - return; - } catch (_) { - // AI 카테고리가 없으면 첫 번째 카테고리 사용 - if (categoryProvider.categories.isNotEmpty) { - setState(() { - _selectedCategoryId = categoryProvider.categories.first.id; - }); - } - } - } - - // 프로그래밍/개발 서비스 확인 - if (SubscriptionUrlMatcher.programmingServices.keys.any((key) => - serviceName.contains(key.toLowerCase()) || - key.toLowerCase().contains(serviceName))) { - // 개발 관련 카테고리 찾기 - try { - final devCategory = categoryProvider.categories.firstWhere( - (cat) => cat.name.contains('개발') || cat.name.contains('프로그래밍'), - ); - - setState(() { - _selectedCategoryId = devCategory.id; - }); - return; - } catch (_) { - // 개발 카테고리가 없으면 첫 번째 카테고리 사용 - if (categoryProvider.categories.isNotEmpty) { - setState(() { - _selectedCategoryId = categoryProvider.categories.first.id; - }); - } - } - } - - // 오피스/협업 툴 확인 - if (SubscriptionUrlMatcher.officeTools.keys.any((key) => - serviceName.contains(key.toLowerCase()) || - key.toLowerCase().contains(serviceName))) { - // 오피스 관련 카테고리 찾기 - try { - final officeCategory = categoryProvider.categories.firstWhere( - (cat) => - cat.name.contains('오피스') || - cat.name.contains('협업') || - cat.name.contains('업무'), - ); - - setState(() { - _selectedCategoryId = officeCategory.id; - }); - return; - } catch (_) { - // 오피스 카테고리가 없으면 첫 번째 카테고리 사용 - if (categoryProvider.categories.isNotEmpty) { - setState(() { - _selectedCategoryId = categoryProvider.categories.first.id; - }); - } - } - } - - // 기타 서비스 확인 - if (SubscriptionUrlMatcher.otherServices.keys.any((key) => - serviceName.contains(key.toLowerCase()) || - key.toLowerCase().contains(serviceName))) { - // 기타 관련 카테고리 찾기 - try { - final otherCategory = categoryProvider.categories.firstWhere( - (cat) => cat.name.contains('기타') || cat.name.contains('게임'), - ); - - setState(() { - _selectedCategoryId = otherCategory.id; - }); - } catch (_) { - // 기타 카테고리가 없으면 첫 번째 카테고리 사용 - if (categoryProvider.categories.isNotEmpty) { - setState(() { - _selectedCategoryId = categoryProvider.categories.first.id; - }); - } - } - } - } - - Future _scanSMS() async { - if (kIsWeb) return; - - setState(() => _isLoading = true); - - try { - if (!await SMSService.hasSMSPermission()) { - final granted = await SMSService.requestSMSPermission(); - if (!granted) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Row( - children: [ - const Icon(Icons.error_outline, color: Colors.white), - const SizedBox(width: 12), - const Expanded(child: Text('SMS 권한이 필요합니다.')), - ], - ), - behavior: SnackBarBehavior.floating, - backgroundColor: Colors.red, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10)), - ), - ); - } - return; - } - } - - final subscriptions = await SMSService.scanSubscriptions(); - if (subscriptions.isEmpty) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Row( - children: [ - const Icon(Icons.info_outline, color: Colors.white), - const SizedBox(width: 12), - const Expanded(child: Text('구독 관련 SMS를 찾을 수 없습니다.')), - ], - ), - behavior: SnackBarBehavior.floating, - backgroundColor: Colors.orange, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10)), - ), - ); - } - return; - } - - final subscription = subscriptions.first; - setState(() { - _serviceNameController.text = subscription['serviceName'] ?? ''; - - // 비용 처리 및 통화 단위 자동 감지 - final costValue = subscription['monthlyCost']?.toString() ?? ''; - - // costValue가 비어있지 않을 경우에만 처리 - if (costValue.isNotEmpty) { - // 달러 표시가 있거나 소수점이 있으면 달러로 판단 - if (costValue.contains('\$') || costValue.contains('.')) { - // 달러로 설정 - _currency = 'USD'; - - // 달러 기호 제거 및 숫자만 추출 - String numericValue = costValue.replaceAll('\$', '').trim(); - - // 소수점이 없는 경우 소수점 추가 - if (!numericValue.contains('.')) { - numericValue = '$numericValue.00'; - } - - // 3자리마다 콤마 추가하여 포맷팅 - final double parsedValue = - double.tryParse(numericValue.replaceAll(',', '')) ?? 0.0; - _monthlyCostController.text = - NumberFormat('#,##0.00').format(parsedValue); - } else { - // 원화로 설정 - _currency = 'KRW'; - - // ₩ 기호와 콤마 제거 - String numericValue = - costValue.replaceAll('₩', '').replaceAll(',', '').trim(); - - // 숫자로 변환하여 정수로 포맷팅 - final int parsedValue = int.tryParse(numericValue) ?? 0; - _monthlyCostController.text = - NumberFormat.decimalPattern().format(parsedValue); - } - } else { - _monthlyCostController.text = ''; - } - - _billingCycle = subscription['billingCycle'] ?? '월간'; - _nextBillingDate = subscription['nextBillingDate'] != null - ? DateTime.parse(subscription['nextBillingDate']) - : DateTime.now(); - - // 서비스명이 있으면 URL 자동 매칭 시도 - if (subscription['serviceName'] != null && - subscription['serviceName'].isNotEmpty) { - final suggestedUrl = - SubscriptionUrlMatcher.suggestUrl(subscription['serviceName']); - if (suggestedUrl != null) { - _websiteUrlController.text = suggestedUrl; - } - - // 서비스명 기반으로 카테고리 자동 선택 - _autoSelectCategory(); - } - - // 애니메이션 재생 - _animationController.reset(); - _animationController.forward(); - }); - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Row( - children: [ - const Icon(Icons.error_outline, color: Colors.white), - const SizedBox(width: 12), - Expanded(child: Text('SMS 스캔 중 오류 발생: $e')), - ], - ), - behavior: SnackBarBehavior.floating, - backgroundColor: Colors.red, - shape: - RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - ), - ); - } - } finally { - if (mounted) { - setState(() => _isLoading = false); - } - } - } - - Future _saveSubscription() async { - if (_formKey.currentState!.validate() && _nextBillingDate != null) { - setState(() { - _isLoading = true; - }); - - try { - // 콤마 제거하고 숫자만 추출 - final monthlyCost = - double.parse(_monthlyCostController.text.replaceAll(',', '')); - - // 이벤트 가격 파싱 - double? eventPrice; - if (_isEventActive && _eventPriceController.text.isNotEmpty) { - eventPrice = double.tryParse(_eventPriceController.text.replaceAll(',', '')); - } - - await Provider.of(context, listen: false) - .addSubscription( - serviceName: _serviceNameController.text.trim(), - monthlyCost: monthlyCost, - billingCycle: _billingCycle, - nextBillingDate: _nextBillingDate!, - websiteUrl: _websiteUrlController.text.trim(), - categoryId: _selectedCategoryId, - currency: _currency, - isEventActive: _isEventActive, - eventStartDate: _eventStartDate, - eventEndDate: _eventEndDate, - eventPrice: eventPrice, - ); - - if (mounted) { - Navigator.pop(context, true); // 성공 여부 반환 - } - } catch (e) { - setState(() { - _isLoading = false; - }); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('저장 중 오류가 발생했습니다: $e'), - backgroundColor: Colors.red, - ), - ); - } - } - } else { - _scrollController.animateTo( - 0.0, - duration: const Duration(milliseconds: 300), - curve: Curves.easeOut, - ); - } + void _onScroll() { + setState(() { + _controller.scrollOffset = _controller.scrollController.offset; + }); } @override Widget build(BuildContext context) { - final double appBarOpacity = math.max(0, math.min(1, _scrollOffset / 100)); - - // 통화 기호 텍스트 (디버깅용) - print('현재 통화 단위: $_currency'); - final currencySymbol = _currency == 'KRW' ? '₩' : '\$'; - print('통화 기호: $currencySymbol'); + // 스크롤 리스너 추가 + _controller.scrollController.removeListener(_onScroll); + _controller.scrollController.addListener(_onScroll); return Scaffold( backgroundColor: const Color(0xFFF8FAFC), extendBodyBehindAppBar: true, - appBar: PreferredSize( - preferredSize: const Size.fromHeight(60), - child: Container( - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: appBarOpacity), - boxShadow: appBarOpacity > 0.6 - ? [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.1 * appBarOpacity), - spreadRadius: 1, - blurRadius: 8, - offset: const Offset(0, 4), - ) - ] - : null, - ), - child: SafeArea( - child: AppBar( - title: Text( - '구독 추가', - style: TextStyle( - fontFamily: 'Montserrat', - fontSize: 24, - fontWeight: FontWeight.w800, - letterSpacing: -0.5, - color: const Color(0xFF1E293B), - shadows: appBarOpacity > 0.6 - ? [ - Shadow( - color: Colors.black.withValues(alpha: 0.2), - offset: const Offset(0, 1), - blurRadius: 2, - ) - ] - : null, - ), - ), - elevation: 0, - backgroundColor: Colors.transparent, - actions: [ - if (!kIsWeb) - _isLoading - ? const Padding( - padding: EdgeInsets.only(right: 16.0), - child: Center( - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation( - Color(0xFF3B82F6)), - ), - ), - ), - ) - : IconButton( - icon: const FaIcon(FontAwesomeIcons.message, - size: 20, color: Color(0xFF3B82F6)), - onPressed: _scanSMS, - tooltip: 'SMS에서 구독 정보 스캔', - ), - ], - ), - ), - ), + appBar: AddSubscriptionAppBar( + controller: _controller, + scrollOffset: _controller.scrollOffset, + onScanSMS: () => _controller.scanSMS(setState: setState), ), body: SingleChildScrollView( - controller: _scrollController, + controller: _controller.scrollController, physics: const BouncingScrollPhysics(), padding: const EdgeInsets.all(16), child: Form( - key: _formKey, + key: _controller.formKey, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox(height: MediaQuery.of(context).padding.top + 60), + // 헤더 섹션 - FadeTransition( - opacity: _fadeAnimation, - child: SlideTransition( - position: _slideAnimation, - child: Container( - margin: const EdgeInsets.only(bottom: 24), - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(24), - gradient: LinearGradient( - colors: _gradientColors, - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - boxShadow: [ - BoxShadow( - color: _gradientColors[0].withValues(alpha: 0.3), - blurRadius: 20, - spreadRadius: 0, - offset: const Offset(0, 8), - ), - ], - ), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(16), - ), - child: const Icon( - Icons.add_rounded, - size: 32, - color: Colors.white, - ), - ), - const SizedBox(width: 16), - const Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '새 구독 추가', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.w800, - color: Colors.white, - letterSpacing: -0.5, - ), - ), - SizedBox(height: 4), - Text( - '서비스 정보를 입력해주세요', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Colors.white70, - ), - ), - ], - ), - ), - ], - ), - ), - ), + AddSubscriptionHeader( + controller: _controller, + fadeAnimation: _controller.fadeAnimation!, + slideAnimation: _controller.slideAnimation!, ), - - // 서비스 정보 카드 - FadeTransition( - opacity: Tween(begin: 0.0, end: 1.0).animate( - CurvedAnimation( - parent: _animationController, - curve: const Interval(0.2, 1.0, curve: Curves.easeIn), - ), - ), - child: SlideTransition( - position: Tween( - begin: const Offset(0.0, 0.4), - end: Offset.zero, - ).animate(CurvedAnimation( - parent: _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: Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - ShaderMask( - shaderCallback: (bounds) => LinearGradient( - colors: _gradientColors, - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ).createShader(bounds), - child: const Icon( - FontAwesomeIcons.fileLines, - size: 20, - color: Colors.white, - ), - ), - const SizedBox(width: 12), - const Text( - '서비스 정보', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - letterSpacing: -0.5, - color: Color(0xFF1E293B), - ), - ), - ], - ), - const SizedBox(height: 24), - - // 서비스명 필드 - AnimatedContainer( - duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.only(bottom: 20), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: _currentEditingField == 0 - ? const Color(0xFF3B82F6).withValues(alpha: 0.1) - : Colors.transparent, - ), - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '서비스명', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Color(0xFF3B82F6), - ), - ), - const SizedBox(height: 8), - TextFormField( - controller: _serviceNameController, - focusNode: _serviceNameFocus, - textInputAction: TextInputAction.next, - onTap: () => - setState(() => _currentEditingField = 0), - onEditingComplete: () { - _monthlyCostFocus.requestFocus(); - setState(() => _currentEditingField = -1); - }, - validator: (value) { - if (value == null || value.isEmpty) { - return '서비스명을 입력해주세요'; - } - return null; - }, - style: const TextStyle( - color: Color(0xFF1E293B), - fontSize: 16, - fontWeight: FontWeight.w500, - ), - decoration: InputDecoration( - filled: true, - fillColor: Colors.white, - contentPadding: const EdgeInsets.symmetric( - vertical: 16, horizontal: 20), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: Colors.grey.withValues(alpha: 0.2), - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide( - color: Color(0xFF3B82F6), - width: 2, - ), - ), - errorBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide( - color: Colors.red, - width: 2, - ), - ), - hintText: '넷플릭스', - hintStyle: TextStyle( - color: Colors.grey.shade500, - fontSize: 16, - ), - ), - ), - ], - ), - ), - - // 월 비용 필드 - AnimatedContainer( - duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.only(bottom: 20), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: _currentEditingField == 1 - ? const Color(0xFF3B82F6).withValues(alpha: 0.1) - : Colors.transparent, - ), - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 환율 정보와 비용 입력 제목 표시 (상단) - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Text( - '비용 입력', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Color(0xFF3B82F6), - ), - ), - if (_currency == 'USD') - FutureBuilder( - future: ExchangeRateService() - .getFormattedExchangeRateInfo(), - builder: (context, snapshot) { - if (snapshot.hasData) { - return Text( - snapshot.data!, - style: TextStyle( - fontSize: 12, - color: Colors.grey[600], - fontWeight: FontWeight.w500, - ), - ); - } - return const SizedBox.shrink(); - }, - ), - ], - ), - const SizedBox(height: 8), - Row( - children: [ - // 통화 단위 선택 (좌측) - Expanded( - flex: 3, // 25% 너비 차지 - child: DropdownButtonFormField( - value: _currency, - focusNode: _currencyFocus, - isDense: true, - onTap: () => setState( - () => _currentEditingField = 1), - onChanged: (value) { - if (value != null) { - setState(() { - _currency = value; - - // 통화 단위 변경 시 입력 값 변환 - final currentText = - _monthlyCostController.text; - if (currentText.isNotEmpty) { - // 콤마 제거하고 숫자만 추출 - final numericValue = - double.tryParse(currentText - .replaceAll(',', '')); - - if (numericValue != null) { - if (value == 'KRW') { - // 달러 → 원화: 소수점 제거 - _monthlyCostController - .text = NumberFormat - .decimalPattern() - .format(numericValue - .toInt()); - } else { - // 원화 → 달러: 소수점 2자리 추가 - _monthlyCostController - .text = NumberFormat( - '#,##0.00') - .format(numericValue); - } - } - } - - // 화면 갱신하여 통화 기호도 업데이트 - _monthlyCostFocus.requestFocus(); - }); - } - }, - decoration: InputDecoration( - filled: true, - fillColor: Colors.white, - contentPadding: - const EdgeInsets.symmetric( - vertical: 16, horizontal: 12), - border: OutlineInputBorder( - borderRadius: - BorderRadius.circular(12), - borderSide: BorderSide( - color: - Colors.grey.withValues(alpha: 0.2), - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: - BorderRadius.circular(12), - borderSide: const BorderSide( - color: Color(0xFF3B82F6), - width: 2, - ), - ), - ), - icon: const Icon( - Icons.arrow_drop_down, - color: Color(0xFF3B82F6), - ), - items: ['KRW', 'USD'] - .map((currency) => DropdownMenuItem( - value: currency, - child: Text( - currency == 'KRW' - ? 'KRW' - : 'USD', - style: const TextStyle( - fontSize: 14, - fontWeight: - FontWeight.w500, - color: Colors.black87, - ), - ), - )) - .toList(), - ), - ), - const SizedBox(width: 8), - // 월 비용 입력 필드 (우측) - 테두리 제거 및 배경색 통일 - Expanded( - flex: 7, // 75% 너비 차지 - child: Container( - height: 50, // 높이를 56에서 50으로 줄임 - // 우측에서 40픽셀 줄이기 - margin: const EdgeInsets.only(right: 0), - // 내부 패딩을 고정값으로 설정하여 포커스 상태와 관계없이 일관되게 유지 - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - // 포커스 상태에 따른 배경색 변경 (배경색만 변경) - color: _currentEditingField == 1 - ? const Color( - 0xFFF3F4F6) // 포커스 상태일 때 연한 회색 - : Colors - .transparent, // 포커스 없을 때 투명 - borderRadius: - BorderRadius.circular(12), - // 테두리 설정 (포커스 상태에 따라 색상만 변경) - border: Border.all( - color: _currentEditingField == 1 - ? const Color(0xFF3B82F6) - : Colors.grey.withValues(alpha: - 0.4), // 포커스 없을 때 더 진한 회색 - width: _currentEditingField == 1 - ? 2 - : 1, - ), - ), - child: Row( - children: [ - // 통화 기호 - 항상 표시되도록 수정 - Container( - width: 40, - alignment: Alignment.center, - decoration: BoxDecoration( - // 테두리 추가 (좌측 통화선택란과 동일한 스타일) - border: Border( - right: BorderSide( - color: Colors.grey - .withValues(alpha: 0.2), - width: 1, - ), - ), - ), - child: Text( - _currency == 'KRW' ? '₩' : '\$', - style: const TextStyle( - color: Color(0xFF3B82F6), - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - ), - // 실제 입력 필드 - 기호 관련 코드 제거하여 중복 방지 - Expanded( - child: Stack( - alignment: - Alignment.centerRight, - children: [ - TextField( - controller: - _monthlyCostController, - focusNode: - _monthlyCostFocus, - textInputAction: - TextInputAction.next, - keyboardType: - const TextInputType - .numberWithOptions( - decimal: true), - inputFormatters: [ - // 통화 단위에 따라 다른 입력 형식 적용 - FilteringTextInputFormatter - .allow( - _currency == 'KRW' - ? RegExp( - r'[0-9,]') // 원화: 정수만 허용 - : RegExp( - r'[0-9,.]'), // 달러: 소수점 허용 - ), - // 커스텀 포맷터 - 3자리마다 콤마 추가 - TextInputFormatter - .withFunction( - (oldValue, - newValue) { - // 입력값에서 콤마 제거 - final text = newValue - .text - .replaceAll( - ',', ''); - - if (text.isEmpty) { - return newValue - .copyWith( - text: ''); - } - - // 숫자 형식 검증 - if (_currency == - 'KRW') { - // 원화: 정수 형식 - if (double.tryParse( - text) == - null) { - return oldValue; - } - - // 3자리마다 콤마 추가 - final formattedValue = - NumberFormat - .decimalPattern() - .format( - int.parse( - text)); - - return newValue - .copyWith( - text: - formattedValue, - selection: TextSelection - .collapsed( - offset: formattedValue - .length), - ); - } else { - // 달러: 소수점 형식 - if (double.tryParse( - text) == - null && - text != '.') { - return oldValue; - } - - // 소수점 이하 처리를 위해 부분 분리 - final parts = - text.split('.'); - final integerPart = - parts[0]; - final decimalPart = parts - .length > - 1 - ? '.${parts[1].length > 2 ? parts[1].substring(0, 2) : parts[1]}' - : ''; - - // 3자리마다 콤마 추가 (정수 부분만) - String formattedValue; - if (integerPart - .isEmpty) { - formattedValue = - '0$decimalPart'; - } else { - final formatted = NumberFormat - .decimalPattern() - .format(int.parse( - integerPart)); - formattedValue = - '$formatted$decimalPart'; - } - - return newValue - .copyWith( - text: - formattedValue, - selection: TextSelection - .collapsed( - offset: formattedValue - .length), - ); - } - }), - ], - onTap: () => setState(() => - _currentEditingField = - 1), - onSubmitted: (_) { - _billingCycleFocus - .requestFocus(); - setState(() => - _currentEditingField = - -1); - }, - style: const TextStyle( - color: Color(0xFF1E293B), - fontSize: 16, - fontWeight: - FontWeight.w500, - ), - decoration: InputDecoration( - border: InputBorder.none, - // 포커스 상태와 관계없이 일관된 패딩 유지 - contentPadding: - const EdgeInsets - .symmetric( - vertical: 14, - horizontal: 8), - hintText: - _currency == 'KRW' - ? '9,000' - : '9.99', - hintStyle: TextStyle( - color: Colors - .grey.shade500, - fontSize: 16, - ), - // 모든 테두리 제거 - enabledBorder: - InputBorder.none, - focusedBorder: - InputBorder.none, - errorBorder: - InputBorder.none, - disabledBorder: - InputBorder.none, - focusedErrorBorder: - InputBorder.none, - ), - ), - // 달러일 때 원화 환산 금액 표시 - if (_currency == 'USD') - ValueListenableBuilder< - TextEditingValue>( - valueListenable: - _monthlyCostController, - builder: (context, value, - child) { - // 입력값이 바뀔 때마다 환산 금액 갱신 - return FutureBuilder< - String>( - future: ExchangeRateService() - .getFormattedKrwAmount( - double.tryParse(value - .text - .replaceAll( - ',', - '')) ?? - 0.0), - builder: (context, - snapshot) { - if (snapshot - .hasData && - snapshot.data! - .isNotEmpty) { - return Padding( - padding: - const EdgeInsets - .only( - right: - 12.0), - child: Text( - snapshot - .data!, - style: - const TextStyle( - fontSize: - 14, - color: Colors - .blue, - fontWeight: - FontWeight - .w500, - ), - ), - ); - } - return const SizedBox - .shrink(); - }, - ); - }, - ), - ], - ), - ), - ], - ), - ), - ), - ], - ), - ], - ), - ), - - // 결제 주기 필드 - AnimatedContainer( - duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.only(bottom: 20), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: _currentEditingField == 2 - ? const Color(0xFF3B82F6).withValues(alpha: 0.1) - : Colors.transparent, - ), - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '결제 주기', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Color(0xFF3B82F6), - ), - ), - const SizedBox(height: 8), - DropdownButtonFormField( - value: _billingCycle, - focusNode: _billingCycleFocus, - onTap: () => - setState(() => _currentEditingField = 2), - onChanged: (value) { - if (value != null) { - setState(() { - _billingCycle = value; - _currentEditingField = -1; - _nextBillingDateFocus.requestFocus(); - }); - } - }, - decoration: InputDecoration( - filled: true, - fillColor: Colors.white, - contentPadding: const EdgeInsets.all(16), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: Colors.grey.withValues(alpha: 0.2), - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide( - color: Color(0xFF3B82F6), - width: 2, - ), - ), - prefixIcon: const Icon( - Icons.calendar_today_rounded, - color: Color(0xFF3B82F6), - ), - ), - icon: const Icon( - Icons.arrow_drop_down_circle_outlined, - color: Color(0xFF3B82F6), - ), - elevation: 2, - dropdownColor: Colors.white, - style: const TextStyle( - color: Colors.black87, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - items: ['월간', '연간', '주간'] - .map((cycle) => DropdownMenuItem( - value: cycle, - child: Text( - cycle, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Colors.black87, - ), - ), - )) - .toList(), - ), - ], - ), - ), - - // 다음 결제일 필드 - AnimatedContainer( - duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.only(bottom: 20), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: _currentEditingField == 3 - ? const Color(0xFF3B82F6).withValues(alpha: 0.1) - : Colors.transparent, - ), - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '다음 결제일', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Color(0xFF3B82F6), - ), - ), - const SizedBox(height: 8), - InkWell( - focusNode: _nextBillingDateFocus, - onTap: () async { - setState(() => _currentEditingField = 3); - final DateTime? picked = - await showDatePicker( - context: context, - initialDate: - _nextBillingDate ?? DateTime.now(), - firstDate: DateTime.now(), - lastDate: DateTime.now().add( - const Duration(days: 365 * 2), - ), - builder: (BuildContext context, - Widget? child) { - return Theme( - data: ThemeData.light().copyWith( - colorScheme: ColorScheme.light( - primary: _gradientColors[0], - onPrimary: Colors.white, - surface: Colors.white, - onSurface: Colors.black, - ), - ), - child: child!, - ); - }, - ); - if (picked != null) { - setState(() { - _nextBillingDate = picked; - _currentEditingField = -1; - _websiteUrlFocus.requestFocus(); - }); - } else { - setState(() => _currentEditingField = -1); - } - }, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - border: Border.all( - color: _nextBillingDate == null - ? Colors.red - : Colors.grey.withValues(alpha: 0.2), - ), - borderRadius: BorderRadius.circular(12), - color: Colors.white, - ), - child: Row( - children: [ - const Icon( - Icons.event_rounded, - color: Color(0xFF3B82F6), - ), - const SizedBox(width: 12), - Text( - _nextBillingDate == null - ? '결제일을 선택해주세요' - : DateFormat('yyyy년 MM월 dd일') - .format(_nextBillingDate!), - style: TextStyle( - fontSize: 16, - color: _nextBillingDate == null - ? Colors.grey.shade500 - : const Color(0xFF1E293B), - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ), - ], - ), - ), - - // 웹사이트 URL 필드 - AnimatedContainer( - duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.only(bottom: 20), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: _currentEditingField == 4 - ? const Color(0xFF3B82F6).withValues(alpha: 0.1) - : Colors.transparent, - ), - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '웹사이트 URL (선택)', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Color(0xFF3B82F6), - ), - ), - const SizedBox(height: 8), - TextFormField( - controller: _websiteUrlController, - focusNode: _websiteUrlFocus, - textInputAction: TextInputAction.done, - onTap: () => - setState(() => _currentEditingField = 4), - onEditingComplete: () { - setState(() => _currentEditingField = 5); - _categoryFocus.requestFocus(); - }, - style: const TextStyle( - color: Color(0xFF1E293B), - fontSize: 16, - fontWeight: FontWeight.w500, - ), - decoration: InputDecoration( - filled: true, - fillColor: Colors.white, - contentPadding: const EdgeInsets.symmetric( - vertical: 16, horizontal: 20), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: Colors.grey.withValues(alpha: 0.2), - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide( - color: Color(0xFF3B82F6), - width: 2, - ), - ), - hintText: 'https://netflix.com', - hintStyle: TextStyle( - color: Colors.grey.shade500, - fontSize: 16, - ), - ), - ), - ], - ), - ), - - // 카테고리 선택 필드 - AnimatedContainer( - duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.only(bottom: 20), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: _currentEditingField == 5 - ? const Color(0xFF3B82F6).withValues(alpha: 0.1) - : Colors.transparent, - ), - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '카테고리 (선택)', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Color(0xFF3B82F6), - ), - ), - const SizedBox(height: 8), - Consumer( - builder: (context, categoryProvider, child) { - // 카테고리가 없을 때 메시지 표시 - if (categoryProvider.categories.isEmpty) { - return InkWell( - onTap: () { - // 서비스명 분석 후 카테고리 자동 선택 - _autoSelectCategory(); - }, - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - vertical: 16, - horizontal: 16, - ), - decoration: BoxDecoration( - border: Border.all( - color: - Colors.grey.withValues(alpha: 0.2), - ), - borderRadius: - BorderRadius.circular(12), - color: Colors.white, - ), - child: Row( - children: [ - const Icon( - Icons.category_rounded, - color: Color(0xFF3B82F6), - ), - const SizedBox(width: 12), - const Expanded( - child: Text( - '카테고리 자동 설정', - style: TextStyle( - color: Color(0xFF1E293B), - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - ), - const Icon( - Icons.sync, - color: Color(0xFF3B82F6), - ), - ], - ), - ), - ); - } - - // 카테고리 드롭다운 표시 - return DropdownButtonFormField( - value: _selectedCategoryId, - focusNode: _categoryFocus, - onTap: () => setState( - () => _currentEditingField = 5), - onChanged: (value) { - setState(() { - _selectedCategoryId = value; - _currentEditingField = -1; - _categoryFocus.unfocus(); - }); - }, - icon: const Icon( - Icons.arrow_drop_down_circle_outlined, - color: Color(0xFF3B82F6), - ), - decoration: InputDecoration( - filled: true, - fillColor: Colors.white, - contentPadding: - const EdgeInsets.symmetric( - vertical: 16, horizontal: 20), - border: OutlineInputBorder( - borderRadius: - BorderRadius.circular(12), - borderSide: BorderSide( - color: Colors.grey.withValues(alpha: 0.2), - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: - BorderRadius.circular(12), - borderSide: const BorderSide( - color: Color(0xFF3B82F6), - width: 2, - ), - ), - ), - style: const TextStyle( - color: Colors.black87, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - hint: const Text( - '카테고리 선택', - style: TextStyle( - color: Colors.grey, - fontSize: 16, - ), - ), - isExpanded: true, - items: [ - DropdownMenuItem( - value: null, - child: const Text( - '카테고리 없음', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Colors.grey, - ), - ), - ), - ...categoryProvider.categories - .map((category) { - return DropdownMenuItem( - value: category.id, - child: Text( - category.name, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Colors.black87, - ), - ), - ); - }).toList(), - ], - ); - }, - ), - ], - ), - ), - - // 이벤트 설정 섹션 - Container( - margin: const EdgeInsets.only(bottom: 20), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: _isEventActive - ? const Color(0xFF3B82F6) - : Colors.grey.withValues(alpha: 0.2), - width: _isEventActive ? 2 : 1, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Checkbox( - value: _isEventActive, - onChanged: (value) { - setState(() { - _isEventActive = value ?? false; - if (!_isEventActive) { - // 이벤트 비활성화 시 관련 데이터 초기화 - _eventStartDate = DateTime.now(); // 오늘로 재설정 - _eventEndDate = DateTime.now().add(const Duration(days: 30)); // 30일 후로 재설정 - _eventPriceController.clear(); - } else { - // 이벤트 활성화 시 날짜가 null이면 기본값 설정 - _eventStartDate ??= DateTime.now(); - _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: _isEventActive - ? const Color(0xFF3B82F6) - : Colors.grey, - ), - ], - ), - - // 이벤트 활성화 시 추가 필드 표시 - AnimatedContainer( - duration: const Duration(milliseconds: 300), - height: _isEventActive ? null : 0, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 300), - opacity: _isEventActive ? 1.0 : 0.0, - child: Column( - children: [ - const SizedBox(height: 16), - - // 이벤트 기간 설정 - Row( - children: [ - // 시작일 - Expanded( - child: InkWell( - onTap: () async { - final DateTime? picked = await showDatePicker( - context: context, - initialDate: _eventStartDate ?? DateTime.now(), - firstDate: DateTime.now().subtract(const Duration(days: 365)), - lastDate: DateTime.now().add(const Duration(days: 365 * 2)), - builder: (BuildContext context, Widget? child) { - return Theme( - data: ThemeData.light().copyWith( - colorScheme: ColorScheme.light( - primary: const Color(0xFF3B82F6), - onPrimary: Colors.white, - surface: Colors.white, - onSurface: Colors.black, - ), - ), - child: child!, - ); - }, - ); - if (picked != null) { - setState(() { - _eventStartDate = picked; - }); - } - }, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - border: Border.all(color: Colors.grey.withValues(alpha: 0.3)), - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '시작일', - style: TextStyle( - fontSize: 12, - color: Colors.grey, - ), - ), - const SizedBox(height: 4), - Text( - _eventStartDate == null - ? '선택' - : DateFormat('MM/dd').format(_eventStartDate!), - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ), - ), - const SizedBox(width: 8), - const Icon(Icons.arrow_forward, color: Colors.grey), - const SizedBox(width: 8), - // 종료일 - Expanded( - child: InkWell( - onTap: () async { - final DateTime? picked = await showDatePicker( - context: context, - initialDate: _eventEndDate ?? DateTime.now().add(const Duration(days: 30)), - firstDate: _eventStartDate ?? DateTime.now(), - lastDate: DateTime.now().add(const Duration(days: 365 * 2)), - builder: (BuildContext context, Widget? child) { - return Theme( - data: ThemeData.light().copyWith( - colorScheme: ColorScheme.light( - primary: const Color(0xFF3B82F6), - onPrimary: Colors.white, - surface: Colors.white, - onSurface: Colors.black, - ), - ), - child: child!, - ); - }, - ); - if (picked != null) { - setState(() { - _eventEndDate = picked; - }); - } - }, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - border: Border.all(color: Colors.grey.withValues(alpha: 0.3)), - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '종료일', - style: TextStyle( - fontSize: 12, - color: Colors.grey, - ), - ), - const SizedBox(height: 4), - Text( - _eventEndDate == null - ? '선택' - : DateFormat('MM/dd').format(_eventEndDate!), - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ), - ), - ], - ), - - const SizedBox(height: 16), - - // 이벤트 가격 입력 - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '이벤트 가격', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Color(0xFF3B82F6), - ), - ), - const SizedBox(height: 8), - TextFormField( - controller: _eventPriceController, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp(r'[\d,.]')), - ], - decoration: InputDecoration( - hintText: '할인된 가격을 입력하세요', - prefixText: _currency == 'KRW' ? '₩ ' : '\$ ', - filled: true, - fillColor: Colors.white, - contentPadding: const EdgeInsets.symmetric( - vertical: 16, - horizontal: 16, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: Colors.grey.withValues(alpha: 0.2), - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide( - color: Color(0xFF3B82F6), - width: 2, - ), - ), - ), - onChanged: (value) { - // 콤마 자동 추가 - if (value.isNotEmpty && !value.contains('.')) { - final number = int.tryParse(value.replaceAll(',', '')); - if (number != null) { - final formatted = NumberFormat('#,###').format(number); - if (formatted != value) { - _eventPriceController.value = TextEditingValue( - text: formatted, - selection: TextSelection.collapsed( - offset: formatted.length, - ), - ); - } - } - } - }, - ), - ], - ), - ], - ), - ), - ), - ], - ), - ), - ], - ), - ), - ), - ), + + // 서비스 정보 폼 + AddSubscriptionForm( + controller: _controller, + fadeAnimation: _controller.fadeAnimation!, + slideAnimation: _controller.slideAnimation!, + setState: setState, + ), + const SizedBox(height: 16), + + // 이벤트/할인 섹션 + AddSubscriptionEventSection( + controller: _controller, + fadeAnimation: _controller.fadeAnimation!, + slideAnimation: _controller.slideAnimation!, + setState: setState, ), - const SizedBox(height: 32), - + // 저장 버튼 - FadeTransition( - opacity: Tween(begin: 0.0, end: 1.0).animate( - CurvedAnimation( - parent: _animationController, - curve: const Interval(0.6, 1.0, curve: Curves.easeIn), - ), - ), - child: SlideTransition( - position: Tween( - begin: const Offset(0.0, 0.6), - end: Offset.zero, - ).animate(CurvedAnimation( - parent: _animationController, - curve: const Interval(0.6, 1.0, curve: Curves.easeOutCubic), - )), - child: MouseRegion( - onEnter: (_) => setState(() => _isSaveHovered = true), - onExit: (_) => setState(() => _isSaveHovered = false), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - width: double.infinity, - height: 60, - transform: _isSaveHovered - ? (Matrix4.identity()..scale(1.02)) - : Matrix4.identity(), - child: ElevatedButton( - onPressed: _isLoading ? null : _saveSubscription, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF3B82F6), - foregroundColor: Colors.white, - disabledBackgroundColor: Colors.grey.withValues(alpha: 0.3), - disabledForegroundColor: - Colors.white.withValues(alpha: 0.5), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - padding: const EdgeInsets.symmetric(vertical: 16), - elevation: _isSaveHovered ? 8 : 4, - shadowColor: const Color(0xFF3B82F6).withValues(alpha: 0.5), - ), - child: _isLoading - ? const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation( - Colors.white), - ), - ) - : Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.add_circle_outline, - color: Colors.white, - size: _isSaveHovered ? 24 : 20, - ), - const SizedBox(width: 8), - const Text( - '구독 추가하기', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - ), - ], - ), - ), - ), - ), - ), + AddSubscriptionSaveButton( + controller: _controller, + fadeAnimation: _controller.fadeAnimation!, + slideAnimation: _controller.slideAnimation!, + setState: setState, ), - - const SizedBox(height: 80), ], ), ), ), ); } -} +} \ No newline at end of file diff --git a/lib/screens/add_subscription_screen_old.dart b/lib/screens/add_subscription_screen_old.dart new file mode 100644 index 0000000..0bc3f87 --- /dev/null +++ b/lib/screens/add_subscription_screen_old.dart @@ -0,0 +1,2015 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:intl/intl.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/services.dart'; +import 'dart:math' as math; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import '../providers/subscription_provider.dart'; +import '../providers/category_provider.dart'; +import '../services/sms_service.dart'; +import '../services/subscription_url_matcher.dart'; +import '../services/exchange_rate_service.dart'; + +class AddSubscriptionScreen extends StatefulWidget { + const AddSubscriptionScreen({Key? key}) : super(key: key); + + @override + State createState() => _AddSubscriptionScreenState(); +} + +class _AddSubscriptionScreenState extends State + with SingleTickerProviderStateMixin { + final _formKey = GlobalKey(); + final _serviceNameController = TextEditingController(); + final _monthlyCostController = TextEditingController(); + final _nextBillingDateController = TextEditingController(); + final _websiteUrlController = TextEditingController(); + String _billingCycle = '월간'; + String _currency = 'KRW'; + DateTime? _nextBillingDate; + bool _isLoading = false; + String? _selectedCategoryId; + + // 이벤트 관련 상태 변수 + bool _isEventActive = false; + DateTime? _eventStartDate = DateTime.now(); // 오늘로 초기화 + DateTime? _eventEndDate = DateTime.now().add(const Duration(days: 30)); // 30일 후로 초기화 + final _eventPriceController = TextEditingController(); + + // 포커스 노드 추가 + final _serviceNameFocus = FocusNode(); + final _monthlyCostFocus = FocusNode(); + final _billingCycleFocus = FocusNode(); + final _nextBillingDateFocus = FocusNode(); + final _websiteUrlFocus = FocusNode(); + final _categoryFocus = FocusNode(); + final _currencyFocus = FocusNode(); + + // 애니메이션 컨트롤러 + late AnimationController _animationController; + late Animation _fadeAnimation; + late Animation _slideAnimation; + + // 스크롤 컨트롤러 + final ScrollController _scrollController = ScrollController(); + double _scrollOffset = 0; + + // 현재 편집 중인 필드 + int _currentEditingField = -1; + + // 호버 상태 + bool _isSaveHovered = false; + + final List _gradientColors = [ + const Color(0xFF3B82F6), + const Color(0xFF0EA5E9), + const Color(0xFF06B6D4), + ]; + + @override + void initState() { + super.initState(); + + // 결제일 기본값을 오늘 날짜로 설정 + _nextBillingDate = DateTime.now(); + + // 디버깅 정보 출력 + + // 서비스명 컨트롤러에 리스너 추가 + _serviceNameController.addListener(_onServiceNameChanged); + + // 애니메이션 컨트롤러 초기화 + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 800), + ); + + _fadeAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _animationController, + curve: Curves.easeIn, + )); + + _slideAnimation = Tween( + begin: const Offset(0.0, 0.2), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _animationController, + curve: Curves.easeOutCubic, + )); + + _scrollController.addListener(() { + setState(() { + _scrollOffset = _scrollController.offset; + }); + }); + + _animationController.forward(); + } + + @override + void dispose() { + _serviceNameController.removeListener(_onServiceNameChanged); + _serviceNameController.dispose(); + _monthlyCostController.dispose(); + _nextBillingDateController.dispose(); + _websiteUrlController.dispose(); + _eventPriceController.dispose(); + _animationController.dispose(); + _scrollController.dispose(); + + // 포커스 노드 해제 + _serviceNameFocus.dispose(); + _monthlyCostFocus.dispose(); + _billingCycleFocus.dispose(); + _nextBillingDateFocus.dispose(); + _websiteUrlFocus.dispose(); + _categoryFocus.dispose(); + _currencyFocus.dispose(); + + super.dispose(); + } + + // 서비스명이 변경될 때 호출되는 콜백 함수 + void _onServiceNameChanged() { + if (_serviceNameController.text.isNotEmpty && + _websiteUrlController.text.isEmpty) { + // 자동 URL 매칭 시도 + final suggestedUrl = + SubscriptionUrlMatcher.suggestUrl(_serviceNameController.text); + + // 매칭된 URL이 있으면 텍스트 컨트롤러에 설정 + if (suggestedUrl != null && suggestedUrl.isNotEmpty) { + setState(() { + _websiteUrlController.text = suggestedUrl; + }); + } + } + + // 서비스명이 변경될 때 카테고리 자동 선택 시도 + if (_serviceNameController.text.isNotEmpty && _selectedCategoryId == null) { + _autoSelectCategory(); + } + } + + // 서비스명을 기반으로 카테고리 자동 선택 함수 + void _autoSelectCategory() { + if (_serviceNameController.text.isEmpty) return; + + final serviceName = _serviceNameController.text.toLowerCase(); + final categoryProvider = + Provider.of(context, listen: false); + + // 카테고리가 없으면 리턴 + if (categoryProvider.categories.isEmpty) return; + + // OTT 서비스 확인 + if (SubscriptionUrlMatcher.ottServices.keys.any((key) => + serviceName.contains(key.toLowerCase()) || + key.toLowerCase().contains(serviceName))) { + // OTT 관련 카테고리 찾기 + try { + final ottCategory = categoryProvider.categories.firstWhere( + (cat) => + cat.name.contains('OTT') || + cat.name.contains('미디어') || + cat.name.contains('영상'), + ); + + setState(() { + _selectedCategoryId = ottCategory.id; + }); + return; + } catch (_) { + // OTT 카테고리가 없으면 첫 번째 카테고리 사용 + if (categoryProvider.categories.isNotEmpty) { + setState(() { + _selectedCategoryId = categoryProvider.categories.first.id; + }); + } + } + } + + // 음악 서비스 확인 + if (SubscriptionUrlMatcher.musicServices.keys.any((key) => + serviceName.contains(key.toLowerCase()) || + key.toLowerCase().contains(serviceName))) { + // 음악 관련 카테고리 찾기 + try { + final musicCategory = categoryProvider.categories.firstWhere( + (cat) => cat.name.contains('음악') || cat.name.contains('스트리밍'), + ); + + setState(() { + _selectedCategoryId = musicCategory.id; + }); + return; + } catch (_) { + // 음악 카테고리가 없으면 첫 번째 카테고리 사용 + if (categoryProvider.categories.isNotEmpty) { + setState(() { + _selectedCategoryId = categoryProvider.categories.first.id; + }); + } + } + } + + // AI 서비스 확인 + if (SubscriptionUrlMatcher.aiServices.keys.any((key) => + serviceName.contains(key.toLowerCase()) || + key.toLowerCase().contains(serviceName))) { + // AI 관련 카테고리 찾기 + try { + final aiCategory = categoryProvider.categories.firstWhere( + (cat) => cat.name.contains('AI') || cat.name.contains('인공지능'), + ); + + setState(() { + _selectedCategoryId = aiCategory.id; + }); + return; + } catch (_) { + // AI 카테고리가 없으면 첫 번째 카테고리 사용 + if (categoryProvider.categories.isNotEmpty) { + setState(() { + _selectedCategoryId = categoryProvider.categories.first.id; + }); + } + } + } + + // 프로그래밍/개발 서비스 확인 + if (SubscriptionUrlMatcher.programmingServices.keys.any((key) => + serviceName.contains(key.toLowerCase()) || + key.toLowerCase().contains(serviceName))) { + // 개발 관련 카테고리 찾기 + try { + final devCategory = categoryProvider.categories.firstWhere( + (cat) => cat.name.contains('개발') || cat.name.contains('프로그래밍'), + ); + + setState(() { + _selectedCategoryId = devCategory.id; + }); + return; + } catch (_) { + // 개발 카테고리가 없으면 첫 번째 카테고리 사용 + if (categoryProvider.categories.isNotEmpty) { + setState(() { + _selectedCategoryId = categoryProvider.categories.first.id; + }); + } + } + } + + // 오피스/협업 툴 확인 + if (SubscriptionUrlMatcher.officeTools.keys.any((key) => + serviceName.contains(key.toLowerCase()) || + key.toLowerCase().contains(serviceName))) { + // 오피스 관련 카테고리 찾기 + try { + final officeCategory = categoryProvider.categories.firstWhere( + (cat) => + cat.name.contains('오피스') || + cat.name.contains('협업') || + cat.name.contains('업무'), + ); + + setState(() { + _selectedCategoryId = officeCategory.id; + }); + return; + } catch (_) { + // 오피스 카테고리가 없으면 첫 번째 카테고리 사용 + if (categoryProvider.categories.isNotEmpty) { + setState(() { + _selectedCategoryId = categoryProvider.categories.first.id; + }); + } + } + } + + // 기타 서비스 확인 + if (SubscriptionUrlMatcher.otherServices.keys.any((key) => + serviceName.contains(key.toLowerCase()) || + key.toLowerCase().contains(serviceName))) { + // 기타 관련 카테고리 찾기 + try { + final otherCategory = categoryProvider.categories.firstWhere( + (cat) => cat.name.contains('기타') || cat.name.contains('게임'), + ); + + setState(() { + _selectedCategoryId = otherCategory.id; + }); + } catch (_) { + // 기타 카테고리가 없으면 첫 번째 카테고리 사용 + if (categoryProvider.categories.isNotEmpty) { + setState(() { + _selectedCategoryId = categoryProvider.categories.first.id; + }); + } + } + } + } + + Future _scanSMS() async { + if (kIsWeb) return; + + setState(() => _isLoading = true); + + try { + if (!await SMSService.hasSMSPermission()) { + final granted = await SMSService.requestSMSPermission(); + if (!granted) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.error_outline, color: Colors.white), + const SizedBox(width: 12), + const Expanded(child: Text('SMS 권한이 필요합니다.')), + ], + ), + behavior: SnackBarBehavior.floating, + backgroundColor: Colors.red, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + ), + ); + } + return; + } + } + + final subscriptions = await SMSService.scanSubscriptions(); + if (subscriptions.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.info_outline, color: Colors.white), + const SizedBox(width: 12), + const Expanded(child: Text('구독 관련 SMS를 찾을 수 없습니다.')), + ], + ), + behavior: SnackBarBehavior.floating, + backgroundColor: Colors.orange, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + ), + ); + } + return; + } + + final subscription = subscriptions.first; + setState(() { + _serviceNameController.text = subscription['serviceName'] ?? ''; + + // 비용 처리 및 통화 단위 자동 감지 + final costValue = subscription['monthlyCost']?.toString() ?? ''; + + // costValue가 비어있지 않을 경우에만 처리 + if (costValue.isNotEmpty) { + // 달러 표시가 있거나 소수점이 있으면 달러로 판단 + if (costValue.contains('\$') || costValue.contains('.')) { + // 달러로 설정 + _currency = 'USD'; + + // 달러 기호 제거 및 숫자만 추출 + String numericValue = costValue.replaceAll('\$', '').trim(); + + // 소수점이 없는 경우 소수점 추가 + if (!numericValue.contains('.')) { + numericValue = '$numericValue.00'; + } + + // 3자리마다 콤마 추가하여 포맷팅 + final double parsedValue = + double.tryParse(numericValue.replaceAll(',', '')) ?? 0.0; + _monthlyCostController.text = + NumberFormat('#,##0.00').format(parsedValue); + } else { + // 원화로 설정 + _currency = 'KRW'; + + // ₩ 기호와 콤마 제거 + String numericValue = + costValue.replaceAll('₩', '').replaceAll(',', '').trim(); + + // 숫자로 변환하여 정수로 포맷팅 + final int parsedValue = int.tryParse(numericValue) ?? 0; + _monthlyCostController.text = + NumberFormat.decimalPattern().format(parsedValue); + } + } else { + _monthlyCostController.text = ''; + } + + _billingCycle = subscription['billingCycle'] ?? '월간'; + _nextBillingDate = subscription['nextBillingDate'] != null + ? DateTime.parse(subscription['nextBillingDate']) + : DateTime.now(); + + // 서비스명이 있으면 URL 자동 매칭 시도 + if (subscription['serviceName'] != null && + subscription['serviceName'].isNotEmpty) { + final suggestedUrl = + SubscriptionUrlMatcher.suggestUrl(subscription['serviceName']); + if (suggestedUrl != null) { + _websiteUrlController.text = suggestedUrl; + } + + // 서비스명 기반으로 카테고리 자동 선택 + _autoSelectCategory(); + } + + // 애니메이션 재생 + _animationController.reset(); + _animationController.forward(); + }); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.error_outline, color: Colors.white), + const SizedBox(width: 12), + Expanded(child: Text('SMS 스캔 중 오류 발생: $e')), + ], + ), + behavior: SnackBarBehavior.floating, + backgroundColor: Colors.red, + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + Future _saveSubscription() async { + if (_formKey.currentState!.validate() && _nextBillingDate != null) { + setState(() { + _isLoading = true; + }); + + try { + // 콤마 제거하고 숫자만 추출 + final monthlyCost = + double.parse(_monthlyCostController.text.replaceAll(',', '')); + + // 이벤트 가격 파싱 + double? eventPrice; + if (_isEventActive && _eventPriceController.text.isNotEmpty) { + eventPrice = double.tryParse(_eventPriceController.text.replaceAll(',', '')); + } + + await Provider.of(context, listen: false) + .addSubscription( + serviceName: _serviceNameController.text.trim(), + monthlyCost: monthlyCost, + billingCycle: _billingCycle, + nextBillingDate: _nextBillingDate!, + websiteUrl: _websiteUrlController.text.trim(), + categoryId: _selectedCategoryId, + currency: _currency, + isEventActive: _isEventActive, + eventStartDate: _eventStartDate, + eventEndDate: _eventEndDate, + eventPrice: eventPrice, + ); + + if (mounted) { + Navigator.pop(context, true); // 성공 여부 반환 + } + } catch (e) { + setState(() { + _isLoading = false; + }); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('저장 중 오류가 발생했습니다: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } else { + _scrollController.animateTo( + 0.0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + } + + @override + Widget build(BuildContext context) { + final double appBarOpacity = math.max(0, math.min(1, _scrollOffset / 100)); + + + return Scaffold( + backgroundColor: const Color(0xFFF8FAFC), + extendBodyBehindAppBar: true, + appBar: PreferredSize( + preferredSize: const Size.fromHeight(60), + child: Container( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: appBarOpacity), + boxShadow: appBarOpacity > 0.6 + ? [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1 * appBarOpacity), + spreadRadius: 1, + blurRadius: 8, + offset: const Offset(0, 4), + ) + ] + : null, + ), + child: SafeArea( + child: AppBar( + title: Text( + '구독 추가', + style: TextStyle( + fontFamily: 'Montserrat', + fontSize: 24, + fontWeight: FontWeight.w800, + letterSpacing: -0.5, + color: const Color(0xFF1E293B), + shadows: appBarOpacity > 0.6 + ? [ + Shadow( + color: Colors.black.withValues(alpha: 0.2), + offset: const Offset(0, 1), + blurRadius: 2, + ) + ] + : null, + ), + ), + elevation: 0, + backgroundColor: Colors.transparent, + actions: [ + if (!kIsWeb) + _isLoading + ? const Padding( + padding: EdgeInsets.only(right: 16.0), + child: Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Color(0xFF3B82F6)), + ), + ), + ), + ) + : IconButton( + icon: const FaIcon(FontAwesomeIcons.message, + size: 20, color: Color(0xFF3B82F6)), + onPressed: _scanSMS, + tooltip: 'SMS에서 구독 정보 스캔', + ), + ], + ), + ), + ), + ), + body: SingleChildScrollView( + controller: _scrollController, + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(16), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: MediaQuery.of(context).padding.top + 60), + // 헤더 섹션 + FadeTransition( + opacity: _fadeAnimation, + child: SlideTransition( + position: _slideAnimation, + child: Container( + margin: const EdgeInsets.only(bottom: 24), + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + gradient: LinearGradient( + colors: _gradientColors, + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + boxShadow: [ + BoxShadow( + color: _gradientColors[0].withValues(alpha: 0.3), + blurRadius: 20, + spreadRadius: 0, + offset: const Offset(0, 8), + ), + ], + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(16), + ), + child: const Icon( + Icons.add_rounded, + size: 32, + color: Colors.white, + ), + ), + const SizedBox(width: 16), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '새 구독 추가', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w800, + color: Colors.white, + letterSpacing: -0.5, + ), + ), + SizedBox(height: 4), + Text( + '서비스 정보를 입력해주세요', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.white70, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + + // 서비스 정보 카드 + FadeTransition( + opacity: Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: _animationController, + curve: const Interval(0.2, 1.0, curve: Curves.easeIn), + ), + ), + child: SlideTransition( + position: Tween( + begin: const Offset(0.0, 0.4), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _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: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + ShaderMask( + shaderCallback: (bounds) => LinearGradient( + colors: _gradientColors, + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ).createShader(bounds), + child: const Icon( + FontAwesomeIcons.fileLines, + size: 20, + color: Colors.white, + ), + ), + const SizedBox(width: 12), + const Text( + '서비스 정보', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + color: Color(0xFF1E293B), + ), + ), + ], + ), + const SizedBox(height: 24), + + // 서비스명 필드 + AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: _currentEditingField == 0 + ? const Color(0xFF3B82F6).withValues(alpha: 0.1) + : Colors.transparent, + ), + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '서비스명', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF3B82F6), + ), + ), + const SizedBox(height: 8), + TextFormField( + controller: _serviceNameController, + focusNode: _serviceNameFocus, + textInputAction: TextInputAction.next, + onTap: () => + setState(() => _currentEditingField = 0), + onEditingComplete: () { + _monthlyCostFocus.requestFocus(); + setState(() => _currentEditingField = -1); + }, + validator: (value) { + if (value == null || value.isEmpty) { + return '서비스명을 입력해주세요'; + } + return null; + }, + style: const TextStyle( + color: Color(0xFF1E293B), + fontSize: 16, + fontWeight: FontWeight.w500, + ), + decoration: InputDecoration( + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.symmetric( + vertical: 16, horizontal: 20), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Colors.grey.withValues(alpha: 0.2), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide( + color: Color(0xFF3B82F6), + width: 2, + ), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide( + color: Colors.red, + width: 2, + ), + ), + hintText: '넷플릭스', + hintStyle: TextStyle( + color: Colors.grey.shade500, + fontSize: 16, + ), + ), + ), + ], + ), + ), + + // 월 비용 필드 + AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: _currentEditingField == 1 + ? const Color(0xFF3B82F6).withValues(alpha: 0.1) + : Colors.transparent, + ), + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 환율 정보와 비용 입력 제목 표시 (상단) + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Text( + '비용 입력', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF3B82F6), + ), + ), + if (_currency == 'USD') + FutureBuilder( + future: ExchangeRateService() + .getFormattedExchangeRateInfo(), + builder: (context, snapshot) { + if (snapshot.hasData) { + return Text( + snapshot.data!, + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + fontWeight: FontWeight.w500, + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + // 통화 단위 선택 (좌측) + Expanded( + flex: 3, // 25% 너비 차지 + child: DropdownButtonFormField( + value: _currency, + focusNode: _currencyFocus, + isDense: true, + onTap: () => setState( + () => _currentEditingField = 1), + onChanged: (value) { + if (value != null) { + setState(() { + _currency = value; + + // 통화 단위 변경 시 입력 값 변환 + final currentText = + _monthlyCostController.text; + if (currentText.isNotEmpty) { + // 콤마 제거하고 숫자만 추출 + final numericValue = + double.tryParse(currentText + .replaceAll(',', '')); + + if (numericValue != null) { + if (value == 'KRW') { + // 달러 → 원화: 소수점 제거 + _monthlyCostController + .text = NumberFormat + .decimalPattern() + .format(numericValue + .toInt()); + } else { + // 원화 → 달러: 소수점 2자리 추가 + _monthlyCostController + .text = NumberFormat( + '#,##0.00') + .format(numericValue); + } + } + } + + // 화면 갱신하여 통화 기호도 업데이트 + _monthlyCostFocus.requestFocus(); + }); + } + }, + decoration: InputDecoration( + filled: true, + fillColor: Colors.white, + contentPadding: + const EdgeInsets.symmetric( + vertical: 16, horizontal: 12), + border: OutlineInputBorder( + borderRadius: + BorderRadius.circular(12), + borderSide: BorderSide( + color: + Colors.grey.withValues(alpha: 0.2), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: + BorderRadius.circular(12), + borderSide: const BorderSide( + color: Color(0xFF3B82F6), + width: 2, + ), + ), + ), + icon: const Icon( + Icons.arrow_drop_down, + color: Color(0xFF3B82F6), + ), + items: ['KRW', 'USD'] + .map((currency) => DropdownMenuItem( + value: currency, + child: Text( + currency == 'KRW' + ? 'KRW' + : 'USD', + style: const TextStyle( + fontSize: 14, + fontWeight: + FontWeight.w500, + color: Colors.black87, + ), + ), + )) + .toList(), + ), + ), + const SizedBox(width: 8), + // 월 비용 입력 필드 (우측) - 테두리 제거 및 배경색 통일 + Expanded( + flex: 7, // 75% 너비 차지 + child: Container( + height: 50, // 높이를 56에서 50으로 줄임 + // 우측에서 40픽셀 줄이기 + margin: const EdgeInsets.only(right: 0), + // 내부 패딩을 고정값으로 설정하여 포커스 상태와 관계없이 일관되게 유지 + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + // 포커스 상태에 따른 배경색 변경 (배경색만 변경) + color: _currentEditingField == 1 + ? const Color( + 0xFFF3F4F6) // 포커스 상태일 때 연한 회색 + : Colors + .transparent, // 포커스 없을 때 투명 + borderRadius: + BorderRadius.circular(12), + // 테두리 설정 (포커스 상태에 따라 색상만 변경) + border: Border.all( + color: _currentEditingField == 1 + ? const Color(0xFF3B82F6) + : Colors.grey.withValues(alpha: + 0.4), // 포커스 없을 때 더 진한 회색 + width: _currentEditingField == 1 + ? 2 + : 1, + ), + ), + child: Row( + children: [ + // 통화 기호 - 항상 표시되도록 수정 + Container( + width: 40, + alignment: Alignment.center, + decoration: BoxDecoration( + // 테두리 추가 (좌측 통화선택란과 동일한 스타일) + border: Border( + right: BorderSide( + color: Colors.grey + .withValues(alpha: 0.2), + width: 1, + ), + ), + ), + child: Text( + _currency == 'KRW' ? '₩' : '\$', + style: const TextStyle( + color: Color(0xFF3B82F6), + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), + // 실제 입력 필드 - 기호 관련 코드 제거하여 중복 방지 + Expanded( + child: Stack( + alignment: + Alignment.centerRight, + children: [ + TextField( + controller: + _monthlyCostController, + focusNode: + _monthlyCostFocus, + textInputAction: + TextInputAction.next, + keyboardType: + const TextInputType + .numberWithOptions( + decimal: true), + inputFormatters: [ + // 통화 단위에 따라 다른 입력 형식 적용 + FilteringTextInputFormatter + .allow( + _currency == 'KRW' + ? RegExp( + r'[0-9,]') // 원화: 정수만 허용 + : RegExp( + r'[0-9,.]'), // 달러: 소수점 허용 + ), + // 커스텀 포맷터 - 3자리마다 콤마 추가 + TextInputFormatter + .withFunction( + (oldValue, + newValue) { + // 입력값에서 콤마 제거 + final text = newValue + .text + .replaceAll( + ',', ''); + + if (text.isEmpty) { + return newValue + .copyWith( + text: ''); + } + + // 숫자 형식 검증 + if (_currency == + 'KRW') { + // 원화: 정수 형식 + if (double.tryParse( + text) == + null) { + return oldValue; + } + + // 3자리마다 콤마 추가 + final formattedValue = + NumberFormat + .decimalPattern() + .format( + int.parse( + text)); + + return newValue + .copyWith( + text: + formattedValue, + selection: TextSelection + .collapsed( + offset: formattedValue + .length), + ); + } else { + // 달러: 소수점 형식 + if (double.tryParse( + text) == + null && + text != '.') { + return oldValue; + } + + // 소수점 이하 처리를 위해 부분 분리 + final parts = + text.split('.'); + final integerPart = + parts[0]; + final decimalPart = parts + .length > + 1 + ? '.${parts[1].length > 2 ? parts[1].substring(0, 2) : parts[1]}' + : ''; + + // 3자리마다 콤마 추가 (정수 부분만) + String formattedValue; + if (integerPart + .isEmpty) { + formattedValue = + '0$decimalPart'; + } else { + final formatted = NumberFormat + .decimalPattern() + .format(int.parse( + integerPart)); + formattedValue = + '$formatted$decimalPart'; + } + + return newValue + .copyWith( + text: + formattedValue, + selection: TextSelection + .collapsed( + offset: formattedValue + .length), + ); + } + }), + ], + onTap: () => setState(() => + _currentEditingField = + 1), + onSubmitted: (_) { + _billingCycleFocus + .requestFocus(); + setState(() => + _currentEditingField = + -1); + }, + style: const TextStyle( + color: Color(0xFF1E293B), + fontSize: 16, + fontWeight: + FontWeight.w500, + ), + decoration: InputDecoration( + border: InputBorder.none, + // 포커스 상태와 관계없이 일관된 패딩 유지 + contentPadding: + const EdgeInsets + .symmetric( + vertical: 14, + horizontal: 8), + hintText: + _currency == 'KRW' + ? '9,000' + : '9.99', + hintStyle: TextStyle( + color: Colors + .grey.shade500, + fontSize: 16, + ), + // 모든 테두리 제거 + enabledBorder: + InputBorder.none, + focusedBorder: + InputBorder.none, + errorBorder: + InputBorder.none, + disabledBorder: + InputBorder.none, + focusedErrorBorder: + InputBorder.none, + ), + ), + // 달러일 때 원화 환산 금액 표시 + if (_currency == 'USD') + ValueListenableBuilder< + TextEditingValue>( + valueListenable: + _monthlyCostController, + builder: (context, value, + child) { + // 입력값이 바뀔 때마다 환산 금액 갱신 + return FutureBuilder< + String>( + future: ExchangeRateService() + .getFormattedKrwAmount( + double.tryParse(value + .text + .replaceAll( + ',', + '')) ?? + 0.0), + builder: (context, + snapshot) { + if (snapshot + .hasData && + snapshot.data! + .isNotEmpty) { + return Padding( + padding: + const EdgeInsets + .only( + right: + 12.0), + child: Text( + snapshot + .data!, + style: + const TextStyle( + fontSize: + 14, + color: Colors + .blue, + fontWeight: + FontWeight + .w500, + ), + ), + ); + } + return const SizedBox + .shrink(); + }, + ); + }, + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ], + ), + ), + + // 결제 주기 필드 + AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: _currentEditingField == 2 + ? const Color(0xFF3B82F6).withValues(alpha: 0.1) + : Colors.transparent, + ), + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '결제 주기', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF3B82F6), + ), + ), + const SizedBox(height: 8), + DropdownButtonFormField( + value: _billingCycle, + focusNode: _billingCycleFocus, + onTap: () => + setState(() => _currentEditingField = 2), + onChanged: (value) { + if (value != null) { + setState(() { + _billingCycle = value; + _currentEditingField = -1; + _nextBillingDateFocus.requestFocus(); + }); + } + }, + decoration: InputDecoration( + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.all(16), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Colors.grey.withValues(alpha: 0.2), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide( + color: Color(0xFF3B82F6), + width: 2, + ), + ), + prefixIcon: const Icon( + Icons.calendar_today_rounded, + color: Color(0xFF3B82F6), + ), + ), + icon: const Icon( + Icons.arrow_drop_down_circle_outlined, + color: Color(0xFF3B82F6), + ), + elevation: 2, + dropdownColor: Colors.white, + style: const TextStyle( + color: Colors.black87, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + items: ['월간', '연간', '주간'] + .map((cycle) => DropdownMenuItem( + value: cycle, + child: Text( + cycle, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Colors.black87, + ), + ), + )) + .toList(), + ), + ], + ), + ), + + // 다음 결제일 필드 + AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: _currentEditingField == 3 + ? const Color(0xFF3B82F6).withValues(alpha: 0.1) + : Colors.transparent, + ), + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '다음 결제일', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF3B82F6), + ), + ), + const SizedBox(height: 8), + InkWell( + focusNode: _nextBillingDateFocus, + onTap: () async { + setState(() => _currentEditingField = 3); + final DateTime? picked = + await showDatePicker( + context: context, + initialDate: + _nextBillingDate ?? DateTime.now(), + firstDate: DateTime.now(), + lastDate: DateTime.now().add( + const Duration(days: 365 * 2), + ), + builder: (BuildContext context, + Widget? child) { + return Theme( + data: ThemeData.light().copyWith( + colorScheme: ColorScheme.light( + primary: _gradientColors[0], + onPrimary: Colors.white, + surface: Colors.white, + onSurface: Colors.black, + ), + ), + child: child!, + ); + }, + ); + if (picked != null) { + setState(() { + _nextBillingDate = picked; + _currentEditingField = -1; + _websiteUrlFocus.requestFocus(); + }); + } else { + setState(() => _currentEditingField = -1); + } + }, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all( + color: _nextBillingDate == null + ? Colors.red + : Colors.grey.withValues(alpha: 0.2), + ), + borderRadius: BorderRadius.circular(12), + color: Colors.white, + ), + child: Row( + children: [ + const Icon( + Icons.event_rounded, + color: Color(0xFF3B82F6), + ), + const SizedBox(width: 12), + Text( + _nextBillingDate == null + ? '결제일을 선택해주세요' + : DateFormat('yyyy년 MM월 dd일') + .format(_nextBillingDate!), + style: TextStyle( + fontSize: 16, + color: _nextBillingDate == null + ? Colors.grey.shade500 + : const Color(0xFF1E293B), + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ], + ), + ), + + // 웹사이트 URL 필드 + AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: _currentEditingField == 4 + ? const Color(0xFF3B82F6).withValues(alpha: 0.1) + : Colors.transparent, + ), + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '웹사이트 URL (선택)', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF3B82F6), + ), + ), + const SizedBox(height: 8), + TextFormField( + controller: _websiteUrlController, + focusNode: _websiteUrlFocus, + textInputAction: TextInputAction.done, + onTap: () => + setState(() => _currentEditingField = 4), + onEditingComplete: () { + setState(() => _currentEditingField = 5); + _categoryFocus.requestFocus(); + }, + style: const TextStyle( + color: Color(0xFF1E293B), + fontSize: 16, + fontWeight: FontWeight.w500, + ), + decoration: InputDecoration( + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.symmetric( + vertical: 16, horizontal: 20), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Colors.grey.withValues(alpha: 0.2), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide( + color: Color(0xFF3B82F6), + width: 2, + ), + ), + hintText: 'https://netflix.com', + hintStyle: TextStyle( + color: Colors.grey.shade500, + fontSize: 16, + ), + ), + ), + ], + ), + ), + + // 카테고리 선택 필드 + AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: _currentEditingField == 5 + ? const Color(0xFF3B82F6).withValues(alpha: 0.1) + : Colors.transparent, + ), + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '카테고리 (선택)', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF3B82F6), + ), + ), + const SizedBox(height: 8), + Consumer( + builder: (context, categoryProvider, child) { + // 카테고리가 없을 때 메시지 표시 + if (categoryProvider.categories.isEmpty) { + return InkWell( + onTap: () { + // 서비스명 분석 후 카테고리 자동 선택 + _autoSelectCategory(); + }, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + vertical: 16, + horizontal: 16, + ), + decoration: BoxDecoration( + border: Border.all( + color: + Colors.grey.withValues(alpha: 0.2), + ), + borderRadius: + BorderRadius.circular(12), + color: Colors.white, + ), + child: const Row( + children: [ + const Icon( + Icons.category_rounded, + color: Color(0xFF3B82F6), + ), + const SizedBox(width: 12), + const Expanded( + child: Text( + '카테고리 자동 설정', + style: TextStyle( + color: Color(0xFF1E293B), + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + const Icon( + Icons.sync, + color: Color(0xFF3B82F6), + ), + ], + ), + ), + ); + } + + // 카테고리 드롭다운 표시 + return DropdownButtonFormField( + value: _selectedCategoryId, + focusNode: _categoryFocus, + onTap: () => setState( + () => _currentEditingField = 5), + onChanged: (value) { + setState(() { + _selectedCategoryId = value; + _currentEditingField = -1; + _categoryFocus.unfocus(); + }); + }, + icon: const Icon( + Icons.arrow_drop_down_circle_outlined, + color: Color(0xFF3B82F6), + ), + decoration: InputDecoration( + filled: true, + fillColor: Colors.white, + contentPadding: + const EdgeInsets.symmetric( + vertical: 16, horizontal: 20), + border: OutlineInputBorder( + borderRadius: + BorderRadius.circular(12), + borderSide: BorderSide( + color: Colors.grey.withValues(alpha: 0.2), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: + BorderRadius.circular(12), + borderSide: const BorderSide( + color: Color(0xFF3B82F6), + width: 2, + ), + ), + ), + style: const TextStyle( + color: Colors.black87, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + hint: const Text( + '카테고리 선택', + style: TextStyle( + color: Colors.grey, + fontSize: 16, + ), + ), + isExpanded: true, + items: [ + DropdownMenuItem( + value: null, + child: const Text( + '카테고리 없음', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Colors.grey, + ), + ), + ), + ...categoryProvider.categories + .map((category) { + return DropdownMenuItem( + value: category.id, + child: Text( + category.name, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Colors.black87, + ), + ), + ); + }).toList(), + ], + ); + }, + ), + ], + ), + ), + + // 이벤트 설정 섹션 + Container( + margin: const EdgeInsets.only(bottom: 20), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: _isEventActive + ? const Color(0xFF3B82F6) + : Colors.grey.withValues(alpha: 0.2), + width: _isEventActive ? 2 : 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Checkbox( + value: _isEventActive, + onChanged: (value) { + setState(() { + _isEventActive = value ?? false; + if (!_isEventActive) { + // 이벤트 비활성화 시 관련 데이터 초기화 + _eventStartDate = DateTime.now(); // 오늘로 재설정 + _eventEndDate = DateTime.now().add(const Duration(days: 30)); // 30일 후로 재설정 + _eventPriceController.clear(); + } else { + // 이벤트 활성화 시 날짜가 null이면 기본값 설정 + _eventStartDate ??= DateTime.now(); + _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: _isEventActive + ? const Color(0xFF3B82F6) + : Colors.grey, + ), + ], + ), + + // 이벤트 활성화 시 추가 필드 표시 + AnimatedContainer( + duration: const Duration(milliseconds: 300), + height: _isEventActive ? null : 0, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 300), + opacity: _isEventActive ? 1.0 : 0.0, + child: Column( + children: [ + const SizedBox(height: 16), + + // 이벤트 기간 설정 + Row( + children: [ + // 시작일 + Expanded( + child: InkWell( + onTap: () async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: _eventStartDate ?? DateTime.now(), + firstDate: DateTime.now().subtract(const Duration(days: 365)), + lastDate: DateTime.now().add(const Duration(days: 365 * 2)), + builder: (BuildContext context, Widget? child) { + return Theme( + data: ThemeData.light().copyWith( + colorScheme: const ColorScheme.light( + primary: Color(0xFF3B82F6), + onPrimary: Colors.white, + surface: Colors.white, + onSurface: Colors.black, + ), + ), + child: child!, + ); + }, + ); + if (picked != null) { + setState(() { + _eventStartDate = picked; + }); + } + }, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.withValues(alpha: 0.3)), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '시작일', + style: TextStyle( + fontSize: 12, + color: Colors.grey, + ), + ), + const SizedBox(height: 4), + Text( + _eventStartDate == null + ? '선택' + : DateFormat('MM/dd').format(_eventStartDate!), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ), + const SizedBox(width: 8), + const Icon(Icons.arrow_forward, color: Colors.grey), + const SizedBox(width: 8), + // 종료일 + Expanded( + child: InkWell( + onTap: () async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: _eventEndDate ?? DateTime.now().add(const Duration(days: 30)), + firstDate: _eventStartDate ?? DateTime.now(), + lastDate: DateTime.now().add(const Duration(days: 365 * 2)), + builder: (BuildContext context, Widget? child) { + return Theme( + data: ThemeData.light().copyWith( + colorScheme: const ColorScheme.light( + primary: Color(0xFF3B82F6), + onPrimary: Colors.white, + surface: Colors.white, + onSurface: Colors.black, + ), + ), + child: child!, + ); + }, + ); + if (picked != null) { + setState(() { + _eventEndDate = picked; + }); + } + }, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.withValues(alpha: 0.3)), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '종료일', + style: TextStyle( + fontSize: 12, + color: Colors.grey, + ), + ), + const SizedBox(height: 4), + Text( + _eventEndDate == null + ? '선택' + : DateFormat('MM/dd').format(_eventEndDate!), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ), + ], + ), + + const SizedBox(height: 16), + + // 이벤트 가격 입력 + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '이벤트 가격', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF3B82F6), + ), + ), + const SizedBox(height: 8), + TextFormField( + controller: _eventPriceController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[\d,.]')), + ], + decoration: InputDecoration( + hintText: '할인된 가격을 입력하세요', + prefixText: _currency == 'KRW' ? '₩ ' : '\$ ', + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.symmetric( + vertical: 16, + horizontal: 16, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Colors.grey.withValues(alpha: 0.2), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide( + color: Color(0xFF3B82F6), + width: 2, + ), + ), + ), + onChanged: (value) { + // 콤마 자동 추가 + if (value.isNotEmpty && !value.contains('.')) { + final number = int.tryParse(value.replaceAll(',', '')); + if (number != null) { + final formatted = NumberFormat('#,###').format(number); + if (formatted != value) { + _eventPriceController.value = TextEditingValue( + text: formatted, + selection: TextSelection.collapsed( + offset: formatted.length, + ), + ); + } + } + } + }, + ), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + + const SizedBox(height: 32), + + // 저장 버튼 + FadeTransition( + opacity: Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: _animationController, + curve: const Interval(0.6, 1.0, curve: Curves.easeIn), + ), + ), + child: SlideTransition( + position: Tween( + begin: const Offset(0.0, 0.6), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _animationController, + curve: const Interval(0.6, 1.0, curve: Curves.easeOutCubic), + )), + child: MouseRegion( + onEnter: (_) => setState(() => _isSaveHovered = true), + onExit: (_) => setState(() => _isSaveHovered = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: double.infinity, + height: 60, + transform: _isSaveHovered + ? (Matrix4.identity()..scale(1.02)) + : Matrix4.identity(), + child: ElevatedButton( + onPressed: _isLoading ? null : _saveSubscription, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF3B82F6), + foregroundColor: Colors.white, + disabledBackgroundColor: Colors.grey.withValues(alpha: 0.3), + disabledForegroundColor: + Colors.white.withValues(alpha: 0.5), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + padding: const EdgeInsets.symmetric(vertical: 16), + elevation: _isSaveHovered ? 8 : 4, + shadowColor: const Color(0xFF3B82F6).withValues(alpha: 0.5), + ), + child: _isLoading + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.white), + ), + ) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.add_circle_outline, + color: Colors.white, + size: _isSaveHovered ? 24 : 20, + ), + const SizedBox(width: 8), + const Text( + '구독 추가하기', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ], + ), + ), + ), + ), + ), + ), + + const SizedBox(height: 80), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/app_lock_screen.dart b/lib/screens/app_lock_screen.dart index a7c553f..c3a9184 100644 --- a/lib/screens/app_lock_screen.dart +++ b/lib/screens/app_lock_screen.dart @@ -38,7 +38,7 @@ class AppLockScreen extends StatelessWidget { onPressed: () async { final appLock = context.read(); final success = await appLock.authenticate(); - if (!success) { + if (!success && context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('인증에 실패했습니다. 다시 시도해주세요.'), diff --git a/lib/screens/category_management_screen.dart b/lib/screens/category_management_screen.dart index 5e137a2..1884938 100644 --- a/lib/screens/category_management_screen.dart +++ b/lib/screens/category_management_screen.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/category_provider.dart'; -import '../models/category_model.dart'; class CategoryManagementScreen extends StatefulWidget { const CategoryManagementScreen({super.key}); diff --git a/lib/screens/detail_screen.dart b/lib/screens/detail_screen.dart index 767993b..3cd35e9 100644 --- a/lib/screens/detail_screen.dart +++ b/lib/screens/detail_screen.dart @@ -1,18 +1,13 @@ import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'dart:math' as math; import '../models/subscription_model.dart'; -import '../models/category_model.dart'; -import '../providers/subscription_provider.dart'; -import '../providers/category_provider.dart'; -import 'package:intl/intl.dart'; -import '../widgets/website_icon.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import '../services/subscription_url_matcher.dart'; -import 'package:url_launcher/url_launcher.dart'; -import 'package:flutter/services.dart'; // TextInputFormatter 사용을 위한 import 추가 -import '../services/exchange_rate_service.dart'; // 환율 서비스만 사용 +import '../controllers/detail_screen_controller.dart'; +import '../widgets/detail/detail_header_section.dart'; +import '../widgets/detail/detail_form_section.dart'; +import '../widgets/detail/detail_event_section.dart'; +import '../widgets/detail/detail_url_section.dart'; +import '../widgets/detail/detail_action_buttons.dart'; +/// 구독 상세 정보를 표시하고 편집할 수 있는 화면 class DetailScreen extends StatefulWidget { final SubscriptionModel subscription; @@ -27,2206 +22,125 @@ class DetailScreen extends StatefulWidget { class _DetailScreenState extends State with SingleTickerProviderStateMixin { - late TextEditingController _serviceNameController; - late TextEditingController _monthlyCostController; - late TextEditingController _websiteUrlController; - late String _billingCycle; - late DateTime _nextBillingDate; - late AnimationController _animationController; - late Animation _fadeAnimation; - late Animation _slideAnimation; - late Animation _rotateAnimation; - String? _selectedCategoryId; // 선택된 카테고리 ID - late String _currency; // 통화 단위: '원화' 또는 '달러' - bool _isLoading = false; // 로딩 상태 - - // 이벤트 관련 상태 변수 - late bool _isEventActive; - DateTime? _eventStartDate; - DateTime? _eventEndDate; - late TextEditingController _eventPriceController; - - // 포커스 노드 추가 - final _serviceNameFocus = FocusNode(); - final _monthlyCostFocus = FocusNode(); - final _billingCycleFocus = FocusNode(); - final _nextBillingDateFocus = FocusNode(); - final _websiteUrlFocus = FocusNode(); - final _categoryFocus = FocusNode(); // 카테고리 포커스 노드 - final _currencyFocus = FocusNode(); // 통화 단위 포커스 노드 - - final ScrollController _scrollController = ScrollController(); - double _scrollOffset = 0; - - // 현재 편집 중인 필드 - int _currentEditingField = -1; - - // 호버 상태 - bool _isDeleteHovered = false; - bool _isSaveHovered = false; - bool _isCancelHovered = false; + late DetailScreenController _controller; @override void initState() { super.initState(); - _serviceNameController = - TextEditingController(text: widget.subscription.serviceName); - _monthlyCostController = - TextEditingController(text: widget.subscription.monthlyCost.toString()); - _websiteUrlController = - TextEditingController(text: widget.subscription.websiteUrl ?? ''); - _billingCycle = widget.subscription.billingCycle; - _nextBillingDate = widget.subscription.nextBillingDate; - _selectedCategoryId = widget.subscription.categoryId; // 카테고리 ID 설정 - _currency = widget.subscription.currency; // 통화 단위 설정 - - // 이벤트 관련 초기화 - _isEventActive = widget.subscription.isEventActive; - _eventStartDate = widget.subscription.eventStartDate; - _eventEndDate = widget.subscription.eventEndDate; - _eventPriceController = TextEditingController(); - - // 이벤트 가격 초기화 - if (widget.subscription.eventPrice != null) { - if (_currency == 'KRW') { - _eventPriceController.text = NumberFormat.decimalPattern() - .format(widget.subscription.eventPrice!.toInt()); - } else { - _eventPriceController.text = - NumberFormat('#,##0.00').format(widget.subscription.eventPrice!); - } - } - - // 통화 단위에 따른 금액 표시 형식 조정 - if (_currency == 'KRW') { - // 원화: 정수 형식으로 표시 (콤마 포함) - _monthlyCostController.text = NumberFormat.decimalPattern() - .format(widget.subscription.monthlyCost.toInt()); - } else { - // 달러: 소수점 2자리까지 표시 (콤마 포함) - _monthlyCostController.text = - NumberFormat('#,##0.00').format(widget.subscription.monthlyCost); - } - - // 카테고리 ID가 없으면 서비스명 기반으로 자동 선택 시도 - if (_selectedCategoryId == null) { - _autoSelectCategory(); - } - - // 서비스명 컨트롤러에 리스너 추가 - _serviceNameController.addListener(_onServiceNameChanged); - - _animationController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 800), + _controller = DetailScreenController( + context: context, + subscription: widget.subscription, ); - - _fadeAnimation = Tween( - begin: 0.0, - end: 1.0, - ).animate(CurvedAnimation( - parent: _animationController, - curve: const Interval(0.0, 0.6, curve: Curves.easeIn), - )); - - _slideAnimation = Tween( - begin: const Offset(0.0, 0.2), - end: Offset.zero, - ).animate(CurvedAnimation( - parent: _animationController, - curve: Curves.easeOutCubic, - )); - - _rotateAnimation = Tween( - begin: 0.0, - end: 2 * math.pi, - ).animate(CurvedAnimation( - parent: _animationController, - curve: const Interval(0.0, 0.5, curve: Curves.easeOutBack), - )); - - _scrollController.addListener(() { - setState(() { - _scrollOffset = _scrollController.offset; - }); - }); - - _animationController.forward(); + _controller.initialize(vsync: this); } @override void dispose() { - // 서비스명 컨트롤러 리스너 제거 - _serviceNameController.removeListener(_onServiceNameChanged); - - // 카테고리가 변경되었으면 구독 정보 업데이트 - if (_selectedCategoryId != widget.subscription.categoryId) { - widget.subscription.categoryId = _selectedCategoryId; - final provider = - Provider.of(context, listen: false); - provider.updateSubscription(widget.subscription); - } - - _serviceNameController.dispose(); - _monthlyCostController.dispose(); - _websiteUrlController.dispose(); - _eventPriceController.dispose(); - _animationController.dispose(); - _scrollController.dispose(); - - // 포커스 노드 해제 - _serviceNameFocus.dispose(); - _monthlyCostFocus.dispose(); - _billingCycleFocus.dispose(); - _nextBillingDateFocus.dispose(); - _websiteUrlFocus.dispose(); - _categoryFocus.dispose(); - _currencyFocus.dispose(); - + _controller.dispose(); super.dispose(); } - // 서비스명이 변경될 때 호출되는 콜백 함수 - void _onServiceNameChanged() { - // 웹사이트 URL이 비어있거나 기존 URL이 서비스와 매칭되지 않는 경우에만 자동 매칭 - if (_serviceNameController.text.isNotEmpty && - (_websiteUrlController.text.isEmpty || - SubscriptionUrlMatcher.findMatchingUrl( - _serviceNameController.text) != - _websiteUrlController.text)) { - // 자동 URL 매칭 시도 - final suggestedUrl = - SubscriptionUrlMatcher.suggestUrl(_serviceNameController.text); - // 매칭된 URL이 있으면 텍스트 컨트롤러에 설정 - if (suggestedUrl != null && suggestedUrl.isNotEmpty) { - setState(() { - _websiteUrlController.text = suggestedUrl; - }); - } - } - } + @override + Widget build(BuildContext context) { + final baseColor = _controller.getCardColor(); - Future _updateSubscription() async { - final provider = Provider.of(context, listen: false); - - // 웹사이트 URL이 비어있는 경우 자동 매칭 다시 시도 - String? websiteUrl = _websiteUrlController.text; - if (websiteUrl.isEmpty) { - websiteUrl = - SubscriptionUrlMatcher.suggestUrl(_serviceNameController.text); - } - - // 구독 정보 업데이트 - final oldCategoryId = widget.subscription.categoryId; - final newCategoryId = _selectedCategoryId; - - // 콤마 제거하고 숫자만 추출 - double monthlyCost = 0.0; - try { - monthlyCost = - double.parse(_monthlyCostController.text.replaceAll(',', '')); - } catch (e) { - // 파싱 오류 발생 시 기본값 사용 - monthlyCost = widget.subscription.monthlyCost; - } - - widget.subscription.serviceName = _serviceNameController.text; - widget.subscription.monthlyCost = monthlyCost; - widget.subscription.websiteUrl = websiteUrl; - widget.subscription.billingCycle = _billingCycle; - widget.subscription.nextBillingDate = _nextBillingDate; - widget.subscription.categoryId = _selectedCategoryId; // 카테고리 업데이트 - widget.subscription.currency = _currency; // 통화 단위 업데이트 - - // 이벤트 정보 업데이트 - widget.subscription.isEventActive = _isEventActive; - widget.subscription.eventStartDate = _eventStartDate; - widget.subscription.eventEndDate = _eventEndDate; - - // 이벤트 가격 파싱 - if (_isEventActive && _eventPriceController.text.isNotEmpty) { - try { - widget.subscription.eventPrice = - double.parse(_eventPriceController.text.replaceAll(',', '')); - } catch (e) { - widget.subscription.eventPrice = null; - } - } else { - widget.subscription.eventPrice = null; - } - - // 구독 업데이트 - await provider.updateSubscription(widget.subscription); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Row( - children: [ - const Icon(Icons.check_circle_rounded, color: Colors.white), - const SizedBox(width: 12), - const Text('구독 정보가 업데이트되었습니다.'), - ], - ), - behavior: SnackBarBehavior.floating, - backgroundColor: const Color(0xFF10B981), - duration: const Duration(seconds: 2), - shape: - RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - ), - ); - - // 변경 사항이 반영될 시간을 주기 위해 짧게 지연 후 결과 반환 - // 카테고리가 변경된 경우에만 true를 반환 - final categoryChanged = oldCategoryId != newCategoryId; - await Future.delayed(const Duration(milliseconds: 100)); - Navigator.of(context).pop(true); - } - } - - Future _deleteSubscription() async { - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - title: const Text( - '구독 삭제', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 20, - ), - ), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFFFEF2F2), - borderRadius: BorderRadius.circular(16), - ), - child: const Icon( - Icons.warning_amber_rounded, - color: Color(0xFFDC2626), - size: 48, - ), + return Scaffold( + backgroundColor: const Color(0xFFF5F5F7), + body: CustomScrollView( + controller: _controller.scrollController, + slivers: [ + // 상단 헤더 섹션 + SliverToBoxAdapter( + child: DetailHeaderSection( + subscription: widget.subscription, + controller: _controller, + fadeAnimation: _controller.fadeAnimation!, + slideAnimation: _controller.slideAnimation!, + rotateAnimation: _controller.rotateAnimation!, ), - const SizedBox(height: 16), - const Text('이 구독을 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.'), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('취소'), ), - ElevatedButton( - onPressed: () => Navigator.of(context).pop(true), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFFDC2626), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + // 본문 콘텐츠 + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + // 편집 모드 안내 + Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + decoration: BoxDecoration( + color: baseColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + Icon( + Icons.edit_rounded, + color: baseColor, + size: 20, + ), + const SizedBox(width: 8), + Text( + '편집 모드', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: baseColor, + ), + ), + const Spacer(), + Text( + '변경사항은 저장 후 적용됩니다', + style: TextStyle( + fontSize: 14, + color: baseColor.withValues(alpha: 0.8), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // 기본 정보 폼 섹션 + DetailFormSection( + controller: _controller, + fadeAnimation: _controller.fadeAnimation!, + slideAnimation: _controller.slideAnimation!, + ), + const SizedBox(height: 16), + + // 이벤트 가격 섹션 + DetailEventSection( + controller: _controller, + fadeAnimation: _controller.fadeAnimation!, + slideAnimation: _controller.slideAnimation!, + ), + const SizedBox(height: 16), + + // 웹사이트 URL 섹션 + DetailUrlSection( + controller: _controller, + fadeAnimation: _controller.fadeAnimation!, + slideAnimation: _controller.slideAnimation!, + ), + const SizedBox(height: 32), + + // 액션 버튼 + DetailActionButtons( + controller: _controller, + fadeAnimation: _controller.fadeAnimation!, + slideAnimation: _controller.slideAnimation!, + ), + ], ), ), - child: const Text( - '삭제', - style: TextStyle(color: Colors.white), - ), ), ], ), ); - - if (confirmed == true && mounted) { - final provider = - Provider.of(context, listen: false); - await provider.deleteSubscription(widget.subscription.id); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Row( - children: [ - const Icon(Icons.delete_forever_rounded, color: Colors.white), - const SizedBox(width: 12), - const Text('구독이 삭제되었습니다.'), - ], - ), - behavior: SnackBarBehavior.floating, - backgroundColor: const Color(0xFFDC2626), - duration: const Duration(seconds: 2), - shape: - RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - ), - ); - Navigator.of(context).pop(); - } - } } - - // 배경 그라데이션과 색상 가져오기 - Color _getCardColor() { - // 서비스 이름에 따라 일관된 색상 생성 - final int hash = widget.subscription.serviceName.hashCode.abs(); - final List colors = [ - const Color(0xFF3B82F6), // 파랑 - const Color(0xFF10B981), // 초록 - const Color(0xFF8B5CF6), // 보라 - const Color(0xFFF59E0B), // 노랑 - const Color(0xFFEF4444), // 빨강 - const Color(0xFF0EA5E9), // 하늘 - const Color(0xFFEC4899), // 분홍 - ]; - - return colors[hash % colors.length]; - } - - LinearGradient _getGradient(Color baseColor) { - return LinearGradient( - colors: [ - baseColor, - baseColor.withValues(alpha: 0.7), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ); - } - - // 서비스명을 기반으로 카테고리 자동 선택 함수 - void _autoSelectCategory() { - if (_serviceNameController.text.isEmpty) return; - - final serviceName = _serviceNameController.text.toLowerCase(); - final categoryProvider = - Provider.of(context, listen: false); - - // 카테고리가 없으면 리턴 - if (categoryProvider.categories.isEmpty) return; - - // OTT 서비스 확인 - if (SubscriptionUrlMatcher.ottServices.keys.any((key) => - serviceName.contains(key.toLowerCase()) || - key.toLowerCase().contains(serviceName))) { - // OTT 관련 카테고리 찾기 - try { - final ottCategory = categoryProvider.categories.firstWhere( - (cat) => - cat.name.contains('OTT') || - cat.name.contains('미디어') || - cat.name.contains('영상'), - ); - - setState(() { - _selectedCategoryId = ottCategory.id; - }); - return; - } catch (_) { - // OTT 카테고리가 없으면 첫 번째 카테고리 사용 - if (categoryProvider.categories.isNotEmpty) { - setState(() { - _selectedCategoryId = categoryProvider.categories.first.id; - }); - } - } - } - - // 음악 서비스 확인 - if (SubscriptionUrlMatcher.musicServices.keys.any((key) => - serviceName.contains(key.toLowerCase()) || - key.toLowerCase().contains(serviceName))) { - // 음악 관련 카테고리 찾기 - try { - final musicCategory = categoryProvider.categories.firstWhere( - (cat) => cat.name.contains('음악') || cat.name.contains('스트리밍'), - ); - - setState(() { - _selectedCategoryId = musicCategory.id; - }); - return; - } catch (_) { - // 음악 카테고리가 없으면 첫 번째 카테고리 사용 - if (categoryProvider.categories.isNotEmpty) { - setState(() { - _selectedCategoryId = categoryProvider.categories.first.id; - }); - } - } - } - - // AI 서비스 확인 - if (SubscriptionUrlMatcher.aiServices.keys.any((key) => - serviceName.contains(key.toLowerCase()) || - key.toLowerCase().contains(serviceName))) { - // AI 관련 카테고리 찾기 - try { - final aiCategory = categoryProvider.categories.firstWhere( - (cat) => cat.name.contains('AI') || cat.name.contains('인공지능'), - ); - - setState(() { - _selectedCategoryId = aiCategory.id; - }); - return; - } catch (_) { - // AI 카테고리가 없으면 첫 번째 카테고리 사용 - if (categoryProvider.categories.isNotEmpty) { - setState(() { - _selectedCategoryId = categoryProvider.categories.first.id; - }); - } - } - } - - // 프로그래밍/개발 서비스 확인 - if (SubscriptionUrlMatcher.programmingServices.keys.any((key) => - serviceName.contains(key.toLowerCase()) || - key.toLowerCase().contains(serviceName))) { - // 개발 관련 카테고리 찾기 - try { - final devCategory = categoryProvider.categories.firstWhere( - (cat) => cat.name.contains('개발') || cat.name.contains('프로그래밍'), - ); - - setState(() { - _selectedCategoryId = devCategory.id; - }); - return; - } catch (_) { - // 개발 카테고리가 없으면 첫 번째 카테고리 사용 - if (categoryProvider.categories.isNotEmpty) { - setState(() { - _selectedCategoryId = categoryProvider.categories.first.id; - }); - } - } - } - - // 오피스/협업 툴 확인 - if (SubscriptionUrlMatcher.officeTools.keys.any((key) => - serviceName.contains(key.toLowerCase()) || - key.toLowerCase().contains(serviceName))) { - // 오피스 관련 카테고리 찾기 - try { - final officeCategory = categoryProvider.categories.firstWhere( - (cat) => - cat.name.contains('오피스') || - cat.name.contains('협업') || - cat.name.contains('업무'), - ); - - setState(() { - _selectedCategoryId = officeCategory.id; - }); - return; - } catch (_) { - // 오피스 카테고리가 없으면 첫 번째 카테고리 사용 - if (categoryProvider.categories.isNotEmpty) { - setState(() { - _selectedCategoryId = categoryProvider.categories.first.id; - }); - } - } - } - - // 기타 서비스 확인 - if (SubscriptionUrlMatcher.otherServices.keys.any((key) => - serviceName.contains(key.toLowerCase()) || - key.toLowerCase().contains(serviceName))) { - // 기타 관련 카테고리 찾기 - try { - final otherCategory = categoryProvider.categories.firstWhere( - (cat) => cat.name.contains('기타') || cat.name.contains('게임'), - ); - - setState(() { - _selectedCategoryId = otherCategory.id; - }); - } catch (_) { - // 기타 카테고리가 없으면 첫 번째 카테고리 사용 - if (categoryProvider.categories.isNotEmpty) { - setState(() { - _selectedCategoryId = categoryProvider.categories.first.id; - }); - } - } - } - } - - // URL을 외부 앱에서 여는 함수 - Future _openCancellationPage() async { - final serviceName = widget.subscription.serviceName; - final websiteUrl = widget.subscription.websiteUrl; - - // 해지 안내 페이지 URL 찾기 - final cancellationUrl = - SubscriptionUrlMatcher.findCancellationUrl(serviceName); - - if (cancellationUrl == null) { - // 해지 안내 페이지가 없는 경우 사용자에게 안내 - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: const Text('공식 해지 안내 페이지가 제공되지 않는 서비스입니다.'), - behavior: SnackBarBehavior.floating, - backgroundColor: Colors.grey.shade700, - duration: const Duration(seconds: 2), - shape: - RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - ), - ); - } - return; - } - - try { - final Uri url = Uri.parse(cancellationUrl); - if (await canLaunchUrl(url)) { - await launchUrl(url, mode: LaunchMode.externalApplication); - } else { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: const Text('해지 안내 페이지를 열 수 없습니다.'), - behavior: SnackBarBehavior.floating, - backgroundColor: Colors.red.shade700, - duration: const Duration(seconds: 2), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10)), - ), - ); - } - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('오류가 발생했습니다: $e'), - behavior: SnackBarBehavior.floating, - backgroundColor: Colors.red.shade700, - duration: const Duration(seconds: 2), - shape: - RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - ), - ); - } - } - } - - @override - Widget build(BuildContext context) { - final daysUntilBilling = - widget.subscription.nextBillingDate.difference(DateTime.now()).inDays; - final isNearBilling = daysUntilBilling <= 7; - final baseColor = _getCardColor(); - final double appBarOpacity = math.max(0, math.min(1, _scrollOffset / 150)); - - return Scaffold( - backgroundColor: const Color(0xFFF8FAFC), - extendBodyBehindAppBar: true, - appBar: PreferredSize( - preferredSize: const Size.fromHeight(60), - child: Container( - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: appBarOpacity), - boxShadow: appBarOpacity > 0.6 - ? [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.1 * appBarOpacity), - spreadRadius: 1, - blurRadius: 8, - offset: const Offset(0, 4), - ) - ] - : null, - ), - child: SafeArea( - child: AppBar( - title: Text( - '구독 상세', - style: TextStyle( - fontFamily: 'Montserrat', - fontSize: 24, - fontWeight: FontWeight.w800, - letterSpacing: -0.5, - color: Color(0xFF1E293B), - shadows: appBarOpacity > 0.6 - ? [ - Shadow( - color: Colors.black.withValues(alpha: 0.2), - offset: const Offset(0, 1), - blurRadius: 2, - ) - ] - : null, - ), - ), - elevation: 0, - backgroundColor: Colors.transparent, - actions: [ - // 해지 안내 버튼 - if (SubscriptionUrlMatcher.hasCancellationPage( - widget.subscription.serviceName)) - MouseRegion( - onEnter: (_) => setState(() => _isCancelHovered = true), - onExit: (_) => setState(() => _isCancelHovered = false), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.symmetric( - horizontal: 8, vertical: 6), - decoration: BoxDecoration( - color: _isCancelHovered - ? const Color(0xFFF1F5F9) - : Colors.transparent, - borderRadius: BorderRadius.circular(8), - ), - child: TextButton.icon( - icon: const Icon( - Icons.open_in_browser, - size: 18, - color: Color(0xFF6B7280), - ), - label: const Text( - '해지 안내', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Color(0xFF6B7280), - ), - ), - onPressed: _openCancellationPage, - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 6), - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - ), - ), - ), - MouseRegion( - onEnter: (_) => setState(() => _isDeleteHovered = true), - onExit: (_) => setState(() => _isDeleteHovered = false), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.only(right: 8), - decoration: BoxDecoration( - color: _isDeleteHovered - ? const Color(0xFFFEF2F2) - : Colors.transparent, - borderRadius: BorderRadius.circular(8), - ), - child: IconButton( - icon: const FaIcon(FontAwesomeIcons.trashCan, - size: 20, color: Color(0xFFDC2626)), - tooltip: '삭제', - onPressed: _deleteSubscription, - ), - ), - ), - ], - ), - ), - ), - ), - body: SingleChildScrollView( - controller: _scrollController, - physics: const BouncingScrollPhysics(), - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox(height: MediaQuery.of(context).padding.top + 60), - FadeTransition( - opacity: _fadeAnimation, - child: SlideTransition( - position: _slideAnimation, - child: Hero( - tag: 'subscription_${widget.subscription.id}', - child: Card( - elevation: 8, - shadowColor: baseColor.withValues(alpha: 0.4), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), - ), - child: Container( - constraints: BoxConstraints( - maxHeight: - MediaQuery.of(context).size.height * 0.3), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(24), - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - baseColor.withValues(alpha: 0.8), - baseColor, - ], - ), - ), - padding: const EdgeInsets.all(24), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - AnimatedBuilder( - animation: _animationController, - builder: (context, child) { - return Transform.rotate( - angle: _rotateAnimation.value, - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: - BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black - .withValues(alpha: 0.1), - blurRadius: 10, - spreadRadius: 0, - ), - ], - ), - child: WebsiteIcon( - key: ValueKey( - 'detail_icon_${widget.subscription.id}'), - url: widget.subscription.websiteUrl, - serviceName: - widget.subscription.serviceName, - size: 48, - ), - ), - ); - }, - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - widget.subscription.serviceName, - style: const TextStyle( - fontSize: 28, - fontWeight: FontWeight.w800, - color: Colors.white, - letterSpacing: -0.5, - shadows: [ - Shadow( - color: Colors.black26, - offset: Offset(0, 2), - blurRadius: 4, - ), - ], - ), - ), - const SizedBox(height: 4), - Text( - '${widget.subscription.billingCycle} 결제', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: - Colors.white.withValues(alpha: 0.8), - ), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 20), - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(16), - ), - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - '다음 결제일', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: - Colors.white.withValues(alpha: 0.8), - ), - ), - const SizedBox(height: 4), - Text( - DateFormat('yyyy년 MM월 dd일').format( - widget.subscription - .nextBillingDate), - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - color: Colors.white, - ), - ), - ], - ), - Column( - crossAxisAlignment: - CrossAxisAlignment.end, - children: [ - Text( - '월 지출', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: - Colors.white.withValues(alpha: 0.8), - ), - ), - const SizedBox(height: 4), - Text( - NumberFormat.currency( - locale: _currency == 'KRW' - ? 'ko_KR' - : 'en_US', - symbol: - _currency == 'KRW' ? '₩' : '\$', - decimalDigits: - _currency == 'KRW' ? 0 : 2, - ).format( - widget.subscription.monthlyCost), - style: const TextStyle( - fontSize: 28, - fontWeight: FontWeight.w800, - color: Colors.white, - ), - ), - ], - ), - ], - ), - ), - if (isNearBilling) ...[ - const SizedBox(height: 16), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - decoration: BoxDecoration( - color: const Color(0xFFDC2626) - .withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Colors.white.withValues(alpha: 0.3), - width: 1, - ), - ), - child: Row( - children: [ - const Icon( - Icons.access_time_rounded, - size: 20, - color: Colors.white, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - daysUntilBilling == 0 - ? '오늘 결제 예정' - : '$daysUntilBilling일 후 결제 예정', - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - ), - ), - ], - ), - ), - ], - ], - ), - ), - ), - ), - ), - ), - ), - const SizedBox(height: 32), - FadeTransition( - opacity: _fadeAnimation, - child: SlideTransition( - position: Tween( - begin: const Offset(0.0, 0.4), - end: Offset.zero, - ).animate(CurvedAnimation( - parent: _animationController, - curve: const Interval(0.2, 1.0, curve: Curves.easeOutCubic), - )), - child: Text( - '구독 정보 수정', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w700, - color: baseColor, - letterSpacing: -0.5, - ), - ), - ), - ), - const SizedBox(height: 16), - FadeTransition( - opacity: _fadeAnimation, - child: SlideTransition( - position: Tween( - begin: const Offset(0.0, 0.6), - end: Offset.zero, - ).animate(CurvedAnimation( - parent: _animationController, - curve: const Interval(0.3, 1.0, curve: Curves.easeOutCubic), - )), - child: Card( - elevation: 1, - shadowColor: Colors.black12, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - // 서비스명 필드 - AnimatedContainer( - duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.only(bottom: 20), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: _currentEditingField == 0 - ? baseColor.withValues(alpha: 0.1) - : Colors.transparent, - ), - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '서비스명', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: baseColor, - ), - ), - const SizedBox(height: 8), - TextFormField( - controller: _serviceNameController, - focusNode: _serviceNameFocus, - textInputAction: TextInputAction.next, - onTap: () => - setState(() => _currentEditingField = 0), - onEditingComplete: () { - _monthlyCostFocus.requestFocus(); - setState(() => _currentEditingField = -1); - }, - style: const TextStyle( - color: Color(0xFF1E293B), - fontSize: 16, - fontWeight: FontWeight.w500, - ), - decoration: InputDecoration( - filled: true, - fillColor: Colors.white, - contentPadding: const EdgeInsets.all(16), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: Colors.grey.withValues(alpha: 0.2), - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: baseColor, - width: 2, - ), - ), - prefixIcon: Icon( - Icons.business_rounded, - color: baseColor, - ), - ), - ), - ], - ), - ), - - // 월 비용 필드 - AnimatedContainer( - duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.only(bottom: 20), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: _currentEditingField == 1 - ? baseColor.withValues(alpha: 0.1) - : Colors.transparent, - ), - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 환율 정보와 비용 입력 제목 표시 (상단) - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - '비용 입력', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: baseColor, - ), - ), - if (_currency == 'USD') - FutureBuilder( - future: ExchangeRateService() - .getFormattedExchangeRateInfo(), - builder: (context, snapshot) { - if (snapshot.hasData) { - return Text( - snapshot.data!, - style: TextStyle( - fontSize: 12, - color: Colors.grey[600], - fontWeight: FontWeight.w500, - ), - ); - } - return const SizedBox.shrink(); - }, - ), - ], - ), - const SizedBox(height: 8), - Row( - children: [ - // 통화 단위 선택 (좌측) - Expanded( - flex: 3, // 25% 너비 차지 - child: DropdownButtonFormField( - value: _currency, - focusNode: _currencyFocus, - isDense: true, - onTap: () => setState( - () => _currentEditingField = 1), - onChanged: (value) { - if (value != null) { - setState(() { - _currency = value; - - // 통화 단위 변경 시 입력 값 변환 - final currentText = - _monthlyCostController.text; - if (currentText.isNotEmpty) { - // 콤마 제거하고 숫자만 추출 - final numericValue = - double.tryParse(currentText - .replaceAll(',', '')); - - if (numericValue != null) { - if (value == 'KRW') { - // 달러 → 원화: 소수점 제거 - _monthlyCostController - .text = NumberFormat - .decimalPattern() - .format(numericValue - .toInt()); - } else { - // 원화 → 달러: 소수점 2자리 추가 - _monthlyCostController - .text = NumberFormat( - '#,##0.00') - .format(numericValue); - } - } - } - - // 화면 갱신하여 통화 기호도 업데이트 - _monthlyCostFocus.requestFocus(); - }); - } - }, - decoration: InputDecoration( - filled: true, - fillColor: Colors.white, - contentPadding: - const EdgeInsets.symmetric( - vertical: 16, horizontal: 12), - border: OutlineInputBorder( - borderRadius: - BorderRadius.circular(12), - borderSide: BorderSide( - color: - Colors.grey.withValues(alpha: 0.2), - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: - BorderRadius.circular(12), - borderSide: BorderSide( - color: baseColor, - width: 2, - ), - ), - ), - icon: Icon( - Icons.arrow_drop_down, - color: baseColor, - ), - items: ['KRW', 'USD'] - .map((currency) => DropdownMenuItem( - value: currency, - child: Text( - currency == 'KRW' - ? 'KRW' - : 'USD', - style: const TextStyle( - fontSize: 14, - fontWeight: - FontWeight.w500, - color: Colors.black87, - ), - ), - )) - .toList(), - ), - ), - const SizedBox(width: 8), - // 비용 입력 필드 (우측) - Expanded( - flex: 7, // 75% 너비 차지 - child: Container( - height: 50, // 높이를 56에서 50으로 줄임 - // 우측에서 40픽셀 줄이기 - margin: const EdgeInsets.only(right: 0), - // 내부 패딩을 고정값으로 설정하여 포커스 상태와 관계없이 일관되게 유지 - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - // 포커스 상태에 따른 배경색 변경 - color: _currentEditingField == 1 - ? const Color( - 0xFFF3F4F6) // 포커스 상태일 때 연한 회색 - : Colors - .transparent, // 포커스 없을 때 투명 - borderRadius: - BorderRadius.circular(12), - // 테두리 설정 (포커스 상태에 따라 색상만 변경) - border: Border.all( - color: _currentEditingField == 1 - ? baseColor - : Colors.grey.withValues(alpha: - 0.4), // 포커스 없을 때 더 진한 회색 - width: _currentEditingField == 1 - ? 2 - : 1, - ), - ), - child: Row( - children: [ - // 통화 기호 - 항상 표시되도록 설정 - Container( - width: 40, - alignment: Alignment.center, - decoration: BoxDecoration( - // 테두리 추가 (좌측 통화선택란과 동일한 스타일) - border: Border( - right: BorderSide( - color: Colors.grey - .withValues(alpha: 0.2), - width: 1, - ), - ), - ), - child: Text( - _currency == 'KRW' ? '₩' : '\$', - style: TextStyle( - color: baseColor, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - ), - // 실제 입력 필드 - Expanded( - child: Stack( - alignment: - Alignment.centerRight, - children: [ - TextField( - controller: - _monthlyCostController, - focusNode: - _monthlyCostFocus, - textInputAction: - TextInputAction.next, - keyboardType: - const TextInputType - .numberWithOptions( - decimal: true), - inputFormatters: [ - // 통화 단위에 따라 다른 입력 형식 적용 - FilteringTextInputFormatter - .allow( - _currency == 'KRW' - ? RegExp( - r'[0-9,]') // 원화: 정수만 허용 - : RegExp( - r'[0-9,.]'), // 달러: 소수점 허용 - ), - // 커스텀 포맷터 - 3자리마다 콤마 추가 - TextInputFormatter - .withFunction( - (oldValue, - newValue) { - // 입력값에서 콤마 제거 - final text = newValue - .text - .replaceAll( - ',', ''); - - if (text.isEmpty) { - return newValue - .copyWith( - text: ''); - } - - // 숫자 형식 검증 - if (_currency == - 'KRW') { - // 원화: 정수 형식 - if (double.tryParse( - text) == - null) { - return oldValue; - } - - // 3자리마다 콤마 추가 - final formattedValue = - NumberFormat - .decimalPattern() - .format( - int.parse( - text)); - - return newValue - .copyWith( - text: - formattedValue, - selection: TextSelection - .collapsed( - offset: formattedValue - .length), - ); - } else { - // 달러: 소수점 형식 - if (double.tryParse( - text) == - null && - text != '.') { - return oldValue; - } - - // 소수점 이하 처리를 위해 부분 분리 - final parts = - text.split('.'); - final integerPart = - parts[0]; - final decimalPart = parts - .length > - 1 - ? '.${parts[1].length > 2 ? parts[1].substring(0, 2) : parts[1]}' - : ''; - - // 3자리마다 콤마 추가 (정수 부분만) - String formattedValue; - if (integerPart - .isEmpty) { - formattedValue = - '0$decimalPart'; - } else { - final formatted = NumberFormat - .decimalPattern() - .format(int.parse( - integerPart)); - formattedValue = - '$formatted$decimalPart'; - } - - return newValue - .copyWith( - text: - formattedValue, - selection: TextSelection - .collapsed( - offset: formattedValue - .length), - ); - } - }), - ], - onTap: () => setState(() => - _currentEditingField = - 1), - onSubmitted: (_) { - _billingCycleFocus - .requestFocus(); - setState(() => - _currentEditingField = - -1); - }, - style: const TextStyle( - color: Color(0xFF1E293B), - fontSize: 16, - fontWeight: - FontWeight.w500, - ), - decoration: InputDecoration( - border: InputBorder.none, - // 포커스 상태와 관계없이 일관된 패딩 유지 - contentPadding: - const EdgeInsets - .symmetric( - vertical: 14, - horizontal: 8), - hintText: - _currency == 'KRW' - ? '9,000' - : '9.99', - hintStyle: TextStyle( - color: Colors - .grey.shade500, - fontSize: 16, - ), - // 모든 테두리 제거 - enabledBorder: - InputBorder.none, - focusedBorder: - InputBorder.none, - errorBorder: - InputBorder.none, - disabledBorder: - InputBorder.none, - focusedErrorBorder: - InputBorder.none, - ), - ), - // 달러일 때 원화 환산 금액 표시 - if (_currency == 'USD') - ValueListenableBuilder< - TextEditingValue>( - valueListenable: - _monthlyCostController, - builder: (context, value, - child) { - // 입력값이 바뀔 때마다 환산 금액 갱신 - return FutureBuilder< - String>( - future: ExchangeRateService() - .getFormattedKrwAmount( - double.tryParse(value - .text - .replaceAll( - ',', - '')) ?? - 0.0), - builder: (context, - snapshot) { - if (snapshot - .hasData && - snapshot.data! - .isNotEmpty) { - return Padding( - padding: - const EdgeInsets - .only( - right: - 12.0), - child: Text( - snapshot - .data!, - style: - const TextStyle( - fontSize: - 14, - color: Colors - .blue, - fontWeight: - FontWeight - .w500, - ), - ), - ); - } - return const SizedBox - .shrink(); - }, - ); - }, - ), - ], - ), - ), - ], - ), - ), - ), - ], - ), - // 환율 정보 위젯 추가 (달러 선택 시에만 표시) - ], - ), - ), - - // 결제 주기 필드 - AnimatedContainer( - duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.only(bottom: 20), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: _currentEditingField == 2 - ? baseColor.withValues(alpha: 0.1) - : Colors.transparent, - ), - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '결제 주기', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: baseColor, - ), - ), - const SizedBox(height: 8), - DropdownButtonFormField( - value: _billingCycle, - focusNode: _billingCycleFocus, - onTap: () => - setState(() => _currentEditingField = 2), - onChanged: (value) { - if (value != null) { - setState(() { - _billingCycle = value; - _currentEditingField = -1; - _nextBillingDateFocus.requestFocus(); - }); - } - }, - decoration: InputDecoration( - filled: true, - fillColor: Colors.white, - contentPadding: const EdgeInsets.all(16), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: Colors.grey.withValues(alpha: 0.2), - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: baseColor, - width: 2, - ), - ), - prefixIcon: Icon( - Icons.calendar_today_rounded, - color: baseColor, - ), - ), - dropdownColor: Colors.white, - style: const TextStyle( - color: Colors.black87, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - items: ['월간', '연간', '주간', '일간'] - .map((cycle) => DropdownMenuItem( - value: cycle, - child: Text(cycle), - )) - .toList(), - ), - ], - ), - ), - - // 다음 결제일 필드 - AnimatedContainer( - duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.only(bottom: 20), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: _currentEditingField == 3 - ? baseColor.withValues(alpha: 0.1) - : Colors.transparent, - ), - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '다음 결제일', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: baseColor, - ), - ), - const SizedBox(height: 8), - InkWell( - focusNode: _nextBillingDateFocus, - onTap: () async { - setState(() => _currentEditingField = 3); - final DateTime? picked = - await showDatePicker( - context: context, - initialDate: _nextBillingDate, - firstDate: DateTime.now(), - lastDate: DateTime.now().add( - const Duration(days: 365 * 2), - ), - builder: (BuildContext context, - Widget? child) { - return Theme( - data: ThemeData.light().copyWith( - colorScheme: ColorScheme.light( - primary: baseColor, - onPrimary: Colors.white, - surface: Colors.white, - onSurface: Colors.black, - ), - ), - child: child!, - ); - }, - ); - if (picked != null) { - setState(() { - _nextBillingDate = picked; - _currentEditingField = -1; - _websiteUrlFocus.requestFocus(); - }); - } else { - setState(() => _currentEditingField = -1); - } - }, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - border: Border.all( - color: Colors.grey.withValues(alpha: 0.2), - ), - borderRadius: BorderRadius.circular(12), - color: Colors.white, - ), - child: Row( - children: [ - Icon( - Icons.event_rounded, - color: baseColor, - ), - const SizedBox(width: 12), - Text( - DateFormat('yyyy년 MM월 dd일') - .format(_nextBillingDate), - style: const TextStyle( - fontSize: 16, - color: Color(0xFF1E293B), - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ), - ], - ), - ), - - // 웹사이트 URL 필드 - AnimatedContainer( - duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.only(bottom: 20), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: _currentEditingField == 4 - ? baseColor.withValues(alpha: 0.1) - : Colors.transparent, - ), - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '웹사이트 URL (선택)', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: baseColor, - ), - ), - const SizedBox(height: 8), - TextFormField( - controller: _websiteUrlController, - focusNode: _websiteUrlFocus, - textInputAction: TextInputAction.done, - onTap: () => - setState(() => _currentEditingField = 4), - onEditingComplete: () { - setState(() => _currentEditingField = 5); - _categoryFocus.requestFocus(); - }, - style: const TextStyle( - color: Color(0xFF1E293B), - fontSize: 16, - fontWeight: FontWeight.w500, - ), - decoration: InputDecoration( - filled: true, - fillColor: Colors.white, - contentPadding: const EdgeInsets.all(16), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: Colors.grey.withValues(alpha: 0.2), - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: baseColor, - width: 2, - ), - ), - hintText: 'https://example.com', - hintStyle: TextStyle( - color: Colors.grey.shade500, - fontSize: 16, - ), - prefixIcon: Icon( - Icons.language_rounded, - color: baseColor, - ), - ), - ), - ], - ), - ), - - // 카테고리 선택 필드 추가 - AnimatedContainer( - duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.only(bottom: 20), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: _currentEditingField == 5 - ? baseColor.withValues(alpha: 0.1) - : Colors.transparent, - ), - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '카테고리 (선택)', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: baseColor, - ), - ), - const SizedBox(height: 8), - Consumer( - builder: (context, categoryProvider, child) { - // 카테고리가 없을 때 메시지 표시 - if (categoryProvider.categories.isEmpty) { - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - vertical: 16, - horizontal: 16, - ), - decoration: BoxDecoration( - border: Border.all( - color: Colors.grey.withValues(alpha: 0.2), - ), - borderRadius: - BorderRadius.circular(12), - color: Colors.white, - ), - child: Row( - children: [ - Icon( - Icons.category_rounded, - color: baseColor, - ), - const SizedBox(width: 12), - const Text( - '카테고리가 없습니다', - style: TextStyle( - color: Colors.grey, - fontSize: 16, - ), - ), - ], - ), - ); - } - - // 카테고리 드롭다운 표시 - return DropdownButtonFormField( - value: _selectedCategoryId, - focusNode: _categoryFocus, - onTap: () => setState( - () => _currentEditingField = 5), - onChanged: (value) { - setState(() { - _selectedCategoryId = value; - _currentEditingField = -1; - _categoryFocus.unfocus(); - }); - }, - icon: Icon( - Icons.arrow_drop_down_circle_outlined, - color: baseColor, - ), - decoration: InputDecoration( - filled: true, - fillColor: Colors.white, - contentPadding: - const EdgeInsets.all(16), - border: OutlineInputBorder( - borderRadius: - BorderRadius.circular(12), - borderSide: BorderSide( - color: Colors.grey.withValues(alpha: 0.2), - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: - BorderRadius.circular(12), - borderSide: BorderSide( - color: baseColor, - width: 2, - ), - ), - prefixIcon: Icon( - Icons.category_rounded, - color: baseColor, - ), - ), - style: const TextStyle( - color: Colors.black87, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - hint: const Text( - '카테고리 선택', - style: TextStyle( - color: Colors.grey, - fontSize: 16, - ), - ), - isExpanded: true, - items: [ - DropdownMenuItem( - value: null, - child: const Text( - '카테고리 없음', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Colors.grey, - ), - ), - ), - ...categoryProvider.categories - .map((category) { - return DropdownMenuItem( - value: category.id, - child: Text( - category.name, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Colors.black87, - ), - ), - ); - }).toList(), - ], - ); - }, - ), - ], - ), - ), - - // 이벤트 설정 섹션 - Container( - margin: const EdgeInsets.only(bottom: 20), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: _isEventActive - ? baseColor - : Colors.grey.withValues(alpha: 0.2), - width: _isEventActive ? 2 : 1, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Checkbox( - value: _isEventActive, - onChanged: (value) { - setState(() { - _isEventActive = value ?? false; - if (!_isEventActive) { - // 이벤트 비활성화 시 관련 데이터 초기화 - _eventStartDate = null; - _eventEndDate = null; - _eventPriceController.clear(); - } - }); - }, - activeColor: baseColor, - ), - const Text( - '이벤트/할인 설정', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Color(0xFF1E293B), - ), - ), - const SizedBox(width: 8), - Icon( - Icons.local_offer, - size: 20, - color: _isEventActive - ? baseColor - : Colors.grey, - ), - ], - ), - - // 이벤트 활성화 시 추가 필드 표시 - AnimatedContainer( - duration: const Duration(milliseconds: 300), - height: _isEventActive ? null : 0, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 300), - opacity: _isEventActive ? 1.0 : 0.0, - child: Column( - children: [ - const SizedBox(height: 16), - - // 이벤트 기간 설정 - Row( - children: [ - // 시작일 - Expanded( - child: InkWell( - onTap: () async { - final DateTime? picked = await showDatePicker( - context: context, - initialDate: _eventStartDate ?? DateTime.now(), - firstDate: DateTime.now().subtract(const Duration(days: 365)), - lastDate: DateTime.now().add(const Duration(days: 365 * 2)), - builder: (BuildContext context, Widget? child) { - return Theme( - data: ThemeData.light().copyWith( - colorScheme: ColorScheme.light( - primary: baseColor, - onPrimary: Colors.white, - surface: Colors.white, - onSurface: Colors.black, - ), - ), - child: child!, - ); - }, - ); - if (picked != null) { - setState(() { - _eventStartDate = picked; - }); - } - }, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - border: Border.all(color: Colors.grey.withValues(alpha: 0.3)), - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '시작일', - style: TextStyle( - fontSize: 12, - color: Colors.grey, - ), - ), - const SizedBox(height: 4), - Text( - _eventStartDate == null - ? '선택' - : DateFormat('MM/dd').format(_eventStartDate!), - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ), - ), - const SizedBox(width: 8), - const Icon(Icons.arrow_forward, color: Colors.grey), - const SizedBox(width: 8), - // 종료일 - Expanded( - child: InkWell( - onTap: () async { - final DateTime? picked = await showDatePicker( - context: context, - initialDate: _eventEndDate ?? DateTime.now().add(const Duration(days: 30)), - firstDate: _eventStartDate ?? DateTime.now(), - lastDate: DateTime.now().add(const Duration(days: 365 * 2)), - builder: (BuildContext context, Widget? child) { - return Theme( - data: ThemeData.light().copyWith( - colorScheme: ColorScheme.light( - primary: baseColor, - onPrimary: Colors.white, - surface: Colors.white, - onSurface: Colors.black, - ), - ), - child: child!, - ); - }, - ); - if (picked != null) { - setState(() { - _eventEndDate = picked; - }); - } - }, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - border: Border.all(color: Colors.grey.withValues(alpha: 0.3)), - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '종료일', - style: TextStyle( - fontSize: 12, - color: Colors.grey, - ), - ), - const SizedBox(height: 4), - Text( - _eventEndDate == null - ? '선택' - : DateFormat('MM/dd').format(_eventEndDate!), - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ), - ), - ], - ), - - const SizedBox(height: 16), - - // 이벤트 가격 입력 - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '이벤트 가격', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: baseColor, - ), - ), - const SizedBox(height: 8), - TextFormField( - controller: _eventPriceController, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp(r'[\d,.]')), - ], - decoration: InputDecoration( - hintText: '할인된 가격을 입력하세요', - prefixText: _currency == 'KRW' ? '₩ ' : '\$ ', - filled: true, - fillColor: Colors.white, - contentPadding: const EdgeInsets.symmetric( - vertical: 16, - horizontal: 16, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: Colors.grey.withValues(alpha: 0.2), - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: baseColor, - width: 2, - ), - ), - prefixIcon: Icon( - Icons.sell, - color: baseColor, - ), - ), - onChanged: (value) { - // 콤마 자동 추가 - if (value.isNotEmpty && !value.contains('.')) { - final number = int.tryParse(value.replaceAll(',', '')); - if (number != null) { - final formatted = NumberFormat('#,###').format(number); - if (formatted != value) { - _eventPriceController.value = TextEditingValue( - text: formatted, - selection: TextSelection.collapsed( - offset: formatted.length, - ), - ); - } - } - } - }, - ), - ], - ), - ], - ), - ), - ), - ], - ), - ), - ], - ), - ), - ), - ), - ), - const SizedBox(height: 32), - FadeTransition( - opacity: _fadeAnimation, - child: SlideTransition( - position: Tween( - begin: const Offset(0.0, 0.8), - end: Offset.zero, - ).animate(CurvedAnimation( - parent: _animationController, - curve: const Interval(0.4, 1.0, curve: Curves.easeOutCubic), - )), - child: MouseRegion( - onEnter: (_) => setState(() => _isSaveHovered = true), - onExit: (_) => setState(() => _isSaveHovered = false), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - width: double.infinity, - height: 60, - transform: _isSaveHovered - ? (Matrix4.identity()..scale(1.02)) - : Matrix4.identity(), - child: ElevatedButton( - onPressed: _updateSubscription, - style: ElevatedButton.styleFrom( - backgroundColor: baseColor, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - padding: const EdgeInsets.symmetric(vertical: 16), - elevation: _isSaveHovered ? 8 : 4, - shadowColor: baseColor.withValues(alpha: 0.5), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.save_rounded, - color: Colors.white, - size: _isSaveHovered ? 24 : 20, - ), - const SizedBox(width: 8), - const Text( - '변경사항 저장', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - ), - ], - ), - ), - ), - ), - ), - ), - const SizedBox(height: 80), - ], - ), - ), - ), - ); - } -} +} \ No newline at end of file diff --git a/lib/screens/detail_screen_old.dart b/lib/screens/detail_screen_old.dart new file mode 100644 index 0000000..e7c212f --- /dev/null +++ b/lib/screens/detail_screen_old.dart @@ -0,0 +1,2215 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'dart:math' as math; +import '../models/subscription_model.dart'; +import '../providers/subscription_provider.dart'; +import '../providers/category_provider.dart'; +import 'package:intl/intl.dart'; +import '../widgets/website_icon.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import '../services/subscription_url_matcher.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:flutter/services.dart'; // TextInputFormatter 사용을 위한 import 추가 +import '../services/exchange_rate_service.dart'; // 환율 서비스만 사용 + +class DetailScreen extends StatefulWidget { + final SubscriptionModel subscription; + + const DetailScreen({ + super.key, + required this.subscription, + }); + + @override + State createState() => _DetailScreenState(); +} + +class _DetailScreenState extends State + with SingleTickerProviderStateMixin { + late TextEditingController _serviceNameController; + late TextEditingController _monthlyCostController; + late TextEditingController _websiteUrlController; + late String _billingCycle; + late DateTime _nextBillingDate; + late AnimationController _animationController; + late Animation _fadeAnimation; + late Animation _slideAnimation; + late Animation _rotateAnimation; + String? _selectedCategoryId; // 선택된 카테고리 ID + late String _currency; // 통화 단위: '원화' 또는 '달러' + + // 이벤트 관련 상태 변수 + late bool _isEventActive; + DateTime? _eventStartDate; + DateTime? _eventEndDate; + late TextEditingController _eventPriceController; + + // 포커스 노드 추가 + final _serviceNameFocus = FocusNode(); + final _monthlyCostFocus = FocusNode(); + final _billingCycleFocus = FocusNode(); + final _nextBillingDateFocus = FocusNode(); + final _websiteUrlFocus = FocusNode(); + final _categoryFocus = FocusNode(); // 카테고리 포커스 노드 + final _currencyFocus = FocusNode(); // 통화 단위 포커스 노드 + + final ScrollController _scrollController = ScrollController(); + double _scrollOffset = 0; + + // 현재 편집 중인 필드 + int _currentEditingField = -1; + + // 호버 상태 + bool _isDeleteHovered = false; + bool _isSaveHovered = false; + bool _isCancelHovered = false; + + @override + void initState() { + super.initState(); + _serviceNameController = + TextEditingController(text: widget.subscription.serviceName); + _monthlyCostController = + TextEditingController(text: widget.subscription.monthlyCost.toString()); + _websiteUrlController = + TextEditingController(text: widget.subscription.websiteUrl ?? ''); + _billingCycle = widget.subscription.billingCycle; + _nextBillingDate = widget.subscription.nextBillingDate; + _selectedCategoryId = widget.subscription.categoryId; // 카테고리 ID 설정 + _currency = widget.subscription.currency; // 통화 단위 설정 + + // 이벤트 관련 초기화 + _isEventActive = widget.subscription.isEventActive; + _eventStartDate = widget.subscription.eventStartDate; + _eventEndDate = widget.subscription.eventEndDate; + _eventPriceController = TextEditingController(); + + // 이벤트 가격 초기화 + if (widget.subscription.eventPrice != null) { + if (_currency == 'KRW') { + _eventPriceController.text = NumberFormat.decimalPattern() + .format(widget.subscription.eventPrice!.toInt()); + } else { + _eventPriceController.text = + NumberFormat('#,##0.00').format(widget.subscription.eventPrice!); + } + } + + // 통화 단위에 따른 금액 표시 형식 조정 + if (_currency == 'KRW') { + // 원화: 정수 형식으로 표시 (콤마 포함) + _monthlyCostController.text = NumberFormat.decimalPattern() + .format(widget.subscription.monthlyCost.toInt()); + } else { + // 달러: 소수점 2자리까지 표시 (콤마 포함) + _monthlyCostController.text = + NumberFormat('#,##0.00').format(widget.subscription.monthlyCost); + } + + // 카테고리 ID가 없으면 서비스명 기반으로 자동 선택 시도 + if (_selectedCategoryId == null) { + _autoSelectCategory(); + } + + // 서비스명 컨트롤러에 리스너 추가 + _serviceNameController.addListener(_onServiceNameChanged); + + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 800), + ); + + _fadeAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _animationController, + curve: const Interval(0.0, 0.6, curve: Curves.easeIn), + )); + + _slideAnimation = Tween( + begin: const Offset(0.0, 0.2), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _animationController, + curve: Curves.easeOutCubic, + )); + + _rotateAnimation = Tween( + begin: 0.0, + end: 2 * math.pi, + ).animate(CurvedAnimation( + parent: _animationController, + curve: const Interval(0.0, 0.5, curve: Curves.easeOutBack), + )); + + _scrollController.addListener(() { + setState(() { + _scrollOffset = _scrollController.offset; + }); + }); + + _animationController.forward(); + } + + @override + void dispose() { + // 서비스명 컨트롤러 리스너 제거 + _serviceNameController.removeListener(_onServiceNameChanged); + + // 카테고리가 변경되었으면 구독 정보 업데이트 + if (_selectedCategoryId != widget.subscription.categoryId) { + widget.subscription.categoryId = _selectedCategoryId; + final provider = + Provider.of(context, listen: false); + provider.updateSubscription(widget.subscription); + } + + _serviceNameController.dispose(); + _monthlyCostController.dispose(); + _websiteUrlController.dispose(); + _eventPriceController.dispose(); + _animationController.dispose(); + _scrollController.dispose(); + + // 포커스 노드 해제 + _serviceNameFocus.dispose(); + _monthlyCostFocus.dispose(); + _billingCycleFocus.dispose(); + _nextBillingDateFocus.dispose(); + _websiteUrlFocus.dispose(); + _categoryFocus.dispose(); + _currencyFocus.dispose(); + + super.dispose(); + } + + // 서비스명이 변경될 때 호출되는 콜백 함수 + void _onServiceNameChanged() { + // 웹사이트 URL이 비어있거나 기존 URL이 서비스와 매칭되지 않는 경우에만 자동 매칭 + if (_serviceNameController.text.isNotEmpty && + (_websiteUrlController.text.isEmpty || + SubscriptionUrlMatcher.findMatchingUrl( + _serviceNameController.text) != + _websiteUrlController.text)) { + // 자동 URL 매칭 시도 + final suggestedUrl = + SubscriptionUrlMatcher.suggestUrl(_serviceNameController.text); + + // 매칭된 URL이 있으면 텍스트 컨트롤러에 설정 + if (suggestedUrl != null && suggestedUrl.isNotEmpty) { + setState(() { + _websiteUrlController.text = suggestedUrl; + }); + } + } + } + + Future _updateSubscription() async { + final provider = Provider.of(context, listen: false); + + // 웹사이트 URL이 비어있는 경우 자동 매칭 다시 시도 + String? websiteUrl = _websiteUrlController.text; + if (websiteUrl.isEmpty) { + websiteUrl = + SubscriptionUrlMatcher.suggestUrl(_serviceNameController.text); + } + + // 구독 정보 업데이트 + // 콤마 제거하고 숫자만 추출 + double monthlyCost = 0.0; + try { + monthlyCost = + double.parse(_monthlyCostController.text.replaceAll(',', '')); + } catch (e) { + // 파싱 오류 발생 시 기본값 사용 + monthlyCost = widget.subscription.monthlyCost; + } + + widget.subscription.serviceName = _serviceNameController.text; + widget.subscription.monthlyCost = monthlyCost; + widget.subscription.websiteUrl = websiteUrl; + widget.subscription.billingCycle = _billingCycle; + widget.subscription.nextBillingDate = _nextBillingDate; + widget.subscription.categoryId = _selectedCategoryId; // 카테고리 업데이트 + widget.subscription.currency = _currency; // 통화 단위 업데이트 + + // 이벤트 정보 업데이트 + widget.subscription.isEventActive = _isEventActive; + widget.subscription.eventStartDate = _eventStartDate; + widget.subscription.eventEndDate = _eventEndDate; + + // 이벤트 가격 파싱 + if (_isEventActive && _eventPriceController.text.isNotEmpty) { + try { + widget.subscription.eventPrice = + double.parse(_eventPriceController.text.replaceAll(',', '')); + } catch (e) { + widget.subscription.eventPrice = null; + } + } else { + widget.subscription.eventPrice = null; + } + + // 구독 업데이트 + await provider.updateSubscription(widget.subscription); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.check_circle_rounded, color: Colors.white), + const SizedBox(width: 12), + const Text('구독 정보가 업데이트되었습니다.'), + ], + ), + behavior: SnackBarBehavior.floating, + backgroundColor: const Color(0xFF10B981), + duration: const Duration(seconds: 2), + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ); + + // 변경 사항이 반영될 시간을 주기 위해 짧게 지연 후 결과 반환 + await Future.delayed(const Duration(milliseconds: 100)); + if (!context.mounted) return; + Navigator.of(context).pop(true); + } + } + + Future _deleteSubscription() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: const Text( + '구독 삭제', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 20, + ), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFFEF2F2), + borderRadius: BorderRadius.circular(16), + ), + child: const Icon( + Icons.warning_amber_rounded, + color: Color(0xFFDC2626), + size: 48, + ), + ), + const SizedBox(height: 16), + const Text('이 구독을 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.'), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('취소'), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFDC2626), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text( + '삭제', + style: TextStyle(color: Colors.white), + ), + ), + ], + ), + ); + + if (confirmed == true && mounted) { + final provider = + Provider.of(context, listen: false); + await provider.deleteSubscription(widget.subscription.id); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.delete_forever_rounded, color: Colors.white), + const SizedBox(width: 12), + const Text('구독이 삭제되었습니다.'), + ], + ), + behavior: SnackBarBehavior.floating, + backgroundColor: const Color(0xFFDC2626), + duration: const Duration(seconds: 2), + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ); + Navigator.of(context).pop(); + } + } + } + + // 배경 그라데이션과 색상 가져오기 + Color _getCardColor() { + // 서비스 이름에 따라 일관된 색상 생성 + final int hash = widget.subscription.serviceName.hashCode.abs(); + final List colors = [ + const Color(0xFF3B82F6), // 파랑 + const Color(0xFF10B981), // 초록 + const Color(0xFF8B5CF6), // 보라 + const Color(0xFFF59E0B), // 노랑 + const Color(0xFFEF4444), // 빨강 + const Color(0xFF0EA5E9), // 하늘 + const Color(0xFFEC4899), // 분홍 + ]; + + return colors[hash % colors.length]; + } + + + // 서비스명을 기반으로 카테고리 자동 선택 함수 + void _autoSelectCategory() { + if (_serviceNameController.text.isEmpty) return; + + final serviceName = _serviceNameController.text.toLowerCase(); + final categoryProvider = + Provider.of(context, listen: false); + + // 카테고리가 없으면 리턴 + if (categoryProvider.categories.isEmpty) return; + + // OTT 서비스 확인 + if (SubscriptionUrlMatcher.ottServices.keys.any((key) => + serviceName.contains(key.toLowerCase()) || + key.toLowerCase().contains(serviceName))) { + // OTT 관련 카테고리 찾기 + try { + final ottCategory = categoryProvider.categories.firstWhere( + (cat) => + cat.name.contains('OTT') || + cat.name.contains('미디어') || + cat.name.contains('영상'), + ); + + setState(() { + _selectedCategoryId = ottCategory.id; + }); + return; + } catch (_) { + // OTT 카테고리가 없으면 첫 번째 카테고리 사용 + if (categoryProvider.categories.isNotEmpty) { + setState(() { + _selectedCategoryId = categoryProvider.categories.first.id; + }); + } + } + } + + // 음악 서비스 확인 + if (SubscriptionUrlMatcher.musicServices.keys.any((key) => + serviceName.contains(key.toLowerCase()) || + key.toLowerCase().contains(serviceName))) { + // 음악 관련 카테고리 찾기 + try { + final musicCategory = categoryProvider.categories.firstWhere( + (cat) => cat.name.contains('음악') || cat.name.contains('스트리밍'), + ); + + setState(() { + _selectedCategoryId = musicCategory.id; + }); + return; + } catch (_) { + // 음악 카테고리가 없으면 첫 번째 카테고리 사용 + if (categoryProvider.categories.isNotEmpty) { + setState(() { + _selectedCategoryId = categoryProvider.categories.first.id; + }); + } + } + } + + // AI 서비스 확인 + if (SubscriptionUrlMatcher.aiServices.keys.any((key) => + serviceName.contains(key.toLowerCase()) || + key.toLowerCase().contains(serviceName))) { + // AI 관련 카테고리 찾기 + try { + final aiCategory = categoryProvider.categories.firstWhere( + (cat) => cat.name.contains('AI') || cat.name.contains('인공지능'), + ); + + setState(() { + _selectedCategoryId = aiCategory.id; + }); + return; + } catch (_) { + // AI 카테고리가 없으면 첫 번째 카테고리 사용 + if (categoryProvider.categories.isNotEmpty) { + setState(() { + _selectedCategoryId = categoryProvider.categories.first.id; + }); + } + } + } + + // 프로그래밍/개발 서비스 확인 + if (SubscriptionUrlMatcher.programmingServices.keys.any((key) => + serviceName.contains(key.toLowerCase()) || + key.toLowerCase().contains(serviceName))) { + // 개발 관련 카테고리 찾기 + try { + final devCategory = categoryProvider.categories.firstWhere( + (cat) => cat.name.contains('개발') || cat.name.contains('프로그래밍'), + ); + + setState(() { + _selectedCategoryId = devCategory.id; + }); + return; + } catch (_) { + // 개발 카테고리가 없으면 첫 번째 카테고리 사용 + if (categoryProvider.categories.isNotEmpty) { + setState(() { + _selectedCategoryId = categoryProvider.categories.first.id; + }); + } + } + } + + // 오피스/협업 툴 확인 + if (SubscriptionUrlMatcher.officeTools.keys.any((key) => + serviceName.contains(key.toLowerCase()) || + key.toLowerCase().contains(serviceName))) { + // 오피스 관련 카테고리 찾기 + try { + final officeCategory = categoryProvider.categories.firstWhere( + (cat) => + cat.name.contains('오피스') || + cat.name.contains('협업') || + cat.name.contains('업무'), + ); + + setState(() { + _selectedCategoryId = officeCategory.id; + }); + return; + } catch (_) { + // 오피스 카테고리가 없으면 첫 번째 카테고리 사용 + if (categoryProvider.categories.isNotEmpty) { + setState(() { + _selectedCategoryId = categoryProvider.categories.first.id; + }); + } + } + } + + // 기타 서비스 확인 + if (SubscriptionUrlMatcher.otherServices.keys.any((key) => + serviceName.contains(key.toLowerCase()) || + key.toLowerCase().contains(serviceName))) { + // 기타 관련 카테고리 찾기 + try { + final otherCategory = categoryProvider.categories.firstWhere( + (cat) => cat.name.contains('기타') || cat.name.contains('게임'), + ); + + setState(() { + _selectedCategoryId = otherCategory.id; + }); + } catch (_) { + // 기타 카테고리가 없으면 첫 번째 카테고리 사용 + if (categoryProvider.categories.isNotEmpty) { + setState(() { + _selectedCategoryId = categoryProvider.categories.first.id; + }); + } + } + } + } + + // URL을 외부 앱에서 여는 함수 + Future _openCancellationPage() async { + final serviceName = widget.subscription.serviceName; + + // 해지 안내 페이지 URL 찾기 + final cancellationUrl = + SubscriptionUrlMatcher.findCancellationUrl(serviceName); + + if (cancellationUrl == null) { + // 해지 안내 페이지가 없는 경우 사용자에게 안내 + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('공식 해지 안내 페이지가 제공되지 않는 서비스입니다.'), + behavior: SnackBarBehavior.floating, + backgroundColor: Colors.grey.shade700, + duration: const Duration(seconds: 2), + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ); + } + return; + } + + try { + final Uri url = Uri.parse(cancellationUrl); + if (await canLaunchUrl(url)) { + await launchUrl(url, mode: LaunchMode.externalApplication); + } else { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('해지 안내 페이지를 열 수 없습니다.'), + behavior: SnackBarBehavior.floating, + backgroundColor: Colors.red.shade700, + duration: const Duration(seconds: 2), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + ), + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('오류가 발생했습니다: $e'), + behavior: SnackBarBehavior.floating, + backgroundColor: Colors.red.shade700, + duration: const Duration(seconds: 2), + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ); + } + } + } + + @override + Widget build(BuildContext context) { + final daysUntilBilling = + widget.subscription.nextBillingDate.difference(DateTime.now()).inDays; + final isNearBilling = daysUntilBilling <= 7; + final baseColor = _getCardColor(); + final double appBarOpacity = math.max(0, math.min(1, _scrollOffset / 150)); + + return Scaffold( + backgroundColor: const Color(0xFFF8FAFC), + extendBodyBehindAppBar: true, + appBar: PreferredSize( + preferredSize: const Size.fromHeight(60), + child: Container( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: appBarOpacity), + boxShadow: appBarOpacity > 0.6 + ? [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1 * appBarOpacity), + spreadRadius: 1, + blurRadius: 8, + offset: const Offset(0, 4), + ) + ] + : null, + ), + child: SafeArea( + child: AppBar( + title: Text( + '구독 상세', + style: TextStyle( + fontFamily: 'Montserrat', + fontSize: 24, + fontWeight: FontWeight.w800, + letterSpacing: -0.5, + color: const Color(0xFF1E293B), + shadows: appBarOpacity > 0.6 + ? [ + Shadow( + color: Colors.black.withValues(alpha: 0.2), + offset: const Offset(0, 1), + blurRadius: 2, + ) + ] + : null, + ), + ), + elevation: 0, + backgroundColor: Colors.transparent, + actions: [ + // 해지 안내 버튼 + if (SubscriptionUrlMatcher.hasCancellationPage( + widget.subscription.serviceName)) + MouseRegion( + onEnter: (_) => setState(() => _isCancelHovered = true), + onExit: (_) => setState(() => _isCancelHovered = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.symmetric( + horizontal: 8, vertical: 6), + decoration: BoxDecoration( + color: _isCancelHovered + ? const Color(0xFFF1F5F9) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: TextButton.icon( + icon: const Icon( + Icons.open_in_browser, + size: 18, + color: Color(0xFF6B7280), + ), + label: const Text( + '해지 안내', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF6B7280), + ), + ), + onPressed: _openCancellationPage, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ), + ), + MouseRegion( + onEnter: (_) => setState(() => _isDeleteHovered = true), + onExit: (_) => setState(() => _isDeleteHovered = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + color: _isDeleteHovered + ? const Color(0xFFFEF2F2) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: IconButton( + icon: const FaIcon(FontAwesomeIcons.trashCan, + size: 20, color: Color(0xFFDC2626)), + tooltip: '삭제', + onPressed: _deleteSubscription, + ), + ), + ), + ], + ), + ), + ), + ), + body: SingleChildScrollView( + controller: _scrollController, + physics: const BouncingScrollPhysics(), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: MediaQuery.of(context).padding.top + 60), + FadeTransition( + opacity: _fadeAnimation, + child: SlideTransition( + position: _slideAnimation, + child: Hero( + tag: 'subscription_${widget.subscription.id}', + child: Card( + elevation: 8, + shadowColor: baseColor.withValues(alpha: 0.4), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + child: Container( + constraints: BoxConstraints( + maxHeight: + MediaQuery.of(context).size.height * 0.3), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + baseColor.withValues(alpha: 0.8), + baseColor, + ], + ), + ), + padding: const EdgeInsets.all(24), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + return Transform.rotate( + angle: _rotateAnimation.value, + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: + BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black + .withValues(alpha: 0.1), + blurRadius: 10, + spreadRadius: 0, + ), + ], + ), + child: WebsiteIcon( + key: ValueKey( + 'detail_icon_${widget.subscription.id}'), + url: widget.subscription.websiteUrl, + serviceName: + widget.subscription.serviceName, + size: 48, + ), + ), + ); + }, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + widget.subscription.serviceName, + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.w800, + color: Colors.white, + letterSpacing: -0.5, + shadows: [ + Shadow( + color: Colors.black26, + offset: Offset(0, 2), + blurRadius: 4, + ), + ], + ), + ), + const SizedBox(height: 4), + Text( + '${widget.subscription.billingCycle} 결제', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: + Colors.white.withValues(alpha: 0.8), + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 20), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + '다음 결제일', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: + Colors.white.withValues(alpha: 0.8), + ), + ), + const SizedBox(height: 4), + Text( + DateFormat('yyyy년 MM월 dd일').format( + widget.subscription + .nextBillingDate), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ], + ), + Column( + crossAxisAlignment: + CrossAxisAlignment.end, + children: [ + Text( + '월 지출', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: + Colors.white.withValues(alpha: 0.8), + ), + ), + const SizedBox(height: 4), + Text( + NumberFormat.currency( + locale: _currency == 'KRW' + ? 'ko_KR' + : 'en_US', + symbol: + _currency == 'KRW' ? '₩' : '\$', + decimalDigits: + _currency == 'KRW' ? 0 : 2, + ).format( + widget.subscription.monthlyCost), + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.w800, + color: Colors.white, + ), + ), + ], + ), + ], + ), + ), + if (isNearBilling) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + decoration: BoxDecoration( + color: const Color(0xFFDC2626) + .withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.white.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Row( + children: [ + const Icon( + Icons.access_time_rounded, + size: 20, + color: Colors.white, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + daysUntilBilling == 0 + ? '오늘 결제 예정' + : '$daysUntilBilling일 후 결제 예정', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ], + ), + ), + ], + ], + ), + ), + ), + ), + ), + ), + ), + const SizedBox(height: 32), + FadeTransition( + opacity: _fadeAnimation, + child: SlideTransition( + position: Tween( + begin: const Offset(0.0, 0.4), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _animationController, + curve: const Interval(0.2, 1.0, curve: Curves.easeOutCubic), + )), + child: Text( + '구독 정보 수정', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: baseColor, + letterSpacing: -0.5, + ), + ), + ), + ), + const SizedBox(height: 16), + FadeTransition( + opacity: _fadeAnimation, + child: SlideTransition( + position: Tween( + begin: const Offset(0.0, 0.6), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _animationController, + curve: const Interval(0.3, 1.0, curve: Curves.easeOutCubic), + )), + child: Card( + elevation: 1, + shadowColor: Colors.black12, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // 서비스명 필드 + AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: _currentEditingField == 0 + ? baseColor.withValues(alpha: 0.1) + : Colors.transparent, + ), + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '서비스명', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: baseColor, + ), + ), + const SizedBox(height: 8), + TextFormField( + controller: _serviceNameController, + focusNode: _serviceNameFocus, + textInputAction: TextInputAction.next, + onTap: () => + setState(() => _currentEditingField = 0), + onEditingComplete: () { + _monthlyCostFocus.requestFocus(); + setState(() => _currentEditingField = -1); + }, + style: const TextStyle( + color: Color(0xFF1E293B), + fontSize: 16, + fontWeight: FontWeight.w500, + ), + decoration: InputDecoration( + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.all(16), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Colors.grey.withValues(alpha: 0.2), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: baseColor, + width: 2, + ), + ), + prefixIcon: Icon( + Icons.business_rounded, + color: baseColor, + ), + ), + ), + ], + ), + ), + + // 월 비용 필드 + AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: _currentEditingField == 1 + ? baseColor.withValues(alpha: 0.1) + : Colors.transparent, + ), + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 환율 정보와 비용 입력 제목 표시 (상단) + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + '비용 입력', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: baseColor, + ), + ), + if (_currency == 'USD') + FutureBuilder( + future: ExchangeRateService() + .getFormattedExchangeRateInfo(), + builder: (context, snapshot) { + if (snapshot.hasData) { + return Text( + snapshot.data!, + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + fontWeight: FontWeight.w500, + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + // 통화 단위 선택 (좌측) + Expanded( + flex: 3, // 25% 너비 차지 + child: DropdownButtonFormField( + value: _currency, + focusNode: _currencyFocus, + isDense: true, + onTap: () => setState( + () => _currentEditingField = 1), + onChanged: (value) { + if (value != null) { + setState(() { + _currency = value; + + // 통화 단위 변경 시 입력 값 변환 + final currentText = + _monthlyCostController.text; + if (currentText.isNotEmpty) { + // 콤마 제거하고 숫자만 추출 + final numericValue = + double.tryParse(currentText + .replaceAll(',', '')); + + if (numericValue != null) { + if (value == 'KRW') { + // 달러 → 원화: 소수점 제거 + _monthlyCostController + .text = NumberFormat + .decimalPattern() + .format(numericValue + .toInt()); + } else { + // 원화 → 달러: 소수점 2자리 추가 + _monthlyCostController + .text = NumberFormat( + '#,##0.00') + .format(numericValue); + } + } + } + + // 화면 갱신하여 통화 기호도 업데이트 + _monthlyCostFocus.requestFocus(); + }); + } + }, + decoration: InputDecoration( + filled: true, + fillColor: Colors.white, + contentPadding: + const EdgeInsets.symmetric( + vertical: 16, horizontal: 12), + border: OutlineInputBorder( + borderRadius: + BorderRadius.circular(12), + borderSide: BorderSide( + color: + Colors.grey.withValues(alpha: 0.2), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: + BorderRadius.circular(12), + borderSide: BorderSide( + color: baseColor, + width: 2, + ), + ), + ), + icon: Icon( + Icons.arrow_drop_down, + color: baseColor, + ), + items: ['KRW', 'USD'] + .map((currency) => DropdownMenuItem( + value: currency, + child: Text( + currency == 'KRW' + ? 'KRW' + : 'USD', + style: const TextStyle( + fontSize: 14, + fontWeight: + FontWeight.w500, + color: Colors.black87, + ), + ), + )) + .toList(), + ), + ), + const SizedBox(width: 8), + // 비용 입력 필드 (우측) + Expanded( + flex: 7, // 75% 너비 차지 + child: Container( + height: 50, // 높이를 56에서 50으로 줄임 + // 우측에서 40픽셀 줄이기 + margin: const EdgeInsets.only(right: 0), + // 내부 패딩을 고정값으로 설정하여 포커스 상태와 관계없이 일관되게 유지 + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + // 포커스 상태에 따른 배경색 변경 + color: _currentEditingField == 1 + ? const Color( + 0xFFF3F4F6) // 포커스 상태일 때 연한 회색 + : Colors + .transparent, // 포커스 없을 때 투명 + borderRadius: + BorderRadius.circular(12), + // 테두리 설정 (포커스 상태에 따라 색상만 변경) + border: Border.all( + color: _currentEditingField == 1 + ? baseColor + : Colors.grey.withValues(alpha: + 0.4), // 포커스 없을 때 더 진한 회색 + width: _currentEditingField == 1 + ? 2 + : 1, + ), + ), + child: Row( + children: [ + // 통화 기호 - 항상 표시되도록 설정 + Container( + width: 40, + alignment: Alignment.center, + decoration: BoxDecoration( + // 테두리 추가 (좌측 통화선택란과 동일한 스타일) + border: Border( + right: BorderSide( + color: Colors.grey + .withValues(alpha: 0.2), + width: 1, + ), + ), + ), + child: Text( + _currency == 'KRW' ? '₩' : '\$', + style: TextStyle( + color: baseColor, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), + // 실제 입력 필드 + Expanded( + child: Stack( + alignment: + Alignment.centerRight, + children: [ + TextField( + controller: + _monthlyCostController, + focusNode: + _monthlyCostFocus, + textInputAction: + TextInputAction.next, + keyboardType: + const TextInputType + .numberWithOptions( + decimal: true), + inputFormatters: [ + // 통화 단위에 따라 다른 입력 형식 적용 + FilteringTextInputFormatter + .allow( + _currency == 'KRW' + ? RegExp( + r'[0-9,]') // 원화: 정수만 허용 + : RegExp( + r'[0-9,.]'), // 달러: 소수점 허용 + ), + // 커스텀 포맷터 - 3자리마다 콤마 추가 + TextInputFormatter + .withFunction( + (oldValue, + newValue) { + // 입력값에서 콤마 제거 + final text = newValue + .text + .replaceAll( + ',', ''); + + if (text.isEmpty) { + return newValue + .copyWith( + text: ''); + } + + // 숫자 형식 검증 + if (_currency == + 'KRW') { + // 원화: 정수 형식 + if (double.tryParse( + text) == + null) { + return oldValue; + } + + // 3자리마다 콤마 추가 + final formattedValue = + NumberFormat + .decimalPattern() + .format( + int.parse( + text)); + + return newValue + .copyWith( + text: + formattedValue, + selection: TextSelection + .collapsed( + offset: formattedValue + .length), + ); + } else { + // 달러: 소수점 형식 + if (double.tryParse( + text) == + null && + text != '.') { + return oldValue; + } + + // 소수점 이하 처리를 위해 부분 분리 + final parts = + text.split('.'); + final integerPart = + parts[0]; + final decimalPart = parts + .length > + 1 + ? '.${parts[1].length > 2 ? parts[1].substring(0, 2) : parts[1]}' + : ''; + + // 3자리마다 콤마 추가 (정수 부분만) + String formattedValue; + if (integerPart + .isEmpty) { + formattedValue = + '0$decimalPart'; + } else { + final formatted = NumberFormat + .decimalPattern() + .format(int.parse( + integerPart)); + formattedValue = + '$formatted$decimalPart'; + } + + return newValue + .copyWith( + text: + formattedValue, + selection: TextSelection + .collapsed( + offset: formattedValue + .length), + ); + } + }), + ], + onTap: () => setState(() => + _currentEditingField = + 1), + onSubmitted: (_) { + _billingCycleFocus + .requestFocus(); + setState(() => + _currentEditingField = + -1); + }, + style: const TextStyle( + color: Color(0xFF1E293B), + fontSize: 16, + fontWeight: + FontWeight.w500, + ), + decoration: InputDecoration( + border: InputBorder.none, + // 포커스 상태와 관계없이 일관된 패딩 유지 + contentPadding: + const EdgeInsets + .symmetric( + vertical: 14, + horizontal: 8), + hintText: + _currency == 'KRW' + ? '9,000' + : '9.99', + hintStyle: TextStyle( + color: Colors + .grey.shade500, + fontSize: 16, + ), + // 모든 테두리 제거 + enabledBorder: + InputBorder.none, + focusedBorder: + InputBorder.none, + errorBorder: + InputBorder.none, + disabledBorder: + InputBorder.none, + focusedErrorBorder: + InputBorder.none, + ), + ), + // 달러일 때 원화 환산 금액 표시 + if (_currency == 'USD') + ValueListenableBuilder< + TextEditingValue>( + valueListenable: + _monthlyCostController, + builder: (context, value, + child) { + // 입력값이 바뀔 때마다 환산 금액 갱신 + return FutureBuilder< + String>( + future: ExchangeRateService() + .getFormattedKrwAmount( + double.tryParse(value + .text + .replaceAll( + ',', + '')) ?? + 0.0), + builder: (context, + snapshot) { + if (snapshot + .hasData && + snapshot.data! + .isNotEmpty) { + return Padding( + padding: + const EdgeInsets + .only( + right: + 12.0), + child: Text( + snapshot + .data!, + style: + const TextStyle( + fontSize: + 14, + color: Colors + .blue, + fontWeight: + FontWeight + .w500, + ), + ), + ); + } + return const SizedBox + .shrink(); + }, + ); + }, + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + // 환율 정보 위젯 추가 (달러 선택 시에만 표시) + ], + ), + ), + + // 결제 주기 필드 + AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: _currentEditingField == 2 + ? baseColor.withValues(alpha: 0.1) + : Colors.transparent, + ), + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '결제 주기', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: baseColor, + ), + ), + const SizedBox(height: 8), + DropdownButtonFormField( + value: _billingCycle, + focusNode: _billingCycleFocus, + onTap: () => + setState(() => _currentEditingField = 2), + onChanged: (value) { + if (value != null) { + setState(() { + _billingCycle = value; + _currentEditingField = -1; + _nextBillingDateFocus.requestFocus(); + }); + } + }, + decoration: InputDecoration( + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.all(16), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Colors.grey.withValues(alpha: 0.2), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: baseColor, + width: 2, + ), + ), + prefixIcon: Icon( + Icons.calendar_today_rounded, + color: baseColor, + ), + ), + dropdownColor: Colors.white, + style: const TextStyle( + color: Colors.black87, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + items: ['월간', '연간', '주간', '일간'] + .map((cycle) => DropdownMenuItem( + value: cycle, + child: Text(cycle), + )) + .toList(), + ), + ], + ), + ), + + // 다음 결제일 필드 + AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: _currentEditingField == 3 + ? baseColor.withValues(alpha: 0.1) + : Colors.transparent, + ), + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '다음 결제일', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: baseColor, + ), + ), + const SizedBox(height: 8), + InkWell( + focusNode: _nextBillingDateFocus, + onTap: () async { + setState(() => _currentEditingField = 3); + final DateTime? picked = + await showDatePicker( + context: context, + initialDate: _nextBillingDate, + firstDate: DateTime.now(), + lastDate: DateTime.now().add( + const Duration(days: 365 * 2), + ), + builder: (BuildContext context, + Widget? child) { + return Theme( + data: ThemeData.light().copyWith( + colorScheme: ColorScheme.light( + primary: baseColor, + onPrimary: Colors.white, + surface: Colors.white, + onSurface: Colors.black, + ), + ), + child: child!, + ); + }, + ); + if (picked != null) { + setState(() { + _nextBillingDate = picked; + _currentEditingField = -1; + _websiteUrlFocus.requestFocus(); + }); + } else { + setState(() => _currentEditingField = -1); + } + }, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all( + color: Colors.grey.withValues(alpha: 0.2), + ), + borderRadius: BorderRadius.circular(12), + color: Colors.white, + ), + child: Row( + children: [ + Icon( + Icons.event_rounded, + color: baseColor, + ), + const SizedBox(width: 12), + Text( + DateFormat('yyyy년 MM월 dd일') + .format(_nextBillingDate), + style: const TextStyle( + fontSize: 16, + color: Color(0xFF1E293B), + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ], + ), + ), + + // 웹사이트 URL 필드 + AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: _currentEditingField == 4 + ? baseColor.withValues(alpha: 0.1) + : Colors.transparent, + ), + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '웹사이트 URL (선택)', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: baseColor, + ), + ), + const SizedBox(height: 8), + TextFormField( + controller: _websiteUrlController, + focusNode: _websiteUrlFocus, + textInputAction: TextInputAction.done, + onTap: () => + setState(() => _currentEditingField = 4), + onEditingComplete: () { + setState(() => _currentEditingField = 5); + _categoryFocus.requestFocus(); + }, + style: const TextStyle( + color: Color(0xFF1E293B), + fontSize: 16, + fontWeight: FontWeight.w500, + ), + decoration: InputDecoration( + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.all(16), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Colors.grey.withValues(alpha: 0.2), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: baseColor, + width: 2, + ), + ), + hintText: 'https://example.com', + hintStyle: TextStyle( + color: Colors.grey.shade500, + fontSize: 16, + ), + prefixIcon: Icon( + Icons.language_rounded, + color: baseColor, + ), + ), + ), + ], + ), + ), + + // 카테고리 선택 필드 추가 + AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: _currentEditingField == 5 + ? baseColor.withValues(alpha: 0.1) + : Colors.transparent, + ), + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '카테고리 (선택)', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: baseColor, + ), + ), + const SizedBox(height: 8), + Consumer( + builder: (context, categoryProvider, child) { + // 카테고리가 없을 때 메시지 표시 + if (categoryProvider.categories.isEmpty) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + vertical: 16, + horizontal: 16, + ), + decoration: BoxDecoration( + border: Border.all( + color: Colors.grey.withValues(alpha: 0.2), + ), + borderRadius: + BorderRadius.circular(12), + color: Colors.white, + ), + child: Row( + children: [ + Icon( + Icons.category_rounded, + color: baseColor, + ), + const SizedBox(width: 12), + const Text( + '카테고리가 없습니다', + style: TextStyle( + color: Colors.grey, + fontSize: 16, + ), + ), + ], + ), + ); + } + + // 카테고리 드롭다운 표시 + return DropdownButtonFormField( + value: _selectedCategoryId, + focusNode: _categoryFocus, + onTap: () => setState( + () => _currentEditingField = 5), + onChanged: (value) { + setState(() { + _selectedCategoryId = value; + _currentEditingField = -1; + _categoryFocus.unfocus(); + }); + }, + icon: Icon( + Icons.arrow_drop_down_circle_outlined, + color: baseColor, + ), + decoration: InputDecoration( + filled: true, + fillColor: Colors.white, + contentPadding: + const EdgeInsets.all(16), + border: OutlineInputBorder( + borderRadius: + BorderRadius.circular(12), + borderSide: BorderSide( + color: Colors.grey.withValues(alpha: 0.2), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: + BorderRadius.circular(12), + borderSide: BorderSide( + color: baseColor, + width: 2, + ), + ), + prefixIcon: Icon( + Icons.category_rounded, + color: baseColor, + ), + ), + style: const TextStyle( + color: Colors.black87, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + hint: const Text( + '카테고리 선택', + style: TextStyle( + color: Colors.grey, + fontSize: 16, + ), + ), + isExpanded: true, + items: [ + DropdownMenuItem( + value: null, + child: const Text( + '카테고리 없음', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Colors.grey, + ), + ), + ), + ...categoryProvider.categories + .map((category) { + return DropdownMenuItem( + value: category.id, + child: Text( + category.name, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Colors.black87, + ), + ), + ); + }).toList(), + ], + ); + }, + ), + ], + ), + ), + + // 이벤트 설정 섹션 + Container( + margin: const EdgeInsets.only(bottom: 20), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: _isEventActive + ? baseColor + : Colors.grey.withValues(alpha: 0.2), + width: _isEventActive ? 2 : 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Checkbox( + value: _isEventActive, + onChanged: (value) { + setState(() { + _isEventActive = value ?? false; + if (!_isEventActive) { + // 이벤트 비활성화 시 관련 데이터 초기화 + _eventStartDate = null; + _eventEndDate = null; + _eventPriceController.clear(); + } + }); + }, + activeColor: baseColor, + ), + const Text( + '이벤트/할인 설정', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF1E293B), + ), + ), + const SizedBox(width: 8), + Icon( + Icons.local_offer, + size: 20, + color: _isEventActive + ? baseColor + : Colors.grey, + ), + ], + ), + + // 이벤트 활성화 시 추가 필드 표시 + AnimatedContainer( + duration: const Duration(milliseconds: 300), + height: _isEventActive ? null : 0, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 300), + opacity: _isEventActive ? 1.0 : 0.0, + child: Column( + children: [ + const SizedBox(height: 16), + + // 이벤트 기간 설정 + Row( + children: [ + // 시작일 + Expanded( + child: InkWell( + onTap: () async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: _eventStartDate ?? DateTime.now(), + firstDate: DateTime.now().subtract(const Duration(days: 365)), + lastDate: DateTime.now().add(const Duration(days: 365 * 2)), + builder: (BuildContext context, Widget? child) { + return Theme( + data: ThemeData.light().copyWith( + colorScheme: ColorScheme.light( + primary: baseColor, + onPrimary: Colors.white, + surface: Colors.white, + onSurface: Colors.black, + ), + ), + child: child!, + ); + }, + ); + if (picked != null) { + setState(() { + _eventStartDate = picked; + }); + } + }, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.withValues(alpha: 0.3)), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '시작일', + style: TextStyle( + fontSize: 12, + color: Colors.grey, + ), + ), + const SizedBox(height: 4), + Text( + _eventStartDate == null + ? '선택' + : DateFormat('MM/dd').format(_eventStartDate!), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ), + const SizedBox(width: 8), + const Icon(Icons.arrow_forward, color: Colors.grey), + const SizedBox(width: 8), + // 종료일 + Expanded( + child: InkWell( + onTap: () async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: _eventEndDate ?? DateTime.now().add(const Duration(days: 30)), + firstDate: _eventStartDate ?? DateTime.now(), + lastDate: DateTime.now().add(const Duration(days: 365 * 2)), + builder: (BuildContext context, Widget? child) { + return Theme( + data: ThemeData.light().copyWith( + colorScheme: ColorScheme.light( + primary: baseColor, + onPrimary: Colors.white, + surface: Colors.white, + onSurface: Colors.black, + ), + ), + child: child!, + ); + }, + ); + if (picked != null) { + setState(() { + _eventEndDate = picked; + }); + } + }, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.withValues(alpha: 0.3)), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '종료일', + style: TextStyle( + fontSize: 12, + color: Colors.grey, + ), + ), + const SizedBox(height: 4), + Text( + _eventEndDate == null + ? '선택' + : DateFormat('MM/dd').format(_eventEndDate!), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ), + ], + ), + + const SizedBox(height: 16), + + // 이벤트 가격 입력 + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '이벤트 가격', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: baseColor, + ), + ), + const SizedBox(height: 8), + TextFormField( + controller: _eventPriceController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[\d,.]')), + ], + decoration: InputDecoration( + hintText: '할인된 가격을 입력하세요', + prefixText: _currency == 'KRW' ? '₩ ' : '\$ ', + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.symmetric( + vertical: 16, + horizontal: 16, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Colors.grey.withValues(alpha: 0.2), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: baseColor, + width: 2, + ), + ), + prefixIcon: Icon( + Icons.sell, + color: baseColor, + ), + ), + onChanged: (value) { + // 콤마 자동 추가 + if (value.isNotEmpty && !value.contains('.')) { + final number = int.tryParse(value.replaceAll(',', '')); + if (number != null) { + final formatted = NumberFormat('#,###').format(number); + if (formatted != value) { + _eventPriceController.value = TextEditingValue( + text: formatted, + selection: TextSelection.collapsed( + offset: formatted.length, + ), + ); + } + } + } + }, + ), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + const SizedBox(height: 32), + FadeTransition( + opacity: _fadeAnimation, + child: SlideTransition( + position: Tween( + begin: const Offset(0.0, 0.8), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _animationController, + curve: const Interval(0.4, 1.0, curve: Curves.easeOutCubic), + )), + child: MouseRegion( + onEnter: (_) => setState(() => _isSaveHovered = true), + onExit: (_) => setState(() => _isSaveHovered = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: double.infinity, + height: 60, + transform: _isSaveHovered + ? (Matrix4.identity()..scale(1.02)) + : Matrix4.identity(), + child: ElevatedButton( + onPressed: _updateSubscription, + style: ElevatedButton.styleFrom( + backgroundColor: baseColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + padding: const EdgeInsets.symmetric(vertical: 16), + elevation: _isSaveHovered ? 8 : 4, + shadowColor: baseColor.withValues(alpha: 0.5), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.save_rounded, + color: Colors.white, + size: _isSaveHovered ? 24 : 20, + ), + const SizedBox(width: 8), + const Text( + '변경사항 저장', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ], + ), + ), + ), + ), + ), + ), + const SizedBox(height: 80), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 5da87eb..a061335 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; -import '../providers/subscription_provider.dart'; import '../providers/app_lock_provider.dart'; import '../providers/navigation_provider.dart'; import '../theme/app_colors.dart'; @@ -13,7 +12,6 @@ import 'sms_scan_screen.dart'; import '../utils/animation_controller_helper.dart'; import '../widgets/floating_navigation_bar.dart'; import '../widgets/glassmorphic_scaffold.dart'; -import '../widgets/glassmorphic_app_bar.dart'; import '../widgets/home_content.dart'; class MainScreen extends StatefulWidget { @@ -33,7 +31,6 @@ class _MainScreenState extends State late AnimationController _waveController; late ScrollController _scrollController; late FloatingNavBarScrollController _navBarScrollController; - bool _isNavBarVisible = true; // 화면 목록 late final List _screens; @@ -67,8 +64,8 @@ class _MainScreenState extends State _navBarScrollController = FloatingNavBarScrollController( scrollController: _scrollController, - onHide: () => setState(() => _isNavBarVisible = false), - onShow: () => setState(() => _isNavBarVisible = true), + onHide: () {}, + onShow: () {}, ); // 화면 목록 초기화 @@ -162,17 +159,18 @@ class _MainScreenState extends State // 구독이 성공적으로 추가된 경우 if (result == true) { // 상단에 스낵바 표시 + if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Row( + content: const Row( children: [ - const Icon( + Icon( Icons.check_circle, color: Colors.white, size: 20, ), - const SizedBox(width: 12), - const Text( + SizedBox(width: 12), + Text( '구독이 추가되었습니다', style: TextStyle( fontSize: 16, diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index bb26510..4d6467b 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1,24 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:provider/provider.dart'; -import '../providers/app_lock_provider.dart'; import '../providers/notification_provider.dart'; -import '../providers/subscription_provider.dart'; -import '../providers/navigation_provider.dart'; -import 'package:share_plus/share_plus.dart'; -import 'package:path_provider/path_provider.dart'; import 'dart:io'; -import 'package:path/path.dart' as path; import '../services/notification_service.dart'; -import '../screens/sms_scan_screen.dart'; import 'package:url_launcher/url_launcher.dart'; import '../providers/theme_provider.dart'; import '../theme/adaptive_theme.dart'; -import '../widgets/glassmorphic_scaffold.dart'; -import '../widgets/glassmorphic_app_bar.dart'; import '../widgets/glassmorphism_card.dart'; -import '../widgets/app_navigator.dart'; -import '../theme/app_colors.dart'; class SettingsScreen extends StatelessWidget { const SettingsScreen({super.key}); @@ -76,65 +65,7 @@ class SettingsScreen extends StatelessWidget { ); } - Future _backupData(BuildContext context) async { - try { - final provider = context.read(); - final subscriptions = provider.subscriptions; - // 임시 디렉토리에 백업 파일 생성 - final tempDir = await getTemporaryDirectory(); - final backupFile = - File(path.join(tempDir.path, 'submanager_backup.json')); - - // 구독 데이터를 JSON 형식으로 저장 - final jsonData = subscriptions - .map((sub) => { - 'id': sub.id, - 'serviceName': sub.serviceName, - 'monthlyCost': sub.monthlyCost, - 'billingCycle': sub.billingCycle, - 'nextBillingDate': sub.nextBillingDate.toIso8601String(), - 'isAutoDetected': sub.isAutoDetected, - 'repeatCount': sub.repeatCount, - 'lastPaymentDate': sub.lastPaymentDate?.toIso8601String(), - }) - .toList(); - - await backupFile.writeAsString(jsonData.toString()); - - // 파일 공유 - await Share.shareXFiles( - [XFile(backupFile.path)], - text: 'SubManager 백업 파일', - ); - - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('백업 파일이 생성되었습니다')), - ); - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('백업 중 오류가 발생했습니다: $e')), - ); - } - } - } - - // SMS 스캔 화면으로 이동 - void _navigateToSmsScan(BuildContext context) async { - final added = await Navigator.push( - context, - MaterialPageRoute(builder: (context) => const SmsScanScreen()), - ); - - if (added == true && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('구독이 성공적으로 추가되었습니다')), - ); - } - } @override Widget build(BuildContext context) { @@ -455,10 +386,10 @@ class SettingsScreen extends StatelessWidget { ), // 데이터 관리 - GlassmorphismCard( + const GlassmorphismCard( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.all(8), - child: Column( + child: const Column( children: [ // 데이터 백업 기능 비활성화 // ListTile( diff --git a/lib/screens/sms_scan_screen.dart b/lib/screens/sms_scan_screen.dart index 025d725..62c9c6d 100644 --- a/lib/screens/sms_scan_screen.dart +++ b/lib/screens/sms_scan_screen.dart @@ -7,11 +7,8 @@ import '../models/subscription.dart'; import '../models/subscription_model.dart'; import '../services/subscription_url_matcher.dart'; import 'package:intl/intl.dart'; // NumberFormat을 사용하기 위한 import 추가 -import '../widgets/glassmorphic_scaffold.dart'; -import '../widgets/glassmorphic_app_bar.dart'; import '../widgets/glassmorphism_card.dart'; import '../widgets/themed_text.dart'; -import '../theme/app_colors.dart'; class SmsScanScreen extends StatefulWidget { const SmsScanScreen({super.key}); diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart index e2121c5..ac3bceb 100644 --- a/lib/screens/splash_screen.dart +++ b/lib/screens/splash_screen.dart @@ -1,14 +1,8 @@ import 'dart:async'; import 'dart:ui'; import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import '../providers/app_lock_provider.dart'; -import '../providers/navigation_provider.dart'; import '../theme/app_colors.dart'; -import '../widgets/glassmorphism_card.dart'; import '../routes/app_routes.dart'; -import 'app_lock_screen.dart'; -import 'main_screen.dart'; class SplashScreen extends StatefulWidget { const SplashScreen({super.key}); @@ -289,7 +283,7 @@ class _SplashScreenState extends State BlendMode.srcIn, shaderCallback: (bounds) => - LinearGradient( + const LinearGradient( colors: AppColors .blueGradient, begin: diff --git a/lib/services/exchange_rate_service.dart b/lib/services/exchange_rate_service.dart index 33cc4a9..978a470 100644 --- a/lib/services/exchange_rate_service.dart +++ b/lib/services/exchange_rate_service.dart @@ -1,6 +1,5 @@ import 'package:http/http.dart' as http; import 'dart:convert'; -import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; /// 환율 정보 서비스 클래스 diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 21cb530..7549e75 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -152,13 +152,6 @@ class NotificationService { } } - // 알림 서비스가 초기화되었는지 확인하는 메서드 - static bool _isInitialized() { - // 웹 플랫폼인 경우 항상 false 반환 - if (_isWeb) return false; - // 초기화 플래그 확인 - return _initialized; - } static Future requestPermission() async { final result = await _notifications @@ -182,7 +175,7 @@ class NotificationService { } try { - final androidDetails = AndroidNotificationDetails( + const androidDetails = AndroidNotificationDetails( 'subscription_channel', '구독 알림', channelDescription: '구독 관련 알림을 보여줍니다.', @@ -257,7 +250,7 @@ class NotificationService { try { final notificationId = subscription.id.hashCode; - final androidDetails = AndroidNotificationDetails( + const androidDetails = AndroidNotificationDetails( 'subscription_channel', '구독 알림', channelDescription: '구독 만료 알림을 보내는 채널입니다.', @@ -265,13 +258,13 @@ class NotificationService { priority: Priority.high, ); - final iosDetails = DarwinNotificationDetails( + const iosDetails = DarwinNotificationDetails( presentAlert: true, presentBadge: true, presentSound: true, ); - final notificationDetails = NotificationDetails( + const notificationDetails = NotificationDetails( android: androidDetails, iOS: iosDetails, ); diff --git a/lib/services/sms_scanner.dart b/lib/services/sms_scanner.dart index c90b72f..2808949 100644 --- a/lib/services/sms_scanner.dart +++ b/lib/services/sms_scanner.dart @@ -80,7 +80,6 @@ class SmsScanner { final nextBillingDateStr = sms['nextBillingDate'] as String?; // 실제 반복 횟수 사용 (테스트 데이터에서는 이미 제공됨) final actualRepeatCount = repeatCount > 0 ? repeatCount : 1; - final isRecurring = (sms['isRecurring'] as bool?) ?? (repeatCount >= 2); final message = sms['message'] as String? ?? ''; // 통화 단위 감지 - 메시지 내용과 서비스명 모두 검사 @@ -205,79 +204,7 @@ class SmsScanner { return serviceUrls[serviceName]; } - bool _containsSubscriptionKeywords(String text) { - final keywords = [ - '구독', - '결제', - '청구', - '정기', - '자동', - 'subscription', - 'payment', - 'bill', - 'invoice' - ]; - return keywords - .any((keyword) => text.toLowerCase().contains(keyword.toLowerCase())); - } - double? _extractAmount(String text) { - final RegExp amountRegex = RegExp(r'(\d{1,3}(?:,\d{3})*(?:\.\d{2})?)'); - final match = amountRegex.firstMatch(text); - if (match != null) { - final amountStr = match.group(1)?.replaceAll(',', ''); - return double.tryParse(amountStr ?? ''); - } - return null; - } - - String? _extractServiceName(String text) { - final serviceNames = [ - 'Netflix', - 'Spotify', - 'Disney+', - 'Apple Music', - 'YouTube Premium', - 'Amazon Prime', - 'Microsoft 365', - 'Google One', - 'iCloud', - 'Dropbox' - ]; - - for (final name in serviceNames) { - if (text.contains(name)) { - return name; - } - } - return null; - } - - String _extractBillingCycle(String text) { - if (text.contains('월') || text.contains('month')) { - return 'monthly'; - } else if (text.contains('년') || text.contains('year')) { - return 'yearly'; - } else if (text.contains('주') || text.contains('week')) { - return 'weekly'; - } - return 'monthly'; // 기본값 - } - - DateTime _extractNextBillingDate(String text) { - final RegExp dateRegex = RegExp(r'(\d{4}[-/]\d{2}[-/]\d{2})'); - final match = dateRegex.firstMatch(text); - if (match != null) { - final dateStr = match.group(1); - if (dateStr != null) { - final date = DateTime.tryParse(dateStr); - if (date != null) { - return date; - } - } - } - return DateTime.now().add(const Duration(days: 30)); // 기본값: 30일 후 - } // 메시지에서 통화 단위를 감지하는 함수 String _detectCurrency(String message) { diff --git a/lib/services/subscription_url_matcher.dart b/lib/services/subscription_url_matcher.dart index f16565c..41d9493 100644 --- a/lib/services/subscription_url_matcher.dart +++ b/lib/services/subscription_url_matcher.dart @@ -1,4 +1,3 @@ -import 'package:flutter/material.dart'; /// 구독 서비스와 웹사이트 URL 매칭을 처리하는 서비스 클래스 class SubscriptionUrlMatcher { diff --git a/lib/theme/adaptive_theme.dart b/lib/theme/adaptive_theme.dart index cc93e95..87b2196 100644 --- a/lib/theme/adaptive_theme.dart +++ b/lib/theme/adaptive_theme.dart @@ -13,7 +13,7 @@ class AdaptiveTheme { return ThemeData( useMaterial3: true, brightness: Brightness.dark, - colorScheme: ColorScheme.dark( + colorScheme: const ColorScheme.dark( primary: AppColors.primaryColor, onPrimary: Colors.white, secondary: AppColors.secondaryColor, @@ -134,11 +134,11 @@ class AdaptiveTheme { ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: AppColors.primaryColor, width: 1.5), + borderSide: const BorderSide(color: AppColors.primaryColor, width: 1.5), ), errorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: AppColors.dangerColor, width: 1), + borderSide: const BorderSide(color: AppColors.dangerColor, width: 1), ), labelStyle: TextStyle( color: Colors.white.withValues(alpha: 0.7), diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index 2753e98..5faac04 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -4,7 +4,7 @@ import 'app_colors.dart'; class AppTheme { static ThemeData lightTheme = ThemeData( useMaterial3: true, - colorScheme: ColorScheme.light( + colorScheme: const ColorScheme.light( primary: AppColors.primaryColor, onPrimary: Colors.white, secondary: AppColors.secondaryColor, @@ -24,48 +24,48 @@ class AppTheme { shadowColor: Colors.black.withValues(alpha: 0.04), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), - side: BorderSide(color: AppColors.borderColor, width: 0.5), + side: const BorderSide(color: AppColors.borderColor, width: 0.5), ), clipBehavior: Clip.antiAlias, margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), ), // 앱바 스타일 - 깔끔하고 투명한 디자인 - appBarTheme: AppBarTheme( + appBarTheme: const AppBarTheme( backgroundColor: AppColors.surfaceColor, foregroundColor: AppColors.textPrimary, elevation: 0, centerTitle: false, - titleTextStyle: TextStyle( + titleTextStyle: const TextStyle( color: AppColors.textPrimary, fontSize: 22, fontWeight: FontWeight.w600, letterSpacing: -0.2, ), - iconTheme: IconThemeData( + iconTheme: const IconThemeData( color: AppColors.secondaryColor, size: 24, ), ), // 타이포그래피 - Metronic Tailwind 스타일 - textTheme: TextTheme( + textTheme: const TextTheme( // 헤드라인 - 페이지 제목 - headlineLarge: TextStyle( + headlineLarge: const TextStyle( color: AppColors.textPrimary, fontSize: 32, fontWeight: FontWeight.w700, letterSpacing: -0.5, height: 1.2, ), - headlineMedium: TextStyle( + headlineMedium: const TextStyle( color: AppColors.textPrimary, fontSize: 28, fontWeight: FontWeight.w700, letterSpacing: -0.5, height: 1.2, ), - headlineSmall: TextStyle( + headlineSmall: const TextStyle( color: AppColors.textPrimary, fontSize: 24, fontWeight: FontWeight.w600, @@ -74,7 +74,7 @@ class AppTheme { ), // 타이틀 - 카드, 섹션 제목 - titleLarge: TextStyle( + titleLarge: const TextStyle( color: AppColors.textPrimary, fontSize: 20, fontWeight: FontWeight.w600, @@ -154,31 +154,31 @@ class AppTheme { ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: AppColors.borderColor, width: 1), + borderSide: const BorderSide(color: AppColors.borderColor, width: 1), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: AppColors.primaryColor, width: 1.5), + borderSide: const BorderSide(color: AppColors.primaryColor, width: 1.5), ), errorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: AppColors.dangerColor, width: 1), + borderSide: const BorderSide(color: AppColors.dangerColor, width: 1), ), focusedErrorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: AppColors.dangerColor, width: 1.5), + borderSide: const BorderSide(color: AppColors.dangerColor, width: 1.5), ), - labelStyle: TextStyle( + labelStyle: const TextStyle( color: AppColors.textSecondary, fontSize: 14, fontWeight: FontWeight.w500, ), - hintStyle: TextStyle( + hintStyle: const TextStyle( color: AppColors.textMuted, fontSize: 14, fontWeight: FontWeight.w400, ), - errorStyle: TextStyle( + errorStyle: const TextStyle( color: AppColors.dangerColor, fontSize: 12, fontWeight: FontWeight.w400, @@ -230,7 +230,7 @@ class AppTheme { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), - side: BorderSide(color: AppColors.borderColor, width: 1), + side: const BorderSide(color: AppColors.borderColor, width: 1), textStyle: const TextStyle( fontSize: 15, fontWeight: FontWeight.w600, @@ -282,7 +282,7 @@ class AppTheme { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(4), ), - side: BorderSide(color: AppColors.borderColor, width: 1.5), + side: const BorderSide(color: AppColors.borderColor, width: 1.5), ), // 라디오 버튼 스타일 @@ -307,16 +307,16 @@ class AppTheme { ), // 탭바 스타일 - tabBarTheme: TabBarTheme( + tabBarTheme: const TabBarTheme( labelColor: AppColors.primaryColor, unselectedLabelColor: AppColors.textSecondary, indicatorColor: AppColors.primaryColor, - labelStyle: TextStyle( + labelStyle: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, letterSpacing: 0.1, ), - unselectedLabelStyle: TextStyle( + unselectedLabelStyle: const TextStyle( fontSize: 14, fontWeight: FontWeight.w500, letterSpacing: 0.1, @@ -324,7 +324,7 @@ class AppTheme { ), // 디바이더 스타일 - dividerTheme: DividerThemeData( + dividerTheme: const DividerThemeData( color: AppColors.dividerColor, thickness: 1, space: 16, @@ -342,7 +342,7 @@ class AppTheme { // 스낵바 스타일 snackBarTheme: SnackBarThemeData( backgroundColor: AppColors.textPrimary, - contentTextStyle: TextStyle( + contentTextStyle: const TextStyle( color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500, diff --git a/lib/utils/memory_manager.dart b/lib/utils/memory_manager.dart index 7751f25..b7f6d57 100644 --- a/lib/utils/memory_manager.dart +++ b/lib/utils/memory_manager.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; -import 'dart:collection'; import 'dart:async'; /// 메모리 관리를 위한 헬퍼 클래스 diff --git a/lib/utils/performance_optimizer.dart b/lib/utils/performance_optimizer.dart index 3059806..4568720 100644 --- a/lib/utils/performance_optimizer.dart +++ b/lib/utils/performance_optimizer.dart @@ -2,7 +2,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'dart:async'; -import 'dart:developer' as developer; /// 성능 최적화를 위한 유틸리티 클래스 class PerformanceOptimizer { diff --git a/lib/utils/subscription_category_helper.dart b/lib/utils/subscription_category_helper.dart index 74eb0cc..ae0057f 100644 --- a/lib/utils/subscription_category_helper.dart +++ b/lib/utils/subscription_category_helper.dart @@ -1,4 +1,3 @@ -import 'package:flutter/material.dart'; import '../models/subscription_model.dart'; import '../providers/category_provider.dart'; import '../services/subscription_url_matcher.dart'; diff --git a/lib/widgets/add_subscription/add_subscription_app_bar.dart b/lib/widgets/add_subscription/add_subscription_app_bar.dart new file mode 100644 index 0000000..65a4a8f --- /dev/null +++ b/lib/widgets/add_subscription/add_subscription_app_bar.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'dart:math' as math; +import '../../controllers/add_subscription_controller.dart'; + +/// 구독 추가 화면의 App Bar +class AddSubscriptionAppBar extends StatelessWidget implements PreferredSizeWidget { + final AddSubscriptionController controller; + final double scrollOffset; + final VoidCallback onScanSMS; + + const AddSubscriptionAppBar({ + super.key, + required this.controller, + required this.scrollOffset, + required this.onScanSMS, + }); + + @override + Size get preferredSize => const Size.fromHeight(60); + + @override + Widget build(BuildContext context) { + final double appBarOpacity = math.max(0, math.min(1, scrollOffset / 100)); + + return Container( + decoration: BoxDecoration( + color: Colors.white.withOpacity(appBarOpacity), + boxShadow: appBarOpacity > 0.6 + ? [ + BoxShadow( + color: Colors.black.withOpacity(0.1 * appBarOpacity), + spreadRadius: 1, + blurRadius: 8, + offset: const Offset(0, 4), + ) + ] + : null, + ), + child: SafeArea( + child: AppBar( + title: Text( + '구독 추가', + style: TextStyle( + fontFamily: 'Montserrat', + fontSize: 24, + fontWeight: FontWeight.w800, + letterSpacing: -0.5, + color: const Color(0xFF1E293B), + shadows: appBarOpacity > 0.6 + ? [ + Shadow( + color: Colors.black.withOpacity(0.2), + offset: const Offset(0, 1), + blurRadius: 2, + ) + ] + : null, + ), + ), + elevation: 0, + backgroundColor: Colors.transparent, + actions: [ + if (!kIsWeb) + controller.isLoading + ? const Padding( + padding: EdgeInsets.only(right: 16.0), + child: Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Color(0xFF3B82F6)), + ), + ), + ), + ) + : IconButton( + icon: const FaIcon( + FontAwesomeIcons.message, + size: 20, + color: Color(0xFF3B82F6), + ), + onPressed: onScanSMS, + tooltip: 'SMS에서 구독 정보 스캔', + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/add_subscription/add_subscription_event_section.dart b/lib/widgets/add_subscription/add_subscription_event_section.dart new file mode 100644 index 0000000..d88a343 --- /dev/null +++ b/lib/widgets/add_subscription/add_subscription_event_section.dart @@ -0,0 +1,142 @@ +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'; + +/// 구독 추가 화면의 이벤트/할인 섹션 +class AddSubscriptionEventSection extends StatelessWidget { + final AddSubscriptionController controller; + final Animation fadeAnimation; + final Animation slideAnimation; + final Function setState; + + const AddSubscriptionEventSection({ + super.key, + required this.controller, + required this.fadeAnimation, + required this.slideAnimation, + required this.setState, + }); + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: controller.animationController!, + curve: const Interval(0.4, 1.0, curve: Curves.easeIn), + ), + ), + child: SlideTransition( + position: Tween( + begin: const Offset(0.0, 0.5), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: controller.animationController!, + curve: const Interval(0.4, 1.0, curve: Curves.easeOutCubic), + )), + child: Container( + margin: const EdgeInsets.only(bottom: 20), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: controller.isEventActive + ? const Color(0xFF3B82F6) + : Colors.grey.withOpacity(0.2), + width: controller.isEventActive ? 2 : 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Checkbox( + value: controller.isEventActive, + onChanged: (value) { + setState(() { + controller.isEventActive = value ?? false; + if (!controller.isEventActive) { + // 이벤트 비활성화 시 관련 데이터 초기화 + controller.eventStartDate = DateTime.now(); + controller.eventEndDate = DateTime.now().add(const Duration(days: 30)); + 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, + ), + ], + ), + + // 이벤트 활성화 시 추가 필드 표시 + AnimatedContainer( + duration: const Duration(milliseconds: 300), + height: controller.isEventActive ? null : 0, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 300), + opacity: controller.isEventActive ? 1.0 : 0.0, + child: Column( + children: [ + const SizedBox(height: 20), + + // 이벤트 기간 + DateRangePickerField( + startDate: controller.eventStartDate, + endDate: controller.eventEndDate, + onStartDateSelected: (date) { + setState(() { + controller.eventStartDate = date; + }); + }, + onEndDateSelected: (date) { + setState(() { + controller.eventEndDate = date; + }); + }, + startLabel: '시작일', + endLabel: '종료일', + primaryColor: const Color(0xFF3B82F6), + ), + const SizedBox(height: 20), + + // 이벤트 가격 + CurrencyInputField( + controller: controller.eventPriceController, + currency: controller.currency, + label: '이벤트 가격', + hintText: '할인된 가격을 입력하세요', + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/add_subscription/add_subscription_form.dart b/lib/widgets/add_subscription/add_subscription_form.dart new file mode 100644 index 0000000..71f06e8 --- /dev/null +++ b/lib/widgets/add_subscription/add_subscription_form.dart @@ -0,0 +1,431 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import '../../controllers/add_subscription_controller.dart'; +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'; + +/// 구독 추가 화면의 폼 섹션 +class AddSubscriptionForm extends StatelessWidget { + final AddSubscriptionController controller; + final Animation fadeAnimation; + final Animation slideAnimation; + final Function setState; + + const AddSubscriptionForm({ + super.key, + required this.controller, + required this.fadeAnimation, + required this.slideAnimation, + required this.setState, + }); + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: controller.animationController!, + curve: const Interval(0.2, 1.0, curve: Curves.easeIn), + ), + ), + child: SlideTransition( + position: Tween( + begin: const Offset(0.0, 0.4), + end: Offset.zero, + ).animate(CurvedAnimation( + 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: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 헤더 + Row( + children: [ + ShaderMask( + shaderCallback: (bounds) => LinearGradient( + colors: controller.gradientColors, + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ).createShader(bounds), + child: const Icon( + FontAwesomeIcons.fileLines, + size: 20, + color: Colors.white, + ), + ), + const SizedBox(width: 12), + const Text( + '서비스 정보', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + color: Color(0xFF1E293B), + ), + ), + ], + ), + const SizedBox(height: 24), + + // 서비스명 필드 + BaseTextField( + controller: controller.serviceNameController, + focusNode: controller.serviceNameFocus, + label: '서비스명', + hintText: '예: Netflix, Spotify', + textInputAction: TextInputAction.next, + onEditingComplete: () { + controller.monthlyCostFocus.requestFocus(); + }, + validator: (value) { + if (value == null || value.isEmpty) { + return '서비스명을 입력해주세요'; + } + return null; + }, + ), + const SizedBox(height: 20), + + // 월 지출 및 통화 선택 + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 2, + child: CurrencyInputField( + controller: controller.monthlyCostController, + currency: controller.currency, + label: '월 지출', + focusNode: controller.monthlyCostFocus, + textInputAction: TextInputAction.next, + onEditingComplete: () { + controller.billingCycleFocus.requestFocus(); + }, + validator: (value) { + if (value == null || value.isEmpty) { + return '금액을 입력해주세요'; + } + return null; + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '통화', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + _CurrencySelector( + currency: controller.currency, + onChanged: (value) { + setState(() { + controller.currency = value; + }); + }, + ), + ], + ), + ), + ], + ), + const SizedBox(height: 20), + + // 결제 주기 + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '결제 주기', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + _BillingCycleSelector( + billingCycle: controller.billingCycle, + gradientColors: controller.gradientColors, + onChanged: (value) { + setState(() { + controller.billingCycle = value; + }); + }, + ), + ], + ), + const SizedBox(height: 20), + + // 다음 결제일 + DatePickerField( + selectedDate: controller.nextBillingDate ?? DateTime.now(), + onDateSelected: (date) { + setState(() { + controller.nextBillingDate = date; + }); + }, + label: '다음 결제일', + firstDate: DateTime.now(), + lastDate: DateTime.now().add(const Duration(days: 365 * 2)), + primaryColor: controller.gradientColors[0], + ), + const SizedBox(height: 20), + + // 웹사이트 URL + BaseTextField( + controller: controller.websiteUrlController, + focusNode: controller.websiteUrlFocus, + label: '웹사이트 URL (선택)', + hintText: 'https://example.com', + keyboardType: TextInputType.url, + prefixIcon: Icon( + Icons.link_rounded, + color: Colors.grey[600], + ), + ), + const SizedBox(height: 20), + + // 카테고리 선택 + Consumer( + builder: (context, categoryProvider, child) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '카테고리', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + _CategorySelector( + categories: categoryProvider.categories, + selectedCategoryId: controller.selectedCategoryId, + gradientColors: controller.gradientColors, + onChanged: (categoryId) { + setState(() { + controller.selectedCategoryId = categoryId; + }); + }, + ), + ], + ); + }, + ), + ], + ), + ), + ), + ), + ); + } +} + +/// 통화 선택기 +class _CurrencySelector extends StatelessWidget { + final String currency; + final ValueChanged 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.withOpacity(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 gradientColors; + final ValueChanged 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.withOpacity(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 categories; + final String? selectedCategoryId; + final List gradientColors; + final ValueChanged 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.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + category.emoji, + 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(), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/add_subscription/add_subscription_header.dart b/lib/widgets/add_subscription/add_subscription_header.dart new file mode 100644 index 0000000..8265184 --- /dev/null +++ b/lib/widgets/add_subscription/add_subscription_header.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import '../../controllers/add_subscription_controller.dart'; + +/// 구독 추가 화면의 헤더 섹션 +class AddSubscriptionHeader extends StatelessWidget { + final AddSubscriptionController controller; + final Animation fadeAnimation; + final Animation slideAnimation; + + const AddSubscriptionHeader({ + super.key, + required this.controller, + required this.fadeAnimation, + required this.slideAnimation, + }); + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: fadeAnimation, + child: SlideTransition( + position: slideAnimation, + child: Container( + margin: const EdgeInsets.only(bottom: 24), + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + gradient: LinearGradient( + colors: controller.gradientColors, + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + boxShadow: [ + BoxShadow( + color: controller.gradientColors[0].withOpacity(0.3), + blurRadius: 20, + spreadRadius: 0, + offset: const Offset(0, 8), + ), + ], + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(16), + ), + child: const Icon( + Icons.add_rounded, + size: 32, + color: Colors.white, + ), + ), + const SizedBox(width: 16), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '새 구독 추가', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w800, + color: Colors.white, + letterSpacing: -0.5, + ), + ), + SizedBox(height: 4), + Text( + '서비스 정보를 입력해주세요', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.white70, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/add_subscription/add_subscription_save_button.dart b/lib/widgets/add_subscription/add_subscription_save_button.dart new file mode 100644 index 0000000..b924956 --- /dev/null +++ b/lib/widgets/add_subscription/add_subscription_save_button.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import '../../controllers/add_subscription_controller.dart'; +import '../common/buttons/primary_button.dart'; + +/// 구독 추가 화면의 저장 버튼 +class AddSubscriptionSaveButton extends StatelessWidget { + final AddSubscriptionController controller; + final Animation fadeAnimation; + final Animation slideAnimation; + final Function setState; + + const AddSubscriptionSaveButton({ + super.key, + required this.controller, + required this.fadeAnimation, + required this.slideAnimation, + required this.setState, + }); + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: controller.animationController!, + curve: const Interval(0.6, 1.0, curve: Curves.easeIn), + ), + ), + child: SlideTransition( + position: Tween( + begin: const Offset(0.0, 0.6), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: controller.animationController!, + curve: const Interval(0.6, 1.0, curve: Curves.easeOutCubic), + )), + child: Padding( + padding: const EdgeInsets.only(bottom: 80), + child: PrimaryButton( + text: '구독 추가하기', + icon: Icons.add_circle_outline, + onPressed: controller.isLoading + ? null + : () => controller.saveSubscription(setState: setState), + isLoading: controller.isLoading, + backgroundColor: const Color(0xFF3B82F6), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/app_navigator.dart b/lib/widgets/app_navigator.dart index 4e44de6..f0a689f 100644 --- a/lib/widgets/app_navigator.dart +++ b/lib/widgets/app_navigator.dart @@ -1,12 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; -import '../screens/main_screen.dart'; -import '../screens/analysis_screen.dart'; -import '../screens/add_subscription_screen.dart'; -import '../screens/detail_screen.dart'; -import '../screens/settings_screen.dart'; -import '../screens/sms_scan_screen.dart'; import '../screens/category_management_screen.dart'; import '../screens/app_lock_screen.dart'; import '../models/subscription_model.dart'; diff --git a/lib/widgets/common/buttons/danger_button.dart b/lib/widgets/common/buttons/danger_button.dart new file mode 100644 index 0000000..65967d8 --- /dev/null +++ b/lib/widgets/common/buttons/danger_button.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; + +/// 위험한 액션에 사용되는 Danger 버튼 +/// 삭제, 취소, 종료 등의 위험한 액션에 사용됩니다. +class DangerButton extends StatefulWidget { + final String text; + final VoidCallback? onPressed; + final bool requireConfirmation; + final String? confirmationTitle; + final String? confirmationMessage; + final IconData? icon; + final double? width; + final double height; + final double fontSize; + final EdgeInsetsGeometry? padding; + final double borderRadius; + final bool enableHoverEffect; + + const DangerButton({ + super.key, + required this.text, + this.onPressed, + this.requireConfirmation = false, + this.confirmationTitle, + this.confirmationMessage, + this.icon, + this.width, + this.height = 60, + this.fontSize = 18, + this.padding, + this.borderRadius = 16, + this.enableHoverEffect = true, + }); + + @override + State createState() => _DangerButtonState(); +} + +class _DangerButtonState extends State { + bool _isHovered = false; + + static const Color _dangerColor = Color(0xFFDC2626); + + Future _handlePress() async { + if (widget.requireConfirmation) { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + title: Text( + widget.confirmationTitle ?? widget.text, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 20, + ), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: _dangerColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + widget.icon ?? Icons.warning_amber_rounded, + color: _dangerColor, + size: 48, + ), + ), + const SizedBox(height: 16), + Text( + widget.confirmationMessage ?? + '이 작업은 되돌릴 수 없습니다.\n계속하시겠습니까?', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 16, + height: 1.5, + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('취소'), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: _dangerColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + widget.text, + style: const TextStyle(color: Colors.white), + ), + ), + ], + ), + ); + + if (confirmed == true) { + widget.onPressed?.call(); + } + } else { + widget.onPressed?.call(); + } + } + + @override + Widget build(BuildContext context) { + Widget button = AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: widget.width ?? double.infinity, + height: widget.height, + transform: widget.enableHoverEffect && _isHovered + ? (Matrix4.identity()..scale(1.02)) + : Matrix4.identity(), + child: ElevatedButton( + onPressed: widget.onPressed != null ? _handlePress : null, + style: ElevatedButton.styleFrom( + backgroundColor: _dangerColor, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(widget.borderRadius), + ), + padding: widget.padding ?? const EdgeInsets.symmetric(vertical: 16), + elevation: widget.enableHoverEffect && _isHovered ? 8 : 4, + shadowColor: _dangerColor.withOpacity(0.5), + disabledBackgroundColor: _dangerColor.withOpacity(0.6), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.icon != null) ...[ + Icon( + widget.icon, + color: Colors.white, + size: _isHovered ? 24 : 20, + ), + const SizedBox(width: 8), + ], + Text( + widget.text, + style: TextStyle( + fontSize: widget.fontSize, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ], + ), + ), + ); + + if (widget.enableHoverEffect) { + return MouseRegion( + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) => setState(() => _isHovered = false), + child: button, + ); + } + + return button; + } +} \ No newline at end of file diff --git a/lib/widgets/common/buttons/primary_button.dart b/lib/widgets/common/buttons/primary_button.dart new file mode 100644 index 0000000..49a598c --- /dev/null +++ b/lib/widgets/common/buttons/primary_button.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; + +/// 주요 액션에 사용되는 Primary 버튼 +/// 저장, 추가, 확인 등의 주요 액션에 사용됩니다. +class PrimaryButton extends StatefulWidget { + final String text; + final VoidCallback? onPressed; + final bool isLoading; + final IconData? icon; + final double? width; + final double height; + final Color? backgroundColor; + final Color? foregroundColor; + final double fontSize; + final EdgeInsetsGeometry? padding; + final double borderRadius; + final bool enableHoverEffect; + + const PrimaryButton({ + super.key, + required this.text, + this.onPressed, + this.isLoading = false, + this.icon, + this.width, + this.height = 60, + this.backgroundColor, + this.foregroundColor, + this.fontSize = 18, + this.padding, + this.borderRadius = 16, + this.enableHoverEffect = true, + }); + + @override + State createState() => _PrimaryButtonState(); +} + +class _PrimaryButtonState extends State { + bool _isHovered = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final effectiveBackgroundColor = widget.backgroundColor ?? theme.primaryColor; + final effectiveForegroundColor = widget.foregroundColor ?? Colors.white; + + Widget button = AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: widget.width ?? double.infinity, + height: widget.height, + transform: widget.enableHoverEffect && _isHovered + ? (Matrix4.identity()..scale(1.02)) + : Matrix4.identity(), + child: ElevatedButton( + onPressed: widget.isLoading ? null : widget.onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: effectiveBackgroundColor, + foregroundColor: effectiveForegroundColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(widget.borderRadius), + ), + padding: widget.padding ?? const EdgeInsets.symmetric(vertical: 16), + elevation: widget.enableHoverEffect && _isHovered ? 8 : 4, + shadowColor: effectiveBackgroundColor.withOpacity(0.5), + disabledBackgroundColor: effectiveBackgroundColor.withOpacity(0.6), + ), + child: widget.isLoading + ? SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: effectiveForegroundColor, + ), + ) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.icon != null) ...[ + Icon( + widget.icon, + color: effectiveForegroundColor, + size: _isHovered ? 24 : 20, + ), + const SizedBox(width: 8), + ], + Text( + widget.text, + style: TextStyle( + fontSize: widget.fontSize, + fontWeight: FontWeight.w600, + color: effectiveForegroundColor, + ), + ), + ], + ), + ), + ); + + if (widget.enableHoverEffect) { + return MouseRegion( + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) => setState(() => _isHovered = false), + child: button, + ); + } + + return button; + } +} \ No newline at end of file diff --git a/lib/widgets/common/buttons/secondary_button.dart b/lib/widgets/common/buttons/secondary_button.dart new file mode 100644 index 0000000..bcbc2a1 --- /dev/null +++ b/lib/widgets/common/buttons/secondary_button.dart @@ -0,0 +1,203 @@ +import 'package:flutter/material.dart'; + +/// 부차적인 액션에 사용되는 Secondary 버튼 +/// 취소, 되돌아가기, 부가 옵션 등에 사용됩니다. +class SecondaryButton extends StatefulWidget { + final String text; + final VoidCallback? onPressed; + final IconData? icon; + final double? width; + final double height; + final Color? borderColor; + final Color? textColor; + final double fontSize; + final EdgeInsetsGeometry? padding; + final double borderRadius; + final double borderWidth; + final bool enableHoverEffect; + + const SecondaryButton({ + super.key, + required this.text, + this.onPressed, + this.icon, + this.width, + this.height = 56, + this.borderColor, + this.textColor, + this.fontSize = 16, + this.padding, + this.borderRadius = 16, + this.borderWidth = 1.5, + this.enableHoverEffect = true, + }); + + @override + State createState() => _SecondaryButtonState(); +} + +class _SecondaryButtonState extends State { + bool _isHovered = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final effectiveBorderColor = widget.borderColor ?? + theme.colorScheme.onSurface.withOpacity(0.2); + final effectiveTextColor = widget.textColor ?? + theme.colorScheme.onSurface.withOpacity(0.8); + + Widget button = AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: widget.width, + height: widget.height, + transform: widget.enableHoverEffect && _isHovered + ? (Matrix4.identity()..scale(1.02)) + : Matrix4.identity(), + child: OutlinedButton( + onPressed: widget.onPressed, + style: OutlinedButton.styleFrom( + foregroundColor: effectiveTextColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(widget.borderRadius), + ), + side: BorderSide( + color: _isHovered + ? effectiveBorderColor.withOpacity(0.4) + : effectiveBorderColor, + width: widget.borderWidth, + ), + padding: widget.padding ?? const EdgeInsets.symmetric( + vertical: 12, + horizontal: 24, + ), + backgroundColor: _isHovered + ? theme.colorScheme.onSurface.withOpacity(0.05) + : Colors.transparent, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.icon != null) ...[ + Icon( + widget.icon, + color: effectiveTextColor, + size: 20, + ), + const SizedBox(width: 8), + ], + Text( + widget.text, + style: TextStyle( + fontSize: widget.fontSize, + fontWeight: FontWeight.w500, + color: effectiveTextColor, + ), + ), + ], + ), + ), + ); + + if (widget.enableHoverEffect) { + return MouseRegion( + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) => setState(() => _isHovered = false), + child: button, + ); + } + + return button; + } +} + +/// 텍스트 링크 스타일의 버튼 +/// 간단한 액션이나 링크에 사용됩니다. +class TextLinkButton extends StatefulWidget { + final String text; + final VoidCallback? onPressed; + final IconData? icon; + final Color? color; + final double fontSize; + final bool enableHoverEffect; + + const TextLinkButton({ + super.key, + required this.text, + this.onPressed, + this.icon, + this.color, + this.fontSize = 14, + this.enableHoverEffect = true, + }); + + @override + State createState() => _TextLinkButtonState(); +} + +class _TextLinkButtonState extends State { + bool _isHovered = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final effectiveColor = widget.color ?? theme.colorScheme.primary; + + Widget button = AnimatedContainer( + duration: const Duration(milliseconds: 200), + decoration: BoxDecoration( + color: _isHovered + ? theme.colorScheme.onSurface.withOpacity(0.05) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: TextButton( + onPressed: widget.onPressed, + style: TextButton.styleFrom( + foregroundColor: effectiveColor, + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.icon != null) ...[ + Icon( + widget.icon, + size: 18, + color: effectiveColor, + ), + const SizedBox(width: 6), + ], + Text( + widget.text, + style: TextStyle( + fontSize: widget.fontSize, + fontWeight: FontWeight.w500, + color: effectiveColor, + decoration: _isHovered + ? TextDecoration.underline + : TextDecoration.none, + ), + ), + ], + ), + ), + ); + + if (widget.enableHoverEffect) { + return MouseRegion( + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) => setState(() => _isHovered = false), + child: button, + ); + } + + return button; + } +} \ No newline at end of file diff --git a/lib/widgets/common/cards/section_card.dart b/lib/widgets/common/cards/section_card.dart new file mode 100644 index 0000000..1680d66 --- /dev/null +++ b/lib/widgets/common/cards/section_card.dart @@ -0,0 +1,229 @@ +import 'package:flutter/material.dart'; + +/// 섹션별 컨텐츠를 감싸는 기본 카드 위젯 +/// 폼 섹션, 정보 표시 섹션 등에 사용됩니다. +class SectionCard extends StatelessWidget { + final String? title; + final Widget child; + final EdgeInsetsGeometry? padding; + final EdgeInsetsGeometry? margin; + final Color? backgroundColor; + final double borderRadius; + final List? boxShadow; + final Border? border; + final double? height; + final double? width; + final VoidCallback? onTap; + + const SectionCard({ + super.key, + this.title, + required this.child, + this.padding, + this.margin, + this.backgroundColor, + this.borderRadius = 20, + this.boxShadow, + this.border, + this.height, + this.width, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final effectiveBackgroundColor = backgroundColor ?? Colors.white; + final effectiveShadow = boxShadow ?? [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ]; + + Widget card = Container( + height: height, + width: width, + margin: margin, + decoration: BoxDecoration( + color: effectiveBackgroundColor, + borderRadius: BorderRadius.circular(borderRadius), + boxShadow: effectiveShadow, + border: border, + ), + child: Padding( + padding: padding ?? const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (title != null) ...[ + Text( + title!, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: theme.colorScheme.onSurface, + ), + ), + const SizedBox(height: 16), + ], + child, + ], + ), + ), + ); + + if (onTap != null) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(borderRadius), + child: card, + ); + } + + return card; + } +} + +/// 투명한 배경의 섹션 카드 +/// 어두운 배경 위에서 사용하기 적합합니다. +class TransparentSectionCard extends StatelessWidget { + final String? title; + final Widget child; + final EdgeInsetsGeometry? padding; + final EdgeInsetsGeometry? margin; + final double opacity; + final double borderRadius; + final Color? borderColor; + final VoidCallback? onTap; + + const TransparentSectionCard({ + super.key, + this.title, + required this.child, + this.padding, + this.margin, + this.opacity = 0.15, + this.borderRadius = 16, + this.borderColor, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + Widget card = Container( + margin: margin, + decoration: BoxDecoration( + color: Colors.white.withOpacity(opacity), + borderRadius: BorderRadius.circular(borderRadius), + border: borderColor != null + ? Border.all(color: borderColor!, width: 1) + : null, + ), + child: Padding( + padding: padding ?? const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (title != null) ...[ + Text( + title!, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white.withOpacity(0.9), + ), + ), + const SizedBox(height: 12), + ], + child, + ], + ), + ), + ); + + if (onTap != null) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(borderRadius), + child: card, + ); + } + + return card; + } +} + +/// 정보 표시용 카드 +/// 읽기 전용 정보를 표시할 때 사용합니다. +class InfoCard extends StatelessWidget { + final String label; + final String value; + final IconData? icon; + final Color? iconColor; + final Color? backgroundColor; + final EdgeInsetsGeometry? padding; + final double borderRadius; + + const InfoCard({ + super.key, + required this.label, + required this.value, + this.icon, + this.iconColor, + this.backgroundColor, + this.padding, + this.borderRadius = 12, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + padding: padding ?? const EdgeInsets.all(16), + decoration: BoxDecoration( + color: backgroundColor ?? theme.colorScheme.surface, + borderRadius: BorderRadius.circular(borderRadius), + ), + child: Row( + children: [ + if (icon != null) ...[ + Icon( + icon, + size: 24, + color: iconColor ?? theme.colorScheme.primary, + ), + const SizedBox(width: 12), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 14, + color: theme.colorScheme.onSurface.withOpacity(0.6), + ), + ), + const SizedBox(height: 4), + Text( + value, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + ), + ], + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/common/dialogs/confirmation_dialog.dart b/lib/widgets/common/dialogs/confirmation_dialog.dart new file mode 100644 index 0000000..589cd0a --- /dev/null +++ b/lib/widgets/common/dialogs/confirmation_dialog.dart @@ -0,0 +1,353 @@ +import 'package:flutter/material.dart'; + +/// 확인 다이얼로그 위젯 +/// 사용자에게 중요한 작업을 확인받을 때 사용합니다. +class ConfirmationDialog extends StatelessWidget { + final String title; + final String? message; + final Widget? content; + final String confirmText; + final String cancelText; + final VoidCallback? onConfirm; + final VoidCallback? onCancel; + final Color? confirmColor; + final IconData? icon; + final Color? iconColor; + final double iconSize; + + const ConfirmationDialog({ + super.key, + required this.title, + this.message, + this.content, + this.confirmText = '확인', + this.cancelText = '취소', + this.onConfirm, + this.onCancel, + this.confirmColor, + this.icon, + this.iconColor, + this.iconSize = 48, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final effectiveConfirmColor = confirmColor ?? theme.primaryColor; + + return AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + title: Text( + title, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 20, + ), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: (iconColor ?? effectiveConfirmColor).withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + icon, + color: iconColor ?? effectiveConfirmColor, + size: iconSize, + ), + ), + const SizedBox(height: 16), + ], + if (content != null) + content! + else if (message != null) + Text( + message!, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 16, + height: 1.5, + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(false); + onCancel?.call(); + }, + child: Text(cancelText), + ), + ElevatedButton( + onPressed: () { + Navigator.of(context).pop(true); + onConfirm?.call(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: effectiveConfirmColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + confirmText, + style: const TextStyle(color: Colors.white), + ), + ), + ], + ); + } + + /// 다이얼로그를 표시하고 결과를 반환하는 정적 메서드 + static Future show({ + required BuildContext context, + required String title, + String? message, + Widget? content, + String confirmText = '확인', + String cancelText = '취소', + Color? confirmColor, + IconData? icon, + Color? iconColor, + double iconSize = 48, + }) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (context) => ConfirmationDialog( + title: title, + message: message, + content: content, + confirmText: confirmText, + cancelText: cancelText, + confirmColor: confirmColor, + icon: icon, + iconColor: iconColor, + iconSize: iconSize, + ), + ); + } +} + +/// 성공 다이얼로그 +class SuccessDialog extends StatelessWidget { + final String title; + final String? message; + final String buttonText; + final VoidCallback? onPressed; + + const SuccessDialog({ + super.key, + required this.title, + this.message, + this.buttonText = '확인', + this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.green.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.check_circle, + color: Colors.green, + size: 64, + ), + ), + const SizedBox(height: 24), + Text( + title, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + if (message != null) ...[ + const SizedBox(height: 12), + Text( + message!, + style: TextStyle( + fontSize: 16, + color: Colors.grey[600], + ), + textAlign: TextAlign.center, + ), + ], + ], + ), + actions: [ + Center( + child: ElevatedButton( + onPressed: () { + Navigator.of(context).pop(); + onPressed?.call(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 12, + ), + ), + child: Text( + buttonText, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + ), + ), + ), + ), + ], + ); + } + + static Future show({ + required BuildContext context, + required String title, + String? message, + String buttonText = '확인', + VoidCallback? onPressed, + }) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (context) => SuccessDialog( + title: title, + message: message, + buttonText: buttonText, + onPressed: onPressed, + ), + ); + } +} + +/// 에러 다이얼로그 +class ErrorDialog extends StatelessWidget { + final String title; + final String? message; + final String buttonText; + final VoidCallback? onPressed; + + const ErrorDialog({ + super.key, + required this.title, + this.message, + this.buttonText = '확인', + this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.error_outline, + color: Colors.red, + size: 64, + ), + ), + const SizedBox(height: 24), + Text( + title, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + if (message != null) ...[ + const SizedBox(height: 12), + Text( + message!, + style: TextStyle( + fontSize: 16, + color: Colors.grey[600], + ), + textAlign: TextAlign.center, + ), + ], + ], + ), + actions: [ + Center( + child: ElevatedButton( + onPressed: () { + Navigator.of(context).pop(); + onPressed?.call(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 12, + ), + ), + child: Text( + buttonText, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + ), + ), + ), + ), + ], + ); + } + + static Future show({ + required BuildContext context, + required String title, + String? message, + String buttonText = '확인', + VoidCallback? onPressed, + }) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (context) => ErrorDialog( + title: title, + message: message, + buttonText: buttonText, + onPressed: onPressed, + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/common/dialogs/loading_overlay.dart b/lib/widgets/common/dialogs/loading_overlay.dart new file mode 100644 index 0000000..2440d4d --- /dev/null +++ b/lib/widgets/common/dialogs/loading_overlay.dart @@ -0,0 +1,238 @@ +import 'package:flutter/material.dart'; + +/// 로딩 오버레이 위젯 +/// 비동기 작업 중 화면을 덮는 로딩 인디케이터를 표시합니다. +class LoadingOverlay extends StatelessWidget { + final bool isLoading; + final Widget child; + final String? message; + final Color? backgroundColor; + final Color? indicatorColor; + final double opacity; + + const LoadingOverlay({ + super.key, + required this.isLoading, + required this.child, + this.message, + this.backgroundColor, + this.indicatorColor, + this.opacity = 0.7, + }); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + child, + if (isLoading) + Container( + color: (backgroundColor ?? Colors.black).withOpacity(opacity), + child: Center( + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator( + color: indicatorColor ?? Theme.of(context).primaryColor, + ), + if (message != null) ...[ + const SizedBox(height: 16), + Text( + message!, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ], + ], + ), + ), + ), + ), + ], + ); + } +} + +/// 로딩 다이얼로그 +/// 모달 형태의 로딩 인디케이터를 표시합니다. +class LoadingDialog { + static Future show({ + required BuildContext context, + String? message, + Color? barrierColor, + bool barrierDismissible = false, + }) { + return showDialog( + context: context, + barrierDismissible: barrierDismissible, + barrierColor: barrierColor ?? Colors.black54, + builder: (context) => WillPopScope( + onWillPop: () async => barrierDismissible, + child: Center( + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator( + color: Theme.of(context).primaryColor, + ), + if (message != null) ...[ + const SizedBox(height: 16), + Text( + message, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ], + ], + ), + ), + ), + ), + ); + } + + static void hide(BuildContext context) { + Navigator.of(context).pop(); + } +} + +/// 커스텀 로딩 인디케이터 +/// 다양한 스타일의 로딩 애니메이션을 제공합니다. +class CustomLoadingIndicator extends StatefulWidget { + final double size; + final Color? color; + final LoadingStyle style; + + const CustomLoadingIndicator({ + super.key, + this.size = 50, + this.color, + this.style = LoadingStyle.circular, + }); + + @override + State createState() => _CustomLoadingIndicatorState(); +} + +class _CustomLoadingIndicatorState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(seconds: 1), + vsync: this, + )..repeat(); + + _animation = CurvedAnimation( + parent: _controller, + curve: Curves.easeInOut, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final effectiveColor = widget.color ?? Theme.of(context).primaryColor; + + switch (widget.style) { + case LoadingStyle.circular: + return SizedBox( + width: widget.size, + height: widget.size, + child: CircularProgressIndicator( + color: effectiveColor, + strokeWidth: 3, + ), + ); + + case LoadingStyle.dots: + return SizedBox( + width: widget.size, + height: widget.size / 3, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: List.generate(3, (index) { + return AnimatedBuilder( + animation: _animation, + builder: (context, child) { + final delay = index * 0.2; + final value = (_animation.value - delay).clamp(0.0, 1.0); + return Container( + width: widget.size / 5, + height: widget.size / 5, + decoration: BoxDecoration( + color: effectiveColor.withOpacity(0.3 + value * 0.7), + shape: BoxShape.circle, + ), + ); + }, + ); + }), + ), + ); + + case LoadingStyle.pulse: + return AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return Container( + width: widget.size, + height: widget.size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: effectiveColor.withOpacity(0.3), + ), + child: Center( + child: Container( + width: widget.size * (0.3 + _animation.value * 0.5), + height: widget.size * (0.3 + _animation.value * 0.5), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: effectiveColor.withOpacity(1 - _animation.value), + ), + ), + ), + ); + }, + ); + } + } +} + +enum LoadingStyle { + circular, + dots, + pulse, +} \ No newline at end of file diff --git a/lib/widgets/common/form_fields/base_text_field.dart b/lib/widgets/common/form_fields/base_text_field.dart new file mode 100644 index 0000000..061158d --- /dev/null +++ b/lib/widgets/common/form_fields/base_text_field.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// 공통 텍스트 필드 위젯 +/// 프로젝트 전체에서 일관된 스타일의 텍스트 입력 필드를 제공합니다. +class BaseTextField extends StatelessWidget { + final TextEditingController controller; + final FocusNode? focusNode; + final String? label; + final String? hintText; + final TextInputAction? textInputAction; + final TextInputType? keyboardType; + final List? inputFormatters; + final Function()? onTap; + final Function()? onEditingComplete; + final Function(String)? onChanged; + final String? Function(String?)? validator; + final bool enabled; + final Widget? prefixIcon; + final String? prefixText; + final Widget? suffixIcon; + final bool obscureText; + final int? maxLines; + final int? minLines; + final bool readOnly; + final TextStyle? style; + final EdgeInsetsGeometry? contentPadding; + final Color? fillColor; + final Color? cursorColor; + + const BaseTextField({ + super.key, + required this.controller, + this.focusNode, + this.label, + this.hintText, + this.textInputAction = TextInputAction.next, + this.keyboardType, + this.inputFormatters, + this.onTap, + this.onEditingComplete, + this.onChanged, + this.validator, + this.enabled = true, + this.prefixIcon, + this.prefixText, + this.suffixIcon, + this.obscureText = false, + this.maxLines = 1, + this.minLines, + this.readOnly = false, + this.style, + this.contentPadding, + this.fillColor, + this.cursorColor, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (label != null) ...[ + Text( + label!, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + ), + const SizedBox(height: 8), + ], + TextFormField( + controller: controller, + focusNode: focusNode, + textInputAction: textInputAction, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + onTap: onTap, + onEditingComplete: onEditingComplete, + onChanged: onChanged, + validator: validator, + enabled: enabled, + obscureText: obscureText, + maxLines: maxLines, + minLines: minLines, + readOnly: readOnly, + cursorColor: cursorColor ?? theme.primaryColor, + style: style ?? TextStyle( + fontSize: 16, + color: theme.colorScheme.onSurface, + ), + decoration: InputDecoration( + hintText: hintText, + hintStyle: TextStyle( + color: theme.colorScheme.onSurface.withOpacity(0.6), + ), + prefixIcon: prefixIcon, + prefixText: prefixText, + suffixIcon: suffixIcon, + filled: true, + fillColor: fillColor ?? Colors.white, + contentPadding: contentPadding ?? const EdgeInsets.all(16), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide( + color: theme.primaryColor, + width: 2, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + disabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide( + color: theme.colorScheme.error, + width: 1, + ), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide( + color: theme.colorScheme.error, + width: 2, + ), + ), + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/widgets/common/form_fields/currency_input_field.dart b/lib/widgets/common/form_fields/currency_input_field.dart new file mode 100644 index 0000000..3c32ece --- /dev/null +++ b/lib/widgets/common/form_fields/currency_input_field.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:intl/intl.dart'; +import 'base_text_field.dart'; + +/// 통화 입력 필드 위젯 +/// 원화(KRW)와 달러(USD)를 지원하며 자동 포맷팅을 제공합니다. +class CurrencyInputField extends StatefulWidget { + final TextEditingController controller; + final String currency; // 'KRW' or 'USD' + final String? label; + final String? hintText; + final Function(double?)? onChanged; + final String? Function(String?)? validator; + final FocusNode? focusNode; + final TextInputAction? textInputAction; + final Function()? onEditingComplete; + final bool enabled; + + const CurrencyInputField({ + super.key, + required this.controller, + required this.currency, + this.label, + this.hintText, + this.onChanged, + this.validator, + this.focusNode, + this.textInputAction, + this.onEditingComplete, + this.enabled = true, + }); + + @override + State createState() => _CurrencyInputFieldState(); +} + +class _CurrencyInputFieldState extends State { + late TextEditingController _formattedController; + + @override + void initState() { + super.initState(); + _formattedController = TextEditingController(); + _updateFormattedValue(); + widget.controller.addListener(_onControllerChanged); + } + + @override + void dispose() { + widget.controller.removeListener(_onControllerChanged); + _formattedController.dispose(); + 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 = ''; + } + } + + String _formatCurrency(double value) { + if (widget.currency == 'KRW') { + return NumberFormat.decimalPattern().format(value.toInt()); + } else { + return NumberFormat('#,##0.00').format(value); + } + } + + double? _parseValue(String text) { + final cleanText = text.replaceAll(',', '').replaceAll('₩', '').replaceAll('\$', '').trim(); + return double.tryParse(cleanText); + } + + String get _prefixText { + return widget.currency == 'KRW' ? '₩ ' : '\$ '; + } + + String get _defaultHintText { + return widget.currency == 'KRW' ? '금액을 입력하세요' : 'Enter amount'; + } + + @override + Widget build(BuildContext context) { + return BaseTextField( + controller: _formattedController, + focusNode: widget.focusNode, + label: widget.label, + hintText: widget.hintText ?? _defaultHintText, + textInputAction: widget.textInputAction, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[\d,.]')), + ], + 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), + ); + } + } + }, + validator: widget.validator ?? (value) { + if (value == null || value.isEmpty) { + return '금액을 입력해주세요'; + } + final parsedValue = _parseValue(value); + if (parsedValue == null || parsedValue <= 0) { + return '올바른 금액을 입력해주세요'; + } + return null; + }, + ); + } +} \ No newline at end of file diff --git a/lib/widgets/common/form_fields/date_picker_field.dart b/lib/widgets/common/form_fields/date_picker_field.dart new file mode 100644 index 0000000..d9e5d83 --- /dev/null +++ b/lib/widgets/common/form_fields/date_picker_field.dart @@ -0,0 +1,263 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +/// 날짜 선택 필드 위젯 +/// 탭하면 날짜 선택기가 표시되며, 선택된 날짜를 보기 좋은 형식으로 표시합니다. +class DatePickerField extends StatelessWidget { + final DateTime selectedDate; + final Function(DateTime) onDateSelected; + final String label; + final String? hintText; + final DateTime? firstDate; + final DateTime? lastDate; + final bool enabled; + final FocusNode? focusNode; + final Color? backgroundColor; + final EdgeInsetsGeometry? contentPadding; + final String? dateFormat; + final Color? primaryColor; + + const DatePickerField({ + super.key, + required this.selectedDate, + required this.onDateSelected, + required this.label, + this.hintText, + this.firstDate, + this.lastDate, + this.enabled = true, + this.focusNode, + this.backgroundColor, + this.contentPadding, + this.dateFormat, + this.primaryColor, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final effectivePrimaryColor = primaryColor ?? theme.primaryColor; + final effectiveDateFormat = dateFormat ?? 'yyyy년 MM월 dd일'; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + ), + const SizedBox(height: 8), + InkWell( + focusNode: focusNode, + onTap: enabled ? () async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: selectedDate, + firstDate: firstDate ?? DateTime.now().subtract(const Duration(days: 365 * 10)), + lastDate: lastDate ?? DateTime.now().add(const Duration(days: 365 * 10)), + builder: (BuildContext context, Widget? child) { + return Theme( + data: ThemeData.light().copyWith( + colorScheme: ColorScheme.light( + primary: effectivePrimaryColor, + onPrimary: Colors.white, + surface: Colors.white, + onSurface: Colors.black, + ), + dialogBackgroundColor: Colors.white, + ), + child: child!, + ); + }, + ); + + if (picked != null && picked != selectedDate) { + onDateSelected(picked); + } + } : null, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: contentPadding ?? const EdgeInsets.all(16), + decoration: BoxDecoration( + color: backgroundColor ?? Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Colors.transparent, + ), + ), + child: Row( + children: [ + Expanded( + child: Text( + DateFormat(effectiveDateFormat).format(selectedDate), + style: TextStyle( + fontSize: 16, + color: enabled + ? theme.colorScheme.onSurface + : theme.colorScheme.onSurface.withOpacity(0.5), + ), + ), + ), + Icon( + Icons.calendar_today, + size: 20, + color: enabled + ? theme.colorScheme.onSurface.withOpacity(0.6) + : theme.colorScheme.onSurface.withOpacity(0.3), + ), + ], + ), + ), + ), + ], + ); + } +} + +/// 날짜 범위 선택 필드 위젯 +/// 시작일과 종료일을 선택할 수 있는 필드입니다. +class DateRangePickerField extends StatelessWidget { + final DateTime? startDate; + final DateTime? endDate; + final Function(DateTime?) onStartDateSelected; + final Function(DateTime?) onEndDateSelected; + final String startLabel; + final String endLabel; + final bool enabled; + final Color? primaryColor; + + const DateRangePickerField({ + super.key, + required this.startDate, + required this.endDate, + required this.onStartDateSelected, + required this.onEndDateSelected, + this.startLabel = '시작일', + this.endLabel = '종료일', + this.enabled = true, + this.primaryColor, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: _DateRangeItem( + date: startDate, + label: startLabel, + enabled: enabled, + primaryColor: primaryColor, + onDateSelected: onStartDateSelected, + firstDate: DateTime.now().subtract(const Duration(days: 365)), + lastDate: endDate ?? DateTime.now().add(const Duration(days: 365 * 2)), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _DateRangeItem( + date: endDate, + label: endLabel, + enabled: enabled && startDate != null, + primaryColor: primaryColor, + onDateSelected: onEndDateSelected, + firstDate: startDate ?? DateTime.now(), + lastDate: DateTime.now().add(const Duration(days: 365 * 2)), + ), + ), + ], + ); + } +} + +class _DateRangeItem extends StatelessWidget { + final DateTime? date; + final String label; + final bool enabled; + final Color? primaryColor; + final Function(DateTime?) onDateSelected; + final DateTime firstDate; + final DateTime lastDate; + + const _DateRangeItem({ + required this.date, + required this.label, + required this.enabled, + required this.primaryColor, + required this.onDateSelected, + required this.firstDate, + required this.lastDate, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final effectivePrimaryColor = primaryColor ?? theme.primaryColor; + + return InkWell( + onTap: enabled ? () async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: date ?? DateTime.now(), + firstDate: firstDate, + lastDate: lastDate, + builder: (BuildContext context, Widget? child) { + return Theme( + data: ThemeData.light().copyWith( + colorScheme: ColorScheme.light( + primary: effectivePrimaryColor, + onPrimary: Colors.white, + surface: Colors.white, + onSurface: Colors.black, + ), + dialogBackgroundColor: Colors.white, + ), + child: child!, + ); + }, + ); + + if (picked != null) { + onDateSelected(picked); + } + } : null, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurface.withOpacity(0.6), + ), + ), + const SizedBox(height: 4), + Text( + date != null + ? DateFormat('MM/dd').format(date!) + : '선택', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: date != null + ? theme.colorScheme.onSurface + : theme.colorScheme.onSurface.withOpacity(0.4), + ), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/detail/detail_action_buttons.dart b/lib/widgets/detail/detail_action_buttons.dart new file mode 100644 index 0000000..4f70b22 --- /dev/null +++ b/lib/widgets/detail/detail_action_buttons.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import '../../controllers/detail_screen_controller.dart'; +import '../common/buttons/primary_button.dart'; + +/// 상세 화면 액션 버튼 섹션 +/// 저장 버튼을 포함하는 섹션입니다. +class DetailActionButtons extends StatelessWidget { + final DetailScreenController controller; + final Animation fadeAnimation; + final Animation slideAnimation; + + const DetailActionButtons({ + super.key, + required this.controller, + required this.fadeAnimation, + required this.slideAnimation, + }); + + @override + Widget build(BuildContext context) { + final baseColor = controller.getCardColor(); + + return FadeTransition( + opacity: fadeAnimation, + child: SlideTransition( + position: Tween( + begin: const Offset(0.0, 0.8), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: controller.animationController!, + curve: const Interval(0.4, 1.0, curve: Curves.easeOutCubic), + )), + child: Padding( + padding: const EdgeInsets.only(bottom: 80), + child: PrimaryButton( + text: '변경사항 저장', + icon: Icons.save_rounded, + onPressed: controller.updateSubscription, + isLoading: controller.isLoading, + backgroundColor: baseColor, + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/detail/detail_event_section.dart b/lib/widgets/detail/detail_event_section.dart new file mode 100644 index 0000000..1d84c5d --- /dev/null +++ b/lib/widgets/detail/detail_event_section.dart @@ -0,0 +1,221 @@ +import 'package:flutter/material.dart'; +import '../../controllers/detail_screen_controller.dart'; +import '../common/form_fields/currency_input_field.dart'; +import '../common/form_fields/date_picker_field.dart'; + +/// 이벤트 가격 섹션 +/// 할인 이벤트 정보를 관리하는 섹션입니다. +class DetailEventSection extends StatelessWidget { + final DetailScreenController controller; + final Animation fadeAnimation; + final Animation slideAnimation; + + const DetailEventSection({ + super.key, + required this.controller, + required this.fadeAnimation, + required this.slideAnimation, + }); + + @override + Widget build(BuildContext context) { + final baseColor = controller.getCardColor(); + + return FadeTransition( + opacity: fadeAnimation, + child: SlideTransition( + position: Tween( + begin: const Offset(0.0, 0.8), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: controller.animationController!, + curve: const Interval(0.4, 1.0, curve: Curves.easeOutCubic), + )), + child: Card( + elevation: 1, + shadowColor: Colors.black12, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 헤더 + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: baseColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.local_offer_rounded, + color: baseColor, + size: 24, + ), + ), + const SizedBox(width: 12), + const Text( + '이벤트 가격', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + // 이벤트 활성화 스위치 + Switch.adaptive( + value: controller.isEventActive, + onChanged: (value) { + controller.isEventActive = value; + if (!value) { + // 이벤트 비활성화시 관련 정보 초기화 + controller.eventStartDate = null; + controller.eventEndDate = null; + controller.eventPriceController.clear(); + } + }, + activeColor: baseColor, + ), + ], + ), + // 이벤트 활성화시 표시될 필드들 + if (controller.isEventActive) ...[ + const SizedBox(height: 20), + // 이벤트 설명 + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + Icons.info_outline_rounded, + color: Colors.blue[700], + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + '할인 또는 프로모션 가격을 설정하세요', + style: TextStyle( + fontSize: 14, + color: Colors.blue[700], + ), + ), + ), + ], + ), + ), + const SizedBox(height: 20), + // 이벤트 기간 + DateRangePickerField( + startDate: controller.eventStartDate, + endDate: controller.eventEndDate, + onStartDateSelected: (date) { + controller.eventStartDate = date; + }, + onEndDateSelected: (date) { + controller.eventEndDate = date; + }, + startLabel: '시작일', + endLabel: '종료일', + primaryColor: baseColor, + ), + const SizedBox(height: 20), + // 이벤트 가격 + CurrencyInputField( + controller: controller.eventPriceController, + currency: controller.currency, + label: '이벤트 가격', + hintText: '할인된 가격을 입력하세요', + ), + const SizedBox(height: 16), + // 할인율 표시 + if (controller.eventPriceController.text.isNotEmpty) + _DiscountBadge( + originalPrice: controller.subscription.monthlyCost, + eventPrice: double.tryParse( + controller.eventPriceController.text.replaceAll(',', '') + ) ?? 0, + currency: controller.currency, + ), + ], + ], + ), + ), + ), + ), + ); + } +} + +/// 할인율 배지 +class _DiscountBadge extends StatelessWidget { + final double originalPrice; + final double eventPrice; + final String currency; + + const _DiscountBadge({ + required this.originalPrice, + required this.eventPrice, + required this.currency, + }); + + @override + Widget build(BuildContext context) { + if (eventPrice >= originalPrice || eventPrice <= 0) { + return const SizedBox.shrink(); + } + + final discountPercentage = ((originalPrice - eventPrice) / originalPrice * 100).round(); + final discountAmount = originalPrice - eventPrice; + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.green.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.green, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '$discountPercentage% 할인', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 12), + Text( + currency == 'KRW' + ? '₩${discountAmount.toInt().toString()}원 절약' + : '\$${discountAmount.toStringAsFixed(2)} 절약', + style: TextStyle( + color: Colors.green[700], + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/detail/detail_form_section.dart b/lib/widgets/detail/detail_form_section.dart new file mode 100644 index 0000000..8cab419 --- /dev/null +++ b/lib/widgets/detail/detail_form_section.dart @@ -0,0 +1,370 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../controllers/detail_screen_controller.dart'; +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'; + +/// 상세 화면 폼 섹션 +/// 구독 정보를 편집할 수 있는 폼 필드들을 포함합니다. +class DetailFormSection extends StatelessWidget { + final DetailScreenController controller; + final Animation fadeAnimation; + final Animation slideAnimation; + + const DetailFormSection({ + super.key, + required this.controller, + required this.fadeAnimation, + required this.slideAnimation, + }); + + @override + Widget build(BuildContext context) { + final baseColor = controller.getCardColor(); + + return FadeTransition( + opacity: fadeAnimation, + child: SlideTransition( + position: Tween( + begin: const Offset(0.0, 0.6), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: controller.animationController!, + curve: const Interval(0.3, 1.0, curve: Curves.easeOutCubic), + )), + child: Card( + elevation: 1, + shadowColor: Colors.black12, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // 서비스명 필드 + BaseTextField( + controller: controller.serviceNameController, + focusNode: controller.serviceNameFocus, + label: '서비스명', + hintText: '예: Netflix, Spotify', + textInputAction: TextInputAction.next, + onEditingComplete: () { + controller.monthlyCostFocus.requestFocus(); + }, + ), + const SizedBox(height: 20), + + // 월 지출 및 통화 선택 + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 2, + child: CurrencyInputField( + controller: controller.monthlyCostController, + currency: controller.currency, + label: '월 지출', + focusNode: controller.monthlyCostFocus, + textInputAction: TextInputAction.next, + onEditingComplete: () { + controller.billingCycleFocus.requestFocus(); + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '통화', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + _CurrencySelector( + currency: controller.currency, + onChanged: (value) { + controller.currency = value; + // 통화 변경시 금액 포맷 업데이트 + if (value == 'KRW') { + final amount = double.tryParse( + controller.monthlyCostController.text.replaceAll(',', '') + ); + if (amount != null) { + controller.monthlyCostController.text = + amount.toInt().toString(); + } + } + }, + ), + ], + ), + ), + ], + ), + const SizedBox(height: 20), + + // 결제 주기 + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '결제 주기', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + _BillingCycleSelector( + billingCycle: controller.billingCycle, + baseColor: baseColor, + onChanged: (value) { + controller.billingCycle = value; + }, + ), + ], + ), + const SizedBox(height: 20), + + // 다음 결제일 + DatePickerField( + selectedDate: controller.nextBillingDate, + onDateSelected: (date) { + controller.nextBillingDate = date; + }, + label: '다음 결제일', + firstDate: DateTime.now(), + lastDate: DateTime.now().add(const Duration(days: 365 * 2)), + primaryColor: baseColor, + ), + const SizedBox(height: 20), + + // 카테고리 선택 + Consumer( + builder: (context, categoryProvider, child) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '카테고리', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + _CategorySelector( + categories: categoryProvider.categories, + selectedCategoryId: controller.selectedCategoryId, + baseColor: baseColor, + onChanged: (categoryId) { + controller.selectedCategoryId = categoryId; + }, + ), + ], + ); + }, + ), + ], + ), + ), + ), + ), + ); + } +} + +/// 통화 선택기 +class _CurrencySelector extends StatelessWidget { + final String currency; + final ValueChanged 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.withOpacity(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 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.withOpacity(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 categories; + final String? selectedCategoryId; + final Color baseColor; + final ValueChanged 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.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + category.emoji, + 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(), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/detail/detail_header_section.dart b/lib/widgets/detail/detail_header_section.dart new file mode 100644 index 0000000..83527bb --- /dev/null +++ b/lib/widgets/detail/detail_header_section.dart @@ -0,0 +1,247 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import '../../models/subscription_model.dart'; +import '../../controllers/detail_screen_controller.dart'; +import '../website_icon.dart'; + +/// 상세 화면 상단 헤더 섹션 +/// 서비스 아이콘, 이름, 결제 정보를 표시합니다. +class DetailHeaderSection extends StatelessWidget { + final SubscriptionModel subscription; + final DetailScreenController controller; + final Animation fadeAnimation; + final Animation slideAnimation; + final Animation rotateAnimation; + + const DetailHeaderSection({ + super.key, + required this.subscription, + required this.controller, + required this.fadeAnimation, + required this.slideAnimation, + required this.rotateAnimation, + }); + + @override + Widget build(BuildContext context) { + final baseColor = controller.getCardColor(); + final gradient = controller.getGradient(baseColor); + + return Container( + height: 320, + decoration: BoxDecoration(gradient: gradient), + child: Stack( + children: [ + // 배경 패턴 + Positioned( + top: -50, + right: -50, + child: RotationTransition( + turns: rotateAnimation, + child: Container( + width: 200, + height: 200, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.1), + ), + ), + ), + ), + Positioned( + bottom: -30, + left: -30, + child: Container( + width: 150, + height: 150, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.08), + ), + ), + ), + // 콘텐츠 + SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 뒤로가기 버튼 + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: const Icon( + Icons.arrow_back_ios_new_rounded, + color: Colors.white, + ), + onPressed: () => Navigator.of(context).pop(), + ), + IconButton( + icon: const Icon( + Icons.delete_outline_rounded, + color: Colors.white, + ), + onPressed: controller.deleteSubscription, + ), + ], + ), + const Spacer(), + // 서비스 정보 + FadeTransition( + opacity: fadeAnimation, + child: SlideTransition( + position: slideAnimation, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 서비스 아이콘과 이름 + Row( + children: [ + Hero( + tag: 'icon_${subscription.id}', + child: Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: WebsiteIcon( + url: subscription.websiteUrl, + serviceName: subscription.serviceName, + size: 48, + ), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + subscription.serviceName, + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.w800, + color: Colors.white, + letterSpacing: -0.5, + shadows: [ + Shadow( + color: Colors.black26, + offset: Offset(0, 2), + blurRadius: 4, + ), + ], + ), + ), + const SizedBox(height: 4), + Text( + '${subscription.billingCycle} 결제', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Colors.white.withValues(alpha: 0.8), + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 20), + // 결제 정보 카드 + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _InfoColumn( + label: '다음 결제일', + value: DateFormat('yyyy년 MM월 dd일') + .format(subscription.nextBillingDate), + ), + _InfoColumn( + label: '월 지출', + value: NumberFormat.currency( + locale: subscription.currency == 'KRW' + ? 'ko_KR' + : 'en_US', + symbol: subscription.currency == 'KRW' + ? '₩' + : '\$', + decimalDigits: + subscription.currency == 'KRW' ? 0 : 2, + ).format(subscription.monthlyCost), + alignment: CrossAxisAlignment.end, + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +/// 정보 표시 컬럼 +class _InfoColumn extends StatelessWidget { + final String label; + final String value; + final CrossAxisAlignment alignment; + + const _InfoColumn({ + required this.label, + required this.value, + this.alignment = CrossAxisAlignment.start, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: alignment, + children: [ + Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.white.withValues(alpha: 0.8), + ), + ), + const SizedBox(height: 4), + Text( + value, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/widgets/detail/detail_url_section.dart b/lib/widgets/detail/detail_url_section.dart new file mode 100644 index 0000000..426c62f --- /dev/null +++ b/lib/widgets/detail/detail_url_section.dart @@ -0,0 +1,178 @@ +import 'package:flutter/material.dart'; +import '../../controllers/detail_screen_controller.dart'; +import '../common/form_fields/base_text_field.dart'; +import '../common/buttons/secondary_button.dart'; + +/// 웹사이트 URL 섹션 +/// 서비스 웹사이트 URL과 해지 관련 정보를 관리하는 섹션입니다. +class DetailUrlSection extends StatelessWidget { + final DetailScreenController controller; + final Animation fadeAnimation; + final Animation slideAnimation; + + const DetailUrlSection({ + super.key, + required this.controller, + required this.fadeAnimation, + required this.slideAnimation, + }); + + @override + Widget build(BuildContext context) { + final baseColor = controller.getCardColor(); + + return FadeTransition( + opacity: fadeAnimation, + child: SlideTransition( + position: Tween( + begin: const Offset(0.0, 0.8), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: controller.animationController!, + curve: const Interval(0.4, 1.0, curve: Curves.easeOutCubic), + )), + child: Card( + elevation: 1, + shadowColor: Colors.black12, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 헤더 + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: baseColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.language_rounded, + color: baseColor, + size: 24, + ), + ), + const SizedBox(width: 12), + const Text( + '웹사이트 정보', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + const SizedBox(height: 20), + + // URL 입력 필드 + BaseTextField( + controller: controller.websiteUrlController, + focusNode: controller.websiteUrlFocus, + label: '웹사이트 URL', + hintText: 'https://example.com', + keyboardType: TextInputType.url, + prefixIcon: Icon( + Icons.link_rounded, + color: Colors.grey[600], + ), + ), + + // 해지 안내 섹션 + if (controller.subscription.websiteUrl != null && + controller.subscription.websiteUrl!.isNotEmpty) ...[ + const SizedBox(height: 24), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.orange.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Colors.orange.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.info_outline_rounded, + color: Colors.orange[700], + size: 20, + ), + const SizedBox(width: 8), + Text( + '해지 안내', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.orange[700], + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + '이 서비스를 해지하려면 아래 링크를 통해 해지 페이지로 이동하세요.', + style: TextStyle( + fontSize: 14, + color: Colors.grey[700], + height: 1.5, + ), + ), + const SizedBox(height: 12), + TextLinkButton( + text: '해지 페이지로 이동', + icon: Icons.open_in_new_rounded, + onPressed: controller.openCancellationPage, + color: Colors.orange[700], + ), + ], + ), + ), + ], + + // URL 자동 매칭 정보 + if (controller.websiteUrlController.text.isEmpty) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + Icons.auto_fix_high_rounded, + color: Colors.blue[700], + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'URL이 비어있으면 서비스명을 기반으로 자동 매칭됩니다', + style: TextStyle( + fontSize: 14, + color: Colors.blue[700], + ), + ), + ), + ], + ), + ), + ], + ], + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/exchange_rate_widget.dart b/lib/widgets/exchange_rate_widget.dart index bd7efe4..9448a34 100644 --- a/lib/widgets/exchange_rate_widget.dart +++ b/lib/widgets/exchange_rate_widget.dart @@ -137,7 +137,7 @@ class _ExchangeRateWidgetState extends State { return const SizedBox.shrink(); // 표시할 필요가 없으면 빈 위젯 반환 } - return Column( + return const Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ // 이 위젯은 이제 환율 정보만 제공하고, 실제 UI는 스크린에서 구성 diff --git a/lib/widgets/floating_navigation_bar.dart b/lib/widgets/floating_navigation_bar.dart index 52ab9af..9771cde 100644 --- a/lib/widgets/floating_navigation_bar.dart +++ b/lib/widgets/floating_navigation_bar.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'dart:ui'; import '../theme/app_colors.dart'; import 'glassmorphism_card.dart'; @@ -62,8 +61,6 @@ class _FloatingNavigationBarState extends State @override Widget build(BuildContext context) { - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - return AnimatedBuilder( animation: _animation, builder: (context, child) { diff --git a/lib/widgets/glassmorphic_scaffold.dart b/lib/widgets/glassmorphic_scaffold.dart index 13c9d9b..4f88297 100644 --- a/lib/widgets/glassmorphic_scaffold.dart +++ b/lib/widgets/glassmorphic_scaffold.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'dart:math' as math; import '../theme/app_colors.dart'; -import 'glassmorphic_app_bar.dart'; import 'floating_navigation_bar.dart'; /// 글래스모피즘 디자인이 적용된 통일된 스캐폴드 @@ -79,7 +78,6 @@ class _GlassmorphicScaffoldState extends State void _setupScrollListener() { _scrollController?.addListener(() { final currentScroll = _scrollController!.position.pixels; - final maxScroll = _scrollController!.position.maxScrollExtent; // 스크롤 방향에 따라 플로팅 네비게이션 바 표시/숨김 if (currentScroll > 50 && _scrollController!.position.userScrollDirection == ScrollDirection.reverse) { diff --git a/lib/widgets/glassmorphism_card.dart b/lib/widgets/glassmorphism_card.dart index 1a12f27..21658f8 100644 --- a/lib/widgets/glassmorphism_card.dart +++ b/lib/widgets/glassmorphism_card.dart @@ -126,7 +126,6 @@ class _AnimatedGlassmorphismCardState extends State late AnimationController _controller; late Animation _scaleAnimation; late Animation _blurAnimation; - bool _isPressed = false; @override void initState() { @@ -160,23 +159,14 @@ class _AnimatedGlassmorphismCardState extends State } void _handleTapDown(TapDownDetails details) { - setState(() { - _isPressed = true; - }); _controller.forward(); } void _handleTapUp(TapUpDetails details) { - setState(() { - _isPressed = false; - }); _controller.reverse(); } void _handleTapCancel() { - setState(() { - _isPressed = false; - }); _controller.reverse(); } diff --git a/lib/widgets/home_content.dart b/lib/widgets/home_content.dart index 971d68d..60ae091 100644 --- a/lib/widgets/home_content.dart +++ b/lib/widgets/home_content.dart @@ -9,7 +9,6 @@ import '../widgets/subscription_list_widget.dart'; import '../widgets/empty_state_widget.dart'; import '../widgets/glassmorphic_app_bar.dart'; import '../theme/app_colors.dart'; -import '../routes/app_routes.dart'; class HomeContent extends StatelessWidget { final AnimationController fadeController; @@ -73,8 +72,8 @@ class HomeContent extends StatelessWidget { pinned: true, expandedHeight: kToolbarHeight, ), - SliverToBoxAdapter( - child: NativeAdWidget(key: const ValueKey('home_ad')), + const SliverToBoxAdapter( + child: NativeAdWidget(key: ValueKey('home_ad')), ), SliverToBoxAdapter( child: SlideTransition( @@ -119,14 +118,14 @@ class HomeContent extends StatelessWidget { children: [ Text( '${provider.subscriptions.length}개', - style: TextStyle( + style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.primaryColor, ), ), const SizedBox(width: 4), - Icon( + const Icon( Icons.arrow_forward_ios, size: 14, color: AppColors.primaryColor, diff --git a/lib/widgets/main_summary_card.dart b/lib/widgets/main_summary_card.dart index 4f1f1a3..3690cba 100644 --- a/lib/widgets/main_summary_card.dart +++ b/lib/widgets/main_summary_card.dart @@ -1,9 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; import '../providers/subscription_provider.dart'; import '../theme/app_colors.dart'; -import '../utils/format_helper.dart'; import 'animated_wave_background.dart'; import 'glassmorphism_card.dart'; diff --git a/lib/widgets/spring_animation_widget.dart b/lib/widgets/spring_animation_widget.dart index d42483a..f3a13b6 100644 --- a/lib/widgets/spring_animation_widget.dart +++ b/lib/widgets/spring_animation_widget.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter/physics.dart'; -import 'dart:math' as math; /// 물리 기반 스프링 애니메이션을 적용하는 위젯 class SpringAnimationWidget extends StatefulWidget { @@ -44,14 +43,6 @@ class _SpringAnimationWidgetState extends State duration: const Duration(seconds: 2), ); - // 스프링 시뮬레이션 - final simulation = SpringSimulation( - widget.spring, - 0.0, - 1.0, - 0.0, - ); - // 오프셋 애니메이션 _offsetAnimation = Tween( begin: widget.initialOffset ?? const Offset(0, 50), diff --git a/lib/widgets/subscription_card.dart b/lib/widgets/subscription_card.dart index d781aec..4ab204b 100644 --- a/lib/widgets/subscription_card.dart +++ b/lib/widgets/subscription_card.dart @@ -1,14 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; -import 'dart:math' as math; import '../models/subscription_model.dart'; -import '../screens/detail_screen.dart'; import 'website_icon.dart'; import 'app_navigator.dart'; import '../theme/app_colors.dart'; -import 'package:provider/provider.dart'; -import '../providers/subscription_provider.dart'; import 'glassmorphism_card.dart'; class SubscriptionCard extends StatefulWidget { @@ -27,9 +22,6 @@ class _SubscriptionCardState extends State with SingleTickerProviderStateMixin { late AnimationController _hoverController; bool _isHovering = false; - final double _initialElevation = 1.0; - final double _hoveredElevation = 3.0; - late SubscriptionProvider _subscriptionProvider; @override void initState() { @@ -40,12 +32,6 @@ class _SubscriptionCardState extends State ); } - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _subscriptionProvider = - Provider.of(context, listen: false); - } @override void dispose() { @@ -221,10 +207,6 @@ class _SubscriptionCardState extends State child: AnimatedBuilder( animation: _hoverController, builder: (context, child) { - final elevation = _initialElevation + - (_hoveredElevation - _initialElevation) * - _hoverController.value; - final scale = 1.0 + (0.02 * _hoverController.value); return Transform.scale( @@ -337,10 +319,10 @@ class _SubscriptionCardState extends State vertical: 3, ), decoration: BoxDecoration( - gradient: LinearGradient( + gradient: const LinearGradient( colors: [ - const Color(0xFFFF6B6B), - const Color(0xFFFF8787), + Color(0xFFFF6B6B), + Color(0xFFFF8787), ], ), borderRadius: @@ -349,12 +331,12 @@ class _SubscriptionCardState extends State child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon( + Icon( Icons.local_offer_rounded, size: 11, color: Colors.white, ), - const SizedBox(width: 3), + SizedBox(width: 3), Text( '이벤트', style: TextStyle( @@ -386,7 +368,7 @@ class _SubscriptionCardState extends State ), child: Text( widget.subscription.billingCycle, - style: TextStyle( + style: const TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: AppColors.textSecondary, @@ -424,7 +406,7 @@ class _SubscriptionCardState extends State decimalDigits: 0, ).format(widget .subscription.monthlyCost), - style: TextStyle( + style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w500, color: AppColors.textSecondary, @@ -555,7 +537,7 @@ class _SubscriptionCardState extends State if (widget.subscription.eventEndDate != null) ...[ Text( '${widget.subscription.eventEndDate!.difference(DateTime.now()).inDays}일 남음', - style: TextStyle( + style: const TextStyle( fontSize: 11, color: AppColors.textSecondary, ), diff --git a/lib/widgets/subscription_list_widget.dart b/lib/widgets/subscription_list_widget.dart index fb53561..46fe98f 100644 --- a/lib/widgets/subscription_list_widget.dart +++ b/lib/widgets/subscription_list_widget.dart @@ -1,11 +1,8 @@ import 'package:flutter/material.dart'; import '../models/subscription_model.dart'; -import '../widgets/subscription_card.dart'; import '../widgets/category_header_widget.dart'; import '../widgets/swipeable_subscription_card.dart'; import '../widgets/staggered_list_animation.dart'; -import '../screens/detail_screen.dart'; -import '../widgets/animated_page_transitions.dart'; import '../widgets/app_navigator.dart'; import 'package:provider/provider.dart'; import '../providers/subscription_provider.dart'; @@ -62,8 +59,8 @@ class SubscriptionListWidget extends StatelessWidget { itemBuilder: (context, subIndex) { // 각 구독의 지연값 계산 (순차적으로 나타나도록) final delay = 0.05 * subIndex; - final animationBegin = 0.2; - final animationEnd = 1.0; + const animationBegin = 0.2; + const animationEnd = 1.0; final intervalStart = delay; final intervalEnd = intervalStart + 0.4; diff --git a/lib/widgets/swipeable_subscription_card.dart b/lib/widgets/swipeable_subscription_card.dart index 3cccc6d..dd32d25 100644 --- a/lib/widgets/swipeable_subscription_card.dart +++ b/lib/widgets/swipeable_subscription_card.dart @@ -1,6 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'dart:math' as math; import '../models/subscription_model.dart'; import '../utils/haptic_feedback_helper.dart'; import 'subscription_card.dart'; diff --git a/lib/widgets/themed_text.dart b/lib/widgets/themed_text.dart index 29367d5..33822f4 100644 --- a/lib/widgets/themed_text.dart +++ b/lib/widgets/themed_text.dart @@ -43,7 +43,6 @@ class ThemedText extends StatelessWidget { if (forceDark) return AppColors.textPrimary; final brightness = Theme.of(context).brightness; - final backgroundColor = Theme.of(context).scaffoldBackgroundColor; // 글래스모피즘 환경에서는 보통 어두운 배경 위에 밝은 텍스트 if (_isGlassmorphicContext(context)) { diff --git a/lib/widgets/website_icon.dart b/lib/widgets/website_icon.dart index e406fc4..0dc2bcf 100644 --- a/lib/widgets/website_icon.dart +++ b/lib/widgets/website_icon.dart @@ -1,16 +1,11 @@ import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'package:cached_network_image/cached_network_image.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:octo_image/octo_image.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; -import 'package:html/parser.dart' as html_parser; -import 'package:html/dom.dart' as html_dom; import '../theme/app_colors.dart'; -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:path_provider/path_provider.dart'; import 'package:crypto/crypto.dart'; @@ -57,7 +52,7 @@ class FaviconCache { final prefs = await SharedPreferences.getInstance(); return prefs.getString('favicon_$serviceKey'); } catch (e) { - print('파비콘 캐시 로드 오류: $e'); + // 파비콘 캐시 로드 오류 return null; } } @@ -68,7 +63,7 @@ class FaviconCache { final prefs = await SharedPreferences.getInstance(); await prefs.setString('favicon_$serviceKey', logoUrl); } catch (e) { - print('파비콘 캐시 저장 오류: $e'); + // 파비콘 캐시 저장 오류 } } @@ -80,7 +75,7 @@ class FaviconCache { final prefs = await SharedPreferences.getInstance(); await prefs.remove('favicon_$serviceKey'); } catch (e) { - print('파비콘 캐시 삭제 오류: $e'); + // 파비콘 캐시 삭제 오류 } } @@ -90,7 +85,7 @@ class FaviconCache { final prefs = await SharedPreferences.getInstance(); return prefs.getString('local_favicon_$serviceKey'); } catch (e) { - print('로컬 파비콘 경로 로드 오류: $e'); + // 로컬 파비콘 경로 로드 오류 return null; } } @@ -102,39 +97,14 @@ class FaviconCache { final prefs = await SharedPreferences.getInstance(); await prefs.setString('local_favicon_$serviceKey', filePath); } catch (e) { - print('로컬 파비콘 경로 저장 오류: $e'); + // 로컬 파비콘 경로 저장 오류 } } } // 구글 파비콘 API 서비스 class GoogleFaviconService { - // CORS 프록시 서버 목록 - static final List _corsProxies = [ - 'https://corsproxy.io/?', - 'https://api.allorigins.win/raw?url=', - 'https://cors-anywhere.herokuapp.com/', - ]; - // 현재 사용 중인 프록시 인덱스 - static int _currentProxyIndex = 0; - - // 프록시를 사용하여 URL 생성 - static String _getProxiedUrl(String url) { - // 앱 환경에서는 프록시 없이 직접 URL 반환 - if (!kIsWeb) { - return url; - } - - // 웹 환경에서는 CORS 프록시 사용 - final proxy = _corsProxies[_currentProxyIndex]; - _currentProxyIndex = - (_currentProxyIndex + 1) % _corsProxies.length; // 다음 요청은 다른 프록시 사용 - - // URL 인코딩 - final encodedUrl = Uri.encodeComponent(url); - return '$proxy$encodedUrl'; - } // 구글 파비콘 API URL 생성 static String getFaviconUrl(String domain, int size) { @@ -167,7 +137,7 @@ class GoogleFaviconService { static String getBase64PlaceholderIcon(String serviceName, Color color) { // 간단한 SVG 생성 (서비스 이름의 첫 글자를 원 안에 표시) final initial = serviceName.isNotEmpty ? serviceName[0].toUpperCase() : '?'; - final colorHex = color.value.toRadixString(16).padLeft(8, '0').substring(2); + final colorHex = color.toARGB32().toRadixString(16).padLeft(8, '0').substring(2); // 공백 없이 SVG 생성 (공백이 있으면 Base64 디코딩 후 이미지 로드 시 문제 발생) final svgContent = @@ -207,15 +177,12 @@ class _WebsiteIconState extends State bool _isLoading = true; late AnimationController _animationController; late Animation _scaleAnimation; - late Animation _opacityAnimation; // 각 인스턴스에 대한 고유 식별자 추가 final String _uniqueId = DateTime.now().millisecondsSinceEpoch.toString(); // 서비스와 URL 조합으로 캐시 키 생성 String get _serviceKey => '${widget.serviceName}_${widget.url ?? ''}'; // 이전에 사용한 서비스 키 (URL이 변경됐는지 확인용) String? _previousServiceKey; - // 로드 시작된 시점 - DateTime? _loadStartTime; @override void initState() { @@ -231,15 +198,9 @@ class _WebsiteIconState extends State CurvedAnimation( parent: _animationController, curve: Curves.easeOutCubic)); - _opacityAnimation = Tween(begin: 0.0, end: 1.0).animate( - CurvedAnimation(parent: _animationController, curve: Curves.easeOut)); - // 초기 _previousServiceKey 설정 _previousServiceKey = _serviceKey; - // 로드 시작 시간 기록 - _loadStartTime = DateTime.now(); - // 최초 로딩 _loadFaviconWithCache(); } @@ -263,7 +224,7 @@ class _WebsiteIconState extends State // 이미 로딩 중인지 확인 if (!FaviconCache.markAsLoading(_serviceKey)) { - await Future.delayed(Duration(milliseconds: 500)); + await Future.delayed(const Duration(milliseconds: 500)); cachedLogo = FaviconCache.getFromMemory(_serviceKey); if (cachedLogo != null) { setState(() { @@ -312,7 +273,7 @@ class _WebsiteIconState extends State // 2. 이미 로딩 중인지 확인 if (!FaviconCache.markAsLoading(_serviceKey)) { - await Future.delayed(Duration(milliseconds: 500)); + await Future.delayed(const Duration(milliseconds: 500)); localPath = await FaviconCache.getLocalFaviconPath(_serviceKey); if (localPath != null) { final file = File(localPath); @@ -344,12 +305,9 @@ class _WebsiteIconState extends State // 서비스명이나 URL이 변경된 경우에만 다시 로드 final currentServiceKey = _serviceKey; if (_previousServiceKey != currentServiceKey) { - print('서비스 키 변경 감지: $_previousServiceKey -> $currentServiceKey'); + // 서비스 키 변경 감지: $_previousServiceKey -> $currentServiceKey _previousServiceKey = currentServiceKey; - // 로드 시작 시간 기록 - _loadStartTime = DateTime.now(); - // 변경된 서비스 정보로 파비콘 로드 _loadFaviconWithCache(); } @@ -472,7 +430,7 @@ class _WebsiteIconState extends State return; } } catch (e) { - print('DuckDuckGo 파비콘 API 요청 실패: $e'); + // DuckDuckGo 파비콘 API 요청 실패 // 실패 시 백업 방법으로 진행 } @@ -501,7 +459,7 @@ class _WebsiteIconState extends State FaviconCache.cancelLoading(_serviceKey); } catch (e) { - print('웹용 파비콘 가져오기 오류: $e'); + // 웹용 파비콘 가져오기 오류 if (mounted) { setState(() { _isLoading = false; @@ -579,7 +537,7 @@ class _WebsiteIconState extends State FaviconCache.cancelLoading(_serviceKey); } catch (e) { - print('앱용 파비콘 다운로드 오류: $e'); + // 앱용 파비콘 다운로드 오류 if (mounted) { setState(() { _isLoading = false; @@ -610,7 +568,7 @@ class _WebsiteIconState extends State boxShadow: widget.isHovered ? [ BoxShadow( - color: _getColorFromName().withAlpha(76), // 약 0.3 알파값 + color: _getColorFromName().withValues(alpha: 0.3), // 약 0.3 알파값 blurRadius: 12, spreadRadius: 0, offset: const Offset(0, 4), @@ -643,7 +601,7 @@ class _WebsiteIconState extends State child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation( - AppColors.primaryColor.withAlpha(179)), + AppColors.primaryColor.withValues(alpha: 0.7)), ), ), ), @@ -684,7 +642,7 @@ class _WebsiteIconState extends State child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation( - AppColors.primaryColor.withAlpha(179)), + AppColors.primaryColor.withValues(alpha: 0.7)), ), ), ), @@ -726,7 +684,7 @@ class _WebsiteIconState extends State gradient: LinearGradient( colors: [ color, - color.withAlpha(204), // 약 0.8 알파값 + color.withValues(alpha: 0.8), // 약 0.8 알파값 ], begin: Alignment.topLeft, end: Alignment.bottomRight,