diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 217a2ef..89f49a1 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -47,5 +47,8 @@ NSMessageUsageDescription 구독 결제 정보를 자동으로 추가하기 위해 SMS 접근이 필요합니다. + + GADApplicationIdentifier + ca-app-pub-6691216385521068~6638409932 diff --git a/lib/controllers/add_subscription_controller.dart b/lib/controllers/add_subscription_controller.dart index 5f925d5..6a44951 100644 --- a/lib/controllers/add_subscription_controller.dart +++ b/lib/controllers/add_subscription_controller.dart @@ -13,29 +13,29 @@ import '../l10n/app_localizations.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 = 'monthly'; 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(); @@ -44,20 +44,20 @@ class AddSubscriptionController { 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), @@ -71,19 +71,19 @@ class AddSubscriptionController { 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, @@ -91,7 +91,7 @@ class AddSubscriptionController { parent: animationController!, curve: Curves.easeIn, )); - + slideAnimation = Tween( begin: const Offset(0.0, 0.2), end: Offset.zero, @@ -99,12 +99,12 @@ class AddSubscriptionController { parent: animationController!, curve: Curves.easeOut, )); - + // 스크롤 리스너 scrollController.addListener(() { scrollOffset = scrollController.offset; }); - + // 애니메이션 시작 animationController!.forward(); } @@ -117,7 +117,7 @@ class AddSubscriptionController { nextBillingDateController.dispose(); websiteUrlController.dispose(); eventPriceController.dispose(); - + // Focus Nodes serviceNameFocus.dispose(); monthlyCostFocus.dispose(); @@ -126,10 +126,10 @@ class AddSubscriptionController { websiteUrlFocus.dispose(); categoryFocus.dispose(); currencyFocus.dispose(); - + // Animation animationController?.dispose(); - + // Scroll scrollController.dispose(); } @@ -138,43 +138,46 @@ class AddSubscriptionController { 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 categoryProvider = + Provider.of(context, listen: false); final categories = categoryProvider.categories; - + // 카테고리 ID로 매칭 final matchedCategory = categories.firstWhere( - (cat) => cat.name == serviceInfo.categoryNameKr || - cat.name == serviceInfo.categoryNameEn, + (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), + message: AppLocalizations.of(context) + .serviceRecognized(serviceInfo.serviceName), ); } } @@ -187,17 +190,18 @@ class AddSubscriptionController { /// 카테고리 자동 선택 void autoSelectCategory() { - final categoryProvider = Provider.of(context, listen: false); + 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') || + if (serviceName.contains('netflix') || + serviceName.contains('youtube') || serviceName.contains('disney') || serviceName.contains('왓챠') || serviceName.contains('티빙') || @@ -210,64 +214,64 @@ class AddSubscriptionController { ); } // 음악 관련 키워드 - else if (serviceName.contains('spotify') || - serviceName.contains('apple music') || - serviceName.contains('멜론') || - serviceName.contains('지니') || - serviceName.contains('플로') || - serviceName.contains('벅스')) { + 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')) { + 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('게임')) { + 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')) { + 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번가')) { + 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; } @@ -276,9 +280,9 @@ class AddSubscriptionController { /// SMS 스캔 Future scanSMS({required Function setState}) async { if (kIsWeb) return; - + setState(() => isLoading = true); - + try { if (!await SMSService.hasSMSPermission()) { final granted = await SMSService.requestSMSPermission(); @@ -292,7 +296,7 @@ class AddSubscriptionController { return; } } - + final subscriptions = await SMSService.scanSubscriptions(); if (subscriptions.isEmpty) { if (context.mounted) { @@ -303,48 +307,51 @@ class AddSubscriptionController { } return; } - + final subscription = subscriptions.first; - + // SMS에서 서비스 정보 추출 시도 ServiceInfo? serviceInfo; final smsContent = subscription['smsContent'] ?? ''; - + if (smsContent.isNotEmpty) { try { - serviceInfo = await SubscriptionUrlMatcher.extractServiceFromSms(smsContent); + serviceInfo = + await SubscriptionUrlMatcher.extractServiceFromSms(smsContent); } catch (e) { if (kDebugMode) { 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 categoryProvider = + Provider.of(context, listen: false); final categories = categoryProvider.categories; - + final matchedCategory = categories.firstWhere( - (cat) => cat.name == serviceInfo!.categoryNameKr || - cat.name == serviceInfo.categoryNameEn, + (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('.')) { @@ -353,41 +360,41 @@ class AddSubscriptionController { if (!numericValue.contains('.')) { numericValue = '$numericValue.00'; } - final double parsedValue = + final double parsedValue = double.tryParse(numericValue.replaceAll(',', '')) ?? 0.0; - monthlyCostController.text = + monthlyCostController.text = NumberFormat('#,##0.00').format(parsedValue); } else { currency = 'KRW'; - String numericValue = + String numericValue = costValue.replaceAll('₩', '').replaceAll(',', '').trim(); final int parsedValue = int.tryParse(numericValue) ?? 0; - monthlyCostController.text = + 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 && + if (serviceInfo == null && + subscription['serviceName'] != null && subscription['serviceName'].isNotEmpty) { - final suggestedUrl = + final suggestedUrl = SubscriptionUrlMatcher.suggestUrl(subscription['serviceName']); if (suggestedUrl != null && websiteUrlController.text.isEmpty) { websiteUrlController.text = suggestedUrl; } - + // 서비스명 기반으로 카테고리 자동 선택 autoSelectCategory(); } - + // 애니메이션 재생 animationController!.reset(); animationController!.forward(); @@ -396,7 +403,8 @@ class AddSubscriptionController { if (context.mounted) { AppSnackBar.showError( context: context, - message: AppLocalizations.of(context).smsScanErrorWithMessage(e.toString()), + message: AppLocalizations.of(context) + .smsScanErrorWithMessage(e.toString()), ); } } finally { @@ -412,20 +420,19 @@ class AddSubscriptionController { setState(() { isLoading = true; }); - + try { // 콤마 제거하고 숫자만 추출 - final monthlyCost = + final monthlyCost = double.parse(monthlyCostController.text.replaceAll(',', '')); - + // 이벤트 가격 파싱 double? eventPrice; if (isEventActive && eventPriceController.text.isNotEmpty) { - eventPrice = double.tryParse( - eventPriceController.text.replaceAll(',', '') - ); + eventPrice = + double.tryParse(eventPriceController.text.replaceAll(',', '')); } - + await Provider.of(context, listen: false) .addSubscription( serviceName: serviceNameController.text.trim(), @@ -440,7 +447,7 @@ class AddSubscriptionController { eventEndDate: eventEndDate, eventPrice: eventPrice, ); - + if (context.mounted) { Navigator.pop(context, true); // 성공 여부 반환 } @@ -448,11 +455,12 @@ class AddSubscriptionController { setState(() { isLoading = false; }); - + if (context.mounted) { AppSnackBar.showError( context: context, - message: AppLocalizations.of(context).saveErrorWithMessage(e.toString()), + message: + AppLocalizations.of(context).saveErrorWithMessage(e.toString()), ); } } @@ -464,4 +472,4 @@ class AddSubscriptionController { ); } } -} \ No newline at end of file +} diff --git a/lib/controllers/detail_screen_controller.dart b/lib/controllers/detail_screen_controller.dart index 0036f79..a2125f3 100644 --- a/lib/controllers/detail_screen_controller.dart +++ b/lib/controllers/detail_screen_controller.dart @@ -17,17 +17,17 @@ import '../l10n/app_localizations.dart'; 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; @@ -35,12 +35,12 @@ class DetailScreenController extends ChangeNotifier { 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; @@ -50,7 +50,7 @@ class DetailScreenController extends ChangeNotifier { bool get isEventActive => _isEventActive; DateTime? get eventStartDate => _eventStartDate; DateTime? get eventEndDate => _eventEndDate; - + // Setters set billingCycle(String value) { if (_billingCycle != value) { @@ -58,21 +58,21 @@ class DetailScreenController extends ChangeNotifier { 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; @@ -80,35 +80,35 @@ class DetailScreenController extends ChangeNotifier { 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(); @@ -117,7 +117,7 @@ class DetailScreenController extends ChangeNotifier { final websiteUrlFocus = FocusNode(); final categoryFocus = FocusNode(); final currencyFocus = FocusNode(); - + // UI State final ScrollController scrollController = ScrollController(); double scrollOffset = 0; @@ -125,7 +125,7 @@ class DetailScreenController extends ChangeNotifier { bool isDeleteHovered = false; bool isSaveHovered = false; bool isCancelHovered = false; - + // Animation Controller AnimationController? animationController; Animation? fadeAnimation; @@ -140,42 +140,45 @@ class DetailScreenController extends ChangeNotifier { /// 초기화 void initialize({required TickerProvider vsync}) { // Text Controllers 초기화 - serviceNameController = TextEditingController(text: subscription.serviceName); - monthlyCostController = TextEditingController(text: subscription.monthlyCost.toString()); - websiteUrlController = TextEditingController(text: subscription.websiteUrl ?? ''); + 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 = + 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, @@ -183,7 +186,7 @@ class DetailScreenController extends ChangeNotifier { parent: animationController!, curve: Curves.easeInOut, )); - + slideAnimation = Tween( begin: const Offset(0.0, 0.3), end: Offset.zero, @@ -191,7 +194,7 @@ class DetailScreenController extends ChangeNotifier { parent: animationController!, curve: Curves.easeOutCubic, )); - + rotateAnimation = Tween( begin: 0.0, end: 1.0, @@ -199,16 +202,16 @@ class DetailScreenController extends ChangeNotifier { parent: animationController!, curve: Curves.easeOutCubic, )); - + // 애니메이션 시작 animationController!.forward(); - + // 로케일에 맞는 서비스명 로드 _loadDisplayName(); - + // 서비스명 변경 감지 리스너 serviceNameController.addListener(onServiceNameChanged); - + // 스크롤 리스너 scrollController.addListener(() { scrollOffset = scrollController.offset; @@ -219,16 +222,16 @@ class DetailScreenController extends ChangeNotifier { 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() { @@ -237,7 +240,7 @@ class DetailScreenController extends ChangeNotifier { monthlyCostController.dispose(); websiteUrlController.dispose(); eventPriceController.dispose(); - + // Focus Nodes serviceNameFocus.dispose(); monthlyCostFocus.dispose(); @@ -246,13 +249,13 @@ class DetailScreenController extends ChangeNotifier { websiteUrlFocus.dispose(); categoryFocus.dispose(); currencyFocus.dispose(); - + // Animation animationController?.dispose(); - + // Scroll scrollController.dispose(); - + super.dispose(); } @@ -261,10 +264,12 @@ class DetailScreenController extends ChangeNotifier { if (_currency == 'KRW') { // 원화는 소수점 없이 표시 final intValue = subscription.monthlyCost.toInt(); - monthlyCostController.text = NumberFormat.decimalPattern().format(intValue); + monthlyCostController.text = + NumberFormat.decimalPattern().format(intValue); } else { // 달러는 소수점 2자리까지 표시 - monthlyCostController.text = NumberFormat('#,##0.00').format(subscription.monthlyCost); + monthlyCostController.text = + NumberFormat('#,##0.00').format(subscription.monthlyCost); } } @@ -275,17 +280,18 @@ class DetailScreenController extends ChangeNotifier { /// 카테고리 자동 선택 void autoSelectCategory() { - final categoryProvider = Provider.of(context, listen: false); + 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') || + if (serviceName.contains('netflix') || + serviceName.contains('youtube') || serviceName.contains('disney') || serviceName.contains('왓챠') || serviceName.contains('티빙') || @@ -298,64 +304,64 @@ class DetailScreenController extends ChangeNotifier { ); } // 음악 관련 키워드 - else if (serviceName.contains('spotify') || - serviceName.contains('apple music') || - serviceName.contains('멜론') || - serviceName.contains('지니') || - serviceName.contains('플로') || - serviceName.contains('벅스')) { + 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')) { + 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')) { + 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')) { + 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번가')) { + 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; } @@ -371,30 +377,32 @@ class DetailScreenController extends ChangeNotifier { ); return; } - + final provider = Provider.of(context, listen: false); - + // 웹사이트 URL이 비어있는 경우 자동 매칭 다시 시도 String? websiteUrl = websiteUrlController.text; if (websiteUrl.isEmpty) { - websiteUrl = SubscriptionUrlMatcher.suggestUrl(serviceNameController.text); + websiteUrl = + SubscriptionUrlMatcher.suggestUrl(serviceNameController.text); } - + // 구독 정보 업데이트 - + // 콤마 제거하고 숫자만 추출 double monthlyCost = 0.0; try { - monthlyCost = double.parse(monthlyCostController.text.replaceAll(',', '')); + 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; @@ -402,16 +410,16 @@ class DetailScreenController extends ChangeNotifier { 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 = + subscription.eventPrice = double.parse(eventPriceController.text.replaceAll(',', '')); } catch (e) { subscription.eventPrice = null; @@ -419,20 +427,20 @@ class DetailScreenController extends ChangeNotifier { } else { subscription.eventPrice = null; } - + debugPrint('[DetailScreenController] 업데이트 정보: ' '현재가격=${subscription.currentPrice}, ' '이벤트활성=${subscription.isEventActive}'); - + // 구독 업데이트 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) { @@ -445,30 +453,33 @@ class DetailScreenController extends ChangeNotifier { Future deleteSubscription() async { if (context.mounted) { // 로케일에 맞는 서비스명 가져오기 - final localeProvider = Provider.of(context, listen: false); + final localeProvider = + Provider.of(context, listen: false); final locale = localeProvider.locale.languageCode; final displayName = await SubscriptionUrlMatcher.getServiceDisplayName( serviceName: subscription.serviceName, locale: locale, ); - + // 삭제 확인 다이얼로그 표시 final shouldDelete = await DeleteConfirmationDialog.show( context: context, serviceName: displayName, ); - + if (!shouldDelete) return; - + // 사용자가 확인한 경우에만 삭제 진행 if (context.mounted) { - final provider = Provider.of(context, listen: false); + 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), + message: + AppLocalizations.of(context).subscriptionDeleted(displayName), icon: Icons.delete_forever_rounded, ); Navigator.of(context).pop(); @@ -482,19 +493,22 @@ class DetailScreenController extends ChangeNotifier { try { // 1. 현재 언어 설정 가져오기 final locale = Localizations.localeOf(context).languageCode; - + // 2. 해지 안내 URL 찾기 - String? cancellationUrl = await SubscriptionUrlMatcher.findCancellationUrl( + 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)}'; - + 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, @@ -502,7 +516,7 @@ class DetailScreenController extends ChangeNotifier { ); } } - + // 4. URL 열기 final Uri url = Uri.parse(cancellationUrl); if (!await launchUrl(url, mode: LaunchMode.externalApplication)) { @@ -517,9 +531,9 @@ class DetailScreenController extends ChangeNotifier { if (kDebugMode) { print('DetailScreenController: 해지 페이지 열기 실패 - $e'); } - + // 오류 발생시 일반 웹사이트로 폴백 - if (subscription.websiteUrl != null && + if (subscription.websiteUrl != null && subscription.websiteUrl!.isNotEmpty) { final Uri url = Uri.parse(subscription.websiteUrl!); if (!await launchUrl(url, mode: LaunchMode.externalApplication)) { @@ -554,7 +568,7 @@ class DetailScreenController extends ChangeNotifier { const Color(0xFF0EA5E9), // 하늘 const Color(0xFFEC4899), // 분홍 ]; - + return colors[hash % colors.length]; } @@ -569,4 +583,4 @@ class DetailScreenController extends ChangeNotifier { end: Alignment.bottomRight, ); } -} \ No newline at end of file +} diff --git a/lib/controllers/sms_scan_controller.dart b/lib/controllers/sms_scan_controller.dart index 7551cab..489819c 100644 --- a/lib/controllers/sms_scan_controller.dart +++ b/lib/controllers/sms_scan_controller.dart @@ -59,11 +59,13 @@ class SmsScanController extends ChangeNotifier { try { // SMS 스캔 실행 print('SMS 스캔 시작'); - final scannedSubscriptionModels = await _smsScanner.scanForSubscriptions(); + final scannedSubscriptionModels = + await _smsScanner.scanForSubscriptions(); print('스캔된 구독: ${scannedSubscriptionModels.length}개'); if (scannedSubscriptionModels.isNotEmpty) { - print('첫 번째 구독: ${scannedSubscriptionModels[0].serviceName}, 반복 횟수: ${scannedSubscriptionModels[0].repeatCount}'); + print( + '첫 번째 구독: ${scannedSubscriptionModels[0].serviceName}, 반복 횟수: ${scannedSubscriptionModels[0].repeatCount}'); } if (!context.mounted) return; @@ -77,14 +79,17 @@ class SmsScanController extends ChangeNotifier { } // SubscriptionModel을 Subscription으로 변환 - final scannedSubscriptions = _converter.convertModelsToSubscriptions(scannedSubscriptionModels); + final scannedSubscriptions = + _converter.convertModelsToSubscriptions(scannedSubscriptionModels); // 2회 이상 반복 결제된 구독만 필터링 - final repeatSubscriptions = _filter.filterByRepeatCount(scannedSubscriptions, 2); + final repeatSubscriptions = + _filter.filterByRepeatCount(scannedSubscriptions, 2); print('반복 결제된 구독: ${repeatSubscriptions.length}개'); if (repeatSubscriptions.isNotEmpty) { - print('첫 번째 반복 구독: ${repeatSubscriptions[0].serviceName}, 반복 횟수: ${repeatSubscriptions[0].repeatCount}'); + print( + '첫 번째 반복 구독: ${repeatSubscriptions[0].serviceName}, 반복 횟수: ${repeatSubscriptions[0].repeatCount}'); } if (repeatSubscriptions.isEmpty) { @@ -96,16 +101,19 @@ class SmsScanController extends ChangeNotifier { } // 구독 목록 가져오기 - final provider = Provider.of(context, listen: false); + final provider = + Provider.of(context, listen: false); final existingSubscriptions = provider.subscriptions; print('기존 구독: ${existingSubscriptions.length}개'); // 중복 구독 필터링 - final filteredSubscriptions = _filter.filterDuplicates(repeatSubscriptions, existingSubscriptions); + final filteredSubscriptions = + _filter.filterDuplicates(repeatSubscriptions, existingSubscriptions); print('중복 제거 후 구독: ${filteredSubscriptions.length}개'); if (filteredSubscriptions.isNotEmpty) { - print('첫 번째 필터링된 구독: ${filteredSubscriptions[0].serviceName}, 반복 횟수: ${filteredSubscriptions[0].repeatCount}'); + print( + '첫 번째 필터링된 구독: ${filteredSubscriptions[0].serviceName}, 반복 횟수: ${filteredSubscriptions[0].repeatCount}'); } // 중복 제거 후 신규 구독이 없는 경우 @@ -123,7 +131,8 @@ class SmsScanController extends ChangeNotifier { } catch (e) { print('SMS 스캔 중 오류 발생: $e'); if (context.mounted) { - _errorMessage = AppLocalizations.of(context).smsScanErrorWithMessage(e.toString()); + _errorMessage = + AppLocalizations.of(context).smsScanErrorWithMessage(e.toString()); _isLoading = false; notifyListeners(); } @@ -134,20 +143,25 @@ class SmsScanController extends ChangeNotifier { if (_currentIndex >= _scannedSubscriptions.length) return; final subscription = _scannedSubscriptions[_currentIndex]; - + try { - final provider = Provider.of(context, listen: false); - final categoryProvider = Provider.of(context, listen: false); - - final finalCategoryId = _selectedCategoryId ?? subscription.category ?? getDefaultCategoryId(categoryProvider); - + final provider = + Provider.of(context, listen: false); + final categoryProvider = + Provider.of(context, listen: false); + + final finalCategoryId = _selectedCategoryId ?? + subscription.category ?? + getDefaultCategoryId(categoryProvider); + // websiteUrl 처리 - final websiteUrl = websiteUrlController.text.trim().isNotEmpty - ? websiteUrlController.text.trim() + final websiteUrl = websiteUrlController.text.trim().isNotEmpty + ? websiteUrlController.text.trim() : subscription.websiteUrl; - print('구독 추가 시도: ${subscription.serviceName}, 카테고리: $finalCategoryId, URL: $websiteUrl'); - + print( + '구독 추가 시도: ${subscription.serviceName}, 카테고리: $finalCategoryId, URL: $websiteUrl'); + // addSubscription 호출 await provider.addSubscription( serviceName: subscription.serviceName, @@ -161,9 +175,9 @@ class SmsScanController extends ChangeNotifier { categoryId: finalCategoryId, currency: subscription.currency, ); - + print('구독 추가 성공: ${subscription.serviceName}'); - + moveToNextSubscription(context); } catch (e) { print('구독 추가 중 오류 발생: $e'); @@ -187,13 +201,14 @@ class SmsScanController extends ChangeNotifier { if (_currentIndex >= _scannedSubscriptions.length) { navigateToHome(context); } - + notifyListeners(); } void navigateToHome(BuildContext context) { // NavigationProvider를 사용하여 홈 화면으로 이동 - final navigationProvider = Provider.of(context, listen: false); + final navigationProvider = + Provider.of(context, listen: false); navigationProvider.updateCurrentIndex(0); } @@ -221,4 +236,4 @@ class SmsScanController extends ChangeNotifier { } } } -} \ No newline at end of file +} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index d22b157..49ae593 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -14,22 +14,21 @@ class AppLocalizations { // JSON 파일에서 번역 데이터 로드 Future load() async { - String jsonString = - await rootBundle.loadString('assets/data/text.json'); + String jsonString = await rootBundle.loadString('assets/data/text.json'); Map jsonMap = json.decode(jsonString); _localizedStrings = jsonMap[locale.languageCode]; } String get appTitle => _localizedStrings['appTitle'] ?? 'SubManager'; - String get appSubtitle => _localizedStrings['appSubtitle'] ?? 'Manage subscriptions easily'; + String get appSubtitle => + _localizedStrings['appSubtitle'] ?? 'Manage subscriptions easily'; String get subscriptionManagement => _localizedStrings['subscriptionManagement'] ?? 'Subscription Management'; String get addSubscription => _localizedStrings['addSubscription'] ?? 'Add Subscription'; String get subscriptionName => _localizedStrings['subscriptionName'] ?? 'Service Name'; - String get monthlyCost => - _localizedStrings['monthlyCost'] ?? 'Monthly Cost'; + String get monthlyCost => _localizedStrings['monthlyCost'] ?? 'Monthly Cost'; String get billingCycle => _localizedStrings['billingCycle'] ?? 'Billing Cycle'; String get nextBillingDate => @@ -55,12 +54,9 @@ class AppLocalizations { _localizedStrings['categoryManagement'] ?? 'Category Management'; String get categoryName => _localizedStrings['categoryName'] ?? 'Category Name'; - String get selectColor => - _localizedStrings['selectColor'] ?? 'Select Color'; - String get selectIcon => - _localizedStrings['selectIcon'] ?? 'Select Icon'; - String get addCategory => - _localizedStrings['addCategory'] ?? 'Add Category'; + String get selectColor => _localizedStrings['selectColor'] ?? 'Select Color'; + String get selectIcon => _localizedStrings['selectIcon'] ?? 'Select Icon'; + String get addCategory => _localizedStrings['addCategory'] ?? 'Add Category'; String get settings => _localizedStrings['settings'] ?? 'Settings'; String get darkMode => _localizedStrings['darkMode'] ?? 'Dark Mode'; String get language => _localizedStrings['language'] ?? 'Language'; @@ -71,13 +67,15 @@ class AppLocalizations { String get notificationPermission => _localizedStrings['notificationPermission'] ?? 'Notification Permission'; String get notificationPermissionDesc => - _localizedStrings['notificationPermissionDesc'] ?? 'Permission is required to receive notifications'; + _localizedStrings['notificationPermissionDesc'] ?? + 'Permission is required to receive notifications'; String get requestPermission => _localizedStrings['requestPermission'] ?? 'Request Permission'; String get paymentNotification => _localizedStrings['paymentNotification'] ?? 'Payment Due Notification'; String get paymentNotificationDesc => - _localizedStrings['paymentNotificationDesc'] ?? 'Receive notification on payment due date'; + _localizedStrings['paymentNotificationDesc'] ?? + 'Receive notification on payment due date'; String get notificationTiming => _localizedStrings['notificationTiming'] ?? 'Notification Timing'; String get notificationTime => @@ -85,11 +83,14 @@ class AppLocalizations { String get dailyReminder => _localizedStrings['dailyReminder'] ?? 'Daily Reminder'; String get dailyReminderEnabled => - _localizedStrings['dailyReminderEnabled'] ?? 'Receive daily notifications until payment date'; + _localizedStrings['dailyReminderEnabled'] ?? + 'Receive daily notifications until payment date'; String get dailyReminderDisabled => - _localizedStrings['dailyReminderDisabled'] ?? 'Receive notification @ day(s) before payment'; + _localizedStrings['dailyReminderDisabled'] ?? + 'Receive notification @ day(s) before payment'; String get notificationPermissionDenied => - _localizedStrings['notificationPermissionDenied'] ?? 'Notification permission denied'; + _localizedStrings['notificationPermissionDenied'] ?? + 'Notification permission denied'; // 앱 정보 String get appInfo => _localizedStrings['appInfo'] ?? 'App Info'; String get version => _localizedStrings['version'] ?? 'Version'; @@ -102,7 +103,8 @@ class AppLocalizations { String get lightTheme => _localizedStrings['lightTheme'] ?? 'Light'; String get darkTheme => _localizedStrings['darkTheme'] ?? 'Dark'; String get oledTheme => _localizedStrings['oledTheme'] ?? 'OLED Black'; - String get systemTheme => _localizedStrings['systemTheme'] ?? 'System Default'; + String get systemTheme => + _localizedStrings['systemTheme'] ?? 'System Default'; // 기타 메시지 String get subscriptionAdded => _localizedStrings['subscriptionAdded'] ?? 'Subscription added'; @@ -112,127 +114,198 @@ class AppLocalizations { String get japanese => _localizedStrings['japanese'] ?? '日本語'; String get chinese => _localizedStrings['chinese'] ?? '中文'; // 날짜 - String get oneDayBefore => _localizedStrings['oneDayBefore'] ?? '1 day before'; - String get twoDaysBefore => _localizedStrings['twoDaysBefore'] ?? '2 days before'; - String get threeDaysBefore => _localizedStrings['threeDaysBefore'] ?? '3 days before'; + String get oneDayBefore => + _localizedStrings['oneDayBefore'] ?? '1 day before'; + String get twoDaysBefore => + _localizedStrings['twoDaysBefore'] ?? '2 days before'; + String get threeDaysBefore => + _localizedStrings['threeDaysBefore'] ?? '3 days before'; // 추가 메시지 - String get requiredFieldsError => _localizedStrings['requiredFieldsError'] ?? 'Please fill in all required fields'; - String get subscriptionUpdated => _localizedStrings['subscriptionUpdated'] ?? 'Subscription information has been updated'; - String get officialCancelPageNotFound => _localizedStrings['officialCancelPageNotFound'] ?? 'Official cancellation page not found. Redirecting to Google search.'; - String get cannotOpenWebsite => _localizedStrings['cannotOpenWebsite'] ?? 'Cannot open website'; - String get noWebsiteInfo => _localizedStrings['noWebsiteInfo'] ?? 'No website information available. Please cancel through the website.'; + String get requiredFieldsError => + _localizedStrings['requiredFieldsError'] ?? + 'Please fill in all required fields'; + String get subscriptionUpdated => + _localizedStrings['subscriptionUpdated'] ?? + 'Subscription information has been updated'; + String get officialCancelPageNotFound => + _localizedStrings['officialCancelPageNotFound'] ?? + 'Official cancellation page not found. Redirecting to Google search.'; + String get cannotOpenWebsite => + _localizedStrings['cannotOpenWebsite'] ?? 'Cannot open website'; + String get noWebsiteInfo => + _localizedStrings['noWebsiteInfo'] ?? + 'No website information available. Please cancel through the website.'; String get editMode => _localizedStrings['editMode'] ?? 'Edit Mode'; - String get changesAppliedAfterSave => _localizedStrings['changesAppliedAfterSave'] ?? 'Changes will be applied after saving'; + String get changesAppliedAfterSave => + _localizedStrings['changesAppliedAfterSave'] ?? + 'Changes will be applied after saving'; String get saveChanges => _localizedStrings['saveChanges'] ?? 'Save Changes'; - String get monthlyExpense => _localizedStrings['monthlyExpense'] ?? 'Monthly Expense'; + String get monthlyExpense => + _localizedStrings['monthlyExpense'] ?? 'Monthly Expense'; String get websiteUrl => _localizedStrings['websiteUrl'] ?? 'Website URL'; - String get websiteUrlOptional => _localizedStrings['websiteUrlOptional'] ?? 'Website URL (Optional)'; + String get websiteUrlOptional => + _localizedStrings['websiteUrlOptional'] ?? 'Website URL (Optional)'; String get eventPrice => _localizedStrings['eventPrice'] ?? 'Event Price'; - String get eventPriceHint => _localizedStrings['eventPriceHint'] ?? 'Enter discounted price'; - String get eventPriceRequired => _localizedStrings['eventPriceRequired'] ?? 'Please enter event price'; - String get invalidPrice => _localizedStrings['invalidPrice'] ?? 'Please enter a valid price'; + String get eventPriceHint => + _localizedStrings['eventPriceHint'] ?? 'Enter discounted price'; + String get eventPriceRequired => + _localizedStrings['eventPriceRequired'] ?? 'Please enter event price'; + String get invalidPrice => + _localizedStrings['invalidPrice'] ?? 'Please enter a valid price'; String get smsScanLabel => _localizedStrings['smsScanLabel'] ?? 'SMS'; String get home => _localizedStrings['home'] ?? 'Home'; String get analysis => _localizedStrings['analysis'] ?? 'Analysis'; String get back => _localizedStrings['back'] ?? 'Back'; String get exitApp => _localizedStrings['exitApp'] ?? 'Exit App'; - String get exitAppConfirm => _localizedStrings['exitAppConfirm'] ?? 'Are you sure you want to exit SubManager?'; + String get exitAppConfirm => + _localizedStrings['exitAppConfirm'] ?? + 'Are you sure you want to exit SubManager?'; String get exit => _localizedStrings['exit'] ?? 'Exit'; - String get pageNotFound => _localizedStrings['pageNotFound'] ?? 'Page not found'; - String get serviceNameExample => _localizedStrings['serviceNameExample'] ?? 'e.g. Netflix, Spotify'; - String get urlExample => _localizedStrings['urlExample'] ?? 'https://example.com'; - String get appLockDesc => _localizedStrings['appLockDesc'] ?? 'App lock with biometric authentication'; - String get unlockWithBiometric => _localizedStrings['unlockWithBiometric'] ?? 'Unlock with biometric authentication'; - String get authenticationFailed => _localizedStrings['authenticationFailed'] ?? 'Authentication failed. Please try again.'; - String get smsPermissionRequired => _localizedStrings['smsPermissionRequired'] ?? 'SMS permission required'; - String get noSubscriptionSmsFound => _localizedStrings['noSubscriptionSmsFound'] ?? 'No subscription related SMS found'; - String get smsScanError => _localizedStrings['smsScanError'] ?? 'Error occurred during SMS scan'; - String get saveError => _localizedStrings['saveError'] ?? 'Error occurred while saving'; - String get newSubscriptionSmsNotFound => _localizedStrings['newSubscriptionSmsNotFound'] ?? 'No new subscription SMS found'; - String get subscriptionAddError => _localizedStrings['subscriptionAddError'] ?? 'Error adding subscription'; - String get allSubscriptionsProcessed => _localizedStrings['allSubscriptionsProcessed'] ?? 'All subscriptions have been processed.'; - String get websiteUrlExtracted => _localizedStrings['websiteUrlExtracted'] ?? 'Website URL (Auto-extracted)'; + String get pageNotFound => + _localizedStrings['pageNotFound'] ?? 'Page not found'; + String get serviceNameExample => + _localizedStrings['serviceNameExample'] ?? 'e.g. Netflix, Spotify'; + String get urlExample => + _localizedStrings['urlExample'] ?? 'https://example.com'; + String get appLockDesc => + _localizedStrings['appLockDesc'] ?? + 'App lock with biometric authentication'; + String get unlockWithBiometric => + _localizedStrings['unlockWithBiometric'] ?? + 'Unlock with biometric authentication'; + String get authenticationFailed => + _localizedStrings['authenticationFailed'] ?? + 'Authentication failed. Please try again.'; + String get smsPermissionRequired => + _localizedStrings['smsPermissionRequired'] ?? 'SMS permission required'; + String get noSubscriptionSmsFound => + _localizedStrings['noSubscriptionSmsFound'] ?? + 'No subscription related SMS found'; + String get smsScanError => + _localizedStrings['smsScanError'] ?? 'Error occurred during SMS scan'; + String get saveError => + _localizedStrings['saveError'] ?? 'Error occurred while saving'; + String get newSubscriptionSmsNotFound => + _localizedStrings['newSubscriptionSmsNotFound'] ?? + 'No new subscription SMS found'; + String get subscriptionAddError => + _localizedStrings['subscriptionAddError'] ?? 'Error adding subscription'; + String get allSubscriptionsProcessed => + _localizedStrings['allSubscriptionsProcessed'] ?? + 'All subscriptions have been processed.'; + String get websiteUrlExtracted => + _localizedStrings['websiteUrlExtracted'] ?? + 'Website URL (Auto-extracted)'; String get startDate => _localizedStrings['startDate'] ?? 'Start Date'; String get endDate => _localizedStrings['endDate'] ?? 'End Date'; - + // 새로 추가된 항목들 - String get monthlyTotalSubscriptionCost => _localizedStrings['monthlyTotalSubscriptionCost'] ?? 'Total Monthly Subscription Cost'; - String get todaysExchangeRate => _localizedStrings['todaysExchangeRate'] ?? 'Today\'s Exchange Rate'; + String get monthlyTotalSubscriptionCost => + _localizedStrings['monthlyTotalSubscriptionCost'] ?? + 'Total Monthly Subscription Cost'; + String get todaysExchangeRate => + _localizedStrings['todaysExchangeRate'] ?? 'Today\'s Exchange Rate'; String get won => _localizedStrings['won'] ?? 'KRW'; - String get estimatedAnnualCost => _localizedStrings['estimatedAnnualCost'] ?? 'Estimated Annual Cost'; - String get totalSubscriptionServices => _localizedStrings['totalSubscriptionServices'] ?? 'Total Subscription Services'; + String get estimatedAnnualCost => + _localizedStrings['estimatedAnnualCost'] ?? 'Estimated Annual Cost'; + String get totalSubscriptionServices => + _localizedStrings['totalSubscriptionServices'] ?? + 'Total Subscription Services'; String get services => _localizedStrings['services'] ?? 'services'; - String get eventDiscountActive => _localizedStrings['eventDiscountActive'] ?? 'Event Discount Active'; + String get eventDiscountActive => + _localizedStrings['eventDiscountActive'] ?? 'Event Discount Active'; String get saving => _localizedStrings['saving'] ?? 'Saving'; - String get paymentDueToday => _localizedStrings['paymentDueToday'] ?? 'Payment Due Today'; - String get paymentInfoNeeded => _localizedStrings['paymentInfoNeeded'] ?? 'Payment Info Needed'; + String get paymentDueToday => + _localizedStrings['paymentDueToday'] ?? 'Payment Due Today'; + String get paymentInfoNeeded => + _localizedStrings['paymentInfoNeeded'] ?? 'Payment Info Needed'; String get event => _localizedStrings['event'] ?? 'Event'; - + // 카테고리 getter들 String get categoryMusic => _localizedStrings['categoryMusic'] ?? 'Music'; - String get categoryOttVideo => _localizedStrings['categoryOttVideo'] ?? 'OTT(Video)'; - String get categoryStorageCloud => _localizedStrings['categoryStorageCloud'] ?? 'Storage/Cloud'; - String get categoryTelecomInternetTv => _localizedStrings['categoryTelecomInternetTv'] ?? 'Telecom · Internet · TV'; - String get categoryLifestyle => _localizedStrings['categoryLifestyle'] ?? 'Lifestyle'; - String get categoryShoppingEcommerce => _localizedStrings['categoryShoppingEcommerce'] ?? 'Shopping/E-commerce'; - String get categoryProgramming => _localizedStrings['categoryProgramming'] ?? 'Programming'; - String get categoryCollaborationOffice => _localizedStrings['categoryCollaborationOffice'] ?? 'Collaboration/Office'; - String get categoryAiService => _localizedStrings['categoryAiService'] ?? 'AI Service'; + String get categoryOttVideo => + _localizedStrings['categoryOttVideo'] ?? 'OTT(Video)'; + String get categoryStorageCloud => + _localizedStrings['categoryStorageCloud'] ?? 'Storage/Cloud'; + String get categoryTelecomInternetTv => + _localizedStrings['categoryTelecomInternetTv'] ?? + 'Telecom · Internet · TV'; + String get categoryLifestyle => + _localizedStrings['categoryLifestyle'] ?? 'Lifestyle'; + String get categoryShoppingEcommerce => + _localizedStrings['categoryShoppingEcommerce'] ?? 'Shopping/E-commerce'; + String get categoryProgramming => + _localizedStrings['categoryProgramming'] ?? 'Programming'; + String get categoryCollaborationOffice => + _localizedStrings['categoryCollaborationOffice'] ?? + 'Collaboration/Office'; + String get categoryAiService => + _localizedStrings['categoryAiService'] ?? 'AI Service'; String get categoryOther => _localizedStrings['categoryOther'] ?? 'Other'; - + // 동적 메시지 생성 메서드 String daysBefore(int days) { return '$days${_localizedStrings['daysBefore'] ?? 'day(s) before'}'; } - + String dailyReminderDisabledWithDays(int days) { - final template = _localizedStrings['dailyReminderDisabled'] ?? 'Receive notification @ day(s) before payment'; + final template = _localizedStrings['dailyReminderDisabled'] ?? + 'Receive notification @ day(s) before payment'; return template.replaceAll('@', days.toString()); } - + String subscriptionAddedWithName(String serviceName) { - final template = _localizedStrings['subscriptionAddedTemplate'] ?? '@ 구독이 추가되었습니다.'; + final template = + _localizedStrings['subscriptionAddedTemplate'] ?? '@ 구독이 추가되었습니다.'; return template.replaceAll('@', serviceName); } - + String subscriptionDeleted(String serviceName) { - final template = _localizedStrings['subscriptionDeleted'] ?? '@ subscription has been deleted'; + final template = _localizedStrings['subscriptionDeleted'] ?? + '@ subscription has been deleted'; return template.replaceAll('@', serviceName); } - + String totalExpenseCopied(String amount) { - final template = _localizedStrings['totalExpenseCopied'] ?? 'Total expense copied: @'; + final template = + _localizedStrings['totalExpenseCopied'] ?? 'Total expense copied: @'; return template.replaceAll('@', amount); } - + String serviceRecognized(String serviceName) { - final template = _localizedStrings['serviceRecognized'] ?? '@ service has been recognized automatically.'; + final template = _localizedStrings['serviceRecognized'] ?? + '@ service has been recognized automatically.'; return template.replaceAll('@', serviceName); } - + String smsScanErrorWithMessage(String error) { - final template = _localizedStrings['smsScanError'] ?? 'Error occurred during SMS scan: @'; + final template = _localizedStrings['smsScanError'] ?? + 'Error occurred during SMS scan: @'; return template.replaceAll('@', error); } - + String saveErrorWithMessage(String error) { - final template = _localizedStrings['saveError'] ?? 'Error occurred while saving: @'; + final template = + _localizedStrings['saveError'] ?? 'Error occurred while saving: @'; return template.replaceAll('@', error); } - + String subscriptionAddErrorWithMessage(String error) { - final template = _localizedStrings['subscriptionAddError'] ?? 'Error adding subscription: @'; + final template = _localizedStrings['subscriptionAddError'] ?? + 'Error adding subscription: @'; return template.replaceAll('@', error); } - + String subscriptionSkipped(String serviceName) { - final template = _localizedStrings['subscriptionSkipped'] ?? '@ subscription skipped.'; + final template = + _localizedStrings['subscriptionSkipped'] ?? '@ subscription skipped.'; return template.replaceAll('@', serviceName); } - + // 홈화면 관련 - String get mySubscriptions => _localizedStrings['mySubscriptions'] ?? 'My Subscriptions'; - + String get mySubscriptions => + _localizedStrings['mySubscriptions'] ?? 'My Subscriptions'; + String subscriptionCount(int count) { if (locale.languageCode == 'ko') { return '${count}개'; @@ -244,58 +317,99 @@ class AppLocalizations { return count.toString(); } } - + // 분석화면 관련 - String get monthlyExpenseTitle => _localizedStrings['monthlyExpenseTitle'] ?? 'Monthly Expense Status'; - String get recentSixMonthsTrend => _localizedStrings['recentSixMonthsTrend'] ?? 'Recent 6 months trend'; - String get monthlySubscriptionExpense => _localizedStrings['monthlySubscriptionExpense'] ?? 'Monthly subscription expense'; - String get subscriptionServiceRatio => _localizedStrings['subscriptionServiceRatio'] ?? 'Subscription Service Ratio'; - String get monthlyExpenseBasis => _localizedStrings['monthlyExpenseBasis'] ?? 'Based on monthly expense'; - String get noSubscriptionServices => _localizedStrings['noSubscriptionServices'] ?? 'No subscription services'; - String get totalExpenseSummary => _localizedStrings['totalExpenseSummary'] ?? 'Total Expense Summary'; - String get monthlyTotalAmount => _localizedStrings['monthlyTotalAmount'] ?? 'Monthly Total Amount'; - String get totalExpense => _localizedStrings['totalExpense'] ?? 'Total Expense'; - String get totalServices => _localizedStrings['totalServices'] ?? 'Total Services'; + String get monthlyExpenseTitle => + _localizedStrings['monthlyExpenseTitle'] ?? 'Monthly Expense Status'; + String get recentSixMonthsTrend => + _localizedStrings['recentSixMonthsTrend'] ?? 'Recent 6 months trend'; + String get monthlySubscriptionExpense => + _localizedStrings['monthlySubscriptionExpense'] ?? + 'Monthly subscription expense'; + String get subscriptionServiceRatio => + _localizedStrings['subscriptionServiceRatio'] ?? + 'Subscription Service Ratio'; + String get monthlyExpenseBasis => + _localizedStrings['monthlyExpenseBasis'] ?? 'Based on monthly expense'; + String get noSubscriptionServices => + _localizedStrings['noSubscriptionServices'] ?? 'No subscription services'; + String get totalExpenseSummary => + _localizedStrings['totalExpenseSummary'] ?? 'Total Expense Summary'; + String get monthlyTotalAmount => + _localizedStrings['monthlyTotalAmount'] ?? 'Monthly Total Amount'; + String get totalExpense => + _localizedStrings['totalExpense'] ?? 'Total Expense'; + String get totalServices => + _localizedStrings['totalServices'] ?? 'Total Services'; String get servicesUnit => _localizedStrings['servicesUnit'] ?? 'services'; String get averageCost => _localizedStrings['averageCost'] ?? 'Average Cost'; - String get eventDiscountStatus => _localizedStrings['eventDiscountStatus'] ?? 'Event Discount Status'; - String get inProgressUnit => _localizedStrings['inProgressUnit'] ?? 'in progress'; - String get monthlySavingAmount => _localizedStrings['monthlySavingAmount'] ?? 'Monthly Saving Amount'; - String get eventsInProgress => _localizedStrings['eventsInProgress'] ?? 'Events in Progress'; - String get discountPercent => _localizedStrings['discountPercent'] ?? '% discount'; + String get eventDiscountStatus => + _localizedStrings['eventDiscountStatus'] ?? 'Event Discount Status'; + String get inProgressUnit => + _localizedStrings['inProgressUnit'] ?? 'in progress'; + String get monthlySavingAmount => + _localizedStrings['monthlySavingAmount'] ?? 'Monthly Saving Amount'; + String get eventsInProgress => + _localizedStrings['eventsInProgress'] ?? 'Events in Progress'; + String get discountPercent => + _localizedStrings['discountPercent'] ?? '% discount'; String get currencyWon => _localizedStrings['currencyWon'] ?? 'KRW'; - + // SMS 스캔 관련 - String get scanningMessages => _localizedStrings['scanningMessages'] ?? 'Scanning SMS messages...'; - String get findingSubscriptions => _localizedStrings['findingSubscriptions'] ?? 'Finding subscription services'; - String get subscriptionNotFound => _localizedStrings['subscriptionNotFound'] ?? 'Subscription information not found.'; - String get repeatSubscriptionNotFound => _localizedStrings['repeatSubscriptionNotFound'] ?? 'No repeated subscription information found.'; - String get newSubscriptionNotFound => _localizedStrings['newSubscriptionNotFound'] ?? 'No new subscription SMS found'; - String get findRepeatSubscriptions => _localizedStrings['findRepeatSubscriptions'] ?? 'Find subscriptions paid 2+ times'; - String get scanTextMessages => _localizedStrings['scanTextMessages'] ?? 'Scan text messages to automatically find repeatedly paid subscriptions. Service names and amounts can be extracted for easy subscription addition.'; - String get startScanning => _localizedStrings['startScanning'] ?? 'Start Scanning'; - String get foundSubscription => _localizedStrings['foundSubscription'] ?? 'Found subscription'; + String get scanningMessages => + _localizedStrings['scanningMessages'] ?? 'Scanning SMS messages...'; + String get findingSubscriptions => + _localizedStrings['findingSubscriptions'] ?? + 'Finding subscription services'; + String get subscriptionNotFound => + _localizedStrings['subscriptionNotFound'] ?? + 'Subscription information not found.'; + String get repeatSubscriptionNotFound => + _localizedStrings['repeatSubscriptionNotFound'] ?? + 'No repeated subscription information found.'; + String get newSubscriptionNotFound => + _localizedStrings['newSubscriptionNotFound'] ?? + 'No new subscription SMS found'; + String get findRepeatSubscriptions => + _localizedStrings['findRepeatSubscriptions'] ?? + 'Find subscriptions paid 2+ times'; + String get scanTextMessages => + _localizedStrings['scanTextMessages'] ?? + 'Scan text messages to automatically find repeatedly paid subscriptions. Service names and amounts can be extracted for easy subscription addition.'; + String get startScanning => + _localizedStrings['startScanning'] ?? 'Start Scanning'; + String get foundSubscription => + _localizedStrings['foundSubscription'] ?? 'Found subscription'; String get serviceName => _localizedStrings['serviceName'] ?? 'Service Name'; - String get nextBillingDateLabel => _localizedStrings['nextBillingDateLabel'] ?? 'Next Billing Date'; + String get nextBillingDateLabel => + _localizedStrings['nextBillingDateLabel'] ?? 'Next Billing Date'; String get category => _localizedStrings['category'] ?? 'Category'; - String get websiteUrlAuto => _localizedStrings['websiteUrlAuto'] ?? 'Website URL (Auto-extracted)'; - String get websiteUrlHint => _localizedStrings['websiteUrlHint'] ?? 'Edit website URL or leave empty'; + String get websiteUrlAuto => + _localizedStrings['websiteUrlAuto'] ?? 'Website URL (Auto-extracted)'; + String get websiteUrlHint => + _localizedStrings['websiteUrlHint'] ?? 'Edit website URL or leave empty'; String get skip => _localizedStrings['skip'] ?? 'Skip'; String get add => _localizedStrings['add'] ?? 'Add'; - String get nextBillingDateRequired => _localizedStrings['nextBillingDateRequired'] ?? 'Next billing date verification required'; - + String get nextBillingDateRequired => + _localizedStrings['nextBillingDateRequired'] ?? + 'Next billing date verification required'; + String nextBillingDateEstimated(String date, int days) { - final template = _localizedStrings['nextBillingDateEstimated'] ?? 'Next estimated billing date: @ (# days later)'; + final template = _localizedStrings['nextBillingDateEstimated'] ?? + 'Next estimated billing date: @ (# days later)'; return template.replaceAll('@', date).replaceAll('#', days.toString()); } - + String nextBillingDateInfo(String date, int days) { - final template = _localizedStrings['nextBillingDateInfo'] ?? 'Next billing date: @ (# days later)'; + final template = _localizedStrings['nextBillingDateInfo'] ?? + 'Next billing date: @ (# days later)'; return template.replaceAll('@', date).replaceAll('#', days.toString()); } - - String get nextBillingDatePastRequired => _localizedStrings['nextBillingDatePastRequired'] ?? 'Next billing date verification required (past date)'; - + + String get nextBillingDatePastRequired => + _localizedStrings['nextBillingDatePastRequired'] ?? + 'Next billing date verification required (past date)'; + String formatDate(DateTime date) { if (locale.languageCode == 'ko') { return '${date.year}년 ${date.month}월 ${date.day}일'; @@ -304,16 +418,30 @@ class AppLocalizations { } else if (locale.languageCode == 'zh') { return '${date.year}年${date.month}月${date.day}日'; } else { - final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + final months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec' + ]; return '${months[date.month - 1]} ${date.day}, ${date.year}'; } } - + String repeatCountDetected(int count) { - final template = _localizedStrings['repeatCountDetected'] ?? '@ payment(s) detected'; + final template = + _localizedStrings['repeatCountDetected'] ?? '@ payment(s) detected'; return template.replaceAll('@', count.toString()); } - + String servicesInProgress(int count) { if (locale.languageCode == 'ko') { return '${count}개 진행중'; @@ -325,92 +453,130 @@ class AppLocalizations { return '$count in progress'; } } - + // 새로 추가된 동적 메서드들 String paymentDueInDays(int days) { - final template = _localizedStrings['paymentDueInDays'] ?? 'Payment due in @ days'; + final template = + _localizedStrings['paymentDueInDays'] ?? 'Payment due in @ days'; return template.replaceAll('@', days.toString()); } - + String daysRemaining(int days) { final template = _localizedStrings['daysRemaining'] ?? '@ days remaining'; return template.replaceAll('@', days.toString()); } - + String exchangeRateFormat(String rate) { - final template = _localizedStrings['exchangeRateFormat'] ?? 'Today\'s rate: @'; + final template = + _localizedStrings['exchangeRateFormat'] ?? 'Today\'s rate: @'; return template.replaceAll('@', rate); } - + // 결제 주기 결제 메시지 - String get billingCyclePayment => _localizedStrings['billingCyclePayment'] ?? '@ Payment'; - + String get billingCyclePayment => + _localizedStrings['billingCyclePayment'] ?? '@ Payment'; + // 할인 금액 표시 getter들 - String get discountAmountWon => _localizedStrings['discountAmountWon'] ?? 'Save ₩@'; - String get discountAmountDollar => _localizedStrings['discountAmountDollar'] ?? 'Save \$@'; - String get discountAmountYen => _localizedStrings['discountAmountYen'] ?? 'Save ¥@'; - String get discountAmountYuan => _localizedStrings['discountAmountYuan'] ?? 'Save ¥@'; - + String get discountAmountWon => + _localizedStrings['discountAmountWon'] ?? 'Save ₩@'; + String get discountAmountDollar => + _localizedStrings['discountAmountDollar'] ?? 'Save \$@'; + String get discountAmountYen => + _localizedStrings['discountAmountYen'] ?? 'Save ¥@'; + String get discountAmountYuan => + _localizedStrings['discountAmountYuan'] ?? 'Save ¥@'; + // 결제 주기 관련 getter String get monthly => _localizedStrings['monthly'] ?? 'Monthly'; String get weekly => _localizedStrings['weekly'] ?? 'Weekly'; String get yearly => _localizedStrings['yearly'] ?? 'Yearly'; - String get billingCycleMonthly => _localizedStrings['billingCycleMonthly'] ?? 'Monthly'; - String get billingCycleQuarterly => _localizedStrings['billingCycleQuarterly'] ?? 'Quarterly'; - String get billingCycleHalfYearly => _localizedStrings['billingCycleHalfYearly'] ?? 'Half-Yearly'; - String get billingCycleYearly => _localizedStrings['billingCycleYearly'] ?? 'Yearly'; - + String get billingCycleMonthly => + _localizedStrings['billingCycleMonthly'] ?? 'Monthly'; + String get billingCycleQuarterly => + _localizedStrings['billingCycleQuarterly'] ?? 'Quarterly'; + String get billingCycleHalfYearly => + _localizedStrings['billingCycleHalfYearly'] ?? 'Half-Yearly'; + String get billingCycleYearly => + _localizedStrings['billingCycleYearly'] ?? 'Yearly'; + // 색상 관련 getter String get colorBlue => _localizedStrings['colorBlue'] ?? 'Blue'; String get colorGreen => _localizedStrings['colorGreen'] ?? 'Green'; String get colorOrange => _localizedStrings['colorOrange'] ?? 'Orange'; String get colorRed => _localizedStrings['colorRed'] ?? 'Red'; String get colorPurple => _localizedStrings['colorPurple'] ?? 'Purple'; - + // 날짜 형식 관련 getter - String get dateFormatFull => _localizedStrings['dateFormatFull'] ?? 'MMM dd, yyyy'; + String get dateFormatFull => + _localizedStrings['dateFormatFull'] ?? 'MMM dd, yyyy'; String get dateFormatShort => _localizedStrings['dateFormatShort'] ?? 'MM/dd'; - + // USD 환율 표시 형식 - String get exchangeRateDisplay => _localizedStrings['exchangeRateDisplay'] ?? '\$1 = @'; - + String get exchangeRateDisplay => + _localizedStrings['exchangeRateDisplay'] ?? '\$1 = @'; + // 라벨 및 힌트 텍스트 - String get labelServiceName => _localizedStrings['labelServiceName'] ?? 'Service Name'; - String get hintServiceName => _localizedStrings['hintServiceName'] ?? 'e.g. Netflix, Spotify'; - String get labelMonthlyExpense => _localizedStrings['labelMonthlyExpense'] ?? 'Monthly Expense'; - String get labelNextBillingDate => _localizedStrings['labelNextBillingDate'] ?? 'Next Billing Date'; - String get labelWebsiteUrl => _localizedStrings['labelWebsiteUrl'] ?? 'Website URL (Optional)'; - String get hintWebsiteUrl => _localizedStrings['hintWebsiteUrl'] ?? 'https://example.com'; - String get labelEventPrice => _localizedStrings['labelEventPrice'] ?? 'Event Price'; - String get hintEventPrice => _localizedStrings['hintEventPrice'] ?? 'Enter discounted price'; + String get labelServiceName => + _localizedStrings['labelServiceName'] ?? 'Service Name'; + String get hintServiceName => + _localizedStrings['hintServiceName'] ?? 'e.g. Netflix, Spotify'; + String get labelMonthlyExpense => + _localizedStrings['labelMonthlyExpense'] ?? 'Monthly Expense'; + String get labelNextBillingDate => + _localizedStrings['labelNextBillingDate'] ?? 'Next Billing Date'; + String get labelWebsiteUrl => + _localizedStrings['labelWebsiteUrl'] ?? 'Website URL (Optional)'; + String get hintWebsiteUrl => + _localizedStrings['hintWebsiteUrl'] ?? 'https://example.com'; + String get labelEventPrice => + _localizedStrings['labelEventPrice'] ?? 'Event Price'; + String get hintEventPrice => + _localizedStrings['hintEventPrice'] ?? 'Enter discounted price'; String get labelCategory => _localizedStrings['labelCategory'] ?? 'Category'; - + // 기타 번역 - String get subscription => _localizedStrings['subscription'] ?? 'Subscription'; + String get subscription => + _localizedStrings['subscription'] ?? 'Subscription'; String get movie => _localizedStrings['movie'] ?? 'Movie'; - String get music => _localizedStrings['music'] ?? 'Music'; + String get music => _localizedStrings['music'] ?? 'Music'; String get exercise => _localizedStrings['exercise'] ?? 'Exercise'; String get shopping => _localizedStrings['shopping'] ?? 'Shopping'; String get currency => _localizedStrings['currency'] ?? 'Currency'; - String get websiteInfo => _localizedStrings['websiteInfo'] ?? 'Website Information'; - String get cancelGuide => _localizedStrings['cancelGuide'] ?? 'Cancellation Guide'; - String get cancelServiceGuide => _localizedStrings['cancelServiceGuide'] ?? 'To cancel this service, please go to the cancellation page through the link below.'; - String get goToCancelPage => _localizedStrings['goToCancelPage'] ?? 'Go to Cancellation Page'; - String get urlAutoMatchInfo => _localizedStrings['urlAutoMatchInfo'] ?? 'If URL is empty, it will be automatically matched based on the service name'; + String get websiteInfo => + _localizedStrings['websiteInfo'] ?? 'Website Information'; + String get cancelGuide => + _localizedStrings['cancelGuide'] ?? 'Cancellation Guide'; + String get cancelServiceGuide => + _localizedStrings['cancelServiceGuide'] ?? + 'To cancel this service, please go to the cancellation page through the link below.'; + String get goToCancelPage => + _localizedStrings['goToCancelPage'] ?? 'Go to Cancellation Page'; + String get urlAutoMatchInfo => + _localizedStrings['urlAutoMatchInfo'] ?? + 'If URL is empty, it will be automatically matched based on the service name'; String get dateSelect => _localizedStrings['dateSelect'] ?? 'Select'; - + // 새로 추가된 getter들 - String get serviceInfo => _localizedStrings['serviceInfo'] ?? 'Service Information'; - String get newSubscriptionAdd => _localizedStrings['newSubscriptionAdd'] ?? 'Add New Subscription'; - String get enterServiceInfo => _localizedStrings['enterServiceInfo'] ?? 'Enter service information'; - String get addSubscriptionButton => _localizedStrings['addSubscriptionButton'] ?? 'Add Subscription'; - String get serviceNameRequired => _localizedStrings['serviceNameRequired'] ?? 'Please enter service name'; - String get amountRequired => _localizedStrings['amountRequired'] ?? 'Please enter amount'; - String get subscriptionDetail => _localizedStrings['subscriptionDetail'] ?? 'Subscription Detail'; + String get serviceInfo => + _localizedStrings['serviceInfo'] ?? 'Service Information'; + String get newSubscriptionAdd => + _localizedStrings['newSubscriptionAdd'] ?? 'Add New Subscription'; + String get enterServiceInfo => + _localizedStrings['enterServiceInfo'] ?? 'Enter service information'; + String get addSubscriptionButton => + _localizedStrings['addSubscriptionButton'] ?? 'Add Subscription'; + String get serviceNameRequired => + _localizedStrings['serviceNameRequired'] ?? 'Please enter service name'; + String get amountRequired => + _localizedStrings['amountRequired'] ?? 'Please enter amount'; + String get subscriptionDetail => + _localizedStrings['subscriptionDetail'] ?? 'Subscription Detail'; String get enterAmount => _localizedStrings['enterAmount'] ?? 'Enter amount'; - String get invalidAmount => _localizedStrings['invalidAmount'] ?? 'Please enter a valid amount'; - String get featureComingSoon => _localizedStrings['featureComingSoon'] ?? 'This feature is coming soon'; - + String get invalidAmount => + _localizedStrings['invalidAmount'] ?? 'Please enter a valid amount'; + String get featureComingSoon => + _localizedStrings['featureComingSoon'] ?? 'This feature is coming soon'; + // 결제 주기를 키값으로 변환하여 번역된 이름 반환 String getBillingCycleName(String billingCycleKey) { switch (billingCycleKey) { @@ -433,7 +599,7 @@ class AppLocalizations { return billingCycleKey; // 매칭되지 않으면 원본 반환 } } - + // 카테고리 이름을 키로 변환하여 번역된 이름 반환 String getCategoryName(String categoryKey) { switch (categoryKey) { @@ -467,7 +633,8 @@ class AppLocalizationsDelegate extends LocalizationsDelegate { const AppLocalizationsDelegate(); @override - bool isSupported(Locale locale) => ['en', 'ko', 'ja', 'zh'].contains(locale.languageCode); + bool isSupported(Locale locale) => + ['en', 'ko', 'ja', 'zh'].contains(locale.languageCode); @override Future load(Locale locale) async { diff --git a/lib/main.dart b/lib/main.dart index 590296a..9303428 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -26,7 +26,7 @@ import 'utils/performance_optimizer.dart'; import 'navigator_key.dart'; // AdMob 활성화 플래그 (개발 중 false, 프로덕션 시 true로 변경) -const bool enableAdMob = false; +const bool enableAdMob = true; Future main() async { WidgetsFlutterBinding.ensureInitialized(); diff --git a/lib/models/subscription_model.dart b/lib/models/subscription_model.dart index 594c7ce..c5ec3d1 100644 --- a/lib/models/subscription_model.dart +++ b/lib/models/subscription_model.dart @@ -75,7 +75,7 @@ class SubscriptionModel extends HiveObject { if (!isEventActive || eventStartDate == null || eventEndDate == null) { return false; } - + final now = DateTime.now(); return now.isAfter(eventStartDate!) && now.isBefore(eventEndDate!); } @@ -98,7 +98,7 @@ class SubscriptionModel extends HiveObject { // 원래 가격 (이벤트와 관계없이 항상 정상 가격) double get originalPrice => monthlyCost; - + // 결제 주기를 영어 키값으로 정규화 static String normalizeBillingCycle(String cycle) { switch (cycle.toLowerCase()) { @@ -121,7 +121,7 @@ class SubscriptionModel extends HiveObject { return 'monthly'; // 기본값은 monthly } } - + // 결제 주기를 영어 키값으로 반환 (내부 사용) String get billingCycleKey => normalizeBillingCycle(billingCycle); } diff --git a/lib/navigation/app_navigation_observer.dart b/lib/navigation/app_navigation_observer.dart index 3727f99..82db49d 100644 --- a/lib/navigation/app_navigation_observer.dart +++ b/lib/navigation/app_navigation_observer.dart @@ -37,22 +37,24 @@ class AppNavigationObserver extends NavigatorObserver { if (newRoute != null) { _updateNavigationState(newRoute); } - debugPrint('Navigation: Replace ${oldRoute?.settings.name} with ${newRoute?.settings.name}'); + debugPrint( + 'Navigation: Replace ${oldRoute?.settings.name} with ${newRoute?.settings.name}'); } void _updateNavigationState(Route route) { if (navigator?.context == null) return; - + final routeName = route.settings.name; if (routeName == null) return; - + // build 완료 후 업데이트하도록 변경 WidgetsBinding.instance.addPostFrameCallback((_) { if (navigator?.context == null) return; - + try { final context = navigator!.context; - final navigationProvider = Provider.of(context, listen: false); + final navigationProvider = + Provider.of(context, listen: false); navigationProvider.updateByRoute(routeName); } catch (e) { debugPrint('Failed to update navigation state: $e'); @@ -62,18 +64,19 @@ class AppNavigationObserver extends NavigatorObserver { void _handlePopWithProvider() { if (navigator?.context == null) return; - + // build 완료 후 업데이트하도록 변경 WidgetsBinding.instance.addPostFrameCallback((_) { if (navigator?.context == null) return; - + try { final context = navigator!.context; - final navigationProvider = Provider.of(context, listen: false); + final navigationProvider = + Provider.of(context, listen: false); navigationProvider.pop(); } catch (e) { debugPrint('Failed to handle pop with provider: $e'); } }); } -} \ No newline at end of file +} diff --git a/lib/providers/category_provider.dart b/lib/providers/category_provider.dart index 585eadf..57ceed6 100644 --- a/lib/providers/category_provider.dart +++ b/lib/providers/category_provider.dart @@ -28,14 +28,14 @@ class CategoryProvider extends ChangeNotifier { sortedCategories.sort((a, b) { final aIndex = _categoryOrder.indexOf(a.name); final bIndex = _categoryOrder.indexOf(b.name); - + // 순서 목록에 없는 카테고리는 맨 뒤로 if (aIndex == -1) return 1; if (bIndex == -1) return -1; - + return aIndex.compareTo(bIndex); }); - + return sortedCategories; } @@ -59,9 +59,17 @@ class CategoryProvider extends ChangeNotifier { {'name': 'storageCloud', 'color': '#2196F3', 'icon': 'cloud'}, {'name': 'telecomInternetTv', 'color': '#00BCD4', 'icon': 'wifi'}, {'name': 'lifestyle', 'color': '#4CAF50', 'icon': 'home'}, - {'name': 'shoppingEcommerce', 'color': '#FF9800', 'icon': 'shopping_cart'}, + { + 'name': 'shoppingEcommerce', + 'color': '#FF9800', + 'icon': 'shopping_cart' + }, {'name': 'programming', 'color': '#795548', 'icon': 'code'}, - {'name': 'collaborationOffice', 'color': '#607D8B', 'icon': 'business_center'}, + { + 'name': 'collaborationOffice', + 'color': '#607D8B', + 'icon': 'business_center' + }, {'name': 'aiService', 'color': '#673AB7', 'icon': 'smart_toy'}, {'name': 'other', 'color': '#9E9E9E', 'icon': 'category'}, ]; @@ -117,7 +125,7 @@ class CategoryProvider extends ChangeNotifier { return null; } } - + // 카테고리 이름을 현재 언어에 맞게 반환 String getLocalizedCategoryName(BuildContext context, String categoryKey) { final localizations = AppLocalizations.of(context); diff --git a/lib/providers/locale_provider.dart b/lib/providers/locale_provider.dart index 36dcb49..48c844f 100644 --- a/lib/providers/locale_provider.dart +++ b/lib/providers/locale_provider.dart @@ -5,24 +5,24 @@ import 'dart:ui' as ui; class LocaleProvider extends ChangeNotifier { late Box _localeBox; Locale _locale = const Locale('ko'); - + static const List supportedLanguages = ['en', 'ko', 'ja', 'zh']; Locale get locale => _locale; Future init() async { _localeBox = await Hive.openBox('locale'); - + // 저장된 언어 설정 확인 final savedLocale = _localeBox.get('locale'); - + if (savedLocale != null) { // 저장된 언어가 있으면 사용 _locale = Locale(savedLocale); } else { // 저장된 언어가 없으면 시스템 언어 감지 final systemLocale = ui.PlatformDispatcher.instance.locale; - + // 시스템 언어가 지원되는 언어인지 확인 if (supportedLanguages.contains(systemLocale.languageCode)) { _locale = Locale(systemLocale.languageCode); @@ -30,11 +30,11 @@ class LocaleProvider extends ChangeNotifier { // 지원되지 않는 언어면 영어 사용 _locale = const Locale('en'); } - + // 감지된 언어 저장 await _localeBox.put('locale', _locale.languageCode); } - + notifyListeners(); } diff --git a/lib/providers/navigation_provider.dart b/lib/providers/navigation_provider.dart index 584b9fe..011d619 100644 --- a/lib/providers/navigation_provider.dart +++ b/lib/providers/navigation_provider.dart @@ -36,25 +36,25 @@ class NavigationProvider extends ChangeNotifier { void updateCurrentIndex(int index, {bool addToHistory = true}) { if (_currentIndex == index) return; - + _currentIndex = index; _currentRoute = indexToRoute[index] ?? '/'; _currentTitle = indexToTitle[index] ?? 'home'; - + if (addToHistory && index >= 0) { _navigationHistory.add(index); if (_navigationHistory.length > 10) { _navigationHistory.removeAt(0); } } - + notifyListeners(); } void updateByRoute(String route) { final index = routeToIndex[route] ?? 0; _currentRoute = route; - + if (index >= 0) { _currentIndex = index; _currentTitle = indexToTitle[index] ?? 'home'; @@ -70,7 +70,7 @@ class NavigationProvider extends ChangeNotifier { _currentTitle = 'home'; } } - + notifyListeners(); } @@ -103,4 +103,4 @@ class NavigationProvider extends ChangeNotifier { _navigationHistory.add(0); notifyListeners(); } -} \ No newline at end of file +} diff --git a/lib/providers/notification_provider.dart b/lib/providers/notification_provider.dart index d790852..de6a0f5 100644 --- a/lib/providers/notification_provider.dart +++ b/lib/providers/notification_provider.dart @@ -86,12 +86,12 @@ class NotificationProvider extends ChangeNotifier { try { _isEnabled = value; await NotificationService.setNotificationEnabled(value); - + // 첫 권한 부여 시 기본 설정 적용 if (value) { await initializeDefaultSettingsOnFirstPermission(); } - + notifyListeners(); } catch (e) { debugPrint('알림 활성화 설정 중 오류 발생: $e'); @@ -270,15 +270,17 @@ class NotificationProvider extends ChangeNotifier { // 첫 권한 부여 시 기본 설정 초기화 Future initializeDefaultSettingsOnFirstPermission() async { try { - final firstGranted = await _secureStorage.read(key: _firstPermissionGrantedKey); + final firstGranted = + await _secureStorage.read(key: _firstPermissionGrantedKey); if (firstGranted != 'true') { // 첫 권한 부여 시 기본값 설정 await setReminderDays(2); // 2일 전 알림 await setDailyReminderEnabled(true); // 반복 알림 활성화 await setPaymentEnabled(true); // 결제 예정 알림 활성화 - + // 첫 권한 부여 플래그 저장 - await _secureStorage.write(key: _firstPermissionGrantedKey, value: 'true'); + await _secureStorage.write( + key: _firstPermissionGrantedKey, value: 'true'); } } catch (e) { debugPrint('기본 설정 초기화 중 오류 발생: $e'); diff --git a/lib/providers/subscription_provider.dart b/lib/providers/subscription_provider.dart index 339d32f..897e410 100644 --- a/lib/providers/subscription_provider.dart +++ b/lib/providers/subscription_provider.dart @@ -19,9 +19,9 @@ class SubscriptionProvider extends ChangeNotifier { double get totalMonthlyExpense { final exchangeRateService = ExchangeRateService(); - final rate = exchangeRateService.cachedUsdToKrwRate ?? - ExchangeRateService.DEFAULT_USD_TO_KRW_RATE; - + final rate = exchangeRateService.cachedUsdToKrwRate ?? + ExchangeRateService.DEFAULT_USD_TO_KRW_RATE; + final total = _subscriptions.fold( 0.0, (sum, subscription) { @@ -31,11 +31,12 @@ class SubscriptionProvider extends ChangeNotifier { '\$${price} × ₩$rate = ₩${price * rate}'); return sum + (price * rate); } - debugPrint('[SubscriptionProvider] ${subscription.serviceName}: ₩$price'); + debugPrint( + '[SubscriptionProvider] ${subscription.serviceName}: ₩$price'); return sum + price; }, ); - + debugPrint('[SubscriptionProvider] totalMonthlyExpense 계산 완료: ' '${_subscriptions.length}개 구독, 총액 ₩$total'); return total; @@ -69,10 +70,10 @@ class SubscriptionProvider extends ChangeNotifier { _subscriptionBox = await Hive.openBox('subscriptions'); await refreshSubscriptions(); - + // categoryId 마이그레이션 await _migrateCategoryIds(); - + // 앱 시작 시 이벤트 상태 확인 await checkAndUpdateEventStatus(); @@ -90,11 +91,11 @@ class SubscriptionProvider extends ChangeNotifier { try { _subscriptions = _subscriptionBox.values.toList() ..sort((a, b) => a.nextBillingDate.compareTo(b.nextBillingDate)); - + debugPrint('[SubscriptionProvider] refreshSubscriptions 완료: ' '${_subscriptions.length}개 구독, ' '총 월간 지출: ${totalMonthlyExpense.toStringAsFixed(2)}'); - + notifyListeners(); } catch (e) { debugPrint('구독 목록 새로고침 중 오류 발생: $e'); @@ -139,7 +140,7 @@ class SubscriptionProvider extends ChangeNotifier { await _subscriptionBox.put(subscription.id, subscription); await refreshSubscriptions(); - + // 이벤트가 활성화된 경우 알림 스케줄 재설정 if (isEventActive && eventEndDate != null) { await _scheduleEventEndNotification(subscription); @@ -191,7 +192,6 @@ class SubscriptionProvider extends ChangeNotifier { } } - Future clearAllSubscriptions() async { _isLoading = true; notifyListeners(); @@ -217,8 +217,9 @@ class SubscriptionProvider extends ChangeNotifier { } /// 이벤트 종료 알림을 스케줄링합니다. - Future _scheduleEventEndNotification(SubscriptionModel subscription) async { - if (subscription.eventEndDate != null && + Future _scheduleEventEndNotification( + SubscriptionModel subscription) async { + if (subscription.eventEndDate != null && subscription.eventEndDate!.isAfter(DateTime.now())) { await NotificationService.scheduleNotification( id: '${subscription.id}_event_end'.hashCode, @@ -232,19 +233,18 @@ class SubscriptionProvider extends ChangeNotifier { /// 모든 구독의 이벤트 상태를 확인하고 업데이트합니다. Future checkAndUpdateEventStatus() async { bool hasChanges = false; - + for (var subscription in _subscriptions) { // 이벤트가 종료되었지만 아직 활성화되어 있는 경우 - if (subscription.isEventActive && + if (subscription.isEventActive && subscription.eventEndDate != null && subscription.eventEndDate!.isBefore(DateTime.now())) { - subscription.isEventActive = false; await _subscriptionBox.put(subscription.id, subscription); hasChanges = true; } } - + if (hasChanges) { await refreshSubscriptions(); } @@ -253,70 +253,73 @@ class SubscriptionProvider extends ChangeNotifier { /// 총 월간 지출을 계산합니다. (로케일별 기본 통화로 환산) Future calculateTotalExpense({String? locale}) async { if (_subscriptions.isEmpty) return 0.0; - + // locale이 제공되지 않으면 현재 로케일 사용 - final targetCurrency = locale != null - ? CurrencyUtil.getDefaultCurrency(locale) - : 'KRW'; // 기본값 - + final targetCurrency = + locale != null ? CurrencyUtil.getDefaultCurrency(locale) : 'KRW'; // 기본값 + debugPrint('[calculateTotalExpense] 계산 시작 - 타겟 통화: $targetCurrency'); double total = 0.0; - + for (final subscription in _subscriptions) { final currentPrice = subscription.currentPrice; debugPrint('[calculateTotalExpense] ${subscription.serviceName}: ' '${currentPrice} ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})'); - + final converted = await ExchangeRateService().convertBetweenCurrencies( currentPrice, subscription.currency, targetCurrency, ); - + total += converted ?? currentPrice; } - + debugPrint('[calculateTotalExpense] 총 지출 계산 완료: $total $targetCurrency'); return total; } /// 최근 6개월의 월별 지출 데이터를 반환합니다. (로케일별 기본 통화로 환산) - Future>> getMonthlyExpenseData({String? locale}) async { + Future>> getMonthlyExpenseData( + {String? locale}) async { final now = DateTime.now(); final List> monthlyData = []; - + // locale이 제공되지 않으면 현재 로케일 사용 - final targetCurrency = locale != null - ? CurrencyUtil.getDefaultCurrency(locale) - : 'KRW'; // 기본값 - + final targetCurrency = + locale != null ? CurrencyUtil.getDefaultCurrency(locale) : 'KRW'; // 기본값 + // 최근 6개월 데이터 생성 for (int i = 5; i >= 0; i--) { final month = DateTime(now.year, now.month - i, 1); double monthTotal = 0.0; - + // 현재 월인지 확인 - final isCurrentMonth = (month.year == now.year && month.month == now.month); - + final isCurrentMonth = + (month.year == now.year && month.month == now.month); + if (isCurrentMonth) { - debugPrint('[getMonthlyExpenseData] 현재 월(${month.year}-${month.month}) 계산 중...'); + debugPrint( + '[getMonthlyExpenseData] 현재 월(${month.year}-${month.month}) 계산 중...'); } - + // 해당 월에 활성화된 구독 계산 for (final subscription in _subscriptions) { if (isCurrentMonth) { // 현재 월인 경우: 모든 활성 구독 포함 (calculateTotalExpense와 동일하게) final cost = subscription.currentPrice; - debugPrint('[getMonthlyExpenseData] 현재 월 - ${subscription.serviceName}: ' + debugPrint( + '[getMonthlyExpenseData] 현재 월 - ${subscription.serviceName}: ' '${cost} ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})'); - + // 통화 변환 - final converted = await ExchangeRateService().convertBetweenCurrencies( + final converted = + await ExchangeRateService().convertBetweenCurrencies( cost, subscription.currency, targetCurrency, ); - + monthTotal += converted ?? cost; } else { // 과거 월인 경우: 기존 로직 유지 @@ -324,46 +327,50 @@ class SubscriptionProvider extends ChangeNotifier { final subscriptionStartDate = subscription.nextBillingDate.subtract( Duration(days: _getBillingCycleDays(subscription.billingCycle)), ); - - if (subscriptionStartDate.isBefore(DateTime(month.year, month.month + 1, 1)) && + + if (subscriptionStartDate + .isBefore(DateTime(month.year, month.month + 1, 1)) && subscription.nextBillingDate.isAfter(month)) { // 해당 월의 비용 계산 (이벤트 가격 고려) double cost; - - if (subscription.isEventActive && + + if (subscription.isEventActive && subscription.eventStartDate != null && subscription.eventEndDate != null && // 이벤트 기간과 해당 월이 겹치는지 확인 - subscription.eventStartDate!.isBefore(DateTime(month.year, month.month + 1, 1)) && + subscription.eventStartDate! + .isBefore(DateTime(month.year, month.month + 1, 1)) && subscription.eventEndDate!.isAfter(month)) { cost = subscription.eventPrice ?? subscription.monthlyCost; } else { cost = subscription.monthlyCost; } - + // 통화 변환 - final converted = await ExchangeRateService().convertBetweenCurrencies( + final converted = + await ExchangeRateService().convertBetweenCurrencies( cost, subscription.currency, targetCurrency, ); - + monthTotal += converted ?? cost; } } } - + if (isCurrentMonth) { - debugPrint('[getMonthlyExpenseData] 현재 월(${_getMonthLabel(month, locale ?? 'en')}) 총 지출: $monthTotal $targetCurrency'); + debugPrint( + '[getMonthlyExpenseData] 현재 월(${_getMonthLabel(month, locale ?? 'en')}) 총 지출: $monthTotal $targetCurrency'); } - + monthlyData.add({ 'month': month, 'totalExpense': monthTotal, 'monthName': _getMonthLabel(month, locale ?? 'en'), }); } - + return monthlyData; } @@ -409,96 +416,109 @@ class SubscriptionProvider extends ChangeNotifier { /// categoryId가 없는 기존 구독들에 대해 자동으로 카테고리 할당 Future _migrateCategoryIds() async { debugPrint('❎ CategoryId 마이그레이션 시작...'); - + final categoryProvider = CategoryProvider(); await categoryProvider.init(); final categories = categoryProvider.categories; - + int migratedCount = 0; - + for (var subscription in _subscriptions) { if (subscription.categoryId == null) { final serviceName = subscription.serviceName.toLowerCase(); String? categoryId; - + debugPrint('🔍 ${subscription.serviceName} 카테고리 매칭 시도...'); - + // OTT 서비스 - if (serviceName.contains('netflix') || - serviceName.contains('youtube') || + if (serviceName.contains('netflix') || + serviceName.contains('youtube') || serviceName.contains('disney') || serviceName.contains('왓차') || serviceName.contains('티빙') || serviceName.contains('디즈니') || serviceName.contains('넷플릭스')) { - categoryId = categories.firstWhere( - (cat) => cat.name == 'OTT 서비스', - orElse: () => categories.first, - ).id; + categoryId = categories + .firstWhere( + (cat) => cat.name == 'OTT 서비스', + orElse: () => categories.first, + ) + .id; } // 음악 서비스 - else if (serviceName.contains('spotify') || - serviceName.contains('apple music') || - serviceName.contains('멜론') || - serviceName.contains('지니') || - serviceName.contains('플로') || - serviceName.contains('벡스')) { - categoryId = categories.firstWhere( - (cat) => cat.name == 'music', - orElse: () => categories.first, - ).id; + else if (serviceName.contains('spotify') || + serviceName.contains('apple music') || + serviceName.contains('멜론') || + serviceName.contains('지니') || + serviceName.contains('플로') || + serviceName.contains('벡스')) { + categoryId = categories + .firstWhere( + (cat) => cat.name == 'music', + orElse: () => categories.first, + ) + .id; } // AI 서비스 - else if (serviceName.contains('chatgpt') || - serviceName.contains('claude') || - serviceName.contains('midjourney') || - serviceName.contains('copilot')) { - categoryId = categories.firstWhere( - (cat) => cat.name == 'aiService', - orElse: () => categories.first, - ).id; + else if (serviceName.contains('chatgpt') || + serviceName.contains('claude') || + serviceName.contains('midjourney') || + serviceName.contains('copilot')) { + categoryId = categories + .firstWhere( + (cat) => cat.name == 'aiService', + orElse: () => categories.first, + ) + .id; } // 프로그래밍/개발 - else if (serviceName.contains('github') || - serviceName.contains('intellij') || - serviceName.contains('webstorm') || - serviceName.contains('jetbrains')) { - categoryId = categories.firstWhere( - (cat) => cat.name == 'programming', - orElse: () => categories.first, - ).id; + else if (serviceName.contains('github') || + serviceName.contains('intellij') || + serviceName.contains('webstorm') || + serviceName.contains('jetbrains')) { + categoryId = categories + .firstWhere( + (cat) => cat.name == 'programming', + orElse: () => categories.first, + ) + .id; } // 오피스/협업 툴 - else if (serviceName.contains('notion') || - serviceName.contains('microsoft') || - serviceName.contains('office') || - serviceName.contains('slack') || - serviceName.contains('figma') || - serviceName.contains('icloud') || - serviceName.contains('아이클라우드')) { - categoryId = categories.firstWhere( - (cat) => cat.name == 'collaborationOffice', - orElse: () => categories.first, - ).id; + else if (serviceName.contains('notion') || + serviceName.contains('microsoft') || + serviceName.contains('office') || + serviceName.contains('slack') || + serviceName.contains('figma') || + serviceName.contains('icloud') || + serviceName.contains('아이클라우드')) { + categoryId = categories + .firstWhere( + (cat) => cat.name == 'collaborationOffice', + orElse: () => categories.first, + ) + .id; } // 기타 서비스 (기본값) else { - categoryId = categories.firstWhere( - (cat) => cat.name == 'other', - orElse: () => categories.first, - ).id; + categoryId = categories + .firstWhere( + (cat) => cat.name == 'other', + orElse: () => categories.first, + ) + .id; } - + if (categoryId != null) { subscription.categoryId = categoryId; await subscription.save(); migratedCount++; - final categoryName = categories.firstWhere((cat) => cat.id == categoryId).name; + final categoryName = + categories.firstWhere((cat) => cat.id == categoryId).name; debugPrint('✅ ${subscription.serviceName} → $categoryName'); } } } - + if (migratedCount > 0) { debugPrint('❎ 총 ${migratedCount}개의 구독에 categoryId 할당 완료'); await refreshSubscriptions(); diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart index 412fecb..2979d57 100644 --- a/lib/providers/theme_provider.dart +++ b/lib/providers/theme_provider.dart @@ -7,24 +7,24 @@ import '../theme/adaptive_theme.dart'; class ThemeProvider extends ChangeNotifier { static const String _themeBoxName = 'theme_settings'; static const String _themeKey = 'theme_settings'; - + late Box _themeBox; ThemeSettings _themeSettings = const ThemeSettings(); - + ThemeSettings get themeSettings => _themeSettings; - + AppThemeMode get themeMode => _themeSettings.mode; bool get useSystemColors => _themeSettings.useSystemColors; bool get largeText => _themeSettings.largeText; bool get reduceMotion => _themeSettings.reduceMotion; bool get highContrast => _themeSettings.highContrast; - + /// Provider 초기화 Future initialize() async { _themeBox = await Hive.openBox(_themeBoxName); await _loadThemeSettings(); } - + /// 저장된 테마 설정 로드 Future _loadThemeSettings() async { final savedSettings = _themeBox.get(_themeKey); @@ -35,53 +35,53 @@ class ThemeProvider extends ChangeNotifier { notifyListeners(); } } - + /// 테마 설정 저장 Future _saveThemeSettings() async { await _themeBox.put(_themeKey, _themeSettings.toJson()); } - + /// 테마 모드 변경 Future setThemeMode(AppThemeMode mode) async { _themeSettings = _themeSettings.copyWith(mode: mode); await _saveThemeSettings(); notifyListeners(); } - + /// 시스템 색상 사용 설정 Future setUseSystemColors(bool value) async { _themeSettings = _themeSettings.copyWith(useSystemColors: value); await _saveThemeSettings(); notifyListeners(); } - + /// 큰 텍스트 설정 Future setLargeText(bool value) async { _themeSettings = _themeSettings.copyWith(largeText: value); await _saveThemeSettings(); notifyListeners(); } - + /// 모션 감소 설정 Future setReduceMotion(bool value) async { _themeSettings = _themeSettings.copyWith(reduceMotion: value); await _saveThemeSettings(); notifyListeners(); } - + /// 고대비 설정 Future setHighContrast(bool value) async { _themeSettings = _themeSettings.copyWith(highContrast: value); await _saveThemeSettings(); notifyListeners(); } - + /// 현재 설정에 따른 테마 가져오기 ThemeData getTheme(BuildContext context) { final platformBrightness = MediaQuery.of(context).platformBrightness; - + ThemeData baseTheme; - + switch (_themeSettings.mode) { case AppThemeMode.light: baseTheme = AdaptiveTheme.lightTheme; @@ -98,7 +98,7 @@ class ThemeProvider extends ChangeNotifier { : AdaptiveTheme.lightTheme; break; } - + // 접근성 설정 적용 return AdaptiveTheme.getAccessibleTheme( baseTheme, @@ -107,11 +107,11 @@ class ThemeProvider extends ChangeNotifier { highContrast: _themeSettings.highContrast, ); } - + /// 현재 테마가 다크 모드인지 확인 bool isDarkMode(BuildContext context) { final platformBrightness = MediaQuery.of(context).platformBrightness; - + switch (_themeSettings.mode) { case AppThemeMode.light: return false; @@ -122,7 +122,7 @@ class ThemeProvider extends ChangeNotifier { return platformBrightness == Brightness.dark; } } - + /// 테마 토글 (라이트/다크) Future toggleTheme() async { if (_themeSettings.mode == AppThemeMode.light) { @@ -137,7 +137,7 @@ class ThemeProvider extends ChangeNotifier { class AnimatedThemeBuilder extends StatelessWidget { final Widget Function(BuildContext, ThemeData) builder; final Duration duration; - + const AnimatedThemeBuilder({ super.key, required this.builder, @@ -148,7 +148,7 @@ class AnimatedThemeBuilder extends StatelessWidget { Widget build(BuildContext context) { final themeProvider = context.watch(); final theme = themeProvider.getTheme(context); - + return AnimatedTheme( data: theme, duration: duration, @@ -164,7 +164,7 @@ class ThemedColor extends StatelessWidget { final Color lightColor; final Color darkColor; final Widget child; - + const ThemedColor({ super.key, required this.lightColor, @@ -175,7 +175,7 @@ class ThemedColor extends StatelessWidget { @override Widget build(BuildContext context) { final isDark = context.read().isDarkMode(context); - + return Theme( data: Theme.of(context).copyWith( primaryColor: isDark ? darkColor : lightColor, @@ -183,4 +183,4 @@ class ThemedColor extends StatelessWidget { child: child, ); } -} \ No newline at end of file +} diff --git a/lib/screens/add_subscription_screen.dart b/lib/screens/add_subscription_screen.dart index c407572..46ce537 100644 --- a/lib/screens/add_subscription_screen.dart +++ b/lib/screens/add_subscription_screen.dart @@ -62,14 +62,14 @@ class _AddSubscriptionScreenState extends State crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox(height: MediaQuery.of(context).padding.top + 60), - + // 헤더 섹션 AddSubscriptionHeader( controller: _controller, fadeAnimation: _controller.fadeAnimation!, slideAnimation: _controller.slideAnimation!, ), - + // 서비스 정보 폼 AddSubscriptionForm( controller: _controller, @@ -78,7 +78,7 @@ class _AddSubscriptionScreenState extends State setState: setState, ), const SizedBox(height: 16), - + // 이벤트/할인 섹션 AddSubscriptionEventSection( controller: _controller, @@ -87,7 +87,7 @@ class _AddSubscriptionScreenState extends State setState: setState, ), const SizedBox(height: 32), - + // 저장 버튼 AddSubscriptionSaveButton( controller: _controller, @@ -101,4 +101,4 @@ class _AddSubscriptionScreenState extends State ), ); } -} \ No newline at end of file +} diff --git a/lib/screens/analysis_screen.dart b/lib/screens/analysis_screen.dart index 9d2943a..a8ad53b 100644 --- a/lib/screens/analysis_screen.dart +++ b/lib/screens/analysis_screen.dart @@ -43,12 +43,14 @@ class _AnalysisScreenState extends State // Provider 변경 감지 final provider = Provider.of(context); final currentHash = _calculateDataHash(provider); - + debugPrint('[AnalysisScreen] didChangeDependencies: ' '현재 해시=$currentHash, 이전 해시=$_lastDataHash, 로딩중=$_isLoading'); - + // 데이터가 변경되었고 현재 로딩 중이 아닌 경우에만 리로드 - if (currentHash != _lastDataHash && !_isLoading && _lastDataHash.isNotEmpty) { + if (currentHash != _lastDataHash && + !_isLoading && + _lastDataHash.isNotEmpty) { debugPrint('[AnalysisScreen] 데이터 변경 감지됨, 리로드 시작'); _loadData(); } @@ -65,15 +67,16 @@ class _AnalysisScreenState extends State String _calculateDataHash(SubscriptionProvider provider) { final subscriptions = provider.subscriptions; final buffer = StringBuffer(); - + buffer.write(subscriptions.length); buffer.write('_'); buffer.write(provider.totalMonthlyExpense.toStringAsFixed(2)); - + for (final sub in subscriptions) { - buffer.write('_${sub.id}_${sub.currentPrice.toStringAsFixed(2)}_${sub.currency}'); + buffer.write( + '_${sub.id}_${sub.currentPrice.toStringAsFixed(2)}_${sub.currency}'); } - + return buffer.toString(); } @@ -148,7 +151,7 @@ class _AnalysisScreenState extends State height: kToolbarHeight + MediaQuery.of(context).padding.top, ), ), - + // 네이티브 광고 위젯 SliverToBoxAdapter( child: _buildAnimatedAd(), @@ -197,4 +200,4 @@ class _AnalysisScreenState extends State ], ); } -} \ No newline at end of file +} diff --git a/lib/screens/category_management_screen.dart b/lib/screens/category_management_screen.dart index c7c590d..ad8bbb4 100644 --- a/lib/screens/category_management_screen.dart +++ b/lib/screens/category_management_screen.dart @@ -90,15 +90,35 @@ class _CategoryManagementScreenState extends State { ), items: [ DropdownMenuItem( - value: '#1976D2', child: Text(AppLocalizations.of(context).colorBlue, style: TextStyle(color: AppColors.darkNavy))), + value: '#1976D2', + child: Text( + AppLocalizations.of(context).colorBlue, + style: + TextStyle(color: AppColors.darkNavy))), DropdownMenuItem( - value: '#4CAF50', child: Text(AppLocalizations.of(context).colorGreen, style: TextStyle(color: AppColors.darkNavy))), + value: '#4CAF50', + child: Text( + AppLocalizations.of(context).colorGreen, + style: + TextStyle(color: AppColors.darkNavy))), DropdownMenuItem( - value: '#FF9800', child: Text(AppLocalizations.of(context).colorOrange, style: TextStyle(color: AppColors.darkNavy))), + value: '#FF9800', + child: Text( + AppLocalizations.of(context).colorOrange, + style: + TextStyle(color: AppColors.darkNavy))), DropdownMenuItem( - value: '#F44336', child: Text(AppLocalizations.of(context).colorRed, style: TextStyle(color: AppColors.darkNavy))), + value: '#F44336', + child: Text( + AppLocalizations.of(context).colorRed, + style: + TextStyle(color: AppColors.darkNavy))), DropdownMenuItem( - value: '#9C27B0', child: Text(AppLocalizations.of(context).colorPurple, style: TextStyle(color: AppColors.darkNavy))), + value: '#9C27B0', + child: Text( + AppLocalizations.of(context).colorPurple, + style: + TextStyle(color: AppColors.darkNavy))), ], onChanged: (value) { setState(() { @@ -117,14 +137,30 @@ class _CategoryManagementScreenState extends State { ), items: [ DropdownMenuItem( - value: 'subscriptions', child: Text('구독', style: TextStyle(color: AppColors.darkNavy))), - DropdownMenuItem(value: 'movie', child: Text('영화', style: TextStyle(color: AppColors.darkNavy))), + value: 'subscriptions', + child: Text('구독', + style: + TextStyle(color: AppColors.darkNavy))), DropdownMenuItem( - value: 'music_note', child: Text('음악', style: TextStyle(color: AppColors.darkNavy))), + value: 'movie', + child: Text('영화', + style: + TextStyle(color: AppColors.darkNavy))), DropdownMenuItem( - value: 'fitness_center', child: Text('운동', style: TextStyle(color: AppColors.darkNavy))), + value: 'music_note', + child: Text('음악', + style: + TextStyle(color: AppColors.darkNavy))), DropdownMenuItem( - value: 'shopping_cart', child: Text('쇼핑', style: TextStyle(color: AppColors.darkNavy))), + value: 'fitness_center', + child: Text('운동', + style: + TextStyle(color: AppColors.darkNavy))), + DropdownMenuItem( + value: 'shopping_cart', + child: Text('쇼핑', + style: + TextStyle(color: AppColors.darkNavy))), ], onChanged: (value) { setState(() { @@ -163,7 +199,8 @@ class _CategoryManagementScreenState extends State { int.parse(category.color.replaceAll('#', '0xFF'))), ), title: Text( - provider.getLocalizedCategoryName(context, category.name), + provider.getLocalizedCategoryName( + context, category.name), style: TextStyle( color: AppColors.darkNavy, ), diff --git a/lib/screens/detail_screen.dart b/lib/screens/detail_screen.dart index a430af2..2cad294 100644 --- a/lib/screens/detail_screen.dart +++ b/lib/screens/detail_screen.dart @@ -43,7 +43,6 @@ class _DetailScreenState extends State super.dispose(); } - @override Widget build(BuildContext context) { final baseColor = _controller.getCardColor(); @@ -53,111 +52,112 @@ class _DetailScreenState extends State child: Scaffold( backgroundColor: AppColors.backgroundColor, body: CustomScrollView( - controller: _controller.scrollController, - slivers: [ - // 상단 헤더 섹션 - SliverToBoxAdapter( - child: DetailHeaderSection( - subscription: widget.subscription, - controller: _controller, - fadeAnimation: _controller.fadeAnimation!, - slideAnimation: _controller.slideAnimation!, - rotateAnimation: _controller.rotateAnimation!, - ), - ), - // 본문 콘텐츠 - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - children: [ - // 편집 모드 안내 - Container( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - baseColor.withValues(alpha: 0.15), - baseColor.withValues(alpha: 0.08), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: baseColor.withValues(alpha: 0.2), - width: 1, - ), - ), - child: Row( - children: [ - Icon( - Icons.edit_rounded, - color: baseColor, - size: 20, - ), - const SizedBox(width: 8), - Text( - AppLocalizations.of(context).editMode, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: baseColor, - ), - ), - const Spacer(), - Text( - AppLocalizations.of(context).changesAppliedAfterSave, - style: TextStyle( - fontSize: 14, - color: AppColors.darkNavy, - ), - ), - ], - ), - ), - 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!, - ), - ], + controller: _controller.scrollController, + slivers: [ + // 상단 헤더 섹션 + SliverToBoxAdapter( + child: DetailHeaderSection( + subscription: widget.subscription, + controller: _controller, + fadeAnimation: _controller.fadeAnimation!, + slideAnimation: _controller.slideAnimation!, + rotateAnimation: _controller.rotateAnimation!, ), ), - ), - ], + // 본문 콘텐츠 + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + // 편집 모드 안내 + Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + baseColor.withValues(alpha: 0.15), + baseColor.withValues(alpha: 0.08), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: baseColor.withValues(alpha: 0.2), + width: 1, + ), + ), + child: Row( + children: [ + Icon( + Icons.edit_rounded, + color: baseColor, + size: 20, + ), + const SizedBox(width: 8), + Text( + AppLocalizations.of(context).editMode, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: baseColor, + ), + ), + const Spacer(), + Text( + AppLocalizations.of(context) + .changesAppliedAfterSave, + style: TextStyle( + fontSize: 14, + color: AppColors.darkNavy, + ), + ), + ], + ), + ), + 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!, + ), + ], + ), + ), + ), + ], ), ), ); } -} \ No newline at end of file +} diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 3c3cf15..6d87d46 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -33,7 +33,7 @@ class _MainScreenState extends State late AnimationController _waveController; late ScrollController _scrollController; late FloatingNavBarScrollController _navBarScrollController; - + // 화면 목록 late final List _screens; @@ -63,7 +63,7 @@ class _MainScreenState extends State ); _scrollController = ScrollController(); - + _navBarScrollController = FloatingNavBarScrollController( scrollController: _scrollController, onHide: () {}, @@ -157,7 +157,7 @@ class _MainScreenState extends State AppRoutes.addSubscription, ).then((result) { _resetAnimations(); - + // 구독이 성공적으로 추가된 경우 if (result == true) { // 상단에 스낵바 표시 @@ -203,18 +203,18 @@ class _MainScreenState extends State void _handleNavigation(int index, BuildContext context) { final navigationProvider = context.read(); - + // 이미 같은 인덱스면 무시 if (navigationProvider.currentIndex == index) { return; } - + // 추가 버튼은 별도 처리 if (index == 2) { _navigateToAddSubscription(context); return; } - + // 인덱스 업데이트 navigationProvider.updateCurrentIndex(index); } @@ -222,7 +222,7 @@ class _MainScreenState extends State @override Widget build(BuildContext context) { final navigationProvider = context.watch(); - + // 메인 그라데이션 사용 List backgroundGradient = AppColors.mainGradient; @@ -235,8 +235,12 @@ class _MainScreenState extends State return GlassmorphicScaffold( body: IndexedStack( index: PlatformHelper.isIOS - ? (currentIndex == 3 ? 3 : currentIndex) // iOS: 설정화면은 인덱스 3 - : (currentIndex == 3 ? 3 : currentIndex == 4 ? 4 : currentIndex), // Android: 기존 로직 + ? (currentIndex == 3 ? 3 : currentIndex) // iOS: 설정화면은 인덱스 3 + : (currentIndex == 3 + ? 3 + : currentIndex == 4 + ? 4 + : currentIndex), // Android: 기존 로직 children: _screens, ), backgroundGradient: backgroundGradient, @@ -249,4 +253,4 @@ class _MainScreenState extends State enableWaveAnimation: false, ); } -} \ No newline at end of file +} diff --git a/lib/screens/sms_scan_screen.dart b/lib/screens/sms_scan_screen.dart index 39b5152..4b8ce06 100644 --- a/lib/screens/sms_scan_screen.dart +++ b/lib/screens/sms_scan_screen.dart @@ -75,7 +75,8 @@ class _SmsScanScreenState extends State { ); } - final currentSubscription = _controller.scannedSubscriptions[_controller.currentIndex]; + final currentSubscription = + _controller.scannedSubscriptions[_controller.currentIndex]; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -119,4 +120,4 @@ class _SmsScanScreenState extends State { ), ); } -} \ No newline at end of file +} diff --git a/lib/services/currency_util.dart b/lib/services/currency_util.dart index 1a38f52..8a8bda5 100644 --- a/lib/services/currency_util.dart +++ b/lib/services/currency_util.dart @@ -66,7 +66,7 @@ class CurrencyUtil { final locale = _getLocaleForCurrency(currency); final symbol = getCurrencySymbol(currency); final decimals = (currency == 'KRW' || currency == 'JPY') ? 0 : 2; - + return NumberFormat.currency( locale: locale, symbol: symbol, @@ -81,27 +81,29 @@ class CurrencyUtil { String locale, ) async { final defaultCurrency = getDefaultCurrency(locale); - + // 입력 통화가 기본 통화인 경우 if (currency == defaultCurrency) { return _formatSingleCurrency(amount, currency); } - + // USD 입력인 경우 - 기본 통화로 변환하여 표시 if (currency == 'USD' && defaultCurrency != 'USD') { - final convertedAmount = await _exchangeRateService.convertUsdToTarget(amount, defaultCurrency); + final convertedAmount = await _exchangeRateService.convertUsdToTarget( + amount, defaultCurrency); if (convertedAmount != null) { - final primaryFormatted = _formatSingleCurrency(convertedAmount, defaultCurrency); + final primaryFormatted = + _formatSingleCurrency(convertedAmount, defaultCurrency); final usdFormatted = _formatSingleCurrency(amount, 'USD'); return '$primaryFormatted ($usdFormatted)'; } } - + // 영어 사용자가 KRW 선택한 경우 if (locale == 'en' && currency == 'KRW') { return _formatSingleCurrency(amount, currency); } - + // 기타 통화 입력인 경우 return _formatSingleCurrency(amount, currency); } @@ -116,13 +118,13 @@ class CurrencyUtil { for (var subscription in subscriptions) { final price = subscription.currentPrice; - + final converted = await _exchangeRateService.convertBetweenCurrencies( price, subscription.currency, defaultCurrency, ); - + total += converted ?? price; } @@ -178,13 +180,13 @@ class CurrencyUtil { for (var subscription in subscriptions) { if (subscription.isCurrentlyInEvent) { final savings = subscription.eventSavings; - + final converted = await _exchangeRateService.convertBetweenCurrencies( savings, subscription.currency, defaultCurrency, ); - + total += converted ?? savings; } } @@ -204,7 +206,7 @@ class CurrencyUtil { if (!subscription.isCurrentlyInEvent) { return ''; } - + final savings = subscription.eventSavings; return formatAmountWithLocale(savings, subscription.currency, locale); } @@ -225,4 +227,4 @@ class CurrencyUtil { static Future formatAmount(double amount, String currency) async { return formatAmountWithCurrencyAndLocale(amount, currency, 'ko'); } -} \ No newline at end of file +} diff --git a/lib/services/exchange_rate_service.dart b/lib/services/exchange_rate_service.dart index 574da99..2a61a72 100644 --- a/lib/services/exchange_rate_service.dart +++ b/lib/services/exchange_rate_service.dart @@ -75,9 +75,10 @@ class ExchangeRateService { } /// USD 금액을 지정된 통화로 변환합니다. - Future convertUsdToTarget(double usdAmount, String targetCurrency) async { + Future convertUsdToTarget( + double usdAmount, String targetCurrency) async { await _fetchAllRatesIfNeeded(); - + switch (targetCurrency) { case 'KRW': final rate = _usdToKrwRate ?? DEFAULT_USD_TO_KRW_RATE; @@ -96,9 +97,10 @@ class ExchangeRateService { } /// 지정된 통화를 USD로 변환합니다. - Future convertTargetToUsd(double amount, String sourceCurrency) async { + Future convertTargetToUsd( + double amount, String sourceCurrency) async { await _fetchAllRatesIfNeeded(); - + switch (sourceCurrency) { case 'KRW': final rate = _usdToKrwRate ?? DEFAULT_USD_TO_KRW_RATE; @@ -118,25 +120,22 @@ class ExchangeRateService { /// 두 통화 간 변환을 수행합니다. (USD를 거쳐서 변환) Future convertBetweenCurrencies( - double amount, - String fromCurrency, - String toCurrency - ) async { + double amount, String fromCurrency, String toCurrency) async { if (fromCurrency == toCurrency) { return amount; } - + // fromCurrency → USD → toCurrency double? usdAmount; - + if (fromCurrency == 'USD') { usdAmount = amount; } else { usdAmount = await convertTargetToUsd(amount, fromCurrency); } - + if (usdAmount == null) return null; - + if (toCurrency == 'USD') { return usdAmount; } else { @@ -161,7 +160,7 @@ class ExchangeRateService { /// 언어별 환율 정보를 포맷팅하여 반환합니다. Future getFormattedExchangeRateInfoForLocale(String locale) async { await _fetchAllRatesIfNeeded(); - + switch (locale) { case 'ko': final rate = _usdToKrwRate ?? DEFAULT_USD_TO_KRW_RATE; @@ -204,12 +203,13 @@ class ExchangeRateService { } /// USD 금액을 지정된 언어의 기본 통화로 변환하여 포맷팅된 문자열로 반환합니다. - Future getFormattedAmountForLocale(double usdAmount, String locale) async { + Future getFormattedAmountForLocale( + double usdAmount, String locale) async { String targetCurrency; String localeCode; String symbol; int decimalDigits; - + switch (locale) { case 'ko': targetCurrency = 'KRW'; @@ -232,7 +232,7 @@ class ExchangeRateService { default: return '\$$usdAmount'; } - + final convertedAmount = await convertUsdToTarget(usdAmount, targetCurrency); if (convertedAmount != null) { final formattedAmount = NumberFormat.currency( diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index ab8abb2..59ff18b 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -150,17 +150,16 @@ class NotificationService { } } - static Future requestPermission() async { // 웹 플랫폼인 경우 false 반환 if (_isWeb) return false; - + // iOS 처리 if (Platform.isIOS) { - final iosImplementation = _notifications - .resolvePlatformSpecificImplementation< + final iosImplementation = + _notifications.resolvePlatformSpecificImplementation< IOSFlutterLocalNotificationsPlugin>(); - + if (iosImplementation != null) { final granted = await iosImplementation.requestPermissions( alert: true, @@ -170,20 +169,20 @@ class NotificationService { return granted ?? false; } } - + // Android 처리 if (Platform.isAndroid) { - final androidImplementation = _notifications - .resolvePlatformSpecificImplementation< + final androidImplementation = + _notifications.resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin>(); - + if (androidImplementation != null) { - final granted = await androidImplementation - .requestNotificationsPermission(); + final granted = + await androidImplementation.requestNotificationsPermission(); return granted ?? false; } } - + return false; } @@ -191,32 +190,32 @@ class NotificationService { static Future checkPermission() async { // 웹 플랫폼인 경우 false 반환 if (_isWeb) return false; - + // Android 처리 if (Platform.isAndroid) { - final androidImplementation = _notifications - .resolvePlatformSpecificImplementation< + final androidImplementation = + _notifications.resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin>(); - + if (androidImplementation != null) { // Android 13 이상에서만 권한 확인 필요 final isEnabled = await androidImplementation.areNotificationsEnabled(); return isEnabled ?? false; } } - + // iOS 처리 if (Platform.isIOS) { - final iosImplementation = _notifications - .resolvePlatformSpecificImplementation< + final iosImplementation = + _notifications.resolvePlatformSpecificImplementation< IOSFlutterLocalNotificationsPlugin>(); - + if (iosImplementation != null) { final settings = await iosImplementation.checkPermissions(); return settings?.isEnabled ?? false; } } - + return true; // 기본값 } @@ -232,7 +231,7 @@ class NotificationService { debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.'); return; } - + try { const androidDetails = AndroidNotificationDetails( 'subscription_channel', @@ -243,7 +242,7 @@ class NotificationService { ); final iosDetails = const DarwinNotificationDetails(); - + // tz.local 초기화 확인 및 재시도 tz.Location location; try { @@ -305,7 +304,7 @@ class NotificationService { debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.'); return; } - + try { final notificationId = subscription.id.hashCode; @@ -327,7 +326,7 @@ class NotificationService { android: androidDetails, iOS: iosDetails, ); - + // tz.local 초기화 확인 및 재시도 tz.Location location; try { @@ -380,11 +379,11 @@ class NotificationService { debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.'); return; } - + try { final paymentDate = subscription.nextBillingDate; final reminderDate = paymentDate.subtract(const Duration(days: 3)); - + // tz.local 초기화 확인 및 재시도 tz.Location location; try { @@ -433,11 +432,11 @@ class NotificationService { debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.'); return; } - + try { final expirationDate = subscription.nextBillingDate; final reminderDate = expirationDate.subtract(const Duration(days: 7)); - + // tz.local 초기화 확인 및 재시도 tz.Location location; try { @@ -510,16 +509,17 @@ class NotificationService { location = tz.UTC; } } - + // 기본 알림 예약 (지정된 일수 전) - final scheduledDate = - subscription.nextBillingDate.subtract(Duration(days: reminderDays)).copyWith( - hour: reminderHour, - minute: reminderMinute, - second: 0, - millisecond: 0, - microsecond: 0, - ); + final scheduledDate = subscription.nextBillingDate + .subtract(Duration(days: reminderDays)) + .copyWith( + hour: reminderHour, + minute: reminderMinute, + second: 0, + millisecond: 0, + microsecond: 0, + ); // 남은 일수에 따른 메시지 생성 String daysText = '$reminderDays일 후'; @@ -529,19 +529,21 @@ class NotificationService { // 이벤트 종료로 인한 가격 변동 확인 String notificationBody; - if (subscription.isEventActive && + if (subscription.isEventActive && subscription.eventEndDate != null && subscription.eventEndDate!.isBefore(subscription.nextBillingDate) && subscription.eventEndDate!.isAfter(DateTime.now())) { // 이벤트가 결제일 전에 종료되는 경우 final eventPrice = subscription.eventPrice ?? subscription.monthlyCost; final normalPrice = subscription.monthlyCost; - notificationBody = '${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.\n' - '⚠️ 이벤트 종료로 가격이 ${eventPrice.toStringAsFixed(0)}원에서 ${normalPrice.toStringAsFixed(0)}원으로 변경됩니다.'; + notificationBody = + '${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.\n' + '⚠️ 이벤트 종료로 가격이 ${eventPrice.toStringAsFixed(0)}원에서 ${normalPrice.toStringAsFixed(0)}원으로 변경됩니다.'; } else { // 일반 알림 final currentPrice = subscription.currentPrice; - notificationBody = '${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.'; + notificationBody = + '${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.'; } await _notifications.zonedSchedule( @@ -568,13 +570,14 @@ class NotificationService { if (isDailyReminder && reminderDays >= 2) { // 첫 번째 알림 다음 날부터 결제일 전날까지 매일 알림 예약 for (int i = reminderDays - 1; i >= 1; i--) { - final dailyDate = subscription.nextBillingDate.subtract(Duration(days: i)).copyWith( - hour: reminderHour, - minute: reminderMinute, - second: 0, - millisecond: 0, - microsecond: 0, - ); + final dailyDate = + subscription.nextBillingDate.subtract(Duration(days: i)).copyWith( + hour: reminderHour, + minute: reminderMinute, + second: 0, + millisecond: 0, + microsecond: 0, + ); // 남은 일수에 따른 메시지 생성 String remainingDaysText = '$i일 후'; @@ -584,17 +587,21 @@ class NotificationService { // 각 날짜에 대한 이벤트 종료 확인 String dailyNotificationBody; - if (subscription.isEventActive && + if (subscription.isEventActive && subscription.eventEndDate != null && - subscription.eventEndDate!.isBefore(subscription.nextBillingDate) && + subscription.eventEndDate! + .isBefore(subscription.nextBillingDate) && subscription.eventEndDate!.isAfter(DateTime.now())) { - final eventPrice = subscription.eventPrice ?? subscription.monthlyCost; + final eventPrice = + subscription.eventPrice ?? subscription.monthlyCost; final normalPrice = subscription.monthlyCost; - dailyNotificationBody = '${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.\n' - '⚠️ 이벤트 종료로 가격이 ${eventPrice.toStringAsFixed(0)}원에서 ${normalPrice.toStringAsFixed(0)}원으로 변경됩니다.'; + dailyNotificationBody = + '${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.\n' + '⚠️ 이벤트 종료로 가격이 ${eventPrice.toStringAsFixed(0)}원에서 ${normalPrice.toStringAsFixed(0)}원으로 변경됩니다.'; } else { final currentPrice = subscription.currentPrice; - dailyNotificationBody = '${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.'; + dailyNotificationBody = + '${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.'; } await _notifications.zonedSchedule( diff --git a/lib/services/sms_scan/subscription_converter.dart b/lib/services/sms_scan/subscription_converter.dart index bd61b73..595d894 100644 --- a/lib/services/sms_scan/subscription_converter.dart +++ b/lib/services/sms_scan/subscription_converter.dart @@ -3,15 +3,17 @@ import '../../models/subscription_model.dart'; class SubscriptionConverter { // SubscriptionModel 리스트를 Subscription 리스트로 변환 - List convertModelsToSubscriptions(List models) { + List convertModelsToSubscriptions( + List models) { final result = []; for (var model in models) { try { final subscription = _convertSingle(model); result.add(subscription); - - print('모델 변환 성공: ${model.serviceName}, 카테고리ID: ${model.categoryId}, URL: ${model.websiteUrl}, 통화: ${model.currency}'); + + print( + '모델 변환 성공: ${model.serviceName}, 카테고리ID: ${model.categoryId}, URL: ${model.websiteUrl}, 통화: ${model.currency}'); } catch (e) { print('모델 변환 중 오류 발생: $e'); } @@ -76,4 +78,4 @@ class SubscriptionConverter { return 'monthly'; // 기본값 } } -} \ No newline at end of file +} diff --git a/lib/services/sms_scan/subscription_filter.dart b/lib/services/sms_scan/subscription_filter.dart index 627f692..9a946b0 100644 --- a/lib/services/sms_scan/subscription_filter.dart +++ b/lib/services/sms_scan/subscription_filter.dart @@ -5,7 +5,8 @@ class SubscriptionFilter { // 중복 구독 필터링 (서비스명과 금액이 같으면 중복으로 간주) List filterDuplicates( List scanned, List existing) { - print('_filterDuplicates: 스캔된 구독 ${scanned.length}개, 기존 구독 ${existing.length}개'); + print( + '_filterDuplicates: 스캔된 구독 ${scanned.length}개, 기존 구독 ${existing.length}개'); // 중복되지 않은 구독만 필터링 return scanned.where((scannedSub) { @@ -16,7 +17,8 @@ class SubscriptionFilter { final isSameCost = existingSub.monthlyCost == scannedSub.monthlyCost; if (isSameName && isSameCost) { - print('중복 발견: ${scannedSub.serviceName} (${scannedSub.monthlyCost}원)'); + print( + '중복 발견: ${scannedSub.serviceName} (${scannedSub.monthlyCost}원)'); return true; } return false; @@ -27,7 +29,8 @@ class SubscriptionFilter { } // 반복 횟수 기반 필터링 - List filterByRepeatCount(List subscriptions, int minCount) { + List filterByRepeatCount( + List subscriptions, int minCount) { return subscriptions.where((sub) => sub.repeatCount >= minCount).toList(); } @@ -44,7 +47,8 @@ class SubscriptionFilter { List filterByPriceRange( List subscriptions, double minPrice, double maxPrice) { return subscriptions - .where((sub) => sub.monthlyCost >= minPrice && sub.monthlyCost <= maxPrice) + .where( + (sub) => sub.monthlyCost >= minPrice && sub.monthlyCost <= maxPrice) .toList(); } @@ -52,9 +56,9 @@ class SubscriptionFilter { List filterByCategories( List subscriptions, List categoryIds) { if (categoryIds.isEmpty) return subscriptions; - + return subscriptions.where((sub) { return sub.category != null && categoryIds.contains(sub.category); }).toList(); } -} \ No newline at end of file +} diff --git a/lib/services/sms_scanner.dart b/lib/services/sms_scanner.dart index e2b4db6..723ce03 100644 --- a/lib/services/sms_scanner.dart +++ b/lib/services/sms_scanner.dart @@ -82,7 +82,7 @@ class SmsScanner { try { final messages = await _query.getAllSms; final smsList = >[]; - + // SMS 메시지를 분석하여 구독 서비스 감지 for (final message in messages) { final parsedData = _parseRawSms(message); @@ -90,7 +90,7 @@ class SmsScanner { smsList.add(parsedData); } } - + return smsList; } catch (e) { print('SmsScanner: Android SMS 스캔 실패: $e'); @@ -104,41 +104,59 @@ class SmsScanner { final body = message.body ?? ''; final sender = message.address ?? ''; final date = message.date ?? DateTime.now(); - + // 구독 서비스 키워드 매칭 final subscriptionKeywords = [ - '구독', '결제', '정기결제', '자동결제', '월정액', - 'subscription', 'payment', 'billing', 'charge', - '넷플릭스', 'Netflix', '유튜브', 'YouTube', 'Spotify', - '멜론', '웨이브', 'Disney+', '디즈니플러스', 'Apple', - 'Microsoft', 'GitHub', 'Adobe', 'Amazon' + '구독', + '결제', + '정기결제', + '자동결제', + '월정액', + 'subscription', + 'payment', + 'billing', + 'charge', + '넷플릭스', + 'Netflix', + '유튜브', + 'YouTube', + 'Spotify', + '멜론', + '웨이브', + 'Disney+', + '디즈니플러스', + 'Apple', + 'Microsoft', + 'GitHub', + 'Adobe', + 'Amazon' ]; - + // 구독 관련 키워드가 있는지 확인 - bool isSubscription = subscriptionKeywords.any((keyword) => - body.toLowerCase().contains(keyword.toLowerCase()) || - sender.toLowerCase().contains(keyword.toLowerCase()) - ); - + bool isSubscription = subscriptionKeywords.any((keyword) => + body.toLowerCase().contains(keyword.toLowerCase()) || + sender.toLowerCase().contains(keyword.toLowerCase())); + if (!isSubscription) { return null; } - + // 서비스명 추출 String serviceName = _extractServiceName(body, sender); - + // 금액 추출 double? amount = _extractAmount(body); - + // 결제 주기 추출 String billingCycle = _extractBillingCycle(body); - + return { 'serviceName': serviceName, 'monthlyCost': amount ?? 0.0, 'billingCycle': billingCycle, 'message': body, - 'nextBillingDate': _calculateNextBillingFromDate(date, billingCycle).toIso8601String(), + 'nextBillingDate': + _calculateNextBillingFromDate(date, billingCycle).toIso8601String(), 'previousPaymentDate': date.toIso8601String(), }; } catch (e) { @@ -146,7 +164,7 @@ class SmsScanner { return null; } } - + // 서비스명 추출 로직 String _extractServiceName(String body, String sender) { // 알려진 서비스 매핑 @@ -162,41 +180,41 @@ class SmsScanner { '멜론': '멜론', '웨이브': '웨이브', }; - + // 메시지나 발신자에서 서비스명 찾기 final combinedText = '$body $sender'.toLowerCase(); - + for (final entry in servicePatterns.entries) { if (combinedText.contains(entry.key)) { return entry.value; } } - + // 찾지 못한 경우 return _extractServiceNameFromSender(sender); } - + // 발신자 정보에서 서비스명 추출 String _extractServiceNameFromSender(String sender) { // 숫자만 있으면 제거 if (RegExp(r'^\d+$').hasMatch(sender)) { return '알 수 없는 서비스'; } - + // 특수문자 제거하고 서비스명으로 사용 return sender.replaceAll(RegExp(r'[^\w\s가-힣]'), '').trim(); } - + // 금액 추출 로직 double? _extractAmount(String body) { // 다양한 금액 패턴 매칭 final patterns = [ - RegExp(r'(\d{1,3}(?:,\d{3})*)(?:원|₩)'), // 원화 - RegExp(r'\$(\d+(?:\.\d{2})?)'), // 달러 - RegExp(r'(\d+(?:\.\d{2})?)\s*USD'), // USD - RegExp(r'결제.*?(\d{1,3}(?:,\d{3})*)'), // 결제 금액 + RegExp(r'(\d{1,3}(?:,\d{3})*)(?:원|₩)'), // 원화 + RegExp(r'\$(\d+(?:\.\d{2})?)'), // 달러 + RegExp(r'(\d+(?:\.\d{2})?)\s*USD'), // USD + RegExp(r'결제.*?(\d{1,3}(?:,\d{3})*)'), // 결제 금액 ]; - + for (final pattern in patterns) { final match = pattern.firstMatch(body); if (match != null) { @@ -205,26 +223,29 @@ class SmsScanner { return double.tryParse(amountStr); } } - + return null; } - + // 결제 주기 추출 로직 String _extractBillingCycle(String body) { if (body.contains('월') || body.contains('monthly') || body.contains('매월')) { return 'monthly'; - } else if (body.contains('년') || body.contains('yearly') || body.contains('annual')) { + } else if (body.contains('년') || + body.contains('yearly') || + body.contains('annual')) { return 'yearly'; } else if (body.contains('주') || body.contains('weekly')) { return 'weekly'; } - + // 기본값 return 'monthly'; } - + // 다음 결제일 계산 - DateTime _calculateNextBillingFromDate(DateTime lastDate, String billingCycle) { + DateTime _calculateNextBillingFromDate( + DateTime lastDate, String billingCycle) { switch (billingCycle) { case 'monthly': return DateTime(lastDate.year, lastDate.month + 1, lastDate.day); @@ -241,7 +262,8 @@ class SmsScanner { try { final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스'; final monthlyCost = (sms['monthlyCost'] as num?)?.toDouble() ?? 0.0; - final billingCycle = SubscriptionModel.normalizeBillingCycle(sms['billingCycle'] as String? ?? 'monthly'); + final billingCycle = SubscriptionModel.normalizeBillingCycle( + sms['billingCycle'] as String? ?? 'monthly'); final nextBillingDateStr = sms['nextBillingDate'] as String?; // 실제 반복 횟수 사용 (테스트 데이터에서는 이미 제공됨) final actualRepeatCount = repeatCount > 0 ? repeatCount : 1; @@ -369,8 +391,6 @@ class SmsScanner { return serviceUrls[serviceName]; } - - // 메시지에서 통화 단위를 감지하는 함수 String _detectCurrency(String message) { final dollarKeywords = [ @@ -407,4 +427,4 @@ class SmsScanner { // 기본값은 원화 return 'KRW'; } -} \ No newline at end of file +} diff --git a/lib/services/sms_service.dart b/lib/services/sms_service.dart index e1c4f63..7922783 100644 --- a/lib/services/sms_service.dart +++ b/lib/services/sms_service.dart @@ -9,26 +9,26 @@ class SMSService { static Future requestSMSPermission() async { // 웹이나 iOS에서는 SMS 권한 불필요 if (kIsWeb || PlatformHelper.isIOS) return true; - + // Android에서만 권한 요청 if (PlatformHelper.isAndroid) { final status = await permission.Permission.sms.request(); return status.isGranted; } - + return false; } static Future hasSMSPermission() async { // 웹이나 iOS에서는 항상 true 반환 (권한 불필요) if (kIsWeb || PlatformHelper.isIOS) return true; - + // Android에서만 실제 권한 확인 if (PlatformHelper.isAndroid) { final status = await permission.Permission.sms.status; return status.isGranted; } - + return false; } diff --git a/lib/services/subscription_url_matcher.dart b/lib/services/subscription_url_matcher.dart index 9f5d854..61410b9 100644 --- a/lib/services/subscription_url_matcher.dart +++ b/lib/services/subscription_url_matcher.dart @@ -17,39 +17,40 @@ class SubscriptionUrlMatcher { static CancellationUrlService? _cancellationService; static ServiceNameResolver? _nameResolver; static SmsExtractorService? _smsExtractor; - + /// 서비스 초기화 static Future initialize() async { if (_dataRepository != null && _dataRepository!.isInitialized) return; - + // 1. 데이터 저장소 초기화 _dataRepository = ServiceDataRepository(); await _dataRepository!.initialize(); - + // 2. 서비스 초기화 _categoryMapper = CategoryMapperService(_dataRepository!); _urlMatcher = UrlMatcherService(_dataRepository!, _categoryMapper!); - _cancellationService = CancellationUrlService(_dataRepository!, _urlMatcher!); + _cancellationService = + CancellationUrlService(_dataRepository!, _urlMatcher!); _nameResolver = ServiceNameResolver(_dataRepository!); _smsExtractor = SmsExtractorService(_urlMatcher!, _categoryMapper!); } - + /// 도메인 추출 (www와 TLD 제외) static String? extractDomain(String url) { return _urlMatcher?.extractDomain(url); } - + /// URL로 서비스 찾기 static Future findServiceByUrl(String url) async { await initialize(); return _urlMatcher?.findServiceByUrl(url); } - + /// 서비스명으로 URL 찾기 (기존 suggestUrl 메서드 유지) static String? suggestUrl(String serviceName) { return _urlMatcher?.suggestUrl(serviceName); } - + /// 서비스명 또는 URL로 해지 안내 페이지 URL 찾기 static Future findCancellationUrl({ String? serviceName, @@ -63,19 +64,20 @@ class SubscriptionUrlMatcher { locale: locale, ); } - + /// 서비스에 공식 해지 안내 페이지가 있는지 확인 static Future hasCancellationPage(String serviceNameOrUrl) async { await initialize(); - return await _cancellationService?.hasCancellationPage(serviceNameOrUrl) ?? false; + return await _cancellationService?.hasCancellationPage(serviceNameOrUrl) ?? + false; } - + /// 서비스명으로 카테고리 찾기 static Future findCategoryByServiceName(String serviceName) async { await initialize(); return _categoryMapper?.findCategoryByServiceName(serviceName); } - + /// 현재 로케일에 따라 서비스 표시명 가져오기 static Future getServiceDisplayName({ required String serviceName, @@ -83,17 +85,18 @@ class SubscriptionUrlMatcher { }) async { await initialize(); return await _nameResolver?.getServiceDisplayName( - serviceName: serviceName, - locale: locale, - ) ?? serviceName; + serviceName: serviceName, + locale: locale, + ) ?? + serviceName; } - + /// SMS에서 URL과 서비스 정보 추출 static Future extractServiceFromSms(String smsText) async { await initialize(); return _smsExtractor?.extractServiceFromSms(smsText); } - + /// URL이 알려진 서비스 URL인지 확인 static Future isKnownServiceUrl(String url) async { await initialize(); @@ -104,4 +107,4 @@ class SubscriptionUrlMatcher { static String? findMatchingUrl(String text, {bool usePartialMatch = true}) { return _urlMatcher?.findMatchingUrl(text, usePartialMatch: usePartialMatch); } -} \ No newline at end of file +} diff --git a/lib/services/url_matcher/data/legacy_service_data.dart b/lib/services/url_matcher/data/legacy_service_data.dart index 0e09225..19b0675 100644 --- a/lib/services/url_matcher/data/legacy_service_data.dart +++ b/lib/services/url_matcher/data/legacy_service_data.dart @@ -336,22 +336,22 @@ class LegacyServiceData { // 모든 서비스 매핑을 합친 맵 static Map get allServices => { - ...ottServices, - ...musicServices, - ...storageServices, - ...aiServices, - ...programmingServices, - ...officeTools, - ...lifestyleServices, - ...shoppingServices, - ...telecomServices, - ...otherServices, - }; + ...ottServices, + ...musicServices, + ...storageServices, + ...aiServices, + ...programmingServices, + ...officeTools, + ...lifestyleServices, + ...shoppingServices, + ...telecomServices, + ...otherServices, + }; /// 서비스 카테고리 찾기 static String? getCategoryForService(String serviceName) { final lowerName = serviceName.toLowerCase(); - + if (ottServices.containsKey(lowerName)) return 'ott'; if (musicServices.containsKey(lowerName)) return 'music'; if (storageServices.containsKey(lowerName)) return 'storage'; @@ -362,7 +362,7 @@ class LegacyServiceData { if (shoppingServices.containsKey(lowerName)) return 'shopping'; if (telecomServices.containsKey(lowerName)) return 'telecom'; if (otherServices.containsKey(lowerName)) return 'other'; - + return null; } -} \ No newline at end of file +} diff --git a/lib/services/url_matcher/data/service_data_repository.dart b/lib/services/url_matcher/data/service_data_repository.dart index 018f66c..25ab7f9 100644 --- a/lib/services/url_matcher/data/service_data_repository.dart +++ b/lib/services/url_matcher/data/service_data_repository.dart @@ -5,13 +5,14 @@ import 'package:flutter/services.dart'; class ServiceDataRepository { Map? _servicesData; bool _isInitialized = false; - + /// JSON 데이터 초기화 Future initialize() async { if (_isInitialized) return; - + try { - final jsonString = await rootBundle.loadString('assets/data/subscription_services.json'); + final jsonString = + await rootBundle.loadString('assets/data/subscription_services.json'); _servicesData = json.decode(jsonString); _isInitialized = true; print('ServiceDataRepository: JSON 데이터 로드 완료'); @@ -21,10 +22,10 @@ class ServiceDataRepository { _isInitialized = true; } } - + /// 서비스 데이터 가져오기 Map? getServicesData() => _servicesData; - + /// 초기화 여부 확인 bool get isInitialized => _isInitialized; -} \ No newline at end of file +} diff --git a/lib/services/url_matcher/models/service_info.dart b/lib/services/url_matcher/models/service_info.dart index c371af8..0f8ae21 100644 --- a/lib/services/url_matcher/models/service_info.dart +++ b/lib/services/url_matcher/models/service_info.dart @@ -7,7 +7,7 @@ class ServiceInfo { final String categoryId; final String categoryNameKr; final String categoryNameEn; - + ServiceInfo({ required this.serviceId, required this.serviceName, @@ -17,4 +17,4 @@ class ServiceInfo { required this.categoryNameKr, required this.categoryNameEn, }); -} \ No newline at end of file +} diff --git a/lib/services/url_matcher/services/cancellation_url_service.dart b/lib/services/url_matcher/services/cancellation_url_service.dart index 74542ac..51e91df 100644 --- a/lib/services/url_matcher/services/cancellation_url_service.dart +++ b/lib/services/url_matcher/services/cancellation_url_service.dart @@ -6,9 +6,9 @@ import 'url_matcher_service.dart'; class CancellationUrlService { final ServiceDataRepository _dataRepository; final UrlMatcherService _urlMatcher; - + CancellationUrlService(this._dataRepository, this._urlMatcher); - + /// 서비스명 또는 URL로 해지 안내 페이지 URL 찾기 Future findCancellationUrl({ String? serviceName, @@ -19,47 +19,55 @@ class CancellationUrlService { final servicesData = _dataRepository.getServicesData(); if (servicesData != null) { final categories = servicesData['categories'] as Map; - + // 1. 서비스명으로 찾기 if (serviceName != null && serviceName.isNotEmpty) { final lowerName = serviceName.toLowerCase().trim(); - + for (final categoryData in categories.values) { - final services = (categoryData as Map)['services'] as Map; - + final services = (categoryData as Map)['services'] + as Map; + for (final serviceData in services.values) { - final names = List.from((serviceData as Map)['names'] ?? []); - + final names = List.from( + (serviceData as Map)['names'] ?? []); + for (final name in names) { - if (lowerName.contains(name.toLowerCase()) || name.toLowerCase().contains(lowerName)) { - final cancellationUrls = serviceData['cancellationUrls'] as Map?; + if (lowerName.contains(name.toLowerCase()) || + name.toLowerCase().contains(lowerName)) { + final cancellationUrls = + serviceData['cancellationUrls'] as Map?; if (cancellationUrls != null) { // 요청한 언어의 URL이 있으면 반환, 없으면 다른 언어 URL 반환 - return cancellationUrls[locale] ?? - cancellationUrls[locale == 'kr' ? 'en' : 'kr']; + return cancellationUrls[locale] ?? + cancellationUrls[locale == 'kr' ? 'en' : 'kr']; } } } } } } - + // 2. URL로 찾기 if (websiteUrl != null && websiteUrl.isNotEmpty) { final domain = _urlMatcher.extractDomain(websiteUrl); if (domain != null) { for (final categoryData in categories.values) { - final services = (categoryData as Map)['services'] as Map; - + final services = (categoryData as Map)['services'] + as Map; + for (final serviceData in services.values) { - final domains = List.from((serviceData as Map)['domains'] ?? []); - + final domains = List.from( + (serviceData as Map)['domains'] ?? []); + for (final serviceDomain in domains) { - if (domain.contains(serviceDomain) || serviceDomain.contains(domain)) { - final cancellationUrls = serviceData['cancellationUrls'] as Map?; + if (domain.contains(serviceDomain) || + serviceDomain.contains(domain)) { + final cancellationUrls = + serviceData['cancellationUrls'] as Map?; if (cancellationUrls != null) { - return cancellationUrls[locale] ?? - cancellationUrls[locale == 'kr' ? 'en' : 'kr']; + return cancellationUrls[locale] ?? + cancellationUrls[locale == 'kr' ? 'en' : 'kr']; } } } @@ -68,7 +76,7 @@ class CancellationUrlService { } } } - + // JSON에서 못 찾았으면 레거시 방식으로 찾기 return _findCancellationUrlLegacy(serviceName ?? websiteUrl ?? ''); } @@ -126,4 +134,4 @@ class CancellationUrlService { ); return cancellationUrl != null; } -} \ No newline at end of file +} diff --git a/lib/services/url_matcher/services/category_mapper_service.dart b/lib/services/url_matcher/services/category_mapper_service.dart index 872a093..83c3de4 100644 --- a/lib/services/url_matcher/services/category_mapper_service.dart +++ b/lib/services/url_matcher/services/category_mapper_service.dart @@ -4,41 +4,43 @@ import '../data/legacy_service_data.dart'; /// 카테고리 매핑 관련 기능을 제공하는 서비스 클래스 class CategoryMapperService { final ServiceDataRepository _dataRepository; - + CategoryMapperService(this._dataRepository); - + /// 서비스명으로 카테고리 찾기 Future findCategoryByServiceName(String serviceName) async { if (serviceName.isEmpty) return null; - + final lowerName = serviceName.toLowerCase().trim(); - + // JSON 데이터가 있으면 JSON에서 찾기 final servicesData = _dataRepository.getServicesData(); if (servicesData != null) { final categories = servicesData['categories'] as Map; - + for (final categoryEntry in categories.entries) { final categoryId = categoryEntry.key; final categoryData = categoryEntry.value as Map; final services = categoryData['services'] as Map; - + for (final serviceData in services.values) { - final names = List.from((serviceData as Map)['names'] ?? []); - + final names = List.from( + (serviceData as Map)['names'] ?? []); + for (final name in names) { - if (lowerName.contains(name.toLowerCase()) || name.toLowerCase().contains(lowerName)) { + if (lowerName.contains(name.toLowerCase()) || + name.toLowerCase().contains(lowerName)) { return getCategoryIdByKey(categoryId); } } } } } - + // JSON에서 못 찾았으면 레거시 방식으로 카테고리 추측 return getCategoryForLegacyService(serviceName); } - + /// 카테고리 키를 실제 카테고리 ID로 매핑 String getCategoryIdByKey(String key) { // 여기에 실제 앱의 카테고리 ID 매핑을 추가 @@ -68,21 +70,30 @@ class CategoryMapperService { return 'other'; } } - + /// 레거시 서비스명으로 카테고리 추측 String getCategoryForLegacyService(String serviceName) { final lowerName = serviceName.toLowerCase(); - - if (LegacyServiceData.ottServices.containsKey(lowerName)) return 'ott_services'; - if (LegacyServiceData.musicServices.containsKey(lowerName)) return 'music_streaming'; - if (LegacyServiceData.storageServices.containsKey(lowerName)) return 'cloud_storage'; - if (LegacyServiceData.aiServices.containsKey(lowerName)) return 'ai_services'; - if (LegacyServiceData.programmingServices.containsKey(lowerName)) return 'dev_tools'; - if (LegacyServiceData.officeTools.containsKey(lowerName)) return 'office_tools'; - if (LegacyServiceData.lifestyleServices.containsKey(lowerName)) return 'lifestyle'; - if (LegacyServiceData.shoppingServices.containsKey(lowerName)) return 'shopping'; - if (LegacyServiceData.telecomServices.containsKey(lowerName)) return 'telecom'; - + + if (LegacyServiceData.ottServices.containsKey(lowerName)) + return 'ott_services'; + if (LegacyServiceData.musicServices.containsKey(lowerName)) + return 'music_streaming'; + if (LegacyServiceData.storageServices.containsKey(lowerName)) + return 'cloud_storage'; + if (LegacyServiceData.aiServices.containsKey(lowerName)) + return 'ai_services'; + if (LegacyServiceData.programmingServices.containsKey(lowerName)) + return 'dev_tools'; + if (LegacyServiceData.officeTools.containsKey(lowerName)) + return 'office_tools'; + if (LegacyServiceData.lifestyleServices.containsKey(lowerName)) + return 'lifestyle'; + if (LegacyServiceData.shoppingServices.containsKey(lowerName)) + return 'shopping'; + if (LegacyServiceData.telecomServices.containsKey(lowerName)) + return 'telecom'; + return 'other'; } -} \ No newline at end of file +} diff --git a/lib/services/url_matcher/services/service_name_resolver.dart b/lib/services/url_matcher/services/service_name_resolver.dart index 76eaa17..e032fb8 100644 --- a/lib/services/url_matcher/services/service_name_resolver.dart +++ b/lib/services/url_matcher/services/service_name_resolver.dart @@ -3,9 +3,9 @@ import '../data/service_data_repository.dart'; /// 서비스명 관련 기능을 제공하는 서비스 클래스 class ServiceNameResolver { final ServiceDataRepository _dataRepository; - + ServiceNameResolver(this._dataRepository); - + /// 현재 로케일에 따라 서비스 표시명 가져오기 Future getServiceDisplayName({ required String serviceName, @@ -15,22 +15,23 @@ class ServiceNameResolver { if (servicesData == null) { return serviceName; } - + final lowerName = serviceName.toLowerCase().trim(); final categories = servicesData['categories'] as Map; - + // JSON에서 서비스 찾기 for (final categoryData in categories.values) { - final services = (categoryData as Map)['services'] as Map; - + final services = (categoryData as Map)['services'] + as Map; + for (final serviceData in services.values) { final data = serviceData as Map; final names = List.from(data['names'] ?? []); - + // names 배열에 있는지 확인 for (final name in names) { - if (lowerName == name.toLowerCase() || - lowerName.contains(name.toLowerCase()) || + if (lowerName == name.toLowerCase() || + lowerName.contains(name.toLowerCase()) || name.toLowerCase().contains(lowerName)) { // 로케일에 따라 적절한 이름 반환 if (locale == 'ko' || locale == 'kr') { @@ -40,11 +41,11 @@ class ServiceNameResolver { } } } - + // nameKr/nameEn에 직접 매칭 확인 final nameKr = (data['nameKr'] ?? '').toString().toLowerCase(); final nameEn = (data['nameEn'] ?? '').toString().toLowerCase(); - + if (lowerName == nameKr || lowerName == nameEn) { if (locale == 'ko' || locale == 'kr') { return data['nameKr'] ?? serviceName; @@ -54,8 +55,8 @@ class ServiceNameResolver { } } } - + // 찾지 못한 경우 원래 이름 반환 return serviceName; } -} \ No newline at end of file +} diff --git a/lib/services/url_matcher/services/sms_extractor_service.dart b/lib/services/url_matcher/services/sms_extractor_service.dart index 4e1f7b5..f07e01d 100644 --- a/lib/services/url_matcher/services/sms_extractor_service.dart +++ b/lib/services/url_matcher/services/sms_extractor_service.dart @@ -7,9 +7,9 @@ import 'category_mapper_service.dart'; class SmsExtractorService { final UrlMatcherService _urlMatcher; final CategoryMapperService _categoryMapper; - + SmsExtractorService(this._urlMatcher, this._categoryMapper); - + /// SMS에서 URL과 서비스 정보 추출 Future extractServiceFromSms(String smsText) async { // URL 패턴 찾기 @@ -17,9 +17,9 @@ class SmsExtractorService { r'https?://(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*)', caseSensitive: false, ); - + final matches = urlPattern.allMatches(smsText); - + for (final match in matches) { final url = match.group(0); if (url != null) { @@ -29,15 +29,17 @@ class SmsExtractorService { } } } - + // URL로 못 찾았으면 서비스명으로 시도 final lowerSms = smsText.toLowerCase(); - + // 모든 서비스명 검사 for (final entry in LegacyServiceData.allServices.entries) { if (lowerSms.contains(entry.key.toLowerCase())) { - final categoryId = await _categoryMapper.findCategoryByServiceName(entry.key) ?? 'other'; - + final categoryId = + await _categoryMapper.findCategoryByServiceName(entry.key) ?? + 'other'; + return ServiceInfo( serviceId: entry.key, serviceName: entry.key, @@ -49,7 +51,7 @@ class SmsExtractorService { ); } } - + return null; } -} \ No newline at end of file +} diff --git a/lib/services/url_matcher/services/url_matcher_service.dart b/lib/services/url_matcher/services/url_matcher_service.dart index 090a83d..6df9b7c 100644 --- a/lib/services/url_matcher/services/url_matcher_service.dart +++ b/lib/services/url_matcher/services/url_matcher_service.dart @@ -7,23 +7,23 @@ import 'category_mapper_service.dart'; class UrlMatcherService { final ServiceDataRepository _dataRepository; final CategoryMapperService _categoryMapper; - + UrlMatcherService(this._dataRepository, this._categoryMapper); - + /// 도메인 추출 (www와 TLD 제외) String? extractDomain(String url) { try { final uri = Uri.parse(url); final host = uri.host.toLowerCase(); - + // 도메인 부분 추출 var parts = host.split('.'); - + // www 제거 if (parts.isNotEmpty && parts[0] == 'www') { parts = parts.sublist(1); } - + // 서브도메인 처리 (예: music.youtube.com) if (parts.length >= 3) { // 서브도메인 포함 전체 도메인 반환 @@ -32,40 +32,41 @@ class UrlMatcherService { // 메인 도메인만 반환 return parts[0]; } - + return null; } catch (e) { print('UrlMatcherService: 도메인 추출 실패 - $e'); return null; } } - + /// URL로 서비스 찾기 Future findServiceByUrl(String url) async { final domain = extractDomain(url); if (domain == null) return null; - + // JSON 데이터가 있으면 JSON에서 찾기 final servicesData = _dataRepository.getServicesData(); if (servicesData != null) { final categories = servicesData['categories'] as Map; - + for (final categoryEntry in categories.entries) { final categoryId = categoryEntry.key; final categoryData = categoryEntry.value as Map; final services = categoryData['services'] as Map; - + for (final serviceEntry in services.entries) { final serviceId = serviceEntry.key; final serviceData = serviceEntry.value as Map; final domains = List.from(serviceData['domains'] ?? []); - + // 도메인이 일치하는지 확인 for (final serviceDomain in domains) { - if (domain.contains(serviceDomain) || serviceDomain.contains(domain)) { + if (domain.contains(serviceDomain) || + serviceDomain.contains(domain)) { final names = List.from(serviceData['names'] ?? []); final urls = serviceData['urls'] as Map?; - + return ServiceInfo( serviceId: serviceId, serviceName: names.isNotEmpty ? names[0] : serviceId, @@ -80,13 +81,13 @@ class UrlMatcherService { } } } - + // JSON에서 못 찾았으면 레거시 방식으로 찾기 for (final entry in LegacyServiceData.allServices.entries) { final serviceUrl = entry.value; final serviceDomain = extractDomain(serviceUrl); - - if (serviceDomain != null && + + if (serviceDomain != null && (domain.contains(serviceDomain) || serviceDomain.contains(domain))) { return ServiceInfo( serviceId: entry.key, @@ -99,10 +100,10 @@ class UrlMatcherService { ); } } - + return null; } - + /// 서비스명으로 URL 찾기 String? suggestUrl(String serviceName) { if (serviceName.isEmpty) { @@ -186,7 +187,7 @@ class UrlMatcherService { return null; } } - + /// URL이 알려진 서비스 URL인지 확인 Future isKnownServiceUrl(String url) async { final serviceInfo = await findServiceByUrl(url); @@ -232,4 +233,4 @@ class UrlMatcherService { return null; } -} \ No newline at end of file +} diff --git a/lib/services/url_matcher/url_matcher.dart b/lib/services/url_matcher/url_matcher.dart index d2dbf4e..cc800c8 100644 --- a/lib/services/url_matcher/url_matcher.dart +++ b/lib/services/url_matcher/url_matcher.dart @@ -1,2 +1,2 @@ /// URL Matcher 패키지의 export 파일 -export 'models/service_info.dart'; \ No newline at end of file +export 'models/service_info.dart'; diff --git a/lib/theme/adaptive_theme.dart b/lib/theme/adaptive_theme.dart index cd24ea3..74aaa6c 100644 --- a/lib/theme/adaptive_theme.dart +++ b/lib/theme/adaptive_theme.dart @@ -7,7 +7,7 @@ import 'app_theme.dart'; class AdaptiveTheme { /// 라이트 테마 static ThemeData get lightTheme => AppTheme.lightTheme; - + /// 다크 테마 static ThemeData get darkTheme { return ThemeData( @@ -22,21 +22,19 @@ class AdaptiveTheme { background: const Color(0xFF121212), surface: const Color(0xFF1E1E1E), ), - scaffoldBackgroundColor: const Color(0xFF121212), - cardTheme: CardThemeData( color: const Color(0xFF1E1E1E), elevation: 2, shadowColor: Colors.black.withValues(alpha: 0.3), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), - side: BorderSide(color: Colors.white.withValues(alpha: 0.1), width: 0.5), + side: BorderSide( + color: Colors.white.withValues(alpha: 0.1), width: 0.5), ), clipBehavior: Clip.antiAlias, margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), ), - appBarTheme: AppBarTheme( backgroundColor: const Color(0xFF1E1E1E), foregroundColor: Colors.white, @@ -53,7 +51,6 @@ class AdaptiveTheme { size: 24, ), ), - textTheme: TextTheme( headlineLarge: const TextStyle( color: Colors.white, @@ -119,22 +116,24 @@ class AdaptiveTheme { height: 1.5, ), ), - inputDecorationTheme: InputDecorationTheme( filled: true, fillColor: const Color(0xFF2A2A2A), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 16), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none, ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.1), width: 1), + borderSide: + BorderSide(color: Colors.white.withValues(alpha: 0.1), width: 1), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: AppColors.primaryColor, width: 1.5), + borderSide: + const BorderSide(color: AppColors.primaryColor, width: 1.5), ), errorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), @@ -151,7 +150,6 @@ class AdaptiveTheme { fontWeight: FontWeight.w400, ), ), - elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( backgroundColor: AppColors.primaryColor, @@ -164,7 +162,6 @@ class AdaptiveTheme { elevation: 0, ), ), - dividerTheme: DividerThemeData( color: Colors.white.withValues(alpha: 0.1), thickness: 1, @@ -172,7 +169,7 @@ class AdaptiveTheme { ), ); } - + /// OLED 최적화 다크 테마 static ThemeData get oledTheme { return darkTheme.copyWith( @@ -192,7 +189,7 @@ class AdaptiveTheme { ), ); } - + /// 고대비 테마 static ThemeData get highContrastTheme { return ThemeData( @@ -206,7 +203,6 @@ class AdaptiveTheme { background: Colors.white, surface: Colors.white, ), - textTheme: const TextTheme( headlineLarge: TextStyle( color: Colors.black, @@ -234,7 +230,6 @@ class AdaptiveTheme { fontWeight: FontWeight.w500, ), ), - cardTheme: CardThemeData( elevation: 0, shape: RoundedRectangleBorder( @@ -242,7 +237,6 @@ class AdaptiveTheme { side: const BorderSide(color: Colors.black, width: 2), ), ), - elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( backgroundColor: Colors.black, @@ -255,31 +249,28 @@ class AdaptiveTheme { ), ); } - + /// 시스템 테마에 따른 상태바 스타일 적용 static void applySystemUIOverlay(BuildContext context) { final brightness = Theme.of(context).brightness; final isOled = Theme.of(context).scaffoldBackgroundColor == Colors.black; - + SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle( statusBarColor: Colors.transparent, - statusBarIconBrightness: brightness == Brightness.dark - ? Brightness.light - : Brightness.dark, - statusBarBrightness: brightness == Brightness.dark - ? Brightness.light - : Brightness.dark, - systemNavigationBarColor: isOled - ? Colors.black - : (brightness == Brightness.dark - ? const Color(0xFF121212) + statusBarIconBrightness: + brightness == Brightness.dark ? Brightness.light : Brightness.dark, + statusBarBrightness: + brightness == Brightness.dark ? Brightness.light : Brightness.dark, + systemNavigationBarColor: isOled + ? Colors.black + : (brightness == Brightness.dark + ? const Color(0xFF121212) : Colors.white), - systemNavigationBarIconBrightness: brightness == Brightness.dark - ? Brightness.light - : Brightness.dark, + systemNavigationBarIconBrightness: + brightness == Brightness.dark ? Brightness.light : Brightness.dark, )); } - + /// 접근성 설정에 따른 테마 조정 static ThemeData getAccessibleTheme( ThemeData baseTheme, { @@ -290,9 +281,9 @@ class AdaptiveTheme { if (highContrast) { return highContrastTheme; } - + ThemeData theme = baseTheme; - + if (largeText) { theme = theme.copyWith( textTheme: theme.textTheme.apply( @@ -300,7 +291,7 @@ class AdaptiveTheme { ), ); } - + if (reduceMotion) { theme = theme.copyWith( pageTransitionsTheme: const PageTransitionsTheme( @@ -311,7 +302,7 @@ class AdaptiveTheme { ), ); } - + return theme; } } @@ -331,7 +322,7 @@ class ThemeSettings { final bool largeText; final bool reduceMotion; final bool highContrast; - + const ThemeSettings({ this.mode = AppThemeMode.system, this.useSystemColors = false, @@ -339,7 +330,7 @@ class ThemeSettings { this.reduceMotion = false, this.highContrast = false, }); - + ThemeSettings copyWith({ AppThemeMode? mode, bool? useSystemColors, @@ -355,15 +346,15 @@ class ThemeSettings { highContrast: highContrast ?? this.highContrast, ); } - + Map toJson() => { - 'mode': mode.name, - 'useSystemColors': useSystemColors, - 'largeText': largeText, - 'reduceMotion': reduceMotion, - 'highContrast': highContrast, - }; - + 'mode': mode.name, + 'useSystemColors': useSystemColors, + 'largeText': largeText, + 'reduceMotion': reduceMotion, + 'highContrast': highContrast, + }; + factory ThemeSettings.fromJson(Map json) { return ThemeSettings( mode: AppThemeMode.values.firstWhere( @@ -376,4 +367,4 @@ class ThemeSettings { highContrast: json['highContrast'] ?? false, ); } -} \ No newline at end of file +} diff --git a/lib/theme/app_colors.dart b/lib/theme/app_colors.dart index e738499..0c10bdc 100644 --- a/lib/theme/app_colors.dart +++ b/lib/theme/app_colors.dart @@ -27,14 +27,14 @@ class AppColors { // 보더 & 디바이더 static const borderColor = Color(0xFFE2E8F0); // 슬레이트 200 static const dividerColor = Color(0xFFE2E8F0); // 슬레이트 200 - + // 그림자 (color.md 가이드) static const shadowBlack = Color(0x14000000); // rgba(0,0,0,0.08) - 8% opacity // 그라데이션 컬러 - 다양한 효과를 위한 조합 static const List blueGradient = [ Color(0xFF2563EB), // 딥 블루 - Color(0xFF60A5FA) // 스카이 블루 + Color(0xFF60A5FA) // 스카이 블루 ]; static const List tealGradient = [ Color(0xFF14B8A6), @@ -59,52 +59,52 @@ class AppColors { static const glassCard = Color(0x33FFFFFF); // 반투명 흰색 (20% opacity) static const glassBorder = Color(0xFF2563EB); // 딥 블루 테두리 static const glassOverlay = Color(0x0D000000); // 연한 검정 오버레이 (5% opacity) - + // 다크 모드용 Glassmorphism 색상 static const glassSurfaceDark = Color(0x0F000000); // 매우 연한 검정 (6% opacity) static const glassBackgroundDark = Color(0x1A000000); // 연한 검정 (10% opacity) static const glassCardDark = Color(0x33000000); // 반투명 검정 (20% opacity) static const glassBorderDark = Color(0x4D000000); // 반투명 검정 테두리 (30% opacity) - + // 백드롭 블러 효과를 위한 그라디언트 static const List glassGradient = [ Color(0x33FFFFFF), // 20% white Color(0x1AFFFFFF), // 10% white ]; - + static const List glassGradientDark = [ Color(0x1A000000), // 10% black Color(0x0F000000), // 6% black ]; - + // 메인 그라데이션 static const List mainGradient = [ Color(0xFF2563EB), // 딥 블루 Color(0xFF60A5FA), // 스카이 블루 Color(0xFFE0E7EF), // 라이트 그레이 ]; - + static const List accentGradient = [ Color(0xFF38BDF8), // 소프트 민트 Color(0xFF60A5FA), // 스카이 블루 ]; - + // 시간대별 배경 그라디언트 static const List morningGradient = [ Color(0xFFFED7AA), // 따뜻한 오렌지 Color(0xFFFBBF24), // 부드러운 노랑 ]; - + static const List dayGradient = [ Color(0xFFDDEAFC), // 연한 하늘색 Color(0xFFBFDBFE), // 맑은 파랑 ]; - + static const List eveningGradient = [ Color(0xFFFCA5A5), // 부드러운 핑크 Color(0xFFC084FC), // 연한 보라 ]; - + static const List nightGradient = [ Color(0xFF4338CA), // 깊은 인디고 Color(0xFF1E1B4B), // 다크 네이비 diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index 20ca69e..cfcb80e 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -52,21 +52,21 @@ class AppTheme { textTheme: const TextTheme( // 헤드라인 - 페이지 제목 headlineLarge: const TextStyle( - color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 + color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 fontSize: 32, fontWeight: FontWeight.w700, letterSpacing: -0.5, height: 1.2, ), headlineMedium: const TextStyle( - color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 + color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 fontSize: 28, fontWeight: FontWeight.w700, letterSpacing: -0.5, height: 1.2, ), headlineSmall: const TextStyle( - color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 + color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 fontSize: 24, fontWeight: FontWeight.w600, letterSpacing: -0.25, @@ -75,21 +75,21 @@ class AppTheme { // 타이틀 - 카드, 섹션 제목 titleLarge: const TextStyle( - color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 + color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 fontSize: 20, fontWeight: FontWeight.w600, letterSpacing: -0.2, height: 1.4, ), titleMedium: TextStyle( - color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 + color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 fontSize: 18, fontWeight: FontWeight.w600, letterSpacing: -0.1, height: 1.4, ), titleSmall: TextStyle( - color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 + color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 fontSize: 16, fontWeight: FontWeight.w600, letterSpacing: 0, @@ -98,21 +98,21 @@ class AppTheme { // 본문 텍스트 bodyLarge: TextStyle( - color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 + color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 fontSize: 16, fontWeight: FontWeight.w400, letterSpacing: 0.1, height: 1.5, ), bodyMedium: TextStyle( - color: AppColors.navyGray, // color.md 가이드: 서브 텍스트 + color: AppColors.navyGray, // color.md 가이드: 서브 텍스트 fontSize: 14, fontWeight: FontWeight.w400, letterSpacing: 0.1, height: 1.5, ), bodySmall: TextStyle( - color: AppColors.navyGray, // color.md 가이드: 서브 텍스트 + color: AppColors.navyGray, // color.md 가이드: 서브 텍스트 fontSize: 12, fontWeight: FontWeight.w400, letterSpacing: 0.2, @@ -121,21 +121,21 @@ class AppTheme { // 라벨 텍스트 labelLarge: TextStyle( - color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 + color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 fontSize: 14, fontWeight: FontWeight.w600, letterSpacing: 0.1, height: 1.4, ), labelMedium: TextStyle( - color: AppColors.navyGray, // color.md 가이드: 서브 텍스트 + color: AppColors.navyGray, // color.md 가이드: 서브 텍스트 fontSize: 12, fontWeight: FontWeight.w600, letterSpacing: 0.2, height: 1.4, ), labelSmall: TextStyle( - color: AppColors.navyGray, // color.md 가이드: 서브 텍스트 + color: AppColors.navyGray, // color.md 가이드: 서브 텍스트 fontSize: 11, fontWeight: FontWeight.w500, letterSpacing: 0.2, diff --git a/lib/utils/haptic_feedback_helper.dart b/lib/utils/haptic_feedback_helper.dart index 1062d73..9cbde71 100644 --- a/lib/utils/haptic_feedback_helper.dart +++ b/lib/utils/haptic_feedback_helper.dart @@ -4,42 +4,42 @@ import 'dart:io' show Platform; /// 햅틱 피드백을 관리하는 헬퍼 클래스 class HapticFeedbackHelper { static bool _isEnabled = true; - + /// 햅틱 피드백 활성화 여부 설정 static void setEnabled(bool enabled) { _isEnabled = enabled; } - + /// 가벼운 햅틱 피드백 static Future lightImpact() async { if (!_isEnabled || !_isPlatformSupported()) return; await HapticFeedback.lightImpact(); } - + /// 중간 강도 햅틱 피드백 static Future mediumImpact() async { if (!_isEnabled || !_isPlatformSupported()) return; await HapticFeedback.mediumImpact(); } - + /// 강한 햅틱 피드백 static Future heavyImpact() async { if (!_isEnabled || !_isPlatformSupported()) return; await HapticFeedback.heavyImpact(); } - + /// 선택 햅틱 피드백 (iOS의 경우 Taptic Engine) static Future selectionClick() async { if (!_isEnabled || !_isPlatformSupported()) return; await HapticFeedback.selectionClick(); } - + /// 진동 패턴 (Android) static Future vibrate({int duration = 50}) async { if (!_isEnabled || !_isPlatformSupported()) return; await HapticFeedback.vibrate(); } - + /// 성공 피드백 패턴 static Future success() async { if (!_isEnabled || !_isPlatformSupported()) return; @@ -47,7 +47,7 @@ class HapticFeedbackHelper { await Future.delayed(const Duration(milliseconds: 100)); await HapticFeedback.lightImpact(); } - + /// 에러 피드백 패턴 static Future error() async { if (!_isEnabled || !_isPlatformSupported()) return; @@ -55,13 +55,13 @@ class HapticFeedbackHelper { await Future.delayed(const Duration(milliseconds: 100)); await HapticFeedback.heavyImpact(); } - + /// 경고 피드백 패턴 static Future warning() async { if (!_isEnabled || !_isPlatformSupported()) return; await HapticFeedback.mediumImpact(); } - + /// 플랫폼이 햅틱 피드백을 지원하는지 확인 static bool _isPlatformSupported() { try { @@ -71,4 +71,4 @@ class HapticFeedbackHelper { return false; } } -} \ No newline at end of file +} diff --git a/lib/utils/memory_manager.dart b/lib/utils/memory_manager.dart index b7f6d57..ab4dc6b 100644 --- a/lib/utils/memory_manager.dart +++ b/lib/utils/memory_manager.dart @@ -7,19 +7,19 @@ class MemoryManager { static final MemoryManager _instance = MemoryManager._internal(); factory MemoryManager() => _instance; MemoryManager._internal(); - + // 캐시 관리 final Map _cache = {}; final int _maxCacheSize = 100; final Duration _defaultTTL = const Duration(minutes: 5); - + // 이미지 캐시 관리 static const int maxImageCacheSize = 50 * 1024 * 1024; // 50MB static const int maxImageCacheCount = 100; - + // 위젯 참조 추적 final Map> _widgetReferences = {}; - + /// 캐시에 데이터 저장 void cacheData({ required String key, @@ -27,32 +27,32 @@ class MemoryManager { Duration? ttl, }) { _cleanupExpiredCache(); - + if (_cache.length >= _maxCacheSize) { _evictOldestEntry(); } - + _cache[key] = _CacheEntry( data: data, timestamp: DateTime.now(), ttl: ttl ?? _defaultTTL, ); } - + /// 캐시에서 데이터 가져오기 T? getCachedData(String key) { final entry = _cache[key]; if (entry == null) return null; - + if (entry.isExpired) { _cache.remove(key); return null; } - + entry.lastAccess = DateTime.now(); return entry.data as T?; } - + /// 캐시 비우기 void clearCache() { _cache.clear(); @@ -60,53 +60,52 @@ class MemoryManager { print('🧹 메모리 캐시가 비워졌습니다.'); } } - + /// 특정 패턴의 캐시 제거 void clearCacheByPattern(String pattern) { - final keysToRemove = _cache.keys - .where((key) => key.contains(pattern)) - .toList(); - + final keysToRemove = + _cache.keys.where((key) => key.contains(pattern)).toList(); + for (final key in keysToRemove) { _cache.remove(key); } } - + /// 만료된 캐시 정리 void _cleanupExpiredCache() { final expiredKeys = _cache.entries .where((entry) => entry.value.isExpired) .map((entry) => entry.key) .toList(); - + for (final key in expiredKeys) { _cache.remove(key); } } - + /// 가장 오래된 캐시 항목 제거 void _evictOldestEntry() { if (_cache.isEmpty) return; - + var oldestKey = _cache.keys.first; var oldestTime = _cache[oldestKey]!.lastAccess; - + for (final entry in _cache.entries) { if (entry.value.lastAccess.isBefore(oldestTime)) { oldestKey = entry.key; oldestTime = entry.value.lastAccess; } } - + _cache.remove(oldestKey); } - + /// 이미지 캐시 최적화 static void optimizeImageCache() { PaintingBinding.instance.imageCache.maximumSize = maxImageCacheCount; PaintingBinding.instance.imageCache.maximumSizeBytes = maxImageCacheSize; } - + /// 이미지 캐시 상태 확인 static ImageCacheStatus getImageCacheStatus() { final cache = PaintingBinding.instance.imageCache; @@ -117,7 +116,7 @@ class MemoryManager { maximumSizeBytes: cache.maximumSizeBytes, ); } - + /// 이미지 캐시 비우기 static void clearImageCache() { PaintingBinding.instance.imageCache.clear(); @@ -126,24 +125,22 @@ class MemoryManager { print('🖼️ 이미지 캐시가 비워졌습니다.'); } } - + /// 위젯 참조 추적 void trackWidget(String key, State widget) { _widgetReferences[key] = WeakReference(widget); } - + /// 위젯 참조 제거 void untrackWidget(String key) { _widgetReferences.remove(key); } - + /// 살아있는 위젯 수 확인 int getAliveWidgetCount() { - return _widgetReferences.values - .where((ref) => ref.target != null) - .length; + return _widgetReferences.values.where((ref) => ref.target != null).length; } - + /// 메모리 압박 시 대응 void handleMemoryPressure() { // 캐시 50% 제거 @@ -151,43 +148,43 @@ class MemoryManager { for (final key in keysToRemove) { _cache.remove(key); } - + // 이미지 캐시 축소 final imageCache = PaintingBinding.instance.imageCache; imageCache.maximumSize = maxImageCacheCount ~/ 2; imageCache.maximumSizeBytes = maxImageCacheSize ~/ 2; - + if (kDebugMode) { print('⚠️ 메모리 압박 대응: 캐시 크기 감소'); } } - + /// 자동 메모리 정리 시작 Timer? _cleanupTimer; - + void startAutoCleanup({Duration interval = const Duration(minutes: 1)}) { _cleanupTimer?.cancel(); _cleanupTimer = Timer.periodic(interval, (_) { _cleanupExpiredCache(); - + // 죽은 위젯 참조 제거 final deadKeys = _widgetReferences.entries .where((entry) => entry.value.target == null) .map((entry) => entry.key) .toList(); - + for (final key in deadKeys) { _widgetReferences.remove(key); } }); } - + /// 자동 메모리 정리 중지 void stopAutoCleanup() { _cleanupTimer?.cancel(); _cleanupTimer = null; } - + /// 메모리 사용량 리포트 Map getMemoryReport() { return { @@ -206,13 +203,13 @@ class _CacheEntry { final DateTime timestamp; final Duration ttl; DateTime lastAccess; - + _CacheEntry({ required this.data, required this.timestamp, required this.ttl, }) : lastAccess = timestamp; - + bool get isExpired => DateTime.now().difference(timestamp) > ttl; } @@ -222,25 +219,26 @@ class ImageCacheStatus { final int maximumSize; final int currentSizeBytes; final int maximumSizeBytes; - + ImageCacheStatus({ required this.currentSize, required this.maximumSize, required this.currentSizeBytes, required this.maximumSizeBytes, }); - + double get sizeUsagePercentage => (currentSize / maximumSize) * 100; - double get bytesUsagePercentage => (currentSizeBytes / maximumSizeBytes) * 100; - + double get bytesUsagePercentage => + (currentSizeBytes / maximumSizeBytes) * 100; + Map toJson() => { - 'currentSize': currentSize, - 'maximumSize': maximumSize, - 'currentSizeBytes': currentSizeBytes, - 'maximumSizeBytes': maximumSizeBytes, - 'sizeUsagePercentage': sizeUsagePercentage.toStringAsFixed(2), - 'bytesUsagePercentage': bytesUsagePercentage.toStringAsFixed(2), - }; + 'currentSize': currentSize, + 'maximumSize': maximumSize, + 'currentSizeBytes': currentSizeBytes, + 'maximumSizeBytes': maximumSizeBytes, + 'sizeUsagePercentage': sizeUsagePercentage.toStringAsFixed(2), + 'bytesUsagePercentage': bytesUsagePercentage.toStringAsFixed(2), + }; } /// 메모리 효율적인 리스트 뷰 @@ -249,7 +247,7 @@ class MemoryEfficientListView extends StatefulWidget { final Widget Function(BuildContext, T) itemBuilder; final int cacheExtent; final ScrollPhysics? physics; - + const MemoryEfficientListView({ super.key, required this.items, @@ -257,23 +255,21 @@ class MemoryEfficientListView extends StatefulWidget { this.cacheExtent = 250, this.physics, }); - + @override - State> createState() => + State> createState() => _MemoryEfficientListViewState(); } -class _MemoryEfficientListViewState - extends State> +class _MemoryEfficientListViewState extends State> with AutomaticKeepAliveClientMixin { - @override bool get wantKeepAlive => false; - + @override Widget build(BuildContext context) { super.build(context); - + return ListView.builder( itemCount: widget.items.length, cacheExtent: widget.cacheExtent.toDouble(), @@ -283,4 +279,4 @@ class _MemoryEfficientListViewState }, ); } -} \ No newline at end of file +} diff --git a/lib/utils/performance_optimizer.dart b/lib/utils/performance_optimizer.dart index 4568720..a6ff576 100644 --- a/lib/utils/performance_optimizer.dart +++ b/lib/utils/performance_optimizer.dart @@ -5,19 +5,20 @@ import 'dart:async'; /// 성능 최적화를 위한 유틸리티 클래스 class PerformanceOptimizer { - static final PerformanceOptimizer _instance = PerformanceOptimizer._internal(); + static final PerformanceOptimizer _instance = + PerformanceOptimizer._internal(); factory PerformanceOptimizer() => _instance; PerformanceOptimizer._internal(); - + // 프레임 타이밍 정보 final List _frameTimings = []; bool _isMonitoring = false; - + /// 프레임 성능 모니터링 시작 void startFrameMonitoring() { if (_isMonitoring) return; _isMonitoring = true; - + SchedulerBinding.instance.addTimingsCallback((timings) { _frameTimings.addAll(timings); // 최근 100개 프레임만 유지 @@ -26,27 +27,27 @@ class PerformanceOptimizer { } }); } - + /// 프레임 성능 모니터링 중지 void stopFrameMonitoring() { if (!_isMonitoring) return; _isMonitoring = false; SchedulerBinding.instance.addTimingsCallback((_) {}); } - + /// 평균 FPS 계산 double getAverageFPS() { if (_frameTimings.isEmpty) return 0.0; - + double totalDuration = 0; for (final timing in _frameTimings) { totalDuration += timing.totalSpan.inMicroseconds; } - + final averageDuration = totalDuration / _frameTimings.length; return 1000000 / averageDuration; // microseconds to FPS } - + /// 메모리 사용량 모니터링 static Future getMemoryInfo() async { // Flutter에서는 직접적인 메모리 사용량 측정이 제한적이므로 @@ -57,7 +58,7 @@ class PerformanceOptimizer { capacity: imageCache.maximumSizeBytes, ); } - + /// 위젯 재빌드 최적화를 위한 데바운서 static Timer? _debounceTimer; static void debounce( @@ -67,7 +68,7 @@ class PerformanceOptimizer { _debounceTimer?.cancel(); _debounceTimer = Timer(delay, callback); } - + /// 스로틀링 - 지정된 시간 간격으로만 실행 static DateTime? _lastThrottleTime; static void throttle( @@ -81,7 +82,7 @@ class PerformanceOptimizer { callback(); } } - + /// 무거운 연산을 별도 Isolate에서 실행 static Future runInIsolate( ComputeCallback callback, @@ -89,7 +90,7 @@ class PerformanceOptimizer { ) async { return await compute(callback, parameter); } - + /// 레이지 로딩을 위한 페이지네이션 헬퍼 static List paginate({ required List items, @@ -98,13 +99,14 @@ class PerformanceOptimizer { }) { final startIndex = page * pageSize; final endIndex = (startIndex + pageSize).clamp(0, items.length); - + if (startIndex >= items.length) return []; return items.sublist(startIndex, endIndex); } - + /// 이미지 최적화 - 메모리 효율적인 크기로 조정 - static double getOptimalImageSize(BuildContext context, { + static double getOptimalImageSize( + BuildContext context, { required double originalSize, double maxSize = 1000, }) { @@ -113,29 +115,29 @@ class PerformanceOptimizer { final maxDimension = screenSize.width > screenSize.height ? screenSize.width : screenSize.height; - + final optimalSize = (maxDimension * devicePixelRatio).clamp(100.0, maxSize); return optimalSize < originalSize ? optimalSize : originalSize; } - + /// 위젯 키 최적화 static Key generateOptimizedKey(String prefix, dynamic identifier) { return ValueKey('${prefix}_$identifier'); } - + /// 애니메이션 최적화 - 보이지 않는 애니메이션 중지 static bool shouldAnimateWidget(BuildContext context) { final mediaQuery = MediaQuery.of(context); return !mediaQuery.disableAnimations && mediaQuery.accessibleNavigation; } - + /// 스크롤 성능 최적화 static ScrollPhysics getOptimizedScrollPhysics() { return const BouncingScrollPhysics( parent: AlwaysScrollableScrollPhysics(), ); } - + /// 빌드 최적화를 위한 const 위젯 권장사항 체크 static void checkConstOptimization() { if (kDebugMode) { @@ -147,16 +149,16 @@ class PerformanceOptimizer { print('5. 애니메이션은 AnimatedBuilder 사용'); } } - + /// 메모리 누수 감지 헬퍼 static final Map _widgetCounts = {}; - + static void trackWidget(String widgetName, bool isCreated) { if (!kDebugMode) return; - - _widgetCounts[widgetName] = (_widgetCounts[widgetName] ?? 0) + - (isCreated ? 1 : -1); - + + _widgetCounts[widgetName] = + (_widgetCounts[widgetName] ?? 0) + (isCreated ? 1 : -1); + // 위젯이 비정상적으로 많이 생성되면 경고 if ((_widgetCounts[widgetName] ?? 0) > 100) { print('⚠️ 경고: $widgetName 위젯이 100개 이상 생성됨. 메모리 누수 가능성!'); @@ -168,16 +170,18 @@ class PerformanceOptimizer { class MemoryInfo { final int currentUsage; final int capacity; - + MemoryInfo({ required this.currentUsage, required this.capacity, }); - + double get usagePercentage => (currentUsage / capacity) * 100; - - String get formattedUsage => '${(currentUsage / 1024 / 1024).toStringAsFixed(2)} MB'; - String get formattedCapacity => '${(capacity / 1024 / 1024).toStringAsFixed(2)} MB'; + + String get formattedUsage => + '${(currentUsage / 1024 / 1024).toStringAsFixed(2)} MB'; + String get formattedCapacity => + '${(capacity / 1024 / 1024).toStringAsFixed(2)} MB'; } /// 성능 측정 데코레이터 @@ -187,7 +191,7 @@ class PerformanceMeasure { required Future Function() operation, }) async { if (!kDebugMode) return await operation(); - + final stopwatch = Stopwatch()..start(); try { final result = await operation(); @@ -200,4 +204,4 @@ class PerformanceMeasure { rethrow; } } -} \ No newline at end of file +} diff --git a/lib/utils/platform_helper.dart b/lib/utils/platform_helper.dart index 06f00a4..cb41ea4 100644 --- a/lib/utils/platform_helper.dart +++ b/lib/utils/platform_helper.dart @@ -2,23 +2,23 @@ import 'package:flutter/foundation.dart'; class PlatformHelper { static bool get isWeb => kIsWeb; - + static bool get isIOS { if (kIsWeb) return false; return defaultTargetPlatform == TargetPlatform.iOS; } - + static bool get isAndroid { if (kIsWeb) return false; return defaultTargetPlatform == TargetPlatform.android; } - + static bool get isMobile => isIOS || isAndroid; - + static bool get isDesktop { if (kIsWeb) return false; return defaultTargetPlatform == TargetPlatform.linux || - defaultTargetPlatform == TargetPlatform.macOS || - defaultTargetPlatform == TargetPlatform.windows; + defaultTargetPlatform == TargetPlatform.macOS || + defaultTargetPlatform == TargetPlatform.windows; } -} \ No newline at end of file +} diff --git a/lib/utils/sms_scan/category_icon_mapper.dart b/lib/utils/sms_scan/category_icon_mapper.dart index 3d30f93..9be016c 100644 --- a/lib/utils/sms_scan/category_icon_mapper.dart +++ b/lib/utils/sms_scan/category_icon_mapper.dart @@ -50,4 +50,4 @@ class CategoryIconMapper { return 16.0; } } -} \ No newline at end of file +} diff --git a/lib/utils/sms_scan/date_formatter.dart b/lib/utils/sms_scan/date_formatter.dart index 2d8353e..f8adec9 100644 --- a/lib/utils/sms_scan/date_formatter.dart +++ b/lib/utils/sms_scan/date_formatter.dart @@ -26,7 +26,7 @@ class SmsDateFormatter { ) { // 주기에 따라 다음 결제일 예측 DateTime? predictedDate = _predictNextBillingDate(date, billingCycle, now); - + if (predictedDate != null) { final daysUntil = predictedDate.difference(now).inDays; return AppLocalizations.of(context).nextBillingDateEstimated( @@ -34,7 +34,7 @@ class SmsDateFormatter { daysUntil, ); } - + return '다음 결제일 확인 필요 (과거 날짜)'; } @@ -78,7 +78,7 @@ class SmsDateFormatter { // 다음 월간 결제일 계산 static DateTime _getNextMonthlyDate(DateTime lastDate, DateTime now) { int day = lastDate.day; - + // 현재 월의 마지막 날을 초과하는 경우 조정 final lastDay = DateTime(now.year, now.month + 1, 0).day; if (day > lastDay) { @@ -101,7 +101,7 @@ class SmsDateFormatter { // 다음 연간 결제일 계산 static DateTime _getNextYearlyDate(DateTime lastDate, DateTime now) { int day = lastDate.day; - + // 해당 월의 마지막 날을 초과하는 경우 조정 final lastDay = DateTime(now.year, lastDate.month + 1, 0).day; if (day > lastDay) { @@ -162,4 +162,4 @@ class SmsDateFormatter { static String getRepeatCountText(BuildContext context, int count) { return AppLocalizations.of(context).repeatCountDetected(count); } -} \ No newline at end of file +} diff --git a/lib/utils/subscription_category_helper.dart b/lib/utils/subscription_category_helper.dart index 55dda43..b5b57fa 100644 --- a/lib/utils/subscription_category_helper.dart +++ b/lib/utils/subscription_category_helper.dart @@ -86,8 +86,8 @@ class SubscriptionCategoryHelper { categorizedSubscriptions['shoppingEcommerce']!.add(subscription); } // 프로그래밍 - else if (_isInCategory(subscription.serviceName, - LegacyServiceData.programmingServices)) { + else if (_isInCategory( + subscription.serviceName, LegacyServiceData.programmingServices)) { if (!categorizedSubscriptions.containsKey('programming')) { categorizedSubscriptions['programming'] = []; } diff --git a/lib/widgets/add_subscription/add_subscription_app_bar.dart b/lib/widgets/add_subscription/add_subscription_app_bar.dart index 4be1c8b..d6d28d2 100644 --- a/lib/widgets/add_subscription/add_subscription_app_bar.dart +++ b/lib/widgets/add_subscription/add_subscription_app_bar.dart @@ -6,7 +6,8 @@ import '../../controllers/add_subscription_controller.dart'; import '../../l10n/app_localizations.dart'; /// 구독 추가 화면의 App Bar -class AddSubscriptionAppBar extends StatelessWidget implements PreferredSizeWidget { +class AddSubscriptionAppBar extends StatelessWidget + implements PreferredSizeWidget { final AddSubscriptionController controller; final double scrollOffset; final VoidCallback onScanSMS; @@ -101,4 +102,4 @@ class AddSubscriptionAppBar extends StatelessWidget implements PreferredSizeWidg ), ); } -} \ 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 index 1ab39e2..45b875a 100644 --- a/lib/widgets/add_subscription/add_subscription_event_section.dart +++ b/lib/widgets/add_subscription/add_subscription_event_section.dart @@ -66,7 +66,8 @@ class AddSubscriptionEventSection extends StatelessWidget { Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: controller.gradientColors[0].withValues(alpha: 0.1), + color: controller.gradientColors[0] + .withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), ), child: Icon( @@ -122,7 +123,7 @@ class AddSubscriptionEventSection extends StatelessWidget { ), ], ), - + // 이벤트 활성화 시 추가 필드 표시 AnimatedContainer( duration: const Duration(milliseconds: 300), @@ -155,7 +156,8 @@ class AddSubscriptionEventSection extends StatelessWidget { Expanded( child: Builder( builder: (context) { - final locale = Localizations.localeOf(context); + final locale = + Localizations.localeOf(context); String infoText; switch (locale.languageCode) { case 'ko': @@ -168,7 +170,8 @@ class AddSubscriptionEventSection extends StatelessWidget { infoText = '设置折扣或促销价格'; break; default: - infoText = 'Set up discount or promotion price'; + infoText = + 'Set up discount or promotion price'; } return Text( infoText, @@ -185,7 +188,7 @@ class AddSubscriptionEventSection extends StatelessWidget { ), ), const SizedBox(height: 20), - + // 이벤트 기간 Builder( builder: (context) { @@ -216,8 +219,10 @@ class AddSubscriptionEventSection extends StatelessWidget { setState(() { controller.eventStartDate = date; // 시작일 설정 시 종료일이 없으면 자동으로 1달 후로 설정 - if (date != null && controller.eventEndDate == null) { - controller.eventEndDate = date.add(const Duration(days: 30)); + if (date != null && + controller.eventEndDate == null) { + controller.eventEndDate = + date.add(const Duration(days: 30)); } }); }, @@ -233,17 +238,18 @@ class AddSubscriptionEventSection extends StatelessWidget { }, ), const SizedBox(height: 20), - + // 이벤트 가격 Builder( builder: (BuildContext innerContext) { // 현재 로케일 확인 - final currentLocale = Localizations.localeOf(innerContext); - + final currentLocale = + Localizations.localeOf(innerContext); + // 로케일에 따라 직접 텍스트 설정 String eventPriceLabel; String eventPriceHint; - + switch (currentLocale.languageCode) { case 'ko': eventPriceLabel = '이벤트 가격'; @@ -261,7 +267,7 @@ class AddSubscriptionEventSection extends StatelessWidget { eventPriceLabel = 'Event Price'; eventPriceHint = 'Enter discounted price'; } - + return CurrencyInputField( controller: controller.eventPriceController, currency: controller.currency, @@ -280,4 +286,4 @@ class AddSubscriptionEventSection extends StatelessWidget { ), ); } -} \ 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 index 1b52cd1..b91cea4 100644 --- a/lib/widgets/add_subscription/add_subscription_header.dart +++ b/lib/widgets/add_subscription/add_subscription_header.dart @@ -86,4 +86,4 @@ class AddSubscriptionHeader extends StatelessWidget { ), ); } -} \ 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 index 829b551..6de05cc 100644 --- a/lib/widgets/add_subscription/add_subscription_save_button.dart +++ b/lib/widgets/add_subscription/add_subscription_save_button.dart @@ -40,8 +40,8 @@ class AddSubscriptionSaveButton extends StatelessWidget { child: PrimaryButton( text: AppLocalizations.of(context).addSubscriptionButton, icon: Icons.add_circle_outline, - onPressed: controller.isLoading - ? null + onPressed: controller.isLoading + ? null : () => controller.saveSubscription(setState: setState), isLoading: controller.isLoading, backgroundColor: const Color(0xFF3B82F6), @@ -50,4 +50,4 @@ class AddSubscriptionSaveButton extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/analysis/analysis_badge.dart b/lib/widgets/analysis/analysis_badge.dart index 5c5ed09..d3deaef 100644 --- a/lib/widgets/analysis/analysis_badge.dart +++ b/lib/widgets/analysis/analysis_badge.dart @@ -69,13 +69,17 @@ class AnalysisBadge extends StatelessWidget { String displayText = amountText; if (amountText.length > 12) { // 괄호 안의 내용 제거 - displayText = amountText.replaceAll(RegExp(r'\([^)]*\)'), '').trim(); + displayText = + amountText.replaceAll(RegExp(r'\([^)]*\)'), '').trim(); } if (displayText.length > 10) { // 통화 기호만 남기고 숫자만 표시 - final currencySymbol = CurrencyUtil.getCurrencySymbol(subscription.currency); - displayText = displayText.replaceAll(currencySymbol, '').trim(); - displayText = '$currencySymbol${displayText.substring(0, 6)}...'; + final currencySymbol = + CurrencyUtil.getCurrencySymbol(subscription.currency); + displayText = + displayText.replaceAll(currencySymbol, '').trim(); + displayText = + '$currencySymbol${displayText.substring(0, 6)}...'; } return Text( displayText, @@ -93,4 +97,4 @@ class AnalysisBadge extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/analysis/analysis_screen_spacer.dart b/lib/widgets/analysis/analysis_screen_spacer.dart index f8db192..138d6f0 100644 --- a/lib/widgets/analysis/analysis_screen_spacer.dart +++ b/lib/widgets/analysis/analysis_screen_spacer.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; /// SliverToBoxAdapter 오류를 해결하기 위해 별도 컴포넌트로 분리 class AnalysisScreenSpacer extends StatelessWidget { final double height; - + const AnalysisScreenSpacer({ super.key, this.height = 24, @@ -16,4 +16,4 @@ class AnalysisScreenSpacer extends StatelessWidget { child: SizedBox(height: height), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/analysis/event_analysis_card.dart b/lib/widgets/analysis/event_analysis_card.dart index 2dfe07e..1b2e01f 100644 --- a/lib/widgets/analysis/event_analysis_card.dart +++ b/lib/widgets/analysis/event_analysis_card.dart @@ -48,10 +48,12 @@ class EventAnalysisCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisAlignment: + MainAxisAlignment.spaceBetween, children: [ ThemedText.headline( - text: AppLocalizations.of(context).eventDiscountStatus, + text: AppLocalizations.of(context) + .eventDiscountStatus, style: const TextStyle( fontSize: 18, ), @@ -79,7 +81,10 @@ class EventAnalysisCard extends StatelessWidget { ), const SizedBox(width: 4), Text( - AppLocalizations.of(context).servicesInProgress(provider.activeEventSubscriptions.length), + AppLocalizations.of(context) + .servicesInProgress(provider + .activeEventSubscriptions + .length), style: const TextStyle( fontSize: 12, fontWeight: FontWeight.bold, @@ -97,15 +102,18 @@ class EventAnalysisCard extends StatelessWidget { decoration: BoxDecoration( gradient: LinearGradient( colors: [ - const Color(0xFFFF6B6B).withValues(alpha: 0.1), - const Color(0xFFFF8787).withValues(alpha: 0.1), + const Color(0xFFFF6B6B) + .withValues(alpha: 0.1), + const Color(0xFFFF8787) + .withValues(alpha: 0.1), ], begin: Alignment.topLeft, end: Alignment.bottomRight, ), borderRadius: BorderRadius.circular(8), border: Border.all( - color: const Color(0xFFFF6B6B).withValues(alpha: 0.3), + color: const Color(0xFFFF6B6B) + .withValues(alpha: 0.3), ), ), child: Row( @@ -118,10 +126,12 @@ class EventAnalysisCard extends StatelessWidget { const SizedBox(width: 12), Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: + CrossAxisAlignment.start, children: [ ThemedText( - AppLocalizations.of(context).monthlySavingAmount, + AppLocalizations.of(context) + .monthlySavingAmount, style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w500, @@ -154,24 +164,29 @@ class EventAnalysisCard extends StatelessWidget { ), const SizedBox(height: 12), ...provider.activeEventSubscriptions.map((sub) { - final savings = sub.originalPrice - (sub.eventPrice ?? sub.originalPrice); - final discountRate = - ((savings / sub.originalPrice) * 100).round(); + final savings = sub.originalPrice - + (sub.eventPrice ?? sub.originalPrice); + final discountRate = + ((savings / sub.originalPrice) * 100) + .round(); return Container( margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: AppColors.darkNavy.withValues(alpha: 0.05), + color: AppColors.darkNavy + .withValues(alpha: 0.05), borderRadius: BorderRadius.circular(8), border: Border.all( - color: AppColors.darkNavy.withValues(alpha: 0.1), + color: AppColors.darkNavy + .withValues(alpha: 0.1), ), ), child: Row( children: [ Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: + CrossAxisAlignment.start, children: [ ThemedText( sub.serviceName, @@ -184,8 +199,8 @@ class EventAnalysisCard extends StatelessWidget { Row( children: [ FutureBuilder( - future: CurrencyUtil - .formatAmount( + future: + CurrencyUtil.formatAmount( sub.originalPrice, sub.currency), builder: (context, snapshot) { @@ -194,9 +209,11 @@ class EventAnalysisCard extends StatelessWidget { snapshot.data!, style: const TextStyle( fontSize: 12, - decoration: TextDecoration - .lineThrough, - color: AppColors.navyGray, + decoration: + TextDecoration + .lineThrough, + color: AppColors + .navyGray, ), ); } @@ -211,9 +228,10 @@ class EventAnalysisCard extends StatelessWidget { ), const SizedBox(width: 8), FutureBuilder( - future: CurrencyUtil - .formatAmount( - sub.eventPrice ?? sub.originalPrice, + future: + CurrencyUtil.formatAmount( + sub.eventPrice ?? + sub.originalPrice, sub.currency), builder: (context, snapshot) { if (snapshot.hasData) { @@ -244,7 +262,8 @@ class EventAnalysisCard extends StatelessWidget { decoration: BoxDecoration( color: const Color(0xFFFF6B6B) .withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(4), + borderRadius: + BorderRadius.circular(4), ), child: Text( '$discountRate${AppLocalizations.of(context).discountPercent}', @@ -271,4 +290,4 @@ class EventAnalysisCard extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/analysis/monthly_expense_chart_card.dart b/lib/widgets/analysis/monthly_expense_chart_card.dart index edbe059..ec9bb21 100644 --- a/lib/widgets/analysis/monthly_expense_chart_card.dart +++ b/lib/widgets/analysis/monthly_expense_chart_card.dart @@ -23,7 +23,7 @@ class MonthlyExpenseChartCard extends StatelessWidget { /// Y축 최대값을 계산합니다 (언어별 통화 단위에 맞춰) double _calculateChartMaxY(double maxValue, String locale) { final currency = CurrencyUtil.getDefaultCurrency(locale); - + if (currency == 'KRW' || currency == 'JPY') { // 소수점이 없는 통화 (원화, 엔화) if (maxValue <= 0) return 100000; @@ -33,9 +33,10 @@ class MonthlyExpenseChartCard extends StatelessWidget { if (maxValue <= 200000) return 200000; if (maxValue <= 500000) return 500000; if (maxValue <= 1000000) return 1000000; - + // 큰 금액은 자릿수에 맞춰 반올림 - final magnitude = math.pow(10, maxValue.toString().split('.')[0].length - 1).toDouble(); + final magnitude = + math.pow(10, maxValue.toString().split('.')[0].length - 1).toDouble(); return ((maxValue / magnitude).ceil() * magnitude).toDouble(); } else { // 소수점이 있는 통화 (달러, 위안) @@ -47,7 +48,7 @@ class MonthlyExpenseChartCard extends StatelessWidget { if (maxValue <= 250) return 250.0; if (maxValue <= 500) return 500.0; if (maxValue <= 1000) return 1000.0; - + // 큰 금액은 100 단위로 반올림 return ((maxValue / 100).ceil() * 100).toDouble(); } @@ -164,8 +165,7 @@ class MonthlyExpenseChartCard extends StatelessWidget { 0, (max, data) => math.max( max, data['totalExpense'] as double)), - locale - ), + locale), barGroups: _getMonthlyBarGroups(locale), gridData: FlGridData( show: true, @@ -176,13 +176,12 @@ class MonthlyExpenseChartCard extends StatelessWidget { 0, (max, data) => math.max(max, data['totalExpense'] as double)), - locale - ), - CurrencyUtil.getDefaultCurrency(locale) - ), + locale), + CurrencyUtil.getDefaultCurrency(locale)), getDrawingHorizontalLine: (value) { return FlLine( - color: AppColors.navyGray.withValues(alpha: 0.1), + color: + AppColors.navyGray.withValues(alpha: 0.1), strokeWidth: 1, ); }, @@ -233,10 +232,11 @@ class MonthlyExpenseChartCard extends StatelessWidget { ), children: [ TextSpan( - text: CurrencyUtil.formatTotalAmountWithLocale( - monthlyData[group.x]['totalExpense'] - as double, - locale), + text: CurrencyUtil + .formatTotalAmountWithLocale( + monthlyData[group.x] + ['totalExpense'] as double, + locale), style: const TextStyle( color: Color(0xFFFBBF24), fontSize: 14, @@ -254,7 +254,8 @@ class MonthlyExpenseChartCard extends StatelessWidget { const SizedBox(height: 16), Center( child: ThemedText.caption( - text: AppLocalizations.of(context).monthlySubscriptionExpense, + text: AppLocalizations.of(context) + .monthlySubscriptionExpense, style: const TextStyle( fontSize: 12, fontWeight: FontWeight.bold, @@ -270,4 +271,4 @@ class MonthlyExpenseChartCard extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/analysis/subscription_pie_chart_card.dart b/lib/widgets/analysis/subscription_pie_chart_card.dart index 17cf530..d991fff 100644 --- a/lib/widgets/analysis/subscription_pie_chart_card.dart +++ b/lib/widgets/analysis/subscription_pie_chart_card.dart @@ -23,14 +23,15 @@ class SubscriptionPieChartCard extends StatefulWidget { }); @override - State createState() => _SubscriptionPieChartCardState(); + State createState() => + _SubscriptionPieChartCardState(); } class _SubscriptionPieChartCardState extends State { int _touchedIndex = -1; late Future> _pieSectionsFuture; String? _lastLocale; - + static const _chartColors = [ Color(0xFF3B82F6), Color(0xFF10B981), @@ -52,7 +53,7 @@ class _SubscriptionPieChartCardState extends State { super.didUpdateWidget(oldWidget); // subscriptions나 locale이 변경된 경우만 Future 재생성 final currentLocale = context.read().locale.languageCode; - if (!_listEquals(oldWidget.subscriptions, widget.subscriptions) || + if (!_listEquals(oldWidget.subscriptions, widget.subscriptions) || _lastLocale != currentLocale) { _initializeFuture(); } @@ -66,7 +67,7 @@ class _SubscriptionPieChartCardState extends State { bool _listEquals(List a, List b) { if (a.length != b.length) return false; for (int i = 0; i < a.length; i++) { - if (a[i].id != b[i].id || + if (a[i].id != b[i].id || a[i].currentPrice != b[i].currentPrice || a[i].currency != b[i].currency || a[i].serviceName != b[i].serviceName) { @@ -78,7 +79,6 @@ class _SubscriptionPieChartCardState extends State { // 파이 차트 섹션 데이터 (언어별 기본 통화로 환산) Future> _getPieSections() async { - if (widget.subscriptions.isEmpty) return []; // 현재 locale 가져오기 @@ -91,17 +91,19 @@ class _SubscriptionPieChartCardState extends State { // 각 구독의 현재 가격을 언어별 기본 통화로 환산 for (var subscription in widget.subscriptions) { double value = subscription.currentPrice; - + if (subscription.currency == defaultCurrency) { // 이미 기본 통화인 경우 그대로 사용 sectionValues.add(value); } else if (subscription.currency == 'USD') { // USD를 기본 통화로 변환 - final converted = await ExchangeRateService().convertUsdToTarget(value, defaultCurrency); + final converted = await ExchangeRateService() + .convertUsdToTarget(value, defaultCurrency); sectionValues.add(converted ?? value); } else if (defaultCurrency == 'USD') { // 기본 통화가 USD인 경우 다른 통화를 USD로 변환 - final converted = await ExchangeRateService().convertTargetToUsd(value, subscription.currency); + final converted = await ExchangeRateService() + .convertTargetToUsd(value, subscription.currency); sectionValues.add(converted ?? value); } else { // 기타 통화는 일단 그대로 사용 (환율 정보가 없는 경우) @@ -111,7 +113,7 @@ class _SubscriptionPieChartCardState extends State { // 총합 계산 double sectionsTotal = sectionValues.fold(0, (sum, value) => sum + value); - + // 총합이 0이면 빈 배열 반환 if (sectionsTotal == 0) return []; @@ -138,17 +140,17 @@ class _SubscriptionPieChartCardState extends State { badgePositionPercentageOffset: .98, ); }); - + return sections; } // 배지 위젯 생성 Widget _createBadgeWidget(int index) { if (index >= widget.subscriptions.length) return const SizedBox.shrink(); - + final subscription = widget.subscriptions[index]; final colorIndex = index % _chartColors.length; - + return IgnorePointer( child: AnalysisBadge( size: 40, @@ -159,24 +161,27 @@ class _SubscriptionPieChartCardState extends State { } // 터치 상태를 반영한 섹션 데이터 생성 - List _applyTouchedState(List sections) { + List _applyTouchedState( + List sections) { return List.generate(sections.length, (i) { final section = sections[i]; final isTouched = _touchedIndex == i; final fontSize = isTouched ? 16.0 : 12.0; final radius = isTouched ? 105.0 : 100.0; - + return PieChartSectionData( value: section.value, title: section.title, - titleStyle: section.titleStyle?.copyWith(fontSize: fontSize) ?? TextStyle( - fontSize: fontSize, - fontWeight: FontWeight.bold, - color: AppColors.pureWhite, - shadows: const [ - Shadow(color: Colors.black26, blurRadius: 2, offset: Offset(0, 1)) - ], - ), + titleStyle: section.titleStyle?.copyWith(fontSize: fontSize) ?? + TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.bold, + color: AppColors.pureWhite, + shadows: const [ + Shadow( + color: Colors.black26, blurRadius: 2, offset: Offset(0, 1)) + ], + ), color: section.color, radius: radius, titlePositionPercentageOffset: section.titlePositionPercentageOffset, @@ -217,18 +222,20 @@ class _SubscriptionPieChartCardState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ ThemedText.headline( - text: AppLocalizations.of(context).subscriptionServiceRatio, + text: AppLocalizations.of(context) + .subscriptionServiceRatio, style: const TextStyle( fontSize: 18, ), ), FutureBuilder( future: CurrencyUtil.getExchangeRateInfoForLocale( - context.watch().locale.languageCode - ), + context + .watch() + .locale + .languageCode), builder: (context, snapshot) { - if (snapshot.hasData && - snapshot.data!.isNotEmpty) { + if (snapshot.hasData && snapshot.data!.isNotEmpty) { return Container( padding: const EdgeInsets.symmetric( horizontal: 8, @@ -236,15 +243,15 @@ class _SubscriptionPieChartCardState extends State { ), decoration: BoxDecoration( color: const Color(0xFFE5F2FF), - borderRadius: - BorderRadius.circular(4), + borderRadius: BorderRadius.circular(4), border: Border.all( color: const Color(0xFFBFDBFE), width: 1, ), ), child: Text( - AppLocalizations.of(context).exchangeRateFormat(snapshot.data!), + AppLocalizations.of(context) + .exchangeRateFormat(snapshot.data!), style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w500, @@ -272,7 +279,8 @@ class _SubscriptionPieChartCardState extends State { height: 250, child: Center( child: ThemedText( - AppLocalizations.of(context).noSubscriptionServices, + AppLocalizations.of(context) + .noSubscriptionServices, style: const TextStyle( fontSize: 16, ), @@ -284,36 +292,41 @@ class _SubscriptionPieChartCardState extends State { child: FutureBuilder>( future: _pieSectionsFuture, builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { + if (snapshot.connectionState == + ConnectionState.waiting) { return const Center( child: CircularProgressIndicator(), ); } - - if (!snapshot.hasData || snapshot.data!.isEmpty) { + + if (!snapshot.hasData || + snapshot.data!.isEmpty) { return Center( child: ThemedText( - AppLocalizations.of(context).noSubscriptionServices, + AppLocalizations.of(context) + .noSubscriptionServices, style: const TextStyle( fontSize: 16, ), ), ); } - + return PieChart( PieChartData( borderData: FlBorderData(show: false), sectionsSpace: 2, centerSpaceRadius: 60, - sections: _applyTouchedState(snapshot.data!), + sections: + _applyTouchedState(snapshot.data!), pieTouchData: PieTouchData( enabled: true, touchCallback: (FlTouchEvent event, pieTouchResponse) { // 터치 응답이 없거나 섹션이 없는 경우 if (pieTouchResponse == null || - pieTouchResponse.touchedSection == null) { + pieTouchResponse.touchedSection == + null) { // 차트 밖으로 나갔을 때만 리셋 if (_touchedIndex != -1) { setState(() { @@ -322,22 +335,25 @@ class _SubscriptionPieChartCardState extends State { } return; } - + final touchedIndex = pieTouchResponse .touchedSection! .touchedSectionIndex; - + // 탭 이벤트 처리 (토글) if (event is FlTapUpEvent) { setState(() { // 동일 섹션 탭하면 선택 해제, 아니면 선택 - _touchedIndex = (_touchedIndex == touchedIndex) ? -1 : touchedIndex; + _touchedIndex = (_touchedIndex == + touchedIndex) + ? -1 + : touchedIndex; }); return; } - + // hover 이벤트 처리 (단순 표시) - if (event is FlPointerHoverEvent || + if (event is FlPointerHoverEvent || event is FlPointerEnterEvent) { // 현재 인덱스와 다른 경우만 업데이트 if (_touchedIndex != touchedIndex) { @@ -364,10 +380,10 @@ class _SubscriptionPieChartCardState extends State { (index) { final subscription = widget.subscriptions[index]; - final color = _chartColors[index % _chartColors.length]; + final color = + _chartColors[index % _chartColors.length]; return Padding( - padding: const EdgeInsets.only( - bottom: 4.0), + padding: const EdgeInsets.only(bottom: 4.0), child: Row( children: [ Container( @@ -385,31 +401,31 @@ class _SubscriptionPieChartCardState extends State { style: const TextStyle( fontSize: 14, ), - overflow: - TextOverflow.ellipsis, + overflow: TextOverflow.ellipsis, ), ), FutureBuilder( future: CurrencyUtil .formatSubscriptionAmountWithLocale( subscription, - context.read().locale.languageCode), + context + .read() + .locale + .languageCode), builder: (context, snapshot) { if (snapshot.hasData) { return ThemedText( snapshot.data!, style: const TextStyle( fontSize: 14, - fontWeight: - FontWeight.bold, + fontWeight: FontWeight.bold, ), ); } return const SizedBox( width: 20, height: 20, - child: - CircularProgressIndicator( + child: CircularProgressIndicator( strokeWidth: 2, ), ); @@ -430,4 +446,4 @@ class _SubscriptionPieChartCardState extends State { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/analysis/total_expense_summary_card.dart b/lib/widgets/analysis/total_expense_summary_card.dart index 2e06d94..ad78407 100644 --- a/lib/widgets/analysis/total_expense_summary_card.dart +++ b/lib/widgets/analysis/total_expense_summary_card.dart @@ -56,7 +56,8 @@ class TotalExpenseSummaryCard extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ ThemedText.headline( - text: AppLocalizations.of(context).totalExpenseSummary, + text: + AppLocalizations.of(context).totalExpenseSummary, style: const TextStyle( fontSize: 18, ), @@ -67,20 +68,24 @@ class TotalExpenseSummaryCard extends StatelessWidget { padding: EdgeInsets.zero, constraints: const BoxConstraints(), onPressed: () async { - final totalExpenseText = CurrencyUtil.formatTotalAmountWithLocale(totalExpense, locale); + final totalExpenseText = + CurrencyUtil.formatTotalAmountWithLocale( + totalExpense, locale); await Clipboard.setData( ClipboardData(text: totalExpenseText)); HapticFeedbackHelper.lightImpact(); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(AppLocalizations.of(context).totalExpenseCopied(totalExpenseText)), + content: Text(AppLocalizations.of(context) + .totalExpenseCopied(totalExpenseText)), duration: const Duration(seconds: 2), behavior: SnackBarBehavior.floating, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), - backgroundColor: AppColors.glassBackground.withValues(alpha: 0.3), + backgroundColor: AppColors.glassBackground + .withValues(alpha: 0.3), margin: const EdgeInsets.symmetric( horizontal: 16, vertical: 8, @@ -115,7 +120,8 @@ class TotalExpenseSummaryCard extends StatelessWidget { ), const SizedBox(height: 4), ThemedText( - CurrencyUtil.formatTotalAmountWithLocale(totalExpense, locale), + CurrencyUtil.formatTotalAmountWithLocale( + totalExpense, locale), style: const TextStyle( fontSize: 26, fontWeight: FontWeight.bold, @@ -134,10 +140,12 @@ class TotalExpenseSummaryCard extends StatelessWidget { Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: AppColors.glassBackground.withValues(alpha: 0.3), + color: AppColors.glassBackground + .withValues(alpha: 0.3), borderRadius: BorderRadius.circular(8), border: Border.all( - color: AppColors.glassBorder.withValues(alpha: 0.2), + color: AppColors.glassBorder + .withValues(alpha: 0.2), ), ), child: const FaIcon( @@ -152,7 +160,8 @@ class TotalExpenseSummaryCard extends StatelessWidget { CrossAxisAlignment.start, children: [ ThemedText.caption( - text: AppLocalizations.of(context).totalServices, + text: AppLocalizations.of(context) + .totalServices, style: const TextStyle( fontSize: 12, fontWeight: FontWeight.bold, @@ -160,7 +169,9 @@ class TotalExpenseSummaryCard extends StatelessWidget { ), const SizedBox(height: 2), ThemedText( - AppLocalizations.of(context).subscriptionCount(subscriptions.length), + AppLocalizations.of(context) + .subscriptionCount( + subscriptions.length), style: const TextStyle( fontSize: 18, fontWeight: FontWeight.bold, @@ -176,10 +187,12 @@ class TotalExpenseSummaryCard extends StatelessWidget { Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: AppColors.glassBackground.withValues(alpha: 0.3), + color: AppColors.glassBackground + .withValues(alpha: 0.3), borderRadius: BorderRadius.circular(8), border: Border.all( - color: AppColors.glassBorder.withValues(alpha: 0.2), + color: AppColors.glassBorder + .withValues(alpha: 0.2), ), ), child: const FaIcon( @@ -194,7 +207,8 @@ class TotalExpenseSummaryCard extends StatelessWidget { CrossAxisAlignment.start, children: [ ThemedText.caption( - text: AppLocalizations.of(context).averageCost, + text: AppLocalizations.of(context) + .averageCost, style: const TextStyle( fontSize: 12, fontWeight: FontWeight.bold, @@ -202,11 +216,13 @@ class TotalExpenseSummaryCard extends StatelessWidget { ), const SizedBox(height: 2), ThemedText( - CurrencyUtil.formatTotalAmountWithLocale( - subscriptions.isEmpty - ? 0 - : totalExpense / subscriptions.length, - locale), + CurrencyUtil + .formatTotalAmountWithLocale( + subscriptions.isEmpty + ? 0 + : totalExpense / + subscriptions.length, + locale), style: const TextStyle( fontSize: 18, fontWeight: FontWeight.bold, @@ -230,4 +246,4 @@ class TotalExpenseSummaryCard extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/animated_page_transitions.dart b/lib/widgets/animated_page_transitions.dart index d79deed..e003c9d 100644 --- a/lib/widgets/animated_page_transitions.dart +++ b/lib/widgets/animated_page_transitions.dart @@ -5,7 +5,7 @@ import 'dart:math' as math; class SlidePageRoute extends PageRouteBuilder { final Widget page; final AxisDirection direction; - + SlidePageRoute({ required this.page, this.direction = AxisDirection.right, @@ -29,20 +29,20 @@ class SlidePageRoute extends PageRouteBuilder { begin = const Offset(0.0, -1.0); break; } - + const end = Offset.zero; const curve = Curves.easeOutCubic; - + var tween = Tween(begin: begin, end: end).chain( CurveTween(curve: curve), ); var offsetAnimation = animation.drive(tween); - + var fadeTween = Tween(begin: 0.0, end: 1.0).chain( CurveTween(curve: curve), ); var fadeAnimation = animation.drive(fadeTween); - + return SlideTransition( position: offsetAnimation, child: FadeTransition( @@ -58,7 +58,7 @@ class SlidePageRoute extends PageRouteBuilder { class ScalePageRoute extends PageRouteBuilder { final Widget page; final Alignment alignment; - + ScalePageRoute({ required this.page, this.alignment = Alignment.center, @@ -68,17 +68,17 @@ class ScalePageRoute extends PageRouteBuilder { reverseTransitionDuration: const Duration(milliseconds: 400), transitionsBuilder: (context, animation, secondaryAnimation, child) { const curve = Curves.elasticOut; - + var scaleTween = Tween(begin: 0.0, end: 1.0).chain( CurveTween(curve: curve), ); var scaleAnimation = animation.drive(scaleTween); - + var fadeTween = Tween(begin: 0.0, end: 1.0).chain( CurveTween(curve: Curves.easeIn), ); var fadeAnimation = animation.drive(fadeTween); - + return ScaleTransition( scale: scaleAnimation, alignment: alignment, @@ -94,7 +94,7 @@ class ScalePageRoute extends PageRouteBuilder { /// 회전 + 스케일 전환 class RotatePageRoute extends PageRouteBuilder { final Widget page; - + RotatePageRoute({required this.page}) : super( pageBuilder: (context, animation, secondaryAnimation) => page, @@ -102,17 +102,17 @@ class RotatePageRoute extends PageRouteBuilder { reverseTransitionDuration: const Duration(milliseconds: 500), transitionsBuilder: (context, animation, secondaryAnimation, child) { const curve = Curves.easeInOut; - + var rotateTween = Tween(begin: -0.5, end: 0.0).chain( CurveTween(curve: curve), ); var rotateAnimation = animation.drive(rotateTween); - + var scaleTween = Tween(begin: 0.0, end: 1.0).chain( CurveTween(curve: curve), ); var scaleAnimation = animation.drive(scaleTween); - + return Transform( alignment: Alignment.center, transform: Matrix4.identity() @@ -129,7 +129,7 @@ class RotatePageRoute extends PageRouteBuilder { class FlipPageRoute extends PageRouteBuilder { final Widget page; final bool horizontal; - + FlipPageRoute({ required this.page, this.horizontal = true, @@ -138,8 +138,9 @@ class FlipPageRoute extends PageRouteBuilder { transitionDuration: const Duration(milliseconds: 800), reverseTransitionDuration: const Duration(milliseconds: 800), transitionsBuilder: (context, animation, secondaryAnimation, child) { - final isAnimatingForward = animation.status == AnimationStatus.forward; - + final isAnimatingForward = + animation.status == AnimationStatus.forward; + final flipAnimation = Tween( begin: 0.0, end: isAnimatingForward ? -math.pi : math.pi, @@ -147,12 +148,12 @@ class FlipPageRoute extends PageRouteBuilder { parent: animation, curve: Curves.easeInOut, )); - + return AnimatedBuilder( animation: flipAnimation, builder: (context, child) { final isShowingFront = flipAnimation.value.abs() < math.pi / 2; - + return Transform( alignment: Alignment.center, transform: Matrix4.identity() @@ -181,7 +182,7 @@ class ContainerTransformPageRoute extends PageRouteBuilder { final Widget page; final Widget startWidget; final BorderRadius? borderRadius; - + ContainerTransformPageRoute({ required this.page, required this.startWidget, @@ -208,7 +209,7 @@ class ContainerTransformPageRoute extends PageRouteBuilder { final scale = 0.5 + (0.5 * progress); final radius = borderRadius?.topLeft.x ?? 0; final currentRadius = radius * (1 - progress); - + return Transform.scale( scale: scale, child: ClipRRect( @@ -229,7 +230,7 @@ class ContainerTransformPageRoute extends PageRouteBuilder { class CustomHeroPageRoute extends PageRouteBuilder { final Widget page; final String heroTag; - + CustomHeroPageRoute({ required this.page, required this.heroTag, @@ -253,7 +254,7 @@ class CustomHeroPageRoute extends PageRouteBuilder { class SharedAxisPageRoute extends PageRouteBuilder { final Widget page; final SharedAxisTransitionType transitionType; - + SharedAxisPageRoute({ required this.page, required this.transitionType, @@ -264,7 +265,7 @@ class SharedAxisPageRoute extends PageRouteBuilder { transitionsBuilder: (context, animation, secondaryAnimation, child) { late final Offset begin; late final Offset end; - + switch (transitionType) { case SharedAxisTransitionType.horizontal: begin = const Offset(1.0, 0.0); @@ -279,17 +280,17 @@ class SharedAxisPageRoute extends PageRouteBuilder { end = Offset.zero; break; } - + final slideTween = Tween(begin: begin, end: end); final fadeTween = Tween(begin: 0.0, end: 1.0); final scaleTween = transitionType == SharedAxisTransitionType.scaled ? Tween(begin: 0.8, end: 1.0) : Tween(begin: 1.0, end: 1.0); - + final slideAnimation = animation.drive(slideTween); final fadeAnimation = animation.drive(fadeTween); final scaleAnimation = animation.drive(scaleTween); - + return SlideTransition( position: slideAnimation, child: FadeTransition( @@ -308,4 +309,4 @@ enum SharedAxisTransitionType { horizontal, vertical, scaled, -} \ No newline at end of file +} diff --git a/lib/widgets/animated_wave_background.dart b/lib/widgets/animated_wave_background.dart index 9fb2d8f..8f4ca27 100644 --- a/lib/widgets/animated_wave_background.dart +++ b/lib/widgets/animated_wave_background.dart @@ -109,8 +109,8 @@ class AnimatedWaveBackground extends StatelessWidget { width: 30, height: 30, decoration: BoxDecoration( - color: Colors.white.withValues(alpha: - 0.1 + 0.1 * pulseController.value, + color: Colors.white.withValues( + alpha: 0.1 + 0.1 * pulseController.value, ), borderRadius: BorderRadius.circular(15), ), diff --git a/lib/widgets/app_navigator.dart b/lib/widgets/app_navigator.dart index 7e7a01b..aa1a876 100644 --- a/lib/widgets/app_navigator.dart +++ b/lib/widgets/app_navigator.dart @@ -18,7 +18,7 @@ class AppNavigator { HapticFeedback.lightImpact(); final navigationProvider = context.read(); navigationProvider.clearHistoryAndGoHome(); - + await Navigator.of(context).pushNamedAndRemoveUntil( AppRoutes.main, (route) => false, @@ -30,22 +30,23 @@ class AppNavigator { HapticFeedback.lightImpact(); final navigationProvider = context.read(); navigationProvider.updateCurrentIndex(1); - + await Navigator.of(context).pushNamed(AppRoutes.analysis); } /// 구독 추가 화면으로 네비게이션 static Future toAddSubscription(BuildContext context) async { HapticFeedback.mediumImpact(); - + await Navigator.of(context).pushNamed(AppRoutes.addSubscription); } /// 구독 상세 화면으로 네비게이션 - static Future toDetail(BuildContext context, SubscriptionModel subscription) async { + static Future toDetail( + BuildContext context, SubscriptionModel subscription) async { print('AppNavigator.toDetail 호출됨: ${subscription.serviceName}'); HapticFeedback.lightImpact(); - + try { await Navigator.of(context).pushNamed( AppRoutes.subscriptionDetail, @@ -62,7 +63,7 @@ class AppNavigator { HapticFeedback.lightImpact(); final navigationProvider = context.read(); navigationProvider.updateCurrentIndex(3); - + await Navigator.of(context).pushNamed(AppRoutes.smsScanner); } @@ -71,14 +72,14 @@ class AppNavigator { HapticFeedback.lightImpact(); final navigationProvider = context.read(); navigationProvider.updateCurrentIndex(4); - + await Navigator.of(context).pushNamed(AppRoutes.settings); } /// 카테고리 관리 화면으로 네비게이션 static Future toCategoryManagement(BuildContext context) async { HapticFeedback.lightImpact(); - + await Navigator.of(context).push( SlidePageRoute( page: const CategoryManagementScreen(), @@ -101,20 +102,20 @@ class AppNavigator { static Future handleBackButton(BuildContext context) async { final navigator = Navigator.of(context); final navigationProvider = context.read(); - + // 네비게이션 스택이 있으면 팝 if (navigator.canPop()) { HapticFeedback.lightImpact(); - + // NavigationProvider의 히스토리를 사용하여 이전 인덱스로 복원 if (navigationProvider.canPop()) { navigationProvider.pop(); } - + navigator.pop(); return false; } - + // 앱 종료 확인 final shouldExit = await showDialog( context: context, @@ -133,7 +134,7 @@ class AppNavigator { ], ), ); - + return shouldExit ?? false; } @@ -141,17 +142,17 @@ class AppNavigator { static void handleFloatingNavTap(BuildContext context, int index) { final navigationProvider = context.read(); final currentIndex = navigationProvider.currentIndex; - + // 같은 탭을 다시 탭하면 아무 동작 안 함 if (currentIndex == index) { return; } - + // 현재 화면이 메인이 아니면 먼저 메인으로 돌아가기 if (Navigator.of(context).canPop()) { Navigator.of(context).popUntil((route) => route.isFirst); } - + // 선택된 인덱스에 따라 네비게이션 switch (index) { case 0: // 홈 @@ -196,6 +197,7 @@ class AppNavigationObserver extends NavigatorObserver { @override void didReplace({Route? newRoute, Route? oldRoute}) { super.didReplace(newRoute: newRoute, oldRoute: oldRoute); - debugPrint('Navigation: Replace ${oldRoute?.settings.name} with ${newRoute?.settings.name}'); + debugPrint( + 'Navigation: Replace ${oldRoute?.settings.name} with ${newRoute?.settings.name}'); } -} \ No newline at end of file +} diff --git a/lib/widgets/category_header_widget.dart b/lib/widgets/category_header_widget.dart index 85fd35c..dddba5e 100644 --- a/lib/widgets/category_header_widget.dart +++ b/lib/widgets/category_header_widget.dart @@ -66,13 +66,14 @@ class CategoryHeaderWidget extends StatelessWidget { /// 통화별 합계를 표시하는 문자열을 생성합니다. String _buildCostDisplay(BuildContext context) { final parts = []; - + // 개수는 항상 표시 - parts.add(AppLocalizations.of(context).subscriptionCount(subscriptionCount)); - + parts + .add(AppLocalizations.of(context).subscriptionCount(subscriptionCount)); + // 통화 부분을 별도로 처리 final currencyParts = []; - + // 달러가 있는 경우 if (totalCostUSD > 0) { final formatter = NumberFormat.currency( @@ -82,7 +83,7 @@ class CategoryHeaderWidget extends StatelessWidget { ); currencyParts.add(formatter.format(totalCostUSD)); } - + // 원화가 있는 경우 if (totalCostKRW > 0) { final formatter = NumberFormat.currency( @@ -92,7 +93,7 @@ class CategoryHeaderWidget extends StatelessWidget { ); currencyParts.add(formatter.format(totalCostKRW)); } - + // 엔화가 있는 경우 if (totalCostJPY > 0) { final formatter = NumberFormat.currency( @@ -102,7 +103,7 @@ class CategoryHeaderWidget extends StatelessWidget { ); currencyParts.add(formatter.format(totalCostJPY)); } - + // 위안화가 있는 경우 if (totalCostCNY > 0) { final formatter = NumberFormat.currency( @@ -112,14 +113,14 @@ class CategoryHeaderWidget extends StatelessWidget { ); currencyParts.add(formatter.format(totalCostCNY)); } - + // 통화가 하나 이상 있는 경우 if (currencyParts.isNotEmpty) { // 통화가 여러 개인 경우 + 로 연결, 하나인 경우 그대로 final currencyDisplay = currencyParts.join(' + '); parts.add(currencyDisplay); } - + return parts.join(' · '); } } diff --git a/lib/widgets/common/buttons/danger_button.dart b/lib/widgets/common/buttons/danger_button.dart index 53a1387..5b9a2a9 100644 --- a/lib/widgets/common/buttons/danger_button.dart +++ b/lib/widgets/common/buttons/danger_button.dart @@ -39,7 +39,7 @@ class DangerButton extends StatefulWidget { class _DangerButtonState extends State { bool _isHovered = false; - + static const Color _dangerColor = AppColors.dangerColor; Future _handlePress() async { @@ -74,8 +74,7 @@ class _DangerButtonState extends State { ), const SizedBox(height: 16), Text( - widget.confirmationMessage ?? - '이 작업은 되돌릴 수 없습니다.\n계속하시겠습니까?', + widget.confirmationMessage ?? '이 작업은 되돌릴 수 없습니다.\n계속하시겠습니까?', textAlign: TextAlign.center, style: const TextStyle( fontSize: 16, @@ -171,4 +170,4 @@ class _DangerButtonState extends State { 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 index 9dd3434..fa748d8 100644 --- a/lib/widgets/common/buttons/primary_button.dart +++ b/lib/widgets/common/buttons/primary_button.dart @@ -43,8 +43,10 @@ class _PrimaryButtonState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final effectiveBackgroundColor = widget.backgroundColor ?? theme.primaryColor; - final effectiveForegroundColor = widget.foregroundColor ?? AppColors.pureWhite; + final effectiveBackgroundColor = + widget.backgroundColor ?? theme.primaryColor; + final effectiveForegroundColor = + widget.foregroundColor ?? AppColors.pureWhite; Widget button = AnimatedContainer( duration: const Duration(milliseconds: 200), @@ -64,7 +66,8 @@ class _PrimaryButtonState extends State { padding: widget.padding ?? const EdgeInsets.symmetric(vertical: 16), elevation: widget.enableHoverEffect && _isHovered ? 2 : 0, shadowColor: Colors.black.withValues(alpha: 0.08), - disabledBackgroundColor: effectiveBackgroundColor.withValues(alpha: 0.6), + disabledBackgroundColor: + effectiveBackgroundColor.withValues(alpha: 0.6), ), child: widget.isLoading ? SizedBox( @@ -110,4 +113,4 @@ class _PrimaryButtonState extends State { 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 index 30a0258..5aab3fb 100644 --- a/lib/widgets/common/buttons/secondary_button.dart +++ b/lib/widgets/common/buttons/secondary_button.dart @@ -61,18 +61,18 @@ class _SecondaryButtonState extends State { borderRadius: BorderRadius.circular(widget.borderRadius), ), side: BorderSide( - color: _isHovered + color: _isHovered ? effectiveBorderColor.withValues(alpha: 0.4) : effectiveBorderColor, width: widget.borderWidth, ), - padding: widget.padding ?? const EdgeInsets.symmetric( - vertical: 12, - horizontal: 24, - ), - backgroundColor: _isHovered - ? AppColors.glassBackground - : Colors.transparent, + padding: widget.padding ?? + const EdgeInsets.symmetric( + vertical: 12, + horizontal: 24, + ), + backgroundColor: + _isHovered ? AppColors.glassBackground : Colors.transparent, ), child: Row( mainAxisAlignment: MainAxisAlignment.center, @@ -146,7 +146,7 @@ class _TextLinkButtonState extends State { Widget button = AnimatedContainer( duration: const Duration(milliseconds: 200), decoration: BoxDecoration( - color: _isHovered + color: _isHovered ? theme.colorScheme.onSurface.withValues(alpha: 0.05) : Colors.transparent, borderRadius: BorderRadius.circular(8), @@ -179,9 +179,8 @@ class _TextLinkButtonState extends State { fontSize: widget.fontSize, fontWeight: FontWeight.w500, color: effectiveColor, - decoration: _isHovered - ? TextDecoration.underline - : TextDecoration.none, + decoration: + _isHovered ? TextDecoration.underline : TextDecoration.none, ), ), ], @@ -199,4 +198,4 @@ class _TextLinkButtonState extends State { 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 index 3b83c18..22e8275 100644 --- a/lib/widgets/common/cards/section_card.dart +++ b/lib/widgets/common/cards/section_card.dart @@ -34,13 +34,14 @@ class SectionCard extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final effectiveBackgroundColor = backgroundColor ?? Colors.white; - final effectiveShadow = boxShadow ?? [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ]; + final effectiveShadow = boxShadow ?? + [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ]; Widget card = Container( height: height, @@ -226,4 +227,4 @@ class InfoCard extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/common/dialogs/confirmation_dialog.dart b/lib/widgets/common/dialogs/confirmation_dialog.dart index 5495b5a..8b819dc 100644 --- a/lib/widgets/common/dialogs/confirmation_dialog.dart +++ b/lib/widgets/common/dialogs/confirmation_dialog.dart @@ -53,7 +53,8 @@ class ConfirmationDialog extends StatelessWidget { Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: (iconColor ?? effectiveConfirmColor).withValues(alpha: 0.1), + color: + (iconColor ?? effectiveConfirmColor).withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), ), child: Icon( @@ -350,4 +351,4 @@ class ErrorDialog extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/common/dialogs/loading_overlay.dart b/lib/widgets/common/dialogs/loading_overlay.dart index cafa096..0c7a451 100644 --- a/lib/widgets/common/dialogs/loading_overlay.dart +++ b/lib/widgets/common/dialogs/loading_overlay.dart @@ -193,7 +193,8 @@ class _CustomLoadingIndicatorState extends State width: widget.size / 5, height: widget.size / 5, decoration: BoxDecoration( - color: effectiveColor.withValues(alpha: 0.3 + value * 0.7), + color: + effectiveColor.withValues(alpha: 0.3 + value * 0.7), shape: BoxShape.circle, ), ); @@ -220,7 +221,8 @@ class _CustomLoadingIndicatorState extends State height: widget.size * (0.3 + _animation.value * 0.5), decoration: BoxDecoration( shape: BoxShape.circle, - color: effectiveColor.withValues(alpha: 1 - _animation.value), + color: + effectiveColor.withValues(alpha: 1 - _animation.value), ), ), ), @@ -235,4 +237,4 @@ 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 index 915b79a..63d010e 100644 --- a/lib/widgets/common/form_fields/base_text_field.dart +++ b/lib/widgets/common/form_fields/base_text_field.dart @@ -59,7 +59,7 @@ class BaseTextField extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -90,10 +90,11 @@ class BaseTextField extends StatelessWidget { minLines: minLines, readOnly: readOnly, cursorColor: cursorColor ?? theme.primaryColor, - style: style ?? TextStyle( - fontSize: 16, - color: AppColors.textPrimary, - ), + style: style ?? + TextStyle( + fontSize: 16, + color: AppColors.textPrimary, + ), decoration: InputDecoration( hintText: hintText, hintStyle: TextStyle( @@ -146,4 +147,4 @@ class BaseTextField extends StatelessWidget { ], ); } -} \ No newline at end of file +} diff --git a/lib/widgets/common/form_fields/billing_cycle_selector.dart b/lib/widgets/common/form_fields/billing_cycle_selector.dart index 97315c4..98da23e 100644 --- a/lib/widgets/common/form_fields/billing_cycle_selector.dart +++ b/lib/widgets/common/form_fields/billing_cycle_selector.dart @@ -24,7 +24,7 @@ class BillingCycleSelector extends StatelessWidget { Widget build(BuildContext context) { final localization = AppLocalizations.of(context); // 상세 화면에서는 '매월', 추가 화면에서는 '월간'으로 표시 - final cycles = isGlassmorphism + final cycles = isGlassmorphism ? [ localization.billingCycleMonthly, localization.billingCycleQuarterly, @@ -37,7 +37,7 @@ class BillingCycleSelector extends StatelessWidget { localization.billingCycleHalfYearly, localization.yearly, ]; - + return SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( @@ -76,7 +76,7 @@ class BillingCycleSelector extends StatelessWidget { Color _getBackgroundColor(bool isSelected) { if (!isSelected) { - return isGlassmorphism + return isGlassmorphism ? AppColors.backgroundColor : Colors.grey.withValues(alpha: 0.1); } @@ -84,11 +84,11 @@ class BillingCycleSelector extends StatelessWidget { if (baseColor != null) { return baseColor!; } - + if (gradientColors != null && gradientColors!.isNotEmpty) { return gradientColors![0]; } - + return const Color(0xFF3B82F6); } @@ -106,8 +106,6 @@ class BillingCycleSelector extends StatelessWidget { if (isSelected) { return Colors.white; } - return isGlassmorphism - ? AppColors.darkNavy - : Colors.grey[700]!; + return isGlassmorphism ? AppColors.darkNavy : Colors.grey[700]!; } -} \ No newline at end of file +} diff --git a/lib/widgets/common/form_fields/category_selector.dart b/lib/widgets/common/form_fields/category_selector.dart index 949bc52..e6904c6 100644 --- a/lib/widgets/common/form_fields/category_selector.dart +++ b/lib/widgets/common/form_fields/category_selector.dart @@ -55,7 +55,8 @@ class CategorySelector extends StatelessWidget { Consumer( builder: (context, categoryProvider, child) { return Text( - categoryProvider.getLocalizedCategoryName(context, category.name), + categoryProvider.getLocalizedCategoryName( + context, category.name), style: TextStyle( fontSize: 14, fontWeight: FontWeight.w500, @@ -101,7 +102,7 @@ class CategorySelector extends StatelessWidget { Color _getBackgroundColor(bool isSelected) { if (!isSelected) { - return isGlassmorphism + return isGlassmorphism ? AppColors.backgroundColor : Colors.grey.withValues(alpha: 0.1); } @@ -109,11 +110,11 @@ class CategorySelector extends StatelessWidget { if (baseColor != null) { return baseColor!; } - + if (gradientColors != null && gradientColors!.isNotEmpty) { return gradientColors![0]; } - + return const Color(0xFF3B82F6); } @@ -131,8 +132,6 @@ class CategorySelector extends StatelessWidget { if (isSelected) { return Colors.white; } - return isGlassmorphism - ? AppColors.darkNavy - : Colors.grey[700]!; + return isGlassmorphism ? AppColors.darkNavy : Colors.grey[700]!; } -} \ 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 index ee407e5..b484e14 100644 --- a/lib/widgets/common/form_fields/currency_input_field.dart +++ b/lib/widgets/common/form_fields/currency_input_field.dart @@ -45,7 +45,7 @@ class _CurrencyInputFieldState extends State { super.initState(); _focusNode = widget.focusNode ?? FocusNode(); _focusNode.addListener(_onFocusChanged); - + // 초기값이 있으면 포맷팅 적용 if (widget.controller.text.isNotEmpty) { final value = double.tryParse(widget.controller.text.replaceAll(',', '')); @@ -105,7 +105,11 @@ class _CurrencyInputFieldState extends State { } double? _parseValue(String text) { - final cleanText = text.replaceAll(',', '').replaceAll('₩', '').replaceAll('\$', '').trim(); + final cleanText = text + .replaceAll(',', '') + .replaceAll('₩', '') + .replaceAll('\$', '') + .trim(); return double.tryParse(cleanText); } @@ -128,16 +132,13 @@ class _CurrencyInputFieldState extends State { keyboardType: const TextInputType.numberWithOptions(decimal: true), inputFormatters: [ FilteringTextInputFormatter.allow( - widget.currency == 'KRW' - ? RegExp(r'[0-9]') - : RegExp(r'[0-9.]') - ), + widget.currency == 'KRW' ? RegExp(r'[0-9]') : RegExp(r'[0-9.]')), if (widget.currency == 'USD') // USD의 경우 소수점 이하 2자리까지만 허용 TextInputFormatter.withFunction((oldValue, newValue) { final text = newValue.text; if (text.isEmpty) return newValue; - + final parts = text.split('.'); if (parts.length > 2) { // 소수점이 2개 이상인 경우 거부 @@ -157,16 +158,17 @@ class _CurrencyInputFieldState extends State { final parsedValue = _parseValue(value); widget.onChanged?.call(parsedValue); }, - validator: widget.validator ?? (value) { - if (value == null || value.isEmpty) { - return AppLocalizations.of(context).amountRequired; - } - final parsedValue = _parseValue(value); - if (parsedValue == null || parsedValue <= 0) { - return AppLocalizations.of(context).invalidAmount; - } - return null; - }, + validator: widget.validator ?? + (value) { + if (value == null || value.isEmpty) { + return AppLocalizations.of(context).amountRequired; + } + final parsedValue = _parseValue(value); + if (parsedValue == null || parsedValue <= 0) { + return AppLocalizations.of(context).invalidAmount; + } + return null; + }, ); } -} \ No newline at end of file +} diff --git a/lib/widgets/common/form_fields/currency_selector.dart b/lib/widgets/common/form_fields/currency_selector.dart index ec9dd70..84b0f01 100644 --- a/lib/widgets/common/form_fields/currency_selector.dart +++ b/lib/widgets/common/form_fields/currency_selector.dart @@ -86,7 +86,7 @@ class _CurrencyOption extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - + return Expanded( child: InkWell( onTap: onTap, @@ -131,11 +131,9 @@ class _CurrencyOption extends StatelessWidget { Color _getBackgroundColor(ThemeData theme) { if (isSelected) { - return isGlassmorphism - ? theme.primaryColor - : const Color(0xFF3B82F6); + return isGlassmorphism ? theme.primaryColor : const Color(0xFF3B82F6); } - return isGlassmorphism + return isGlassmorphism ? AppColors.surfaceColorAlt : Colors.grey.withValues(alpha: 0.1); } @@ -154,8 +152,6 @@ class _CurrencyOption extends StatelessWidget { if (isSelected) { return Colors.white; } - return isGlassmorphism - ? AppColors.navyGray - : Colors.grey[600]!; + return isGlassmorphism ? AppColors.navyGray : Colors.grey[600]!; } -} \ 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 index 61f8886..8a88da8 100644 --- a/lib/widgets/common/form_fields/date_picker_field.dart +++ b/lib/widgets/common/form_fields/date_picker_field.dart @@ -42,7 +42,7 @@ class DatePickerField extends StatelessWidget { final localizations = AppLocalizations.of(context); final effectiveDateFormat = dateFormat ?? localizations.dateFormatFull; final locale = Localizations.localeOf(context); - + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -57,31 +57,35 @@ class DatePickerField extends StatelessWidget { 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, - ), - ), - child: child!, - ); - }, - ); - - if (picked != null && picked != selectedDate) { - onDateSelected(picked); - } - } : null, + 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, + ), + ), + child: child!, + ); + }, + ); + + if (picked != null && picked != selectedDate) { + onDateSelected(picked); + } + } + : null, borderRadius: BorderRadius.circular(16), child: Container( padding: contentPadding ?? const EdgeInsets.all(16), @@ -97,21 +101,19 @@ class DatePickerField extends StatelessWidget { children: [ Expanded( child: Text( - DateFormat(effectiveDateFormat, locale.toString()).format(selectedDate), + DateFormat(effectiveDateFormat, locale.toString()) + .format(selectedDate), style: TextStyle( fontSize: 16, - color: enabled - ? AppColors.textPrimary - : AppColors.textMuted, + color: + enabled ? AppColors.textPrimary : AppColors.textMuted, ), ), ), Icon( Icons.calendar_today, size: 20, - color: enabled - ? AppColors.navyGray - : AppColors.textMuted, + color: enabled ? AppColors.navyGray : AppColors.textMuted, ), ], ), @@ -158,7 +160,8 @@ class DateRangePickerField extends StatelessWidget { primaryColor: primaryColor, onDateSelected: onStartDateSelected, firstDate: DateTime.now().subtract(const Duration(days: 365)), - lastDate: endDate ?? DateTime.now().add(const Duration(days: 365 * 2)), + lastDate: + endDate ?? DateTime.now().add(const Duration(days: 365 * 2)), ), ), const SizedBox(width: 12), @@ -203,31 +206,33 @@ class _DateRangeItem extends StatelessWidget { 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, - ), - ), - child: child!, - ); - }, - ); - - if (picked != null) { - onDateSelected(picked); - } - } : null, + 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, + ), + ), + child: child!, + ); + }, + ); + + if (picked != null) { + onDateSelected(picked); + } + } + : null, borderRadius: BorderRadius.circular(16), child: Container( padding: const EdgeInsets.all(16), @@ -252,14 +257,14 @@ class _DateRangeItem extends StatelessWidget { const SizedBox(height: 4), Text( date != null - ? DateFormat(AppLocalizations.of(context).dateFormatShort).format(date!) + ? DateFormat(AppLocalizations.of(context).dateFormatShort) + .format(date!) : AppLocalizations.of(context).dateSelect, style: TextStyle( fontSize: 16, fontWeight: FontWeight.w500, - color: date != null - ? AppColors.textPrimary - : AppColors.textMuted, + color: + date != null ? AppColors.textPrimary : AppColors.textMuted, ), ), ], @@ -267,4 +272,4 @@ class _DateRangeItem extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/common/snackbar/app_snackbar.dart b/lib/widgets/common/snackbar/app_snackbar.dart index 9d4c92d..0bff6b0 100644 --- a/lib/widgets/common/snackbar/app_snackbar.dart +++ b/lib/widgets/common/snackbar/app_snackbar.dart @@ -269,4 +269,4 @@ class AppSnackBar { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/detail/detail_action_buttons.dart b/lib/widgets/detail/detail_action_buttons.dart index 5998512..d4a318b 100644 --- a/lib/widgets/detail/detail_action_buttons.dart +++ b/lib/widgets/detail/detail_action_buttons.dart @@ -44,4 +44,4 @@ class DetailActionButtons extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/detail/detail_event_section.dart b/lib/widgets/detail/detail_event_section.dart index b51f3c4..ea09925 100644 --- a/lib/widgets/detail/detail_event_section.dart +++ b/lib/widgets/detail/detail_event_section.dart @@ -27,172 +27,177 @@ class DetailEventSection extends StatelessWidget { 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: Container( - decoration: BoxDecoration( - color: AppColors.glassCard, - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: AppColors.glassBorder.withValues(alpha: 0.1), - width: 1, - ), - boxShadow: [ - BoxShadow( - color: AppColors.shadowBlack, - blurRadius: 10, - offset: const Offset(0, 4), + 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: Container( + decoration: BoxDecoration( + color: AppColors.glassCard, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: AppColors.glassBorder.withValues(alpha: 0.1), + width: 1, + ), + boxShadow: [ + BoxShadow( + color: AppColors.shadowBlack, + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], ), - ], - ), - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 헤더 - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ + // 헤더 Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, 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, - ), + 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), + Text( + AppLocalizations.of(context).eventPrice, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppColors.darkNavy, + ), + ), + ], ), - const SizedBox(width: 12), - Text( - AppLocalizations.of(context).eventPrice, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - color: AppColors.darkNavy, - ), + // 이벤트 활성화 스위치 + Switch.adaptive( + value: controller.isEventActive, + onChanged: (value) { + controller.isEventActive = value; + if (!value) { + // 이벤트 비활성화시 관련 정보 초기화 + controller.eventStartDate = null; + controller.eventEndDate = null; + controller.eventPriceController.clear(); + } + }, + activeColor: baseColor, ), ], ), - // 이벤트 활성화 스위치 - 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: AppColors.infoColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: AppColors.infoColor.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Row( + children: [ + Icon( + Icons.info_outline_rounded, + color: AppColors.infoColor, + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + AppLocalizations.of(context).eventPriceHint, + style: TextStyle( + fontSize: 14, + color: AppColors.darkNavy, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 20), + // 이벤트 기간 + DateRangePickerField( + startDate: controller.eventStartDate, + endDate: controller.eventEndDate, + onStartDateSelected: (date) { + controller.eventStartDate = date; + // 시작일 설정 시 종료일이 없으면 자동으로 1달 후로 설정 + if (date != null && controller.eventEndDate == null) { + controller.eventEndDate = + date.add(const Duration(days: 30)); + } + }, + onEndDateSelected: (date) { + controller.eventEndDate = date; + }, + startLabel: AppLocalizations.of(context).startDate, + endLabel: AppLocalizations.of(context).endDate, + primaryColor: baseColor, + ), + const SizedBox(height: 20), + // 이벤트 가격 + CurrencyInputField( + controller: controller.eventPriceController, + currency: controller.currency, + label: AppLocalizations.of(context).eventPrice, + hintText: AppLocalizations.of(context).eventPriceHint, + validator: controller.isEventActive + ? (value) { + if (value == null || value.isEmpty) { + return AppLocalizations.of(context) + .eventPriceRequired; + } + final price = + double.tryParse(value.replaceAll(',', '')); + if (price == null || price <= 0) { + return AppLocalizations.of(context) + .invalidPrice; + } + return null; + } + : null, + ), + 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, + ), + ], ], ), - // 이벤트 활성화시 표시될 필드들 - if (controller.isEventActive) ...[ - const SizedBox(height: 20), - // 이벤트 설명 - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppColors.infoColor.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: AppColors.infoColor.withValues(alpha: 0.3), - width: 1, - ), - ), - child: Row( - children: [ - Icon( - Icons.info_outline_rounded, - color: AppColors.infoColor, - size: 20, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - AppLocalizations.of(context).eventPriceHint, - style: TextStyle( - fontSize: 14, - color: AppColors.darkNavy, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - ), - const SizedBox(height: 20), - // 이벤트 기간 - DateRangePickerField( - startDate: controller.eventStartDate, - endDate: controller.eventEndDate, - onStartDateSelected: (date) { - controller.eventStartDate = date; - // 시작일 설정 시 종료일이 없으면 자동으로 1달 후로 설정 - if (date != null && controller.eventEndDate == null) { - controller.eventEndDate = date.add(const Duration(days: 30)); - } - }, - onEndDateSelected: (date) { - controller.eventEndDate = date; - }, - startLabel: AppLocalizations.of(context).startDate, - endLabel: AppLocalizations.of(context).endDate, - primaryColor: baseColor, - ), - const SizedBox(height: 20), - // 이벤트 가격 - CurrencyInputField( - controller: controller.eventPriceController, - currency: controller.currency, - label: AppLocalizations.of(context).eventPrice, - hintText: AppLocalizations.of(context).eventPriceHint, - validator: controller.isEventActive - ? (value) { - if (value == null || value.isEmpty) { - return AppLocalizations.of(context).eventPriceRequired; - } - final price = double.tryParse(value.replaceAll(',', '')); - if (price == null || price <= 0) { - return AppLocalizations.of(context).invalidPrice; - } - return null; - } - : null, - ), - 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, - ), - ], - ], + ), ), ), - ), - ), - ); + ); }, ); } @@ -216,7 +221,8 @@ class _DiscountBadge extends StatelessWidget { return const SizedBox.shrink(); } - final discountPercentage = ((originalPrice - eventPrice) / originalPrice * 100).round(); + final discountPercentage = + ((originalPrice - eventPrice) / originalPrice * 100).round(); final discountAmount = originalPrice - eventPrice; return Container( @@ -234,7 +240,9 @@ class _DiscountBadge extends StatelessWidget { borderRadius: BorderRadius.circular(8), ), child: Text( - AppLocalizations.of(context).discountPercent.replaceAll('@', discountPercentage.toString()), + AppLocalizations.of(context) + .discountPercent + .replaceAll('@', discountPercentage.toString()), style: const TextStyle( color: Colors.white, fontSize: 12, @@ -256,7 +264,8 @@ class _DiscountBadge extends StatelessWidget { ); } - String _getLocalizedDiscountAmount(BuildContext context, String currency, double amount) { + String _getLocalizedDiscountAmount( + BuildContext context, String currency, double amount) { final loc = AppLocalizations.of(context); switch (currency) { case 'KRW': @@ -264,9 +273,11 @@ class _DiscountBadge extends StatelessWidget { case 'JPY': return loc.discountAmountYen.replaceAll('@', amount.toInt().toString()); case 'CNY': - return loc.discountAmountYuan.replaceAll('@', amount.toStringAsFixed(2)); + return loc.discountAmountYuan + .replaceAll('@', amount.toStringAsFixed(2)); default: // USD - return loc.discountAmountDollar.replaceAll('@', amount.toStringAsFixed(2)); + return loc.discountAmountDollar + .replaceAll('@', amount.toStringAsFixed(2)); } } -} \ No newline at end of file +} diff --git a/lib/widgets/detail/detail_form_section.dart b/lib/widgets/detail/detail_form_section.dart index 25b7015..e647f4d 100644 --- a/lib/widgets/detail/detail_form_section.dart +++ b/lib/widgets/detail/detail_form_section.dart @@ -32,151 +32,111 @@ class DetailFormSection extends StatelessWidget { 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: Container( - decoration: BoxDecoration( - color: AppColors.glassCard, - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: AppColors.glassBorder.withValues(alpha: 0.1), - width: 1, - ), - boxShadow: [ - BoxShadow( - color: AppColors.shadowBlack, - blurRadius: 10, - offset: const Offset(0, 4), + 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: Container( + decoration: BoxDecoration( + color: AppColors.glassCard, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: AppColors.glassBorder.withValues(alpha: 0.1), + width: 1, + ), + boxShadow: [ + BoxShadow( + color: AppColors.shadowBlack, + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], ), - ], - ), - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - // 서비스명 필드 - BaseTextField( - controller: controller.serviceNameController, - focusNode: controller.serviceNameFocus, - label: AppLocalizations.of(context).subscriptionName, - hintText: AppLocalizations.of(context).serviceNameExample, - textInputAction: TextInputAction.next, - onEditingComplete: () { - controller.monthlyCostFocus.requestFocus(); - }, - ), - const SizedBox(height: 20), - - // 월 지출 및 통화 선택 - Row( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ - Expanded( - flex: 2, - child: CurrencyInputField( - controller: controller.monthlyCostController, - currency: controller.currency, - label: AppLocalizations.of(context).monthlyExpense, - focusNode: controller.monthlyCostFocus, - textInputAction: TextInputAction.next, - onEditingComplete: () { - controller.billingCycleFocus.requestFocus(); - }, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - AppLocalizations.of(context).currency, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppColors.darkNavy, - ), - ), - const SizedBox(height: 8), - CurrencySelector( - currency: controller.currency, - isGlassmorphism: true, - 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: [ - Text( - AppLocalizations.of(context).billingCycle, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppColors.darkNavy, - ), - ), - const SizedBox(height: 8), - BillingCycleSelector( - billingCycle: controller.billingCycle, - baseColor: baseColor, - isGlassmorphism: true, - onChanged: (value) { - controller.billingCycle = value; + // 서비스명 필드 + BaseTextField( + controller: controller.serviceNameController, + focusNode: controller.serviceNameFocus, + label: AppLocalizations.of(context).subscriptionName, + hintText: AppLocalizations.of(context).serviceNameExample, + textInputAction: TextInputAction.next, + onEditingComplete: () { + controller.monthlyCostFocus.requestFocus(); }, ), - ], - ), - const SizedBox(height: 20), + const SizedBox(height: 20), - // 다음 결제일 - DatePickerField( - selectedDate: controller.nextBillingDate, - onDateSelected: (date) { - controller.nextBillingDate = date; - }, - label: AppLocalizations.of(context).nextBillingDate, - firstDate: DateTime.now(), - lastDate: DateTime.now().add(const Duration(days: 365 * 2)), - primaryColor: baseColor, - ), - const SizedBox(height: 20), + // 월 지출 및 통화 선택 + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 2, + child: CurrencyInputField( + controller: controller.monthlyCostController, + currency: controller.currency, + label: AppLocalizations.of(context).monthlyExpense, + focusNode: controller.monthlyCostFocus, + textInputAction: TextInputAction.next, + onEditingComplete: () { + controller.billingCycleFocus.requestFocus(); + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of(context).currency, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.darkNavy, + ), + ), + const SizedBox(height: 8), + CurrencySelector( + currency: controller.currency, + isGlassmorphism: true, + 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), - // 카테고리 선택 - Consumer( - builder: (context, categoryProvider, child) { - return Column( + // 결제 주기 + Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - AppLocalizations.of(context).category, + AppLocalizations.of(context).billingCycle, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, @@ -184,27 +144,67 @@ class DetailFormSection extends StatelessWidget { ), ), const SizedBox(height: 8), - CategorySelector( - categories: categoryProvider.categories, - selectedCategoryId: controller.selectedCategoryId, + BillingCycleSelector( + billingCycle: controller.billingCycle, baseColor: baseColor, isGlassmorphism: true, - onChanged: (categoryId) { - controller.selectedCategoryId = categoryId; + onChanged: (value) { + controller.billingCycle = value; }, ), ], - ); - }, + ), + const SizedBox(height: 20), + + // 다음 결제일 + DatePickerField( + selectedDate: controller.nextBillingDate, + onDateSelected: (date) { + controller.nextBillingDate = date; + }, + label: AppLocalizations.of(context).nextBillingDate, + 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: [ + Text( + AppLocalizations.of(context).category, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.darkNavy, + ), + ), + const SizedBox(height: 8), + CategorySelector( + categories: categoryProvider.categories, + selectedCategoryId: controller.selectedCategoryId, + baseColor: baseColor, + isGlassmorphism: true, + onChanged: (categoryId) { + controller.selectedCategoryId = categoryId; + }, + ), + ], + ); + }, + ), + ], ), - ], + ), ), ), - ), - ), - ); + ); }, ); } } - diff --git a/lib/widgets/detail/detail_header_section.dart b/lib/widgets/detail/detail_header_section.dart index 5a3415e..55c3e3c 100644 --- a/lib/widgets/detail/detail_header_section.dart +++ b/lib/widgets/detail/detail_header_section.dart @@ -34,191 +34,215 @@ class DetailHeaderSection extends StatelessWidget { 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), + 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, - ), - ], + Positioned( + bottom: -30, + left: -30, + child: Container( + width: 150, + height: 150, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.08), ), - const Spacer(), - // 서비스 정보 - FadeTransition( - opacity: fadeAnimation, - child: SlideTransition( - position: slideAnimation, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + ), + ), + // 콘텐츠 + SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 뒤로가기 버튼 + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - // 서비스 아이콘과 이름 - Row( + 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: [ - 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: controller.websiteUrlController.text, - serviceName: controller.serviceNameController.text, - size: 48, - ), - ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - controller.displayName ?? controller.serviceNameController.text, - style: const TextStyle( - fontSize: 28, - fontWeight: FontWeight.w800, + // 서비스 아이콘과 이름 + Row( + children: [ + Hero( + tag: 'icon_${subscription.id}', + child: Container( + width: 56, + height: 56, + decoration: BoxDecoration( color: Colors.white, - letterSpacing: -0.5, - shadows: [ - Shadow( - color: Colors.black26, - offset: Offset(0, 2), - blurRadius: 4, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black + .withValues(alpha: 0.1), + blurRadius: 8, + offset: const Offset(0, 4), ), ], ), - ), - const SizedBox(height: 4), - Text( - AppLocalizations.of(context).billingCyclePayment.replaceAll('@', - _getLocalizedBillingCycle(context, controller.billingCycle)), - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Colors.white.withValues(alpha: 0.8), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: WebsiteIcon( + url: controller + .websiteUrlController.text, + serviceName: controller + .serviceNameController.text, + size: 48, + ), ), ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + controller.displayName ?? + controller + .serviceNameController.text, + 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( + AppLocalizations.of(context) + .billingCyclePayment + .replaceAll( + '@', + _getLocalizedBillingCycle( + context, + controller.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: AppLocalizations.of(context) + .nextBillingDate, + value: AppLocalizations.of(context) + .formatDate( + controller.nextBillingDate), + ), + FutureBuilder( + future: () async { + final locale = context + .read() + .locale + .languageCode; + final amount = double.tryParse( + controller + .monthlyCostController.text + .replaceAll(',', '')) ?? + 0; + return CurrencyUtil + .formatAmountWithLocale( + amount, + controller.currency, + locale, + ); + }(), + builder: (context, snapshot) { + return _InfoColumn( + label: AppLocalizations.of(context) + .monthlyExpense, + value: snapshot.data ?? '-', + alignment: CrossAxisAlignment.end, + ); + }, + ), ], ), ), ], ), - 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: AppLocalizations.of(context).nextBillingDate, - value: AppLocalizations.of(context).formatDate(controller.nextBillingDate), - ), - FutureBuilder( - future: () async { - final locale = context.read().locale.languageCode; - final amount = double.tryParse( - controller.monthlyCostController.text.replaceAll(',', '') - ) ?? 0; - return CurrencyUtil.formatAmountWithLocale( - amount, - controller.currency, - locale, - ); - }(), - builder: (context, snapshot) { - return _InfoColumn( - label: AppLocalizations.of(context).monthlyExpense, - value: snapshot.data ?? '-', - alignment: CrossAxisAlignment.end, - ); - }, - ), - ], - ), - ), - ], + ), ), - ), + ], ), - ], + ), ), - ), + ], ), - ], - ), - ); + ); }, ); } + String _getLocalizedBillingCycle(BuildContext context, String cycle) { final loc = AppLocalizations.of(context); switch (cycle.toLowerCase()) { @@ -285,4 +309,4 @@ class _InfoColumn extends StatelessWidget { ], ); } -} \ No newline at end of file +} diff --git a/lib/widgets/detail/detail_url_section.dart b/lib/widgets/detail/detail_url_section.dart index 183689e..770d213 100644 --- a/lib/widgets/detail/detail_url_section.dart +++ b/lib/widgets/detail/detail_url_section.dart @@ -81,7 +81,7 @@ class DetailUrlSection extends StatelessWidget { ], ), const SizedBox(height: 20), - + // URL 입력 필드 BaseTextField( controller: controller.websiteUrlController, @@ -94,7 +94,7 @@ class DetailUrlSection extends StatelessWidget { color: AppColors.navyGray, ), ), - + // 해지 안내 섹션 if (controller.subscription.websiteUrl != null && controller.subscription.websiteUrl!.isNotEmpty) ...[ @@ -151,7 +151,7 @@ class DetailUrlSection extends StatelessWidget { ), ), ], - + // URL 자동 매칭 정보 if (controller.websiteUrlController.text.isEmpty) ...[ const SizedBox(height: 16), @@ -194,4 +194,4 @@ class DetailUrlSection extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/dialogs/delete_confirmation_dialog.dart b/lib/widgets/dialogs/delete_confirmation_dialog.dart index 2cf2d83..eb9f362 100644 --- a/lib/widgets/dialogs/delete_confirmation_dialog.dart +++ b/lib/widgets/dialogs/delete_confirmation_dialog.dart @@ -56,7 +56,7 @@ class DeleteConfirmationDialog extends StatelessWidget { ), ), const SizedBox(height: 24), - + // 타이틀 const Text( '구독 삭제', @@ -67,7 +67,7 @@ class DeleteConfirmationDialog extends StatelessWidget { ), ), const SizedBox(height: 12), - + // 설명 RichText( textAlign: TextAlign.center, @@ -91,7 +91,7 @@ class DeleteConfirmationDialog extends StatelessWidget { ), ), const SizedBox(height: 8), - + // 경고 메시지 Container( padding: const EdgeInsets.symmetric( @@ -127,7 +127,7 @@ class DeleteConfirmationDialog extends StatelessWidget { ), ), const SizedBox(height: 32), - + // 버튼들 Row( children: [ @@ -176,7 +176,7 @@ class DeleteConfirmationDialog extends StatelessWidget { serviceName: serviceName, ), ); - + return result ?? false; } -} \ No newline at end of file +} diff --git a/lib/widgets/empty_state_widget.dart b/lib/widgets/empty_state_widget.dart index ddebf72..1fc4f0e 100644 --- a/lib/widgets/empty_state_widget.dart +++ b/lib/widgets/empty_state_widget.dart @@ -58,7 +58,8 @@ class EmptyStateWidget extends StatelessWidget { borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( - color: AppColors.primaryColor.withValues(alpha: 0.3), + color: + AppColors.primaryColor.withValues(alpha: 0.3), spreadRadius: 0, blurRadius: 16, offset: const Offset(0, 8), diff --git a/lib/widgets/expandable_fab.dart b/lib/widgets/expandable_fab.dart index ece5110..18183ed 100644 --- a/lib/widgets/expandable_fab.dart +++ b/lib/widgets/expandable_fab.dart @@ -7,7 +7,7 @@ import 'glassmorphism_card.dart'; class ExpandableFab extends StatefulWidget { final List actions; final double distance; - + const ExpandableFab({ super.key, required this.actions, @@ -32,13 +32,13 @@ class _ExpandableFabState extends State duration: const Duration(milliseconds: 300), vsync: this, ); - + _expandAnimation = CurvedAnimation( parent: _controller, curve: Curves.easeOutBack, reverseCurve: Curves.easeInBack, ); - + _rotateAnimation = Tween( begin: 0.0, end: math.pi / 4, @@ -58,7 +58,7 @@ class _ExpandableFabState extends State setState(() { _isExpanded = !_isExpanded; }); - + if (_isExpanded) { HapticFeedbackHelper.mediumImpact(); _controller.forward(); @@ -81,25 +81,26 @@ class _ExpandableFabState extends State animation: _expandAnimation, builder: (context, child) { return Container( - color: AppColors.shadowBlack.withValues(alpha: 3.75 * _expandAnimation.value), + color: AppColors.shadowBlack + .withValues(alpha: 3.75 * _expandAnimation.value), ); }, ), ), - + // 액션 버튼들 ...widget.actions.asMap().entries.map((entry) { final index = entry.key; final action = entry.value; final angle = (index + 1) * (math.pi / 2 / widget.actions.length); - + return AnimatedBuilder( animation: _expandAnimation, builder: (context, child) { final distance = widget.distance * _expandAnimation.value; final x = distance * math.cos(angle); final y = distance * math.sin(angle); - + return Transform.translate( offset: Offset(-x, -y), child: ScaleTransition( @@ -125,7 +126,7 @@ class _ExpandableFabState extends State }, ); }), - + // 메인 FAB AnimatedBuilder( animation: _rotateAnimation, @@ -144,21 +145,21 @@ class _ExpandableFabState extends State ); }, ), - + // 라벨 표시 if (_isExpanded) ...widget.actions.asMap().entries.map((entry) { final index = entry.key; final action = entry.value; final angle = (index + 1) * (math.pi / 2 / widget.actions.length); - + return AnimatedBuilder( animation: _expandAnimation, builder: (context, child) { final distance = widget.distance * _expandAnimation.value; final x = distance * math.cos(angle); final y = distance * math.sin(angle); - + return Transform.translate( offset: Offset(-x - 80, -y), child: FadeTransition( @@ -194,7 +195,7 @@ class FabAction { final String label; final VoidCallback onPressed; final Color? color; - + const FabAction({ required this.icon, required this.label, @@ -207,7 +208,7 @@ class FabAction { class DraggableFab extends StatefulWidget { final Widget child; final EdgeInsets? padding; - + const DraggableFab({ super.key, required this.child, @@ -226,7 +227,7 @@ class _DraggableFabState extends State { Widget build(BuildContext context) { final screenSize = MediaQuery.of(context).size; final padding = widget.padding ?? const EdgeInsets.all(20); - + return Stack( children: [ Positioned( @@ -265,4 +266,4 @@ class _DraggableFabState extends State { ], ); } -} \ No newline at end of file +} diff --git a/lib/widgets/floating_navigation_bar.dart b/lib/widgets/floating_navigation_bar.dart index 0500b9d..64c2fbe 100644 --- a/lib/widgets/floating_navigation_bar.dart +++ b/lib/widgets/floating_navigation_bar.dart @@ -124,8 +124,11 @@ class _FloatingNavigationBarState extends State _NavigationItem( icon: Icons.settings_rounded, label: AppLocalizations.of(context).settings, - isSelected: PlatformHelper.isIOS ? widget.selectedIndex == 3 : widget.selectedIndex == 4, - onTap: () => _onItemTapped(PlatformHelper.isIOS ? 3 : 4), + isSelected: PlatformHelper.isIOS + ? widget.selectedIndex == 3 + : widget.selectedIndex == 4, + onTap: () => + _onItemTapped(PlatformHelper.isIOS ? 3 : 4), ), ], ), diff --git a/lib/widgets/glassmorphic_app_bar.dart b/lib/widgets/glassmorphic_app_bar.dart index 86f1aa6..6509575 100644 --- a/lib/widgets/glassmorphic_app_bar.dart +++ b/lib/widgets/glassmorphic_app_bar.dart @@ -6,7 +6,8 @@ import 'themed_text.dart'; import '../l10n/app_localizations.dart'; /// 글래스모피즘 효과가 적용된 통일된 앱바 -class GlassmorphicAppBar extends StatelessWidget implements PreferredSizeWidget { +class GlassmorphicAppBar extends StatelessWidget + implements PreferredSizeWidget { final String title; final List? actions; final Widget? leading; @@ -44,7 +45,7 @@ class GlassmorphicAppBar extends StatelessWidget implements PreferredSizeWidget Widget build(BuildContext context) { final isDarkMode = Theme.of(context).brightness == Brightness.dark; final canPop = Navigator.of(context).canPop(); - + return ClipRRect( child: BackdropFilter( filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur), @@ -54,17 +55,21 @@ class GlassmorphicAppBar extends StatelessWidget implements PreferredSizeWidget begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ - (backgroundColor ?? (isDarkMode - ? AppColors.glassBackgroundDark - : AppColors.glassBackground)).withValues(alpha: opacity), - (backgroundColor ?? (isDarkMode - ? AppColors.glassSurfaceDark - : AppColors.glassSurface)).withValues(alpha: opacity * 0.8), + (backgroundColor ?? + (isDarkMode + ? AppColors.glassBackgroundDark + : AppColors.glassBackground)) + .withValues(alpha: opacity), + (backgroundColor ?? + (isDarkMode + ? AppColors.glassSurfaceDark + : AppColors.glassSurface)) + .withValues(alpha: opacity * 0.8), ], ), border: Border( bottom: BorderSide( - color: isDarkMode + color: isDarkMode ? AppColors.primaryColor.withValues(alpha: 0.3) : AppColors.glassBorder.withValues(alpha: 0.5), width: 0.5, @@ -77,26 +82,29 @@ class GlassmorphicAppBar extends StatelessWidget implements PreferredSizeWidget child: Column( mainAxisSize: MainAxisSize.min, children: [ - Flexible( - child: SizedBox( - height: kToolbarHeight, - child: NavigationToolbar( - leading: leading ?? (automaticallyImplyLeading && (canPop || onBackPressed != null) - ? _buildBackButton(context) - : null), - middle: _buildTitle(context), - trailing: actions != null - ? Row( - mainAxisSize: MainAxisSize.min, - children: actions!, - ) - : null, - centerMiddle: centerTitle, - middleSpacing: titleSpacing ?? NavigationToolbar.kMiddleSpacing, + Flexible( + child: SizedBox( + height: kToolbarHeight, + child: NavigationToolbar( + leading: leading ?? + (automaticallyImplyLeading && + (canPop || onBackPressed != null) + ? _buildBackButton(context) + : null), + middle: _buildTitle(context), + trailing: actions != null + ? Row( + mainAxisSize: MainAxisSize.min, + children: actions!, + ) + : null, + centerMiddle: centerTitle, + middleSpacing: + titleSpacing ?? NavigationToolbar.kMiddleSpacing, + ), ), ), - ), - if (bottom != null) bottom!, + if (bottom != null) bottom!, ], ), ), @@ -109,10 +117,11 @@ class GlassmorphicAppBar extends StatelessWidget implements PreferredSizeWidget Widget _buildBackButton(BuildContext context) { return IconButton( icon: const Icon(Icons.arrow_back_ios_new_rounded), - onPressed: onBackPressed ?? () { - HapticFeedback.lightImpact(); - Navigator.of(context).pop(); - }, + onPressed: onBackPressed ?? + () { + HapticFeedback.lightImpact(); + Navigator.of(context).pop(); + }, splashRadius: 24, tooltip: AppLocalizations.of(context).back, color: ThemedText.getContrastColor(context), @@ -205,7 +214,7 @@ class GlassmorphicSliverAppBar extends StatelessWidget { Widget build(BuildContext context) { final isDarkMode = Theme.of(context).brightness == Brightness.dark; final canPop = Navigator.of(context).canPop(); - + return SliverAppBar( expandedHeight: expandedHeight, floating: floating, @@ -214,26 +223,29 @@ class GlassmorphicSliverAppBar extends StatelessWidget { backgroundColor: Colors.transparent, elevation: 0, automaticallyImplyLeading: false, - leading: leading ?? (automaticallyImplyLeading && (canPop || onBackPressed != null) - ? IconButton( - icon: const Icon(Icons.arrow_back_ios_new_rounded), - onPressed: onBackPressed ?? () { - HapticFeedback.lightImpact(); - Navigator.of(context).pop(); - }, - splashRadius: 24, - tooltip: AppLocalizations.of(context).back, - ) - : null), + leading: leading ?? + (automaticallyImplyLeading && (canPop || onBackPressed != null) + ? IconButton( + icon: const Icon(Icons.arrow_back_ios_new_rounded), + onPressed: onBackPressed ?? + () { + HapticFeedback.lightImpact(); + Navigator.of(context).pop(); + }, + splashRadius: 24, + tooltip: AppLocalizations.of(context).back, + ) + : null), actions: actions, centerTitle: centerTitle, flexibleSpace: LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { final top = constraints.biggest.height; - final isCollapsed = top <= kToolbarHeight + MediaQuery.of(context).padding.top; - + final isCollapsed = + top <= kToolbarHeight + MediaQuery.of(context).padding.top; + return FlexibleSpaceBar( - title: isCollapsed + title: isCollapsed ? ThemedText.headline( text: title, style: const TextStyle( @@ -244,7 +256,8 @@ class GlassmorphicSliverAppBar extends StatelessWidget { ) : null, centerTitle: centerTitle, - titlePadding: const EdgeInsets.only(left: 16, bottom: 16, right: 16), + titlePadding: + const EdgeInsets.only(left: 16, bottom: 16, right: 16), background: Stack( fit: StackFit.expand, children: [ @@ -258,17 +271,19 @@ class GlassmorphicSliverAppBar extends StatelessWidget { begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ - (isDarkMode - ? AppColors.glassBackgroundDark - : AppColors.glassBackground).withValues(alpha: opacity), - (isDarkMode - ? AppColors.glassSurfaceDark - : AppColors.glassSurface).withValues(alpha: opacity * 0.8), + (isDarkMode + ? AppColors.glassBackgroundDark + : AppColors.glassBackground) + .withValues(alpha: opacity), + (isDarkMode + ? AppColors.glassSurfaceDark + : AppColors.glassSurface) + .withValues(alpha: opacity * 0.8), ], ), border: Border( bottom: BorderSide( - color: isDarkMode + color: isDarkMode ? AppColors.primaryColor.withValues(alpha: 0.3) : AppColors.glassBorder.withValues(alpha: 0.5), width: 0.5, @@ -302,4 +317,4 @@ class GlassmorphicSliverAppBar extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/glassmorphic_scaffold.dart b/lib/widgets/glassmorphic_scaffold.dart index a46077b..a78a0d1 100644 --- a/lib/widgets/glassmorphic_scaffold.dart +++ b/lib/widgets/glassmorphic_scaffold.dart @@ -63,12 +63,12 @@ class _GlassmorphicScaffoldState extends State duration: const Duration(seconds: 20), vsync: this, )..repeat(); - + _waveController = AnimationController( duration: const Duration(seconds: 10), vsync: this, )..repeat(); - + if (widget.useFloatingNavBar) { _scrollController = ScrollController(); _setupScrollListener(); @@ -78,13 +78,16 @@ class _GlassmorphicScaffoldState extends State void _setupScrollListener() { _scrollController?.addListener(() { final currentScroll = _scrollController!.position.pixels; - + // 스크롤 방향에 따라 플로팅 네비게이션 바 표시/숨김 - if (currentScroll > 50 && _scrollController!.position.userScrollDirection == ScrollDirection.reverse) { + if (currentScroll > 50 && + _scrollController!.position.userScrollDirection == + ScrollDirection.reverse) { if (_isFloatingNavBarVisible) { setState(() => _isFloatingNavBarVisible = false); } - } else if (_scrollController!.position.userScrollDirection == ScrollDirection.forward) { + } else if (_scrollController!.position.userScrollDirection == + ScrollDirection.forward) { if (!_isFloatingNavBarVisible) { setState(() => _isFloatingNavBarVisible = true); } @@ -104,7 +107,7 @@ class _GlassmorphicScaffoldState extends State if (widget.backgroundGradient != null) { return widget.backgroundGradient!; } - + // 디폴트 그라디언트 return AppColors.mainGradient; } @@ -112,18 +115,18 @@ class _GlassmorphicScaffoldState extends State @override Widget build(BuildContext context) { final backgroundGradient = _getBackgroundGradient(); - + return Stack( children: [ // 배경 그라디언트 _buildBackground(backgroundGradient), - + // 파티클 효과 (선택적) if (widget.enableParticles) _buildParticles(), - + // 웨이브 애니메이션 (선택적) if (widget.enableWaveAnimation) _buildWaveAnimation(), - + // 메인 스캐폴드 Scaffold( backgroundColor: widget.backgroundColor ?? Colors.transparent, @@ -138,7 +141,7 @@ class _GlassmorphicScaffoldState extends State drawer: widget.drawer, endDrawer: widget.endDrawer, ), - + // 플로팅 네비게이션 바 (선택적) if (widget.useFloatingNavBar && widget.floatingNavBarIndex != null) FloatingNavigationBar( @@ -159,7 +162,9 @@ class _GlassmorphicScaffoldState extends State gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: gradientColors.map((color) => color.withOpacity(0.3)).toList(), + colors: gradientColors + .map((color) => color.withOpacity(0.3)) + .toList(), ), ), ), @@ -233,11 +238,11 @@ class ParticlePainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { final paint = Paint()..style = PaintingStyle.fill; - + for (final particle in particles) { final progress = animation.value; final y = (particle.y + progress * particle.speed) % 1.0; - + paint.color = AppColors.pureWhite.withValues(alpha: particle.opacity); canvas.drawCircle( Offset(particle.x * size.width, y * size.height), @@ -266,21 +271,23 @@ class WavePainter extends CustomPainter { final paint = Paint() ..color = waveColor ..style = PaintingStyle.fill; - + final path = Path(); final progress = animation.value; - + path.moveTo(0, size.height); - + for (double x = 0; x <= size.width; x++) { - final y = math.sin((x / size.width * 2 * math.pi) + (progress * 2 * math.pi)) * 20 + - size.height * 0.5; + final y = + math.sin((x / size.width * 2 * math.pi) + (progress * 2 * math.pi)) * + 20 + + size.height * 0.5; path.lineTo(x, y); } - + path.lineTo(size.width, size.height); path.close(); - + canvas.drawPath(path, paint); } @@ -303,4 +310,4 @@ class Particle { required this.speed, required this.opacity, }); -} \ No newline at end of file +} diff --git a/lib/widgets/glassmorphism_card.dart b/lib/widgets/glassmorphism_card.dart index 8b2be6c..2ec769c 100644 --- a/lib/widgets/glassmorphism_card.dart +++ b/lib/widgets/glassmorphism_card.dart @@ -38,7 +38,7 @@ class GlassmorphismCard extends StatelessWidget { @override Widget build(BuildContext context) { final isDarkMode = Theme.of(context).brightness == Brightness.dark; - + return Container( width: width, height: height, @@ -56,28 +56,32 @@ class GlassmorphismCard extends StatelessWidget { padding: padding, decoration: BoxDecoration( color: backgroundColor ?? AppColors.glassCard, - gradient: gradient ?? LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: isDarkMode - ? AppColors.glassGradientDark - : AppColors.glassGradient, - ), + gradient: gradient ?? + LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: isDarkMode + ? AppColors.glassGradientDark + : AppColors.glassGradient, + ), borderRadius: BorderRadius.circular(borderRadius), - border: border ?? Border.all( - color: isDarkMode - ? AppColors.primaryColor.withValues(alpha: 0.3) - : AppColors.glassBorder, - width: 1, - ), - boxShadow: boxShadow ?? [ - BoxShadow( - color: AppColors.shadowBlack, // color.md 가이드: rgba(0,0,0,0.08) - blurRadius: 20, - spreadRadius: -5, - offset: const Offset(0, 10), - ), - ], + border: border ?? + Border.all( + color: isDarkMode + ? AppColors.primaryColor.withValues(alpha: 0.3) + : AppColors.glassBorder, + width: 1, + ), + boxShadow: boxShadow ?? + [ + BoxShadow( + color: AppColors + .shadowBlack, // color.md 가이드: rgba(0,0,0,0.08) + blurRadius: 20, + spreadRadius: -5, + offset: const Offset(0, 10), + ), + ], ), child: GlassmorphicIndicator( child: child, @@ -119,10 +123,11 @@ class AnimatedGlassmorphismCard extends StatefulWidget { }); @override - State createState() => _AnimatedGlassmorphismCardState(); + State createState() => + _AnimatedGlassmorphismCardState(); } -class _AnimatedGlassmorphismCardState extends State +class _AnimatedGlassmorphismCardState extends State with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation _scaleAnimation; @@ -135,7 +140,7 @@ class _AnimatedGlassmorphismCardState extends State duration: widget.animationDuration, vsync: this, ); - + _scaleAnimation = Tween( begin: 1.0, end: 0.98, @@ -143,7 +148,7 @@ class _AnimatedGlassmorphismCardState extends State parent: _controller, curve: Curves.easeInOut, )); - + _blurAnimation = Tween( begin: widget.blur, end: widget.blur * 1.5, @@ -187,7 +192,7 @@ class _AnimatedGlassmorphismCardState extends State child: widget.child, ); } - + return GestureDetector( behavior: HitTestBehavior.opaque, onTapDown: _handleTapDown, @@ -221,4 +226,4 @@ class _AnimatedGlassmorphismCardState extends State ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/home_content.dart b/lib/widgets/home_content.dart index c3dd6bf..1ed037a 100644 --- a/lib/widgets/home_content.dart +++ b/lib/widgets/home_content.dart @@ -52,8 +52,10 @@ class HomeContent extends StatelessWidget { } // 카테고리별 구독 구분 - final categoryProvider = Provider.of(context, listen: false); - final categorizedSubscriptions = SubscriptionCategoryHelper.categorizeSubscriptions( + final categoryProvider = + Provider.of(context, listen: false); + final categorizedSubscriptions = + SubscriptionCategoryHelper.categorizeSubscriptions( provider.subscriptions, categoryProvider, context, @@ -107,8 +109,8 @@ class HomeContent extends StatelessWidget { child: Text( AppLocalizations.of(context).mySubscriptions, style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: AppColors.darkNavy, - ), + color: AppColors.darkNavy, + ), ), ), SlideTransition( @@ -120,7 +122,8 @@ class HomeContent extends StatelessWidget { child: Row( children: [ Text( - AppLocalizations.of(context).subscriptionCount(provider.subscriptions.length), + AppLocalizations.of(context) + .subscriptionCount(provider.subscriptions.length), style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, @@ -153,4 +156,4 @@ class HomeContent extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/main_summary_card.dart b/lib/widgets/main_summary_card.dart index d27c104..c404c62 100644 --- a/lib/widgets/main_summary_card.dart +++ b/lib/widgets/main_summary_card.dart @@ -33,7 +33,7 @@ class MainScreenSummaryCard extends StatelessWidget { final locale = context.watch().locale.languageCode; final defaultCurrency = CurrencyUtil.getDefaultCurrency(locale); final currencySymbol = CurrencyUtil.getCurrencySymbol(defaultCurrency); - + final int totalSubscriptions = provider.subscriptions.length; final int activeEvents = provider.activeEventSubscriptions.length; @@ -88,7 +88,8 @@ class MainScreenSummaryCard extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - AppLocalizations.of(context).monthlyTotalSubscriptionCost, + AppLocalizations.of(context) + .monthlyTotalSubscriptionCost, style: TextStyle( color: AppColors .darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트 @@ -99,9 +100,12 @@ class MainScreenSummaryCard extends StatelessWidget { // 환율 정보 표시 (영어 사용자는 표시 안함) if (locale != 'en') FutureBuilder( - future: CurrencyUtil.getExchangeRateInfoForLocale(locale), + future: + CurrencyUtil.getExchangeRateInfoForLocale( + locale), builder: (context, snapshot) { - if (snapshot.hasData && snapshot.data!.isNotEmpty) { + if (snapshot.hasData && + snapshot.data!.isNotEmpty) { return Container( padding: const EdgeInsets.symmetric( horizontal: 8, @@ -116,7 +120,9 @@ class MainScreenSummaryCard extends StatelessWidget { ), ), child: Text( - AppLocalizations.of(context).exchangeRateDisplay.replaceAll('@', snapshot.data!), + AppLocalizations.of(context) + .exchangeRateDisplay + .replaceAll('@', snapshot.data!), style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w500, @@ -133,7 +139,8 @@ class MainScreenSummaryCard extends StatelessWidget { const SizedBox(height: 8), // 월별 총 비용 표시 (언어별 기본 통화) FutureBuilder( - future: CurrencyUtil.calculateTotalMonthlyExpenseInDefaultCurrency( + future: CurrencyUtil + .calculateTotalMonthlyExpenseInDefaultCurrency( provider.subscriptions, locale, ), @@ -142,17 +149,24 @@ class MainScreenSummaryCard extends StatelessWidget { return const CircularProgressIndicator(); } final monthlyCost = snapshot.data!; - final decimals = (defaultCurrency == 'KRW' || defaultCurrency == 'JPY') ? 0 : 2; - + final decimals = (defaultCurrency == 'KRW' || + defaultCurrency == 'JPY') + ? 0 + : 2; + return Row( crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, children: [ Text( NumberFormat.currency( - locale: defaultCurrency == 'KRW' ? 'ko_KR' : - defaultCurrency == 'JPY' ? 'ja_JP' : - defaultCurrency == 'CNY' ? 'zh_CN' : 'en_US', + locale: defaultCurrency == 'KRW' + ? 'ko_KR' + : defaultCurrency == 'JPY' + ? 'ja_JP' + : defaultCurrency == 'CNY' + ? 'zh_CN' + : 'en_US', symbol: '', decimalDigits: decimals, ).format(monthlyCost), @@ -179,7 +193,8 @@ class MainScreenSummaryCard extends StatelessWidget { const SizedBox(height: 16), // 연간 비용 및 총 구독 수 표시 FutureBuilder( - future: CurrencyUtil.calculateTotalMonthlyExpenseInDefaultCurrency( + future: CurrencyUtil + .calculateTotalMonthlyExpenseInDefaultCurrency( provider.subscriptions, locale, ), @@ -189,17 +204,25 @@ class MainScreenSummaryCard extends StatelessWidget { } final monthlyCost = snapshot.data!; final yearlyCost = monthlyCost * 12; - final decimals = (defaultCurrency == 'KRW' || defaultCurrency == 'JPY') ? 0 : 2; - + final decimals = (defaultCurrency == 'KRW' || + defaultCurrency == 'JPY') + ? 0 + : 2; + return Row( children: [ _buildInfoBox( context, - title: AppLocalizations.of(context).estimatedAnnualCost, + title: AppLocalizations.of(context) + .estimatedAnnualCost, value: '${NumberFormat.currency( - locale: defaultCurrency == 'KRW' ? 'ko_KR' : - defaultCurrency == 'JPY' ? 'ja_JP' : - defaultCurrency == 'CNY' ? 'zh_CN' : 'en_US', + locale: defaultCurrency == 'KRW' + ? 'ko_KR' + : defaultCurrency == 'JPY' + ? 'ja_JP' + : defaultCurrency == 'CNY' + ? 'zh_CN' + : 'en_US', symbol: currencySymbol, decimalDigits: decimals, ).format(yearlyCost)}', @@ -207,8 +230,10 @@ class MainScreenSummaryCard extends StatelessWidget { const SizedBox(width: 16), _buildInfoBox( context, - title: AppLocalizations.of(context).totalSubscriptionServices, - value: '$totalSubscriptions${AppLocalizations.of(context).servicesUnit}', + title: AppLocalizations.of(context) + .totalSubscriptionServices, + value: + '$totalSubscriptions${AppLocalizations.of(context).servicesUnit}', ), ], ); @@ -255,7 +280,8 @@ class MainScreenSummaryCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - AppLocalizations.of(context).eventDiscountActive, + AppLocalizations.of(context) + .eventDiscountActive, style: TextStyle( color: AppColors .darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트 @@ -266,7 +292,8 @@ class MainScreenSummaryCard extends StatelessWidget { const SizedBox(height: 2), // 이벤트 절약액 표시 (언어별 기본 통화) FutureBuilder( - future: CurrencyUtil.calculateTotalEventSavingsInDefaultCurrency( + future: CurrencyUtil + .calculateTotalEventSavingsInDefaultCurrency( provider.subscriptions, locale, ), @@ -275,15 +302,24 @@ class MainScreenSummaryCard extends StatelessWidget { return const SizedBox(); } final eventSavings = snapshot.data!; - final decimals = (defaultCurrency == 'KRW' || defaultCurrency == 'JPY') ? 0 : 2; - + final decimals = + (defaultCurrency == 'KRW' || + defaultCurrency == 'JPY') + ? 0 + : 2; + return Row( children: [ Text( NumberFormat.currency( - locale: defaultCurrency == 'KRW' ? 'ko_KR' : - defaultCurrency == 'JPY' ? 'ja_JP' : - defaultCurrency == 'CNY' ? 'zh_CN' : 'en_US', + locale: defaultCurrency == 'KRW' + ? 'ko_KR' + : defaultCurrency == 'JPY' + ? 'ja_JP' + : defaultCurrency == + 'CNY' + ? 'zh_CN' + : 'en_US', symbol: currencySymbol, decimalDigits: decimals, ).format(eventSavings), diff --git a/lib/widgets/native_ad_widget.dart b/lib/widgets/native_ad_widget.dart index d40fd40..a1bdbd9 100644 --- a/lib/widgets/native_ad_widget.dart +++ b/lib/widgets/native_ad_widget.dart @@ -44,7 +44,7 @@ class _NativeAdWidgetState extends State { } _nativeAd = NativeAd( - adUnitId: _testAdUnitId(), // 실제 배포 시 교체 필요 + adUnitId: _testAdUnitId(), // 실제 광고 단위 ID factoryId: 'listTile', // Android/iOS 모두 동일하게 맞춰야 함 request: const AdRequest(), listener: NativeAdListener( @@ -63,15 +63,15 @@ class _NativeAdWidgetState extends State { )..load(); } - /// 테스트 광고 단위 ID 반환 함수 + /// 광고 단위 ID 반환 함수 /// Theme.of(context)를 사용하지 않고 Platform 클래스 직접 사용 String _testAdUnitId() { if (Platform.isAndroid) { - // Android 테스트 네이티브 광고 ID - return 'ca-app-pub-3940256099942544/2247696110'; + // Android 네이티브 광고 ID + return 'ca-app-pub-6691216385521068/4512709971'; } else if (Platform.isIOS) { - // iOS 테스트 네이티브 광고 ID - return 'ca-app-pub-3940256099942544/3986624511'; + // iOS 네이티브 광고 ID + return 'ca-app-pub-6691216385521068/4512709971'; } return ''; } diff --git a/lib/widgets/skeleton_loading.dart b/lib/widgets/skeleton_loading.dart index f3ff7bc..90e8dba 100644 --- a/lib/widgets/skeleton_loading.dart +++ b/lib/widgets/skeleton_loading.dart @@ -5,7 +5,7 @@ class SkeletonLoading extends StatelessWidget { final double? width; final double? height; final double borderRadius; - + const SkeletonLoading({ Key? key, this.width, @@ -19,7 +19,7 @@ class SkeletonLoading extends StatelessWidget { if (width != null || height != null) { return _buildSingleSkeleton(); } - + // 기본 전체 화면 스켈레톤 return Column( children: [ @@ -30,25 +30,25 @@ class SkeletonLoading extends StatelessWidget { blur: 10, opacity: 0.1, child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 100, - height: 24, - decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: BorderRadius.circular(4), - ), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 100, + height: 24, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(4), ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _buildSkeletonColumn(), - _buildSkeletonColumn(), - ], - ), - ], + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildSkeletonColumn(), + _buildSkeletonColumn(), + ], + ), + ], ), ), // 구독 목록 스켈레톤 @@ -156,4 +156,4 @@ class SkeletonLoading extends StatelessWidget { ], ); } -} \ No newline at end of file +} diff --git a/lib/widgets/sms_scan/scan_initial_widget.dart b/lib/widgets/sms_scan/scan_initial_widget.dart index bf2059b..86da4b5 100644 --- a/lib/widgets/sms_scan/scan_initial_widget.dart +++ b/lib/widgets/sms_scan/scan_initial_widget.dart @@ -67,4 +67,4 @@ class ScanInitialWidget extends StatelessWidget { ], ); } -} \ No newline at end of file +} diff --git a/lib/widgets/sms_scan/scan_loading_widget.dart b/lib/widgets/sms_scan/scan_loading_widget.dart index ad28115..dd4873c 100644 --- a/lib/widgets/sms_scan/scan_loading_widget.dart +++ b/lib/widgets/sms_scan/scan_loading_widget.dart @@ -33,4 +33,4 @@ class ScanLoadingWidget extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/sms_scan/scan_progress_widget.dart b/lib/widgets/sms_scan/scan_progress_widget.dart index 7a830fc..e4d72b4 100644 --- a/lib/widgets/sms_scan/scan_progress_widget.dart +++ b/lib/widgets/sms_scan/scan_progress_widget.dart @@ -35,4 +35,4 @@ class ScanProgressWidget extends StatelessWidget { ], ); } -} \ No newline at end of file +} diff --git a/lib/widgets/sms_scan/subscription_card_widget.dart b/lib/widgets/sms_scan/subscription_card_widget.dart index c99e262..d63e028 100644 --- a/lib/widgets/sms_scan/subscription_card_widget.dart +++ b/lib/widgets/sms_scan/subscription_card_widget.dart @@ -43,7 +43,8 @@ class _SubscriptionCardWidgetState extends State { void initState() { super.initState(); // URL 필드 자동 설정 - if (widget.websiteUrlController.text.isEmpty && widget.subscription.websiteUrl != null) { + if (widget.websiteUrlController.text.isEmpty && + widget.subscription.websiteUrl != null) { widget.websiteUrlController.text = widget.subscription.websiteUrl!; } } @@ -110,13 +111,13 @@ class _SubscriptionCardWidgetState extends State { ), ), ), - + // 구분선 Container( height: 1, color: AppColors.navyGray.withValues(alpha: 0.1), ), - + // 클릭 불가능한 액션 영역 Padding( padding: const EdgeInsets.all(16.0), @@ -145,7 +146,7 @@ class _SubscriptionCardWidgetState extends State { forceDark: true, ), const SizedBox(height: 24), - + // 서비스명 ThemedText( AppLocalizations.of(context).serviceName, @@ -256,7 +257,8 @@ class _SubscriptionCardWidgetState extends State { const SizedBox(height: 8), CategorySelector( categories: categoryProvider.categories, - selectedCategoryId: widget.selectedCategoryId ?? widget.subscription.category, + selectedCategoryId: + widget.selectedCategoryId ?? widget.subscription.category, onChanged: widget.onCategoryChanged, baseColor: _getCategoryColor(categoryProvider), isGlassmorphism: true, @@ -304,12 +306,13 @@ class _SubscriptionCardWidgetState extends State { } Color? _getCategoryColor(CategoryProvider categoryProvider) { - final categoryId = widget.selectedCategoryId ?? widget.subscription.category; + final categoryId = + widget.selectedCategoryId ?? widget.subscription.category; if (categoryId == null) return null; - + final category = categoryProvider.getCategoryById(categoryId); if (category == null) return null; - + return CategoryIconMapper.getCategoryColor(category); } -} \ No newline at end of file +} diff --git a/lib/widgets/spring_animation_widget.dart b/lib/widgets/spring_animation_widget.dart index 9e46fb2..8876787 100644 --- a/lib/widgets/spring_animation_widget.dart +++ b/lib/widgets/spring_animation_widget.dart @@ -8,7 +8,7 @@ class SpringAnimationWidget extends StatefulWidget { final Offset? initialOffset; final double? initialScale; final double? initialRotation; - + const SpringAnimationWidget({ super.key, required this.child, @@ -41,7 +41,7 @@ class _SpringAnimationWidgetState extends State vsync: this, duration: const Duration(seconds: 2), ); - + // 오프셋 애니메이션 _offsetAnimation = Tween( begin: widget.initialOffset ?? const Offset(0, 50), @@ -50,7 +50,7 @@ class _SpringAnimationWidgetState extends State parent: _controller, curve: Curves.elasticOut, )); - + // 스케일 애니메이션 _scaleAnimation = Tween( begin: widget.initialScale ?? 0.5, @@ -59,7 +59,7 @@ class _SpringAnimationWidgetState extends State parent: _controller, curve: Curves.elasticOut, )); - + // 회전 애니메이션 _rotationAnimation = Tween( begin: widget.initialRotation ?? 0.0, @@ -68,7 +68,7 @@ class _SpringAnimationWidgetState extends State parent: _controller, curve: Curves.elasticOut, )); - + // 지연 후 애니메이션 시작 Future.delayed(widget.delay, () { if (mounted) { @@ -110,7 +110,7 @@ class BouncyButton extends StatefulWidget { final VoidCallback? onPressed; final EdgeInsetsGeometry? padding; final BoxDecoration? decoration; - + const BouncyButton({ super.key, required this.child, @@ -127,7 +127,7 @@ class _BouncyButtonState extends State with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation _scaleAnimation; - + @override void initState() { super.initState(); @@ -135,7 +135,7 @@ class _BouncyButtonState extends State duration: const Duration(milliseconds: 200), vsync: this, ); - + _scaleAnimation = Tween( begin: 1.0, end: 0.95, @@ -144,26 +144,26 @@ class _BouncyButtonState extends State curve: Curves.easeInOut, )); } - + @override void dispose() { _controller.dispose(); super.dispose(); } - + void _handleTapDown(TapDownDetails details) { _controller.forward(); } - + void _handleTapUp(TapUpDetails details) { _controller.reverse(); widget.onPressed?.call(); } - + void _handleTapCancel() { _controller.reverse(); } - + @override Widget build(BuildContext context) { return GestureDetector( @@ -193,7 +193,7 @@ class GravityAnimation extends StatefulWidget { final double gravity; final double bounceFactor; final double initialVelocity; - + const GravityAnimation({ super.key, required this.child, @@ -221,7 +221,7 @@ class _GravityAnimationState extends State vsync: this, duration: const Duration(seconds: 10), )..addListener(_updatePhysics); - + _controller.repeat(); } @@ -229,15 +229,15 @@ class _GravityAnimationState extends State setState(() { // 속도 업데이트 (중력 적용) _velocity += widget.gravity * 0.016; // 60fps 가정 - + // 위치 업데이트 _position += _velocity; - + // 바닥 충돌 감지 if (_position >= _floor) { _position = _floor; _velocity = -_velocity * widget.bounceFactor; - + // 너무 작은 바운스는 멈춤 if (_velocity.abs() < 1) { _velocity = 0; @@ -266,7 +266,7 @@ class RippleAnimation extends StatefulWidget { final Widget child; final Color rippleColor; final Duration duration; - + const RippleAnimation({ super.key, required this.child, @@ -290,7 +290,7 @@ class _RippleAnimationState extends State duration: widget.duration, vsync: this, ); - + _animation = Tween( begin: 0.0, end: 1.0, @@ -325,8 +325,8 @@ class _RippleAnimationState extends State height: 100 + 200 * _animation.value, decoration: BoxDecoration( shape: BoxShape.circle, - color: widget.rippleColor.withValues(alpha: - (1 - _animation.value) * 0.3, + color: widget.rippleColor.withValues( + alpha: (1 - _animation.value) * 0.3, ), ), ); @@ -337,4 +337,4 @@ class _RippleAnimationState extends State ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/staggered_list_animation.dart b/lib/widgets/staggered_list_animation.dart index 28f8683..ed6068f 100644 --- a/lib/widgets/staggered_list_animation.dart +++ b/lib/widgets/staggered_list_animation.dart @@ -8,7 +8,7 @@ class StaggeredListAnimation extends StatefulWidget { final Duration animationDuration; final Curve curve; final Axis direction; - + const StaggeredListAnimation({ super.key, required this.children, @@ -42,7 +42,7 @@ class _StaggeredListAnimationState extends State duration: widget.animationDuration, vsync: this, ); - + final fadeAnimation = Tween( begin: 0.0, end: 1.0, @@ -50,7 +50,7 @@ class _StaggeredListAnimationState extends State parent: controller, curve: widget.curve, )); - + final slideAnimation = Tween( begin: widget.direction == Axis.vertical ? const Offset(0, 0.3) @@ -60,7 +60,7 @@ class _StaggeredListAnimationState extends State parent: controller, curve: widget.curve, )); - + final scaleAnimation = Tween( begin: 0.8, end: 1.0, @@ -68,7 +68,7 @@ class _StaggeredListAnimationState extends State parent: controller, curve: widget.curve, )); - + _controllers.add(controller); _fadeAnimations.add(fadeAnimation); _slideAnimations.add(slideAnimation); @@ -132,7 +132,7 @@ class StaggeredAnimationItem extends StatefulWidget { final Duration delay; final Duration duration; final Curve curve; - + const StaggeredAnimationItem({ super.key, required this.child, @@ -160,7 +160,7 @@ class _StaggeredAnimationItemState extends State duration: widget.duration, vsync: this, ); - + _fadeAnimation = Tween( begin: 0.0, end: 1.0, @@ -168,7 +168,7 @@ class _StaggeredAnimationItemState extends State parent: _controller, curve: widget.curve, )); - + _slideAnimation = Tween( begin: const Offset(0, 0.3), end: Offset.zero, @@ -176,7 +176,7 @@ class _StaggeredAnimationItemState extends State parent: _controller, curve: widget.curve, )); - + _scaleAnimation = Tween( begin: 0.8, end: 1.0, @@ -184,7 +184,7 @@ class _StaggeredAnimationItemState extends State parent: _controller, curve: widget.curve, )); - + // 지연 후 애니메이션 시작 Future.delayed(widget.delay * widget.index, () { if (mounted) { @@ -224,7 +224,7 @@ class FlipAnimationCard extends StatefulWidget { final Widget front; final Widget back; final Duration duration; - + const FlipAnimationCard({ super.key, required this.front, @@ -249,7 +249,7 @@ class _FlipAnimationCardState extends State duration: widget.duration, vsync: this, ); - + _animation = Tween( begin: 0.0, end: 1.0, @@ -299,4 +299,4 @@ class _FlipAnimationCardState extends State ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/subscription_card.dart b/lib/widgets/subscription_card.dart index 2728d6f..a9d44a7 100644 --- a/lib/widgets/subscription_card.dart +++ b/lib/widgets/subscription_card.dart @@ -41,18 +41,18 @@ class _SubscriptionCardState extends State ); _loadDisplayName(); } - + Future _loadDisplayName() async { if (!mounted) return; - + final localeProvider = context.read(); final locale = localeProvider.locale.languageCode; - + final displayName = await SubscriptionUrlMatcher.getServiceDisplayName( serviceName: widget.subscription.serviceName, locale: locale, ); - + if (mounted) { setState(() { _displayName = displayName; @@ -60,7 +60,6 @@ class _SubscriptionCardState extends State } } - @override void didUpdateWidget(SubscriptionCard oldWidget) { super.didUpdateWidget(oldWidget); @@ -203,7 +202,8 @@ class _SubscriptionCardState extends State daysUntilNext = 7; // 다음 주 같은 요일 } - if (daysUntilNext == 0) return AppLocalizations.of(context).paymentDueToday; + if (daysUntilNext == 0) + return AppLocalizations.of(context).paymentDueToday; return AppLocalizations.of(context).paymentDueInDays(daysUntilNext); } @@ -232,18 +232,18 @@ class _SubscriptionCardState extends State if (widget.subscription.categoryId == null) { return AppColors.blueGradient; } - + final categoryProvider = context.watch(); - final category = categoryProvider.getCategoryById(widget.subscription.categoryId!); - + final category = + categoryProvider.getCategoryById(widget.subscription.categoryId!); + if (category == null) { return AppColors.blueGradient; } - - final categoryColor = Color( - int.parse(category.color.replaceAll('#', '0xFF')) - ); - + + final categoryColor = + Color(int.parse(category.color.replaceAll('#', '0xFF'))); + return [ categoryColor, categoryColor.withValues(alpha: 0.8), @@ -283,12 +283,12 @@ class _SubscriptionCardState extends State Widget build(BuildContext context) { // LocaleProvider를 watch하여 언어 변경시 자동 업데이트 final localeProvider = context.watch(); - + // 언어가 변경되면 displayName 다시 로드 WidgetsBinding.instance.addPostFrameCallback((_) { _loadDisplayName(); }); - + final isNearBilling = _isNearBilling(); return Hero( @@ -300,11 +300,13 @@ class _SubscriptionCardState extends State padding: EdgeInsets.zero, borderRadius: 16, blur: _isHovering ? 15 : 10, - width: double.infinity, // 전체 너비를 차지하도록 설정 - onTap: widget.onTap ?? () async { - print('[SubscriptionCard] AnimatedGlassmorphismCard onTap 호출됨 - ${widget.subscription.serviceName}'); - await AppNavigator.toDetail(context, widget.subscription); - }, + width: double.infinity, // 전체 너비를 차지하도록 설정 + onTap: widget.onTap ?? + () async { + print( + '[SubscriptionCard] AnimatedGlassmorphismCard onTap 호출됨 - ${widget.subscription.serviceName}'); + await AppNavigator.toDetail(context, widget.subscription); + }, child: Column( children: [ // 그라데이션 상단 바 효과 @@ -330,281 +332,290 @@ class _SubscriptionCardState extends State Padding( padding: const EdgeInsets.all(16), child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 서비스 아이콘 + WebsiteIcon( + key: ValueKey( + 'subscription_icon_${widget.subscription.id}'), + url: widget.subscription.websiteUrl, + serviceName: widget.subscription.serviceName, + size: 48, + isHovered: _isHovering, + ), + const SizedBox(width: 16), + + // 서비스 정보 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - // 서비스 아이콘 - WebsiteIcon( - key: ValueKey( - 'subscription_icon_${widget.subscription.id}'), - url: widget.subscription.websiteUrl, - serviceName: widget.subscription.serviceName, - size: 48, - isHovered: _isHovering, + // 서비스명 + Flexible( + child: Text( + _displayName ?? + widget.subscription.serviceName, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 18, + color: AppColors + .darkNavy, // color.md 가이드: 메인 텍스트 + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), - const SizedBox(width: 16), - // 서비스 정보 - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - // 서비스명 - Flexible( - child: Text( - _displayName ?? widget.subscription.serviceName, - style: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 18, - color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - - // 배지들 - Row( - mainAxisSize: MainAxisSize.min, - children: [ - // 이벤트 배지 - if (widget.subscription.isCurrentlyInEvent) ...[ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 3, - ), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [ - Color(0xFFFF6B6B), - Color(0xFFFF8787), - ], - ), - borderRadius: - BorderRadius.circular(12), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.local_offer_rounded, - size: 11, - color: AppColors.pureWhite, - ), - const SizedBox(width: 3), - Text( - AppLocalizations.of(context).event, - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: AppColors.pureWhite, - ), - ), - ], - ), - ), - const SizedBox(width: 6), - ], - - // 결제 주기 배지 - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 3, - ), - decoration: BoxDecoration( - color: AppColors.surfaceColorAlt, - borderRadius: - BorderRadius.circular(12), - border: Border.all( - color: AppColors.borderColor, - width: 0.5, - ), - ), - child: Text( - AppLocalizations.of(context).getBillingCycleName(widget.subscription.billingCycle), - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: AppColors.navyGray, // color.md 가이드: 서브 텍스트 - ), - ), - ), + // 배지들 + Row( + mainAxisSize: MainAxisSize.min, + children: [ + // 이벤트 배지 + if (widget + .subscription.isCurrentlyInEvent) ...[ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [ + Color(0xFFFF6B6B), + Color(0xFFFF8787), ], ), - ], - ), - - const SizedBox(height: 6), - - // 가격 정보 - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - // 가격 표시 (이벤트 가격 반영) - // 가격 표시 (언어별 통화) - FutureBuilder( - future: _getFormattedPrice(), - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const SizedBox(); - } - - if (widget.subscription.isCurrentlyInEvent && snapshot.data!.contains('|')) { - final prices = snapshot.data!.split('|'); - return Row( - children: [ - Text( - prices[0], - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: AppColors.navyGray, - decoration: TextDecoration.lineThrough, - ), - ), - const SizedBox(width: 8), - Text( - prices[1], - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: Color(0xFFFF6B6B), - ), - ), - ], - ); - } else { - return Text( - snapshot.data!, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: widget.subscription.isCurrentlyInEvent - ? const Color(0xFFFF6B6B) - : AppColors.primaryColor, - ), - ); - } - }, - ), - - // 결제 예정일 정보 - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 3, - ), - decoration: BoxDecoration( - color: isNearBilling - ? AppColors.warningColor - .withValues(alpha: 0.1) - : AppColors.successColor - .withValues(alpha: 0.1), - borderRadius: - BorderRadius.circular(12), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - isNearBilling - ? Icons - .access_time_filled_rounded - : Icons - .check_circle_rounded, - size: 12, - color: isNearBilling - ? AppColors.warningColor - : AppColors.successColor, - ), - const SizedBox(width: 4), - Text( - _getNextBillingText(), - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: isNearBilling - ? AppColors.warningColor - : AppColors.successColor, - ), - ), - ], - ), - ), - ], - ), - - // 이벤트 절약액 표시 - if (widget.subscription.isCurrentlyInEvent && - widget.subscription.eventSavings > 0) ...[ - const SizedBox(height: 4), - Row( + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: const Color(0xFFFF6B6B).withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.savings_rounded, - size: 14, - color: Color(0xFFFF6B6B), - ), - const SizedBox(width: 4), - // 이벤트 절약액 표시 (언어별 통화) - FutureBuilder( - future: CurrencyUtil.formatEventSavingsWithLocale( - widget.subscription, - localeProvider.locale.languageCode, - ), - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const SizedBox(); - } - return Text( - '${snapshot.data!} ${AppLocalizations.of(context).saving}', - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Color(0xFFFF6B6B), - ), - ); - }, - ), - ], + const Icon( + Icons.local_offer_rounded, + size: 11, + color: AppColors.pureWhite, + ), + const SizedBox(width: 3), + Text( + AppLocalizations.of(context).event, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.pureWhite, ), ), - const SizedBox(width: 8), - // 이벤트 종료일까지 남은 일수 - if (widget.subscription.eventEndDate != null) ...[ - Text( - AppLocalizations.of(context).daysRemaining(widget.subscription.eventEndDate!.difference(DateTime.now()).inDays), - style: const TextStyle( - fontSize: 11, - color: AppColors.navyGray, // color.md 가이드: 서브 텍스트 - ), - ), - ], ], ), - ], + ), + const SizedBox(width: 6), + ], + + // 결제 주기 배지 + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + color: AppColors.surfaceColorAlt, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: AppColors.borderColor, + width: 0.5, + ), + ), + child: Text( + AppLocalizations.of(context) + .getBillingCycleName( + widget.subscription.billingCycle), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors + .navyGray, // color.md 가이드: 서브 텍스트 + ), + ), + ), + ], + ), + ], + ), + + const SizedBox(height: 6), + + // 가격 정보 + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // 가격 표시 (이벤트 가격 반영) + // 가격 표시 (언어별 통화) + FutureBuilder( + future: _getFormattedPrice(), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const SizedBox(); + } + + if (widget.subscription.isCurrentlyInEvent && + snapshot.data!.contains('|')) { + final prices = snapshot.data!.split('|'); + return Row( + children: [ + Text( + prices[0], + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.navyGray, + decoration: + TextDecoration.lineThrough, + ), + ), + const SizedBox(width: 8), + Text( + prices[1], + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: Color(0xFFFF6B6B), + ), + ), + ], + ); + } else { + return Text( + snapshot.data!, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: widget + .subscription.isCurrentlyInEvent + ? const Color(0xFFFF6B6B) + : AppColors.primaryColor, + ), + ); + } + }, + ), + + // 결제 예정일 정보 + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + color: isNearBilling + ? AppColors.warningColor + .withValues(alpha: 0.1) + : AppColors.successColor + .withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isNearBilling + ? Icons.access_time_filled_rounded + : Icons.check_circle_rounded, + size: 12, + color: isNearBilling + ? AppColors.warningColor + : AppColors.successColor, + ), + const SizedBox(width: 4), + Text( + _getNextBillingText(), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: isNearBilling + ? AppColors.warningColor + : AppColors.successColor, + ), + ), ], ), ), ], + ), + + // 이벤트 절약액 표시 + if (widget.subscription.isCurrentlyInEvent && + widget.subscription.eventSavings > 0) ...[ + const SizedBox(height: 4), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: const Color(0xFFFF6B6B) + .withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.savings_rounded, + size: 14, + color: Color(0xFFFF6B6B), + ), + const SizedBox(width: 4), + // 이벤트 절약액 표시 (언어별 통화) + FutureBuilder( + future: CurrencyUtil + .formatEventSavingsWithLocale( + widget.subscription, + localeProvider.locale.languageCode, + ), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const SizedBox(); + } + return Text( + '${snapshot.data!} ${AppLocalizations.of(context).saving}', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xFFFF6B6B), + ), + ); + }, + ), + ], + ), + ), + const SizedBox(width: 8), + // 이벤트 종료일까지 남은 일수 + if (widget.subscription.eventEndDate != + null) ...[ + Text( + AppLocalizations.of(context).daysRemaining( + widget.subscription.eventEndDate! + .difference(DateTime.now()) + .inDays), + style: const TextStyle( + fontSize: 11, + color: AppColors + .navyGray, // color.md 가이드: 서브 텍스트 + ), + ), + ], + ], + ), + ], + ], + ), + ), + ], ), ), ], diff --git a/lib/widgets/subscription_list_widget.dart b/lib/widgets/subscription_list_widget.dart index 65e3bd9..e22c23e 100644 --- a/lib/widgets/subscription_list_widget.dart +++ b/lib/widgets/subscription_list_widget.dart @@ -46,12 +46,17 @@ class SubscriptionListWidget extends StatelessWidget { child: Consumer( builder: (context, categoryProvider, child) { return CategoryHeaderWidget( - categoryName: categoryProvider.getLocalizedCategoryName(context, category), + categoryName: categoryProvider.getLocalizedCategoryName( + context, category), subscriptionCount: subscriptions.length, - totalCostUSD: _calculateTotalByCurrency(subscriptions, 'USD'), - totalCostKRW: _calculateTotalByCurrency(subscriptions, 'KRW'), - totalCostJPY: _calculateTotalByCurrency(subscriptions, 'JPY'), - totalCostCNY: _calculateTotalByCurrency(subscriptions, 'CNY'), + totalCostUSD: + _calculateTotalByCurrency(subscriptions, 'USD'), + totalCostKRW: + _calculateTotalByCurrency(subscriptions, 'KRW'), + totalCostJPY: + _calculateTotalByCurrency(subscriptions, 'JPY'), + totalCostCNY: + _calculateTotalByCurrency(subscriptions, 'CNY'), ); }, ), @@ -95,41 +100,50 @@ class SubscriptionListWidget extends StatelessWidget { child: SwipeableSubscriptionCard( subscription: subscriptions[subIndex], onTap: () { - print('[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨'); - AppNavigator.toDetail(context, subscriptions[subIndex]); + print( + '[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨'); + AppNavigator.toDetail( + context, subscriptions[subIndex]); }, onDelete: () async { // 현재 로케일에 맞는 서비스명 가져오기 - final localeProvider = Provider.of( + final localeProvider = + Provider.of( context, listen: false, ); - final locale = localeProvider.locale.languageCode; - final displayName = await SubscriptionUrlMatcher.getServiceDisplayName( - serviceName: subscriptions[subIndex].serviceName, + final locale = + localeProvider.locale.languageCode; + final displayName = await SubscriptionUrlMatcher + .getServiceDisplayName( + serviceName: + subscriptions[subIndex].serviceName, locale: locale, ); - + // 삭제 확인 다이얼로그 표시 - final shouldDelete = await DeleteConfirmationDialog.show( + final shouldDelete = + await DeleteConfirmationDialog.show( context: context, serviceName: displayName, ); - + if (shouldDelete && context.mounted) { // 사용자가 확인한 경우에만 삭제 진행 - final provider = Provider.of( - context, + final provider = + Provider.of( + context, listen: false, ); await provider.deleteSubscription( subscriptions[subIndex].id, ); - + if (context.mounted) { AppSnackBar.showError( context: context, - message: AppLocalizations.of(context).subscriptionDeleted(displayName), + message: AppLocalizations.of(context) + .subscriptionDeleted(displayName), icon: Icons.delete_forever_rounded, ); } @@ -152,7 +166,8 @@ class SubscriptionListWidget extends StatelessWidget { } /// 특정 통화의 총 합계를 계산합니다. - double _calculateTotalByCurrency(List subscriptions, String currency) { + double _calculateTotalByCurrency( + List subscriptions, String currency) { return subscriptions .where((sub) => sub.currency == currency) .fold(0.0, (sum, sub) => sum + sub.monthlyCost); diff --git a/lib/widgets/swipeable_subscription_card.dart b/lib/widgets/swipeable_subscription_card.dart index f674a1b..6bb9cfe 100644 --- a/lib/widgets/swipeable_subscription_card.dart +++ b/lib/widgets/swipeable_subscription_card.dart @@ -258,7 +258,8 @@ class _SwipeableSubscriptionCardState extends State angle: _currentOffset / 2000, child: SubscriptionCard( subscription: widget.subscription, - onTap: widget.onTap, // onTap 콜백을 전달하여 SubscriptionCard 내부에서도 탭 처리 가능하도록 함 + onTap: widget + .onTap, // onTap 콜백을 전달하여 SubscriptionCard 내부에서도 탭 처리 가능하도록 함 ), ), ), diff --git a/lib/widgets/themed_text.dart b/lib/widgets/themed_text.dart index e46abc7..97319dd 100644 --- a/lib/widgets/themed_text.dart +++ b/lib/widgets/themed_text.dart @@ -35,47 +35,49 @@ class ThemedText extends StatelessWidget { }); /// 배경 밝기에 따른 텍스트 색상 결정 - static Color getContrastColor(BuildContext context, { + static Color getContrastColor( + BuildContext context, { bool forceLight = false, bool forceDark = false, }) { if (forceLight) return AppColors.pureWhite; if (forceDark) return AppColors.darkNavy; - + final brightness = Theme.of(context).brightness; - + // 글래스모피즘 환경에서는 배경이 밝으므로 어두운 텍스트 사용 if (_isGlassmorphicContext(context)) { - return AppColors.darkNavy; // color.md 가이드: 밝은 배경 위 어두운 텍스트 + return AppColors.darkNavy; // color.md 가이드: 밝은 배경 위 어두운 텍스트 } - + // 일반 환경 - return brightness == Brightness.dark - ? AppColors.pureWhite + return brightness == Brightness.dark + ? AppColors.pureWhite : AppColors.darkNavy; } /// 글래스모피즘 컨텍스트인지 확인 static bool _isGlassmorphicContext(BuildContext context) { // 부모 위젯 체인에서 글래스모피즘 카드가 있는지 확인 - final glassmorphic = context.findAncestorWidgetOfExactType(); + final glassmorphic = + context.findAncestorWidgetOfExactType(); return glassmorphic != null; } @override Widget build(BuildContext context) { - final textColor = color ?? getContrastColor( - context, - forceLight: forceLight, - forceDark: forceDark, - ); - - final finalColor = opacity != null - ? textColor.withValues(alpha: opacity!) - : textColor; - + final textColor = color ?? + getContrastColor( + context, + forceLight: forceLight, + forceDark: forceDark, + ); + + final finalColor = + opacity != null ? textColor.withValues(alpha: opacity!) : textColor; + final defaultStyle = DefaultTextStyle.of(context).style; - + // 개별 스타일 속성들을 병합 final baseStyle = TextStyle( fontSize: fontSize, @@ -83,9 +85,9 @@ class ThemedText extends StatelessWidget { letterSpacing: letterSpacing, color: finalColor, ); - + final effectiveStyle = defaultStyle.merge(baseStyle).merge(style); - + return Text( text, style: effectiveStyle, @@ -193,7 +195,7 @@ class GlassmorphicIndicator extends InheritedWidget { /// 글래스모피즘 환경에서 텍스트 색상을 자동 조정하는 래퍼 class GlassmorphicTextWrapper extends StatelessWidget { final Widget child; - + const GlassmorphicTextWrapper({ super.key, required this.child, @@ -204,10 +206,10 @@ class GlassmorphicTextWrapper extends StatelessWidget { return GlassmorphicIndicator( child: DefaultTextStyle( style: DefaultTextStyle.of(context).style.copyWith( - color: ThemedText.getContrastColor(context), - ), + color: ThemedText.getContrastColor(context), + ), child: child, ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/website_icon.dart b/lib/widgets/website_icon.dart index 788920f..1696982 100644 --- a/lib/widgets/website_icon.dart +++ b/lib/widgets/website_icon.dart @@ -104,8 +104,6 @@ class FaviconCache { // 구글 파비콘 API 서비스 class GoogleFaviconService { - - // 구글 파비콘 API URL 생성 static String getFaviconUrl(String domain, int size) { final directUrl = @@ -137,7 +135,8 @@ class GoogleFaviconService { static String getBase64PlaceholderIcon(String serviceName, Color color) { // 간단한 SVG 생성 (서비스 이름의 첫 글자를 원 안에 표시) final initial = serviceName.isNotEmpty ? serviceName[0].toUpperCase() : '?'; - final colorHex = color.toARGB32().toRadixString(16).padLeft(8, '0').substring(2); + final colorHex = + color.toARGB32().toRadixString(16).padLeft(8, '0').substring(2); // 공백 없이 SVG 생성 (공백이 있으면 Base64 디코딩 후 이미지 로드 시 문제 발생) final svgContent = @@ -568,7 +567,8 @@ class _WebsiteIconState extends State boxShadow: widget.isHovered ? [ BoxShadow( - color: _getColorFromName().withValues(alpha: 0.3), // 약 0.3 알파값 + color: + _getColorFromName().withValues(alpha: 0.3), // 약 0.3 알파값 blurRadius: 12, spreadRadius: 0, offset: const Offset(0, 4),