import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart' show kDebugMode; 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'; import '../widgets/dialogs/delete_confirmation_dialog.dart'; import '../widgets/common/snackbar/app_snackbar.dart'; /// DetailScreen의 비즈니스 로직을 관리하는 Controller class DetailScreenController extends ChangeNotifier { final BuildContext context; final SubscriptionModel subscription; // Text Controllers late TextEditingController serviceNameController; late TextEditingController monthlyCostController; late TextEditingController websiteUrlController; late TextEditingController eventPriceController; // Form State final GlobalKey formKey = GlobalKey(); late String _billingCycle; late DateTime _nextBillingDate; String? _selectedCategoryId; late String _currency; bool _isLoading = false; // Event State late bool _isEventActive; DateTime? _eventStartDate; DateTime? _eventEndDate; // Getters String get billingCycle => _billingCycle; DateTime get nextBillingDate => _nextBillingDate; String? get selectedCategoryId => _selectedCategoryId; String get currency => _currency; bool get isLoading => _isLoading; bool get isEventActive => _isEventActive; DateTime? get eventStartDate => _eventStartDate; DateTime? get eventEndDate => _eventEndDate; // Setters set billingCycle(String value) { if (_billingCycle != value) { _billingCycle = value; notifyListeners(); } } set nextBillingDate(DateTime value) { if (_nextBillingDate != value) { _nextBillingDate = value; notifyListeners(); } } set selectedCategoryId(String? value) { if (_selectedCategoryId != value) { _selectedCategoryId = value; notifyListeners(); } } set currency(String value) { if (_currency != value) { _currency = value; _updateMonthlyCostFormat(); notifyListeners(); } } set isLoading(bool value) { if (_isLoading != value) { _isLoading = value; notifyListeners(); } } set isEventActive(bool value) { if (_isEventActive != value) { _isEventActive = value; notifyListeners(); } } set eventStartDate(DateTime? value) { if (_eventStartDate != value) { _eventStartDate = value; notifyListeners(); } } set eventEndDate(DateTime? value) { if (_eventEndDate != value) { _eventEndDate = value; notifyListeners(); } } // 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; }); } /// 리소스 정리 @override 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(); super.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 == 'OTT 서비스', 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, ); } // AI 관련 키워드 else if (serviceName.contains('chatgpt') || serviceName.contains('claude') || serviceName.contains('gemini') || serviceName.contains('copilot') || serviceName.contains('midjourney')) { matchedCategory = categories.firstWhere( (cat) => cat.name == 'AI 서비스', 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 { // Form 검증 if (formKey.currentState != null && !formKey.currentState!.validate()) { AppSnackBar.showError( context: context, message: '필수 항목을 모두 입력해주세요', ); return; } 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) { AppSnackBar.showSuccess( context: context, message: '구독 정보가 업데이트되었습니다.', ); // 변경 사항이 반영될 시간을 주기 위해 짧게 지연 후 결과 반환 await Future.delayed(const Duration(milliseconds: 100)); if (context.mounted) { Navigator.of(context).pop(true); } } } /// 구독 삭제 Future deleteSubscription() async { if (context.mounted) { // 삭제 확인 다이얼로그 표시 final shouldDelete = await DeleteConfirmationDialog.show( context: context, serviceName: subscription.serviceName, ); if (!shouldDelete) return; // 사용자가 확인한 경우에만 삭제 진행 if (context.mounted) { final provider = Provider.of(context, listen: false); await provider.deleteSubscription(subscription.id); if (context.mounted) { AppSnackBar.showError( context: context, message: '구독이 삭제되었습니다.', icon: Icons.delete_forever_rounded, ); Navigator.of(context).pop(); } } } } /// 해지 페이지 열기 Future openCancellationPage() async { try { // 1. 현재 언어 설정 가져오기 final locale = Localizations.localeOf(context).languageCode; // 2. 해지 안내 URL 찾기 String? cancellationUrl = await SubscriptionUrlMatcher.findCancellationUrl( serviceName: subscription.serviceName, websiteUrl: subscription.websiteUrl, locale: locale == 'ko' ? 'kr' : 'en', ); // 3. 해지 안내 URL이 없으면 구글 검색 if (cancellationUrl == null) { final searchQuery = '${subscription.serviceName} ${locale == 'ko' ? '해지 방법' : 'cancel subscription'}'; cancellationUrl = 'https://www.google.com/search?q=${Uri.encodeComponent(searchQuery)}'; if (context.mounted) { AppSnackBar.showInfo( context: context, message: '공식 해지 페이지를 찾을 수 없어 구글 검색으로 연결합니다.', ); } } // 4. URL 열기 final Uri url = Uri.parse(cancellationUrl); if (!await launchUrl(url, mode: LaunchMode.externalApplication)) { if (context.mounted) { AppSnackBar.showError( context: context, message: '웹사이트를 열 수 없습니다.', ); } } } catch (e) { if (kDebugMode) { print('DetailScreenController: 해지 페이지 열기 실패 - $e'); } // 오류 발생시 일반 웹사이트로 폴백 if (subscription.websiteUrl != null && subscription.websiteUrl!.isNotEmpty) { final Uri url = Uri.parse(subscription.websiteUrl!); if (!await launchUrl(url, mode: LaunchMode.externalApplication)) { if (context.mounted) { AppSnackBar.showError( context: context, message: '웹사이트를 열 수 없습니다.', ); } } } else { if (context.mounted) { AppSnackBar.showWarning( context: context, message: '웹사이트 정보가 없습니다. 해지는 웹사이트에서 진행해주세요.', ); } } } } /// 카드 색상 가져오기 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, ); } }