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 '../providers/locale_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'; import '../l10n/app_localizations.dart'; import '../utils/billing_date_util.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; // Display Names String? _displayName; String? get displayName => _displayName; // Form State final GlobalKey formKey = GlobalKey(); late String _billingCycle; late DateTime _nextBillingDate; String? _selectedCategoryId; String? _selectedPaymentCardId; 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 selectedPaymentCardId => _selectedPaymentCardId; 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 selectedPaymentCardId(String? value) { if (_selectedPaymentCardId != value) { _selectedPaymentCardId = 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; _selectedPaymentCardId = subscription.paymentCardId; _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(); // 로케일에 맞는 서비스명 로드 _loadDisplayName(); // 서비스명 변경 감지 리스너 serviceNameController.addListener(onServiceNameChanged); // 스크롤 리스너 scrollController.addListener(() { scrollOffset = scrollController.offset; }); } /// 로케일에 맞는 서비스명 로드 Future _loadDisplayName() async { final localeProvider = context.read(); final locale = localeProvider.locale.languageCode; final displayName = await SubscriptionUrlMatcher.getServiceDisplayName( serviceName: subscription.serviceName, locale: locale, ); _displayName = displayName; notifyListeners(); } /// 리소스 정리 @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 == 'music', 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 == 'collaborationOffice', 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 == 'aiService', orElse: () => categories.first, ); } // 교육 관련 키워드 else if (serviceName.contains('coursera') || serviceName.contains('udemy') || serviceName.contains('인프런') || serviceName.contains('패스트캠퍼스') || serviceName.contains('클래스101')) { matchedCategory = categories.firstWhere( (cat) => cat.name == 'programming', orElse: () => categories.first, ); } // 쇼핑 관련 키워드 else if (serviceName.contains('쿠팡') || serviceName.contains('coupang') || serviceName.contains('amazon') || serviceName.contains('네이버') || serviceName.contains('11번가')) { matchedCategory = categories.firstWhere( (cat) => cat.name == 'other', 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: AppLocalizations.of(context).requiredFieldsError, ); 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; } debugPrint('[DetailScreenController] 구독 업데이트 시작: ' '${subscription.serviceName} → ${serviceNameController.text}, ' '금액: $subscription.monthlyCost → $monthlyCost $_currency'); subscription.serviceName = serviceNameController.text; subscription.monthlyCost = monthlyCost; subscription.websiteUrl = websiteUrl; subscription.billingCycle = _billingCycle; // 선택일이 오늘(또는 과거)이면 결제 주기에 맞춰 다음 회차로 보정하여 저장 final originalDateOnly = DateTime( _nextBillingDate.year, _nextBillingDate.month, _nextBillingDate.day); var adjustedNext = BillingDateUtil.ensureFutureDate(originalDateOnly, _billingCycle); subscription.nextBillingDate = adjustedNext; subscription.categoryId = _selectedCategoryId; subscription.paymentCardId = _selectedPaymentCardId; 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; } debugPrint('[DetailScreenController] 업데이트 정보: ' '현재가격=${subscription.currentPrice}, ' '이벤트활성=${subscription.isEventActive}'); // 구독 업데이트 // 자동 보정이 발생했으면 안내 if (adjustedNext.isAfter(originalDateOnly)) { AppSnackBar.showInfo( context: context, message: '다음 결제 예정일로 저장됨', ); } await provider.updateSubscription(subscription); if (context.mounted) { AppSnackBar.showSuccess( context: context, message: AppLocalizations.of(context).subscriptionUpdated, ); // 변경 사항이 반영될 시간을 주기 위해 짧게 지연 후 결과 반환 await Future.delayed(const Duration(milliseconds: 100)); if (context.mounted) { Navigator.of(context).pop(true); } } } /// 구독 삭제 Future deleteSubscription() async { if (context.mounted) { // 로케일에 맞는 서비스명 가져오기 final localeProvider = Provider.of(context, listen: false); final locale = localeProvider.locale.languageCode; final displayName = await SubscriptionUrlMatcher.getServiceDisplayName( serviceName: subscription.serviceName, locale: locale, ); if (!context.mounted) return; // 삭제 확인 다이얼로그 표시 final shouldDelete = await DeleteConfirmationDialog.show( context: context, serviceName: displayName, ); if (!context.mounted) return; 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: AppLocalizations.of(context).subscriptionDeleted(displayName), 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: AppLocalizations.of(context).officialCancelPageNotFound, ); } } // 4. URL 열기 final Uri url = Uri.parse(cancellationUrl); if (!await launchUrl(url, mode: LaunchMode.externalApplication)) { if (context.mounted) { AppSnackBar.showError( context: context, message: AppLocalizations.of(context).cannotOpenWebsite, ); } } } catch (e) { if (kDebugMode) { // ignore: avoid_print 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: AppLocalizations.of(context).cannotOpenWebsite, ); } } } else { if (context.mounted) { AppSnackBar.showWarning( context: context, message: AppLocalizations.of(context).noWebsiteInfo, ); } } } } /// 카드 색상 가져오기 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]; } // getGradient 제거됨 (그라데이션 미사용) }