import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode; import 'package:provider/provider.dart'; import 'package:intl/intl.dart'; import '../providers/subscription_provider.dart'; import '../providers/category_provider.dart'; import '../providers/payment_card_provider.dart'; import '../services/sms_service.dart'; import '../services/subscription_url_matcher.dart'; import '../widgets/common/snackbar/app_snackbar.dart'; import '../l10n/app_localizations.dart'; import '../utils/billing_date_util.dart'; import 'package:permission_handler/permission_handler.dart' as permission; /// 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 = 'monthly'; String currency = 'KRW'; DateTime? nextBillingDate; bool isLoading = false; String? selectedCategoryId; String? selectedPaymentCardId; // 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); // 웹사이트 URL 컨트롤러에 리스너 추가 websiteUrlController.addListener(onWebsiteUrlChanged); // 애니메이션 컨트롤러 초기화 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; }); // 언어별 기본 통화 설정 try { final lang = Localizations.localeOf(context).languageCode; switch (lang) { case 'ko': currency = 'KRW'; break; case 'ja': currency = 'JPY'; break; case 'zh': currency = 'CNY'; break; default: currency = 'USD'; } } catch (_) { // Localizations가 아직 준비되지 않은 경우 기본값 유지 } // 기본 결제수단 설정 try { final paymentCardProvider = Provider.of(context, listen: false); selectedPaymentCardId = paymentCardProvider.defaultCard?.id; } catch (_) {} // 애니메이션 시작 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(); } /// 웹사이트 URL 변경시 호출 void onWebsiteUrlChanged() async { final url = websiteUrlController.text.trim(); // URL이 비어있거나 너무 짧으면 무시 if (url.isEmpty || url.length < 5) return; // 이미 서비스명이 입력되어 있으면 자동 매칭하지 않음 if (serviceNameController.text.isNotEmpty) return; try { // URL로 서비스 정보 찾기 final serviceInfo = await SubscriptionUrlMatcher.findServiceByUrl(url); if (serviceInfo != null && context.mounted) { // 서비스명 자동 입력 serviceNameController.text = serviceInfo.serviceName; // 카테고리 자동 선택 final categoryProvider = Provider.of(context, listen: false); final categories = categoryProvider.categories; // 카테고리 ID로 매칭 final matchedCategory = categories.firstWhere( (cat) => cat.name == serviceInfo.categoryNameKr || cat.name == serviceInfo.categoryNameEn, orElse: () => categories.first, ); selectedCategoryId = matchedCategory.id; // 스낵바로 알림 if (context.mounted) { AppSnackBar.showSuccess( context: context, message: AppLocalizations.of(context) .serviceRecognized(serviceInfo.serviceName), ); } } } catch (e) { if (kDebugMode) { // ignore: avoid_print print('AddSubscriptionController: URL 자동 매칭 중 오류 - $e'); } } } /// 카테고리 자동 선택 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 == '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 == '생산성', 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 { final ctx = context; if (!await SMSService.hasSMSPermission()) { final granted = await SMSService.requestSMSPermission(); if (!ctx.mounted) return; if (!granted) { if (ctx.mounted) { // 영구 거부 여부 확인 후 설정 화면 안내 final status = await permission.Permission.sms.status; if (!ctx.mounted) return; if (status.isPermanentlyDenied) { await showDialog( context: ctx, builder: (_) => AlertDialog( title: Text(AppLocalizations.of(ctx).smsPermissionRequired), content: Text(AppLocalizations.of(ctx).permanentlyDeniedMessage), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(), child: Text(AppLocalizations.of(ctx).cancel), ), TextButton( onPressed: () async { await permission.openAppSettings(); if (ctx.mounted) Navigator.of(ctx).pop(); }, child: Text(AppLocalizations.of(ctx).openSettings), ), ], ), ); } else { AppSnackBar.showError( context: ctx, message: AppLocalizations.of(ctx).smsPermissionRequired, ); } } return; } } final subscriptions = await SMSService.scanSubscriptions(); if (!ctx.mounted) return; if (subscriptions.isEmpty) { if (ctx.mounted) { AppSnackBar.showWarning( context: ctx, message: AppLocalizations.of(ctx).noSubscriptionSmsFound, ); } return; } final subscription = subscriptions.first; // SMS에서 서비스 정보 추출 시도 ServiceInfo? serviceInfo; final smsContent = subscription['smsContent'] ?? ''; if (smsContent.isNotEmpty) { try { serviceInfo = await SubscriptionUrlMatcher.extractServiceFromSms(smsContent); } catch (e) { if (kDebugMode) { // ignore: avoid_print print('AddSubscriptionController: SMS 서비스 추출 실패 - $e'); } } } setState(() { // 서비스 정보가 있으면 우선 사용, 없으면 SMS에서 추출한 정보 사용 if (serviceInfo != null) { serviceNameController.text = serviceInfo.serviceName; websiteUrlController.text = serviceInfo.serviceUrl ?? ''; // 카테고리 자동 선택 final categoryProvider = Provider.of(context, listen: false); final categories = categoryProvider.categories; final matchedCategory = categories.firstWhere( (cat) => cat.name == serviceInfo!.categoryNameKr || cat.name == serviceInfo.categoryNameEn, orElse: () => categories.first, ); selectedCategoryId = matchedCategory.id; } else { // 기존 로직 사용 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 (serviceInfo == null && subscription['serviceName'] != null && subscription['serviceName'].isNotEmpty) { final suggestedUrl = SubscriptionUrlMatcher.suggestUrl(subscription['serviceName']); if (suggestedUrl != null && websiteUrlController.text.isEmpty) { websiteUrlController.text = suggestedUrl; } // 서비스명 기반으로 카테고리 자동 선택 autoSelectCategory(); } // 애니메이션 재생 animationController!.reset(); animationController!.forward(); }); } catch (e) { if (context.mounted) { AppSnackBar.showError( context: context, message: AppLocalizations.of(context) .smsScanErrorWithMessage(e.toString()), ); } } 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(',', '')); } // 선택일이 오늘(또는 과거)이면 결제 주기에 맞춰 다음 회차로 보정하여 저장 + 영업일 이월 final originalDateOnly = DateTime( nextBillingDate!.year, nextBillingDate!.month, nextBillingDate!.day, ); var adjustedNext = BillingDateUtil.ensureFutureDate(originalDateOnly, billingCycle); await Provider.of(context, listen: false) .addSubscription( serviceName: serviceNameController.text.trim(), monthlyCost: monthlyCost, billingCycle: billingCycle, nextBillingDate: adjustedNext, websiteUrl: websiteUrlController.text.trim(), categoryId: selectedCategoryId, paymentCardId: selectedPaymentCardId, currency: currency, isEventActive: isEventActive, eventStartDate: eventStartDate, eventEndDate: eventEndDate, eventPrice: eventPrice, ); // 자동 보정이 발생했으면 안내 if (adjustedNext.isAfter(originalDateOnly)) { if (context.mounted) { AppSnackBar.showInfo( context: context, message: '다음 결제 예정일로 저장됨', ); } } if (context.mounted) { Navigator.pop(context, true); // 성공 여부 반환 } } catch (e) { setState(() { isLoading = false; }); if (context.mounted) { AppSnackBar.showError( context: context, message: AppLocalizations.of(context).saveErrorWithMessage(e.toString()), ); } } } else { scrollController.animateTo( 0.0, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); } } }