diff --git a/assets/data/text.json b/assets/data/text.json index a6de54e..3106597 100644 --- a/assets/data/text.json +++ b/assets/data/text.json @@ -28,10 +28,12 @@ "selectIcon": "Select Icon", "addCategory": "Add Category", "settings": "Settings", + "theme": "Theme", "darkMode": "Dark Mode", "language": "Language", "notifications": "Notifications", "appLock": "App Lock", + "appLocked": "App is locked", "paymentCard": "Payment Card", "paymentCardManagement": "Payment Card Management", "paymentCardManagementDescription": "Manage saved cards for subscriptions", @@ -67,6 +69,7 @@ "dailyReminderEnabled": "Receive daily notifications until payment date", "dailyReminderDisabled": "Receive notification @ day(s) before payment", "notificationPermissionDenied": "Notification permission denied", + "permissionGranted": "Permission granted.", "appInfo": "App Info", "version": "Version", "appDescription": "Digital Rent Management App", @@ -86,6 +89,7 @@ "twoDaysBefore": "2 days before", "threeDaysBefore": "3 days before", "requiredFieldsError": "Please fill in all required fields", + "categoryNameRequired": "Please enter category name", "subscriptionUpdated": "Subscription information has been updated", "subscriptionDeleted": "@ subscription has been deleted", "officialCancelPageNotFound": "Official cancellation page not found. Redirecting to Google search.", @@ -114,6 +118,7 @@ "appLockDesc": "App lock with biometric authentication", "unlockWithBiometric": "Unlock with biometric authentication", "authenticationFailed": "Authentication failed. Please try again.", + "nextBillingDateAdjusted": "Saved as the next billing date", "totalExpenseCopied": "Total expense copied: @", "smsPermissionRequired": "SMS permission required", "noSubscriptionSmsFound": "No subscription related SMS found", @@ -158,6 +163,7 @@ "latestSmsMessage": "Latest SMS message", "smsDetectedDate": "Detected on @", "serviceName": "Service Name", + "unknownService": "Unknown service", "nextBillingDateLabel": "Next Billing Date", "category": "Category", "websiteUrlAuto": "Website URL (Auto-extracted)", @@ -245,8 +251,12 @@ "subscriptionDetail": "Subscription Detail", "enterAmount": "Enter amount", "invalidAmount": "Please enter a valid amount", - "featureComingSoon": "This feature is coming soon" - , + "featureComingSoon": "This feature is coming soon", + "exactAlarmPermission": "Exact alarm permission (Alarms & Reminders)", + "exactAlarmPermissionDesc": "We need permission to guarantee precise alarms.", + "allowAlarmsInSettings": "Please allow \"Alarms & reminders\" in Settings.", + "testNotification": "Test notification", + "testSubscriptionBody": "Test subscription • @", "smsPermissionTitle": "Request SMS Permission", "smsPermissionReasonTitle": "Why", "smsPermissionReasonBody": "We analyze payment-related SMS to auto-detect subscriptions. Processing happens locally only.", @@ -256,7 +266,11 @@ "openSettings": "Open Settings", "later": "Later", "requesting": "Requesting...", - "smsPermissionLabel": "SMS Permission" + "smsPermissionLabel": "SMS Permission", + "expirationReminderBody": "@ subscription expires in # days.", + "eventEndNotificationTitle": "Event end notification", + "eventEndNotificationBody": "@'s discount event has ended.", + "paymentChargeNotification": "@ subscription charge @ was completed." }, "ko": { "appTitle": "디지털 월세 관리자", @@ -287,10 +301,12 @@ "selectIcon": "아이콘 선택", "addCategory": "카테고리 추가", "settings": "설정", + "theme": "테마", "darkMode": "다크 모드", "language": "언어", "notifications": "알림", "appLock": "앱 잠금", + "appLocked": "앱이 잠겨 있습니다", "paymentCard": "결제수단", "paymentCardManagement": "결제수단 관리", "paymentCardManagementDescription": "저장된 결제수단을 추가·편집·삭제합니다", @@ -326,6 +342,7 @@ "dailyReminderEnabled": "결제일까지 매일 알림을 받습니다", "dailyReminderDisabled": "결제 @일 전에 알림을 받습니다", "notificationPermissionDenied": "알림 권한이 거부되었습니다", + "permissionGranted": "권한이 허용되었습니다.", "appInfo": "앱 정보", "version": "버전", "appDescription": "디지털 월세 관리 앱", @@ -345,6 +362,7 @@ "twoDaysBefore": "2일 전", "threeDaysBefore": "3일 전", "requiredFieldsError": "필수 항목을 모두 입력해주세요", + "categoryNameRequired": "카테고리 이름을 입력하세요", "subscriptionUpdated": "구독 정보가 업데이트되었습니다.", "subscriptionDeleted": "@ 구독이 삭제되었습니다.", "officialCancelPageNotFound": "공식 해지 페이지를 찾을 수 없어 구글 검색으로 연결합니다.", @@ -373,6 +391,7 @@ "appLockDesc": "생체 인증으로 앱 잠금", "unlockWithBiometric": "생체 인증으로 잠금 해제", "authenticationFailed": "인증에 실패했습니다. 다시 시도해주세요.", + "nextBillingDateAdjusted": "다음 결제 예정일로 저장됨", "totalExpenseCopied": "총 지출액이 복사되었습니다: @", "smsPermissionRequired": "SMS 권한이 필요합니다.", "noSubscriptionSmsFound": "구독 관련 SMS를 찾을 수 없습니다.", @@ -417,6 +436,7 @@ "latestSmsMessage": "최신 SMS 메시지", "smsDetectedDate": "SMS 수신일: @", "serviceName": "서비스명", + "unknownService": "알 수 없는 서비스", "nextBillingDateLabel": "다음 결제일", "category": "카테고리", "websiteUrlAuto": "웹사이트 URL (자동 추출됨)", @@ -504,8 +524,12 @@ "subscriptionDetail": "구독 상세", "enterAmount": "금액을 입력하세요", "invalidAmount": "올바른 금액을 입력해주세요", - "featureComingSoon": "이 기능은 곧 출시됩니다" - , + "featureComingSoon": "이 기능은 곧 출시됩니다", + "exactAlarmPermission": "정확 알람 권한(알람 및 리마인더)", + "exactAlarmPermissionDesc": "정확한 시각에 알림을 보장하려면 권한이 필요합니다.", + "allowAlarmsInSettings": "설정에서 \"알람 및 리마인더\"를 허용해 주세요.", + "testNotification": "테스트 알림", + "testSubscriptionBody": "테스트 구독 • @", "smsPermissionTitle": "SMS 권한 요청", "smsPermissionReasonTitle": "이유", "smsPermissionReasonBody": "문자 메시지에서 반복 결제 내역을 분석해 구독 서비스를 자동으로 탐지합니다. 모든 처리는 기기 내에서만 이루어집니다.", @@ -515,7 +539,11 @@ "openSettings": "설정 열기", "later": "나중에 하기", "requesting": "요청 중...", - "smsPermissionLabel": "SMS 권한" + "smsPermissionLabel": "SMS 권한", + "expirationReminderBody": "@ 구독이 #일 후 만료됩니다.", + "eventEndNotificationTitle": "이벤트 종료 알림", + "eventEndNotificationBody": "@의 할인 이벤트가 종료되었습니다.", + "paymentChargeNotification": "@ 구독료 @이 결제되었습니다." }, "ja": { "appTitle": "デジタル月額管理者", @@ -546,10 +574,12 @@ "selectIcon": "アイコンを選択", "addCategory": "カテゴリー追加", "settings": "設定", + "theme": "テーマ", "darkMode": "ダークモード", "language": "言語", "notifications": "通知", "appLock": "アプリロック", + "appLocked": "アプリがロックされています", "paymentCard": "支払いカード", "paymentCardManagement": "支払いカード管理", "paymentCardManagementDescription": "保存済みのカードを追加・編集・削除します", @@ -585,6 +615,7 @@ "dailyReminderEnabled": "支払い日まで毎日通知を受け取ります", "dailyReminderDisabled": "支払い@日前に通知を受け取ります", "notificationPermissionDenied": "通知権限が拒否されました", + "permissionGranted": "権限が許可されました。", "appInfo": "アプリ情報", "version": "バージョン", "appDescription": "デジタル月額管理アプリ", @@ -604,6 +635,7 @@ "twoDaysBefore": "2日前", "threeDaysBefore": "3日前", "requiredFieldsError": "すべての必須項目を入力してください", + "categoryNameRequired": "カテゴリ名を入力してください", "subscriptionUpdated": "サブスクリプション情報が更新されました", "subscriptionDeleted": "@サブスクリプションが削除されました", "officialCancelPageNotFound": "公式解約ページが見つかりません。Google検索にリダイレクトします。", @@ -632,6 +664,7 @@ "appLockDesc": "生体認証でアプリをロック", "unlockWithBiometric": "生体認証でロック解除", "authenticationFailed": "認証に失敗しました。もう一度お試しください。", + "nextBillingDateAdjusted": "次回請求日に保存しました", "totalExpenseCopied": "総支出がコピーされました:@", "smsPermissionRequired": "SMS権限が必要です", "noSubscriptionSmsFound": "サブスクリプション関連のSMSが見つかりません", @@ -676,6 +709,7 @@ "latestSmsMessage": "最新のSMSメッセージ", "smsDetectedDate": "SMS受信日: @", "serviceName": "サービス名", + "unknownService": "不明なサービス", "nextBillingDateLabel": "次回請求日", "category": "カテゴリー", "websiteUrlAuto": "ウェブサイトURL(自動抽出)", @@ -763,7 +797,16 @@ "subscriptionDetail": "サブスクリプション詳細", "enterAmount": "金額を入力してください", "invalidAmount": "正しい金額を入力してください", - "featureComingSoon": "この機能は近日公開予定です" + "featureComingSoon": "この機能は近日公開予定です", + "exactAlarmPermission": "正確なアラーム権限(アラームとリマインダー)", + "exactAlarmPermissionDesc": "正確な時刻に通知するには権限が必要です。", + "allowAlarmsInSettings": "設定で「アラームとリマインダー」を許可してください。", + "testNotification": "テスト通知", + "testSubscriptionBody": "テストサブスクリプション • @", + "expirationReminderBody": "@ のサブスクリプションは #日後に期限切れになります。", + "eventEndNotificationTitle": "イベント終了通知", + "eventEndNotificationBody": "@ の割引イベントが終了しました。", + "paymentChargeNotification": "@ の購読料 @ が請求されました。" }, "zh": { "appTitle": "数字月租管理器", @@ -794,10 +837,12 @@ "selectIcon": "选择图标", "addCategory": "添加分类", "settings": "设置", + "theme": "主题", "darkMode": "深色模式", "language": "语言", "notifications": "通知", "appLock": "应用锁定", + "appLocked": "应用已锁定", "paymentCard": "支付卡", "paymentCardManagement": "支付卡管理", "paymentCardManagementDescription": "管理已保存的支付卡(新增/编辑/删除)", @@ -833,6 +878,7 @@ "dailyReminderEnabled": "直到付款日期每天接收通知", "dailyReminderDisabled": "在付款@天前接收通知", "notificationPermissionDenied": "通知权限被拒绝", + "permissionGranted": "已获得权限。", "appInfo": "应用信息", "version": "版本", "appDescription": "数字月租管理应用", @@ -852,6 +898,7 @@ "twoDaysBefore": "2天前", "threeDaysBefore": "3天前", "requiredFieldsError": "请填写所有必填项", + "categoryNameRequired": "请输入分类名称", "subscriptionUpdated": "订阅信息已更新", "subscriptionDeleted": "@订阅已删除", "officialCancelPageNotFound": "找不到官方取消页面。重定向到Google搜索。", @@ -880,6 +927,7 @@ "appLockDesc": "使用生物识别锁定应用", "unlockWithBiometric": "使用生物识别解锁", "authenticationFailed": "认证失败。请重试。", + "nextBillingDateAdjusted": "已保存为下一次账单日", "totalExpenseCopied": "总支出已复制:@", "smsPermissionRequired": "需要短信权限", "noSubscriptionSmsFound": "未找到订阅相关的短信", @@ -924,6 +972,7 @@ "latestSmsMessage": "最新短信内容", "smsDetectedDate": "短信接收日期:@", "serviceName": "服务名称", + "unknownService": "未知服务", "nextBillingDateLabel": "下次付款日期", "category": "类别", "websiteUrlAuto": "网站URL(自动提取)", @@ -1011,6 +1060,15 @@ "subscriptionDetail": "订阅详情", "enterAmount": "请输入金额", "invalidAmount": "请输入有效的金额", - "featureComingSoon": "此功能即将推出" + "featureComingSoon": "此功能即将推出", + "exactAlarmPermission": "精确闹钟权限(闹钟和提醒)", + "exactAlarmPermissionDesc": "需要权限以确保在准确时间发送提醒。", + "allowAlarmsInSettings": "请在设置中允许“闹钟和提醒”。", + "testNotification": "测试通知", + "testSubscriptionBody": "测试订阅 • @", + "expirationReminderBody": "@ 订阅将在 # 天后到期。", + "eventEndNotificationTitle": "活动结束通知", + "eventEndNotificationBody": "@ 的优惠活动已结束。", + "paymentChargeNotification": "@ 订阅费用 @ 已扣款。" } } diff --git a/lib/controllers/add_subscription_controller.dart b/lib/controllers/add_subscription_controller.dart index 527b52e..c6dbc8f 100644 --- a/lib/controllers/add_subscription_controller.dart +++ b/lib/controllers/add_subscription_controller.dart @@ -525,7 +525,7 @@ class AddSubscriptionController { if (context.mounted) { AppSnackBar.showInfo( context: context, - message: '다음 결제 예정일로 저장됨', + message: AppLocalizations.of(context).nextBillingDateAdjusted, ); } } diff --git a/lib/controllers/detail_screen_controller.dart b/lib/controllers/detail_screen_controller.dart index 7d01ed2..83c1d19 100644 --- a/lib/controllers/detail_screen_controller.dart +++ b/lib/controllers/detail_screen_controller.dart @@ -454,7 +454,7 @@ class DetailScreenController extends ChangeNotifier { if (adjustedNext.isAfter(originalDateOnly)) { AppSnackBar.showInfo( context: context, - message: '다음 결제 예정일로 저장됨', + message: AppLocalizations.of(context).nextBillingDateAdjusted, ); } diff --git a/lib/controllers/sms_scan_controller.dart b/lib/controllers/sms_scan_controller.dart index 6b9be65..4ba0b07 100644 --- a/lib/controllers/sms_scan_controller.dart +++ b/lib/controllers/sms_scan_controller.dart @@ -14,6 +14,8 @@ import '../providers/navigation_provider.dart'; import '../providers/category_provider.dart'; import '../l10n/app_localizations.dart'; import '../providers/payment_card_provider.dart'; +import 'package:google_mobile_ads/google_mobile_ads.dart'; +import 'dart:io' show Platform; class SmsScanController extends ChangeNotifier { // 상태 관리 @@ -47,6 +49,8 @@ class SmsScanController extends ChangeNotifier { final SubscriptionFilter _filter = SubscriptionFilter(); bool _forceServiceNameEditing = false; bool get isServiceNameEditable => _forceServiceNameEditing; + bool _isAdInProgress = false; + bool get isAdInProgress => _isAdInProgress; @override void dispose() { @@ -73,15 +77,79 @@ class SmsScanController extends ChangeNotifier { serviceNameController.text = ''; } - void updateCurrentServiceName(String value) { + void updateCurrentServiceName(BuildContext context, String value) { if (_currentIndex >= _scannedSubscriptions.length) return; final trimmed = value.trim(); + final unknownLabel = _unknownServiceLabel(context); final updated = _scannedSubscriptions[_currentIndex] - .copyWith(serviceName: trimmed.isEmpty ? '알 수 없는 서비스' : trimmed); + .copyWith(serviceName: trimmed.isEmpty ? unknownLabel : trimmed); _scannedSubscriptions[_currentIndex] = updated; notifyListeners(); } + Future startScan(BuildContext context) async { + if (_isLoading) return; + _isAdInProgress = true; + notifyListeners(); + // 웹/비지원 플랫폼은 바로 스캔 + if (kIsWeb || !(Platform.isAndroid || Platform.isIOS)) { + _isAdInProgress = false; + notifyListeners(); + await scanSms(context); + return; + } + + // 전면 광고 로드 및 노출 후 스캔 진행 + try { + await InterstitialAd.load( + adUnitId: _interstitialAdUnitId(), + request: const AdRequest(), + adLoadCallback: InterstitialAdLoadCallback( + onAdLoaded: (ad) { + ad.fullScreenContentCallback = FullScreenContentCallback( + onAdDismissedFullScreenContent: (ad) { + ad.dispose(); + _startSmsScanIfMounted(context); + }, + onAdFailedToShowFullScreenContent: (ad, error) { + ad.dispose(); + _fallbackAfterDelay(context); + }, + ); + ad.show(); + }, + onAdFailedToLoad: (error) { + _fallbackAfterDelay(context); + }, + ), + ); + } catch (e) { + Log.e('전면 광고 로드 중 오류, 바로 스캔 진행', e); + if (!context.mounted) return; + _fallbackAfterDelay(context); + } + } + + String _interstitialAdUnitId() { + if (Platform.isAndroid || Platform.isIOS) { + return 'ca-app-pub-6691216385521068~6638409932'; + } + return ''; + } + + Future _startSmsScanIfMounted(BuildContext context) async { + if (!context.mounted) return; + _isAdInProgress = false; + notifyListeners(); + await scanSms(context); + } + + Future _fallbackAfterDelay(BuildContext context) async { + await Future.delayed(const Duration(seconds: 5)); + if (!context.mounted) return; + await _startSmsScanIfMounted(context); + } + Future scanSms(BuildContext context) async { _isLoading = true; _errorMessage = null; @@ -366,8 +434,10 @@ class SmsScanController extends ChangeNotifier { } final current = _scannedSubscriptions[_currentIndex]; - _forceServiceNameEditing = _shouldEnableServiceNameEditing(current); - if (_forceServiceNameEditing && current.serviceName == '알 수 없는 서비스') { + final unknownLabel = _unknownServiceLabel(context); + _forceServiceNameEditing = + _shouldEnableServiceNameEditing(current, unknownLabel); + if (_forceServiceNameEditing && current.serviceName == unknownLabel) { serviceNameController.clear(); } else { serviceNameController.text = current.serviceName; @@ -429,8 +499,13 @@ class SmsScanController extends ChangeNotifier { return null; } - bool _shouldEnableServiceNameEditing(Subscription subscription) { + bool _shouldEnableServiceNameEditing( + Subscription subscription, String unknownLabel) { final name = subscription.serviceName.trim(); - return name.isEmpty || name == '알 수 없는 서비스'; + return name.isEmpty || name == unknownLabel; + } + + String _unknownServiceLabel(BuildContext context) { + return AppLocalizations.of(context).unknownService; } } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index d208ee0..06e5e2a 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -68,11 +68,13 @@ class AppLocalizations { String get selectIcon => _localizedStrings['selectIcon'] ?? 'Select Icon'; String get addCategory => _localizedStrings['addCategory'] ?? 'Add Category'; String get settings => _localizedStrings['settings'] ?? 'Settings'; + String get theme => _localizedStrings['theme'] ?? 'Theme'; String get darkMode => _localizedStrings['darkMode'] ?? 'Dark Mode'; String get language => _localizedStrings['language'] ?? 'Language'; String get notifications => _localizedStrings['notifications'] ?? 'Notifications'; String get appLock => _localizedStrings['appLock'] ?? 'App Lock'; + String get appLocked => _localizedStrings['appLocked'] ?? 'App is locked'; String get paymentCard => _localizedStrings['paymentCard'] ?? 'Payment Card'; String get paymentCardManagement => _localizedStrings['paymentCardManagement'] ?? 'Payment Card Management'; @@ -173,6 +175,8 @@ class AppLocalizations { String get notificationPermissionDenied => _localizedStrings['notificationPermissionDenied'] ?? 'Notification permission denied'; + String get permissionGranted => + _localizedStrings['permissionGranted'] ?? 'Permission granted.'; // 앱 정보 String get appInfo => _localizedStrings['appInfo'] ?? 'App Info'; String get version => _localizedStrings['version'] ?? 'Version'; @@ -207,6 +211,8 @@ class AppLocalizations { String get requiredFieldsError => _localizedStrings['requiredFieldsError'] ?? 'Please fill in all required fields'; + String get categoryNameRequired => + _localizedStrings['categoryNameRequired'] ?? 'Please enter category name'; String get subscriptionUpdated => _localizedStrings['subscriptionUpdated'] ?? 'Subscription information has been updated'; @@ -259,6 +265,9 @@ class AppLocalizations { String get authenticationFailed => _localizedStrings['authenticationFailed'] ?? 'Authentication failed. Please try again.'; + String get nextBillingDateAdjusted => + _localizedStrings['nextBillingDateAdjusted'] ?? + 'Saved as the next billing date'; String get smsPermissionRequired => _localizedStrings['smsPermissionRequired'] ?? 'SMS permission required'; String get noSubscriptionSmsFound => @@ -467,6 +476,8 @@ class AppLocalizations { String get foundSubscription => _localizedStrings['foundSubscription'] ?? 'Found subscription'; String get serviceName => _localizedStrings['serviceName'] ?? 'Service Name'; + String get unknownService => + _localizedStrings['unknownService'] ?? 'Unknown service'; String get latestSmsMessage => _localizedStrings['latestSmsMessage'] ?? 'Latest SMS message'; String smsDetectedDate(String date) { @@ -669,6 +680,49 @@ class AppLocalizations { _localizedStrings['invalidAmount'] ?? 'Please enter a valid amount'; String get featureComingSoon => _localizedStrings['featureComingSoon'] ?? 'This feature is coming soon'; + String get exactAlarmPermission => + _localizedStrings['exactAlarmPermission'] ?? + 'Exact alarm permission (Alarms & Reminders)'; + String get exactAlarmPermissionDesc => + _localizedStrings['exactAlarmPermissionDesc'] ?? + 'We need permission to guarantee precise alarms.'; + String get allowAlarmsInSettings => + _localizedStrings['allowAlarmsInSettings'] ?? + 'Please allow "Alarms & reminders" in Settings.'; + String get testNotification => + _localizedStrings['testNotification'] ?? 'Test notification'; + + String testSubscriptionBody(String amountText) { + final template = + _localizedStrings['testSubscriptionBody'] ?? 'Test subscription • @'; + return template.replaceAll('@', amountText); + } + + String expirationReminderBody(String serviceName, int days) { + final template = _localizedStrings['expirationReminderBody'] ?? + '@ subscription expires in # days.'; + return template + .replaceAll('@', serviceName) + .replaceAll('#', days.toString()); + } + + String get eventEndNotificationTitle => + _localizedStrings['eventEndNotificationTitle'] ?? + 'Event end notification'; + + String eventEndNotificationBody(String serviceName) { + final template = _localizedStrings['eventEndNotificationBody'] ?? + "@'s discount event has ended."; + return template.replaceAll('@', serviceName); + } + + String paymentChargeNotification(String serviceName, String amountText) { + final template = _localizedStrings['paymentChargeNotification'] ?? + '@ subscription charge @ was completed.'; + return template + .replaceFirst('@', serviceName) + .replaceFirst('@', amountText); + } // 결제 주기를 키값으로 변환하여 번역된 이름 반환 String getBillingCycleName(String billingCycleKey) { diff --git a/lib/providers/app_lock_provider.dart b/lib/providers/app_lock_provider.dart index abe895f..c5ea650 100644 --- a/lib/providers/app_lock_provider.dart +++ b/lib/providers/app_lock_provider.dart @@ -6,6 +6,8 @@ import '../services/notification_service.dart'; import '../providers/subscription_provider.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:flutter/foundation.dart' show kIsWeb; +import '../l10n/app_localizations.dart'; +import '../navigator_key.dart'; class AppLockProvider extends ChangeNotifier { final Box _appLockBox; @@ -72,8 +74,11 @@ class AppLockProvider extends ChangeNotifier { return true; } + final ctx = navigatorKey.currentContext; + final loc = ctx != null ? AppLocalizations.of(ctx) : null; final authenticated = await _localAuth.authenticate( - localizedReason: '생체 인증을 사용하여 앱 잠금을 해제하세요.', + localizedReason: + loc?.unlockWithBiometric ?? 'Unlock with biometric authentication.', options: const AuthenticationOptions( stickyAuth: true, biometricOnly: true, diff --git a/lib/providers/subscription_provider.dart b/lib/providers/subscription_provider.dart index 5edc9d2..c277409 100644 --- a/lib/providers/subscription_provider.dart +++ b/lib/providers/subscription_provider.dart @@ -8,6 +8,8 @@ import '../services/notification_service.dart'; import '../services/exchange_rate_service.dart'; import '../services/currency_util.dart'; import 'category_provider.dart'; +import '../l10n/app_localizations.dart'; +import '../navigator_key.dart'; class SubscriptionProvider extends ChangeNotifier { late Box _subscriptionBox; @@ -239,10 +241,13 @@ class SubscriptionProvider extends ChangeNotifier { SubscriptionModel subscription) async { if (subscription.eventEndDate != null && subscription.eventEndDate!.isAfter(DateTime.now())) { + final ctx = navigatorKey.currentContext; + final loc = ctx != null ? AppLocalizations.of(ctx) : null; await NotificationService.scheduleNotification( id: '${subscription.id}_event_end'.hashCode, - title: '이벤트 종료 알림', - body: '${subscription.serviceName}의 할인 이벤트가 종료되었습니다.', + title: loc?.eventEndNotificationTitle ?? 'Event end notification', + body: loc?.eventEndNotificationBody(subscription.serviceName) ?? + "${subscription.serviceName}'s discount event has ended.", scheduledDate: subscription.eventEndDate!, channelId: NotificationService.expirationChannelId, ); diff --git a/lib/routes/app_routes.dart b/lib/routes/app_routes.dart index 05ab846..0a9d4a1 100644 --- a/lib/routes/app_routes.dart +++ b/lib/routes/app_routes.dart @@ -9,6 +9,7 @@ import 'package:submanager/screens/splash_screen.dart'; import 'package:submanager/screens/sms_permission_screen.dart'; import 'package:submanager/models/subscription_model.dart'; import 'package:submanager/screens/payment_card_management_screen.dart'; +import '../l10n/app_localizations.dart'; class AppRoutes { static const String splash = '/splash'; @@ -81,9 +82,9 @@ class AppRoutes { static Route _errorRoute() { return MaterialPageRoute( - builder: (_) => const Scaffold( + builder: (context) => Scaffold( body: Center( - child: Text('페이지를 찾을 수 없습니다'), + child: Text(AppLocalizations.of(context).pageNotFound), ), ), ); diff --git a/lib/screens/analysis_screen.dart b/lib/screens/analysis_screen.dart index 2fa3468..14dbe0b 100644 --- a/lib/screens/analysis_screen.dart +++ b/lib/screens/analysis_screen.dart @@ -13,6 +13,7 @@ import '../widgets/analysis/subscription_pie_chart_card.dart'; import '../widgets/analysis/total_expense_summary_card.dart'; import '../widgets/analysis/monthly_expense_chart_card.dart'; import '../widgets/analysis/event_analysis_card.dart'; +import '../theme/ui_constants.dart'; enum AnalysisCardFilterType { all, unassigned, card } @@ -324,21 +325,11 @@ class _AnalysisScreenState extends State controller: _scrollController, physics: const BouncingScrollPhysics(), slivers: [ - SliverToBoxAdapter( - child: SizedBox( - height: kToolbarHeight + MediaQuery.of(context).padding.top, - ), + SliverPadding( + padding: const EdgeInsets.only(top: UIConstants.pageTopPadding), + sliver: _buildCardFilterSection(context, cardProvider), ), - // 네이티브 광고 위젯 - SliverToBoxAdapter( - child: _buildAnimatedAd(), - ), - - const AnalysisScreenSpacer(), - - _buildCardFilterSection(context, cardProvider), - const AnalysisScreenSpacer(), // 1. 구독 비율 파이 차트 @@ -349,6 +340,13 @@ class _AnalysisScreenState extends State const AnalysisScreenSpacer(), + // 네이티브 광고 위젯 (구독 비율 차트 하단) + SliverToBoxAdapter( + child: _buildAnimatedAd(), + ), + + const AnalysisScreenSpacer(), + // 2. 총 지출 요약 카드 TotalExpenseSummaryCard( key: ValueKey('total_expense_$_lastDataHash'), diff --git a/lib/screens/app_lock_screen.dart b/lib/screens/app_lock_screen.dart index c3d473e..489271e 100644 --- a/lib/screens/app_lock_screen.dart +++ b/lib/screens/app_lock_screen.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; + +import '../l10n/app_localizations.dart'; import '../providers/app_lock_provider.dart'; // import '../theme/app_colors.dart'; @@ -8,6 +10,7 @@ class AppLockScreen extends StatelessWidget { @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context); return Scaffold( body: Center( child: Column( @@ -20,7 +23,7 @@ class AppLockScreen extends StatelessWidget { ), const SizedBox(height: 24), Text( - '앱이 잠겨 있습니다', + loc.appLocked, style: TextStyle( fontSize: 24, fontWeight: FontWeight.bold, @@ -29,7 +32,7 @@ class AppLockScreen extends StatelessWidget { ), const SizedBox(height: 16), Text( - '생체 인증으로 잠금을 해제하세요', + loc.appLockDesc, style: TextStyle( fontSize: 16, color: Theme.of(context).colorScheme.onSurfaceVariant, @@ -45,7 +48,7 @@ class AppLockScreen extends StatelessWidget { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - '인증에 실패했습니다. 다시 시도해주세요.', + loc.authenticationFailed, style: TextStyle( color: cs.onPrimary, ), @@ -56,7 +59,7 @@ class AppLockScreen extends StatelessWidget { } }, icon: const Icon(Icons.fingerprint), - label: const Text('생체 인증으로 잠금 해제'), + label: Text(loc.unlockWithBiometric), style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric( horizontal: 24, diff --git a/lib/screens/category_management_screen.dart b/lib/screens/category_management_screen.dart index be8d27a..cea2fa1 100644 --- a/lib/screens/category_management_screen.dart +++ b/lib/screens/category_management_screen.dart @@ -41,10 +41,11 @@ class _CategoryManagementScreenState extends State { @override Widget build(BuildContext context) { + final loc = AppLocalizations.of(context); return Scaffold( appBar: AppBar( title: Text( - '카테고리 관리', + loc.categoryManagement, style: Theme.of(context).textTheme.titleLarge?.copyWith( color: Theme.of(context).colorScheme.onPrimary, ), @@ -67,7 +68,7 @@ class _CategoryManagementScreenState extends State { TextFormField( controller: _nameController, decoration: InputDecoration( - labelText: '카테고리 이름', + labelText: loc.categoryName, labelStyle: TextStyle( color: Theme.of(context) .colorScheme @@ -76,7 +77,7 @@ class _CategoryManagementScreenState extends State { ), validator: (value) { if (value == null || value.isEmpty) { - return '카테고리 이름을 입력하세요'; + return loc.categoryNameRequired; } return null; }, @@ -85,7 +86,7 @@ class _CategoryManagementScreenState extends State { DropdownButtonFormField( initialValue: _selectedColor, decoration: InputDecoration( - labelText: '색상 선택', + labelText: loc.selectColor, labelStyle: TextStyle( color: Theme.of(context) .colorScheme @@ -144,7 +145,7 @@ class _CategoryManagementScreenState extends State { DropdownButtonFormField( initialValue: _selectedIcon, decoration: InputDecoration( - labelText: '아이콘 선택', + labelText: loc.selectIcon, labelStyle: TextStyle( color: Theme.of(context) .colorScheme @@ -154,35 +155,35 @@ class _CategoryManagementScreenState extends State { items: [ DropdownMenuItem( value: 'subscriptions', - child: Text('구독', + child: Text(loc.subscription, style: TextStyle( color: Theme.of(context) .colorScheme .onSurface))), DropdownMenuItem( value: 'movie', - child: Text('영화', + child: Text(loc.movie, style: TextStyle( color: Theme.of(context) .colorScheme .onSurface))), DropdownMenuItem( value: 'music_note', - child: Text('음악', + child: Text(loc.music, style: TextStyle( color: Theme.of(context) .colorScheme .onSurface))), DropdownMenuItem( value: 'fitness_center', - child: Text('운동', + child: Text(loc.exercise, style: TextStyle( color: Theme.of(context) .colorScheme .onSurface))), DropdownMenuItem( value: 'shopping_cart', - child: Text('쇼핑', + child: Text(loc.shopping, style: TextStyle( color: Theme.of(context) .colorScheme @@ -197,7 +198,7 @@ class _CategoryManagementScreenState extends State { const SizedBox(height: 16), ElevatedButton( onPressed: _addCategory, - child: const Text('카테고리 추가'), + child: Text(loc.addCategory), ), ], ), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 05e895f..27a68d2 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -6,7 +6,6 @@ import 'dart:io'; import '../services/notification_service.dart'; // import '../widgets/glassmorphism_card.dart'; // import '../theme/app_colors.dart'; -import '../widgets/native_ad_widget.dart'; import '../widgets/common/snackbar/app_snackbar.dart'; import '../l10n/app_localizations.dart'; import '../providers/locale_provider.dart'; @@ -17,6 +16,7 @@ import '../theme/adaptive_theme.dart'; import '../widgets/common/layout/page_container.dart'; import '../theme/color_scheme_ext.dart'; import '../widgets/app_navigator.dart'; +import '../theme/ui_constants.dart'; class SettingsScreen extends StatelessWidget { const SettingsScreen({super.key}); @@ -86,23 +86,16 @@ class SettingsScreen extends StatelessWidget { child: PageContainer( padding: EdgeInsets.zero, child: ListView( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.fromLTRB( + 16, + UIConstants.pageTopPadding, + 16, + 0, + ), children: [ - // toolbar 높이 추가 - SizedBox( - height: kToolbarHeight + MediaQuery.of(context).padding.top, - ), - // 광고 위젯 추가 - const NativeAdWidget( - key: ValueKey('settings_ad'), - useOuterPadding: true, - ), - const SizedBox(height: 16), - // 테마 모드 설정 Card( - margin: - const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + margin: const EdgeInsets.fromLTRB(16, 0, 16, 8), elevation: 1, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), @@ -191,7 +184,7 @@ class SettingsScreen extends StatelessWidget { leading: Icon(Icons.color_lens, color: cs.onSurfaceVariant), title: Text( - '테마', + loc.theme, style: TextStyle(color: cs.onSurface), ), ), @@ -360,14 +353,14 @@ class SettingsScreen extends StatelessWidget { .colorScheme .onSurfaceVariant), title: Text( - '정확 알람 권한(알람 및 리마인더)', + loc.exactAlarmPermission, style: TextStyle( color: Theme.of(context) .colorScheme .onSurface), ), subtitle: Text( - '정확한 시각에 알림을 보장하려면 권한이 필요합니다.', + loc.exactAlarmPermissionDesc, style: TextStyle( color: Theme.of(context) .colorScheme @@ -385,19 +378,19 @@ class SettingsScreen extends StatelessWidget { if (ok || recheck) { AppSnackBar.showSuccess( context: context, - message: '권한이 허용되었습니다.', + message: loc.permissionGranted, ); } else { AppSnackBar.showInfo( context: context, message: - '설정에서 "알람 및 리마인더"를 허용해 주세요.', + loc.allowAlarmsInSettings, ); } (context as Element).markNeedsBuild(); } }, - child: const Text('허용 요청'), + child: Text(loc.requestPermission), ), ); }, @@ -747,8 +740,8 @@ class SettingsScreen extends StatelessWidget { child: OutlinedButton.icon( icon: const Icon(Icons .notifications_active), - label: - const Text('테스트 알림'), + label: Text( + loc.testNotification), onPressed: () { NotificationService .showTestPaymentNotification(); diff --git a/lib/screens/sms_scan_screen.dart b/lib/screens/sms_scan_screen.dart index 9dc4218..af8873f 100644 --- a/lib/screens/sms_scan_screen.dart +++ b/lib/screens/sms_scan_screen.dart @@ -9,6 +9,7 @@ import '../l10n/app_localizations.dart'; import '../widgets/payment_card/payment_card_form_sheet.dart'; import '../routes/app_routes.dart'; import '../models/payment_card_suggestion.dart'; +import '../theme/ui_constants.dart'; class SmsScanScreen extends StatefulWidget { const SmsScanScreen({super.key}); @@ -56,7 +57,7 @@ class _SmsScanScreenState extends State { if (_controller.scannedSubscriptions.isEmpty) { return ScanInitialWidget( - onScanPressed: () => _controller.scanSms(context), + onScanPressed: () => _controller.startScan(context), errorMessage: _controller.errorMessage, ); } @@ -75,7 +76,7 @@ class _SmsScanScreenState extends State { } }); return ScanInitialWidget( - onScanPressed: () => _controller.scanSms(context), + onScanPressed: () => _controller.startScan(context), errorMessage: _controller.errorMessage, ); } @@ -104,7 +105,7 @@ class _SmsScanScreenState extends State { onPaymentCardChanged: _controller.setSelectedPaymentCardId, enableServiceNameEditing: _controller.isServiceNameEditable, onServiceNameChanged: _controller.isServiceNameEditable - ? _controller.updateCurrentServiceName + ? (value) => _controller.updateCurrentServiceName(context, value) : null, onAddCard: () async { final newCardId = await PaymentCardFormSheet.show(context); @@ -160,22 +161,83 @@ class _SmsScanScreenState extends State { @override Widget build(BuildContext context) { - return SingleChildScrollView( - controller: _scrollController, - padding: EdgeInsets.zero, - child: Column( - children: [ - // toolbar 높이 추가 - SizedBox( - height: kToolbarHeight + MediaQuery.of(context).padding.top, + return Stack( + children: [ + SingleChildScrollView( + controller: _scrollController, + padding: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.only(top: UIConstants.pageTopPadding), + child: Column( + children: [ + _buildContent(), + // FloatingNavigationBar를 위한 충분한 하단 여백 + SizedBox( + height: 120 + MediaQuery.of(context).padding.bottom, + ), + ], + ), ), - _buildContent(), - // FloatingNavigationBar를 위한 충분한 하단 여백 - SizedBox( - height: 120 + MediaQuery.of(context).padding.bottom, + ), + if (_controller.isAdInProgress) + Positioned.fill( + child: IgnorePointer( + child: Stack( + children: [ + Container( + color: Theme.of(context) + .colorScheme + .surface + .withValues(alpha: 0.4), + ), + Center( + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, vertical: 16), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Theme.of(context).colorScheme.primary, + ), + ), + ), + const SizedBox(width: 12), + Text( + AppLocalizations.of(context).scanningMessages, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith( + color: + Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.w700, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), + ], + ), + ), ), - ], - ), + ], ); } } diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index a266253..c7afef6 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -635,6 +635,8 @@ class NotificationService { try { final expirationDate = subscription.nextBillingDate; final reminderDate = expirationDate.subtract(const Duration(days: 7)); + final ctx = navigatorKey.currentContext; + final loc = ctx != null ? AppLocalizations.of(ctx) : null; // tz.local 초기화 확인 및 재시도 tz.Location location; @@ -656,8 +658,9 @@ class NotificationService { await _notifications.zonedSchedule( ('${subscription.id}_expiration').hashCode, - '구독 만료 예정 알림', - '${subscription.serviceName} 구독이 7일 후 만료됩니다.', + loc?.expirationReminder ?? _paymentReminderTitle(_getLocaleCode()), + loc?.expirationReminderBody(subscription.serviceName, 7) ?? + '${subscription.serviceName} subscription expires in 7 days.', tz.TZDateTime.from(reminderDate, location), const NotificationDetails( android: AndroidNotificationDetails( @@ -849,11 +852,14 @@ class NotificationService { if (_isWeb || !_initialized) return; try { final locale = _getLocaleCode(); - final title = _paymentReminderTitle(locale); + final ctx = navigatorKey.currentContext; + final loc = ctx != null ? AppLocalizations.of(ctx) : null; + final title = loc?.paymentReminder ?? _paymentReminderTitle(locale); final amountText = await CurrencyUtil.formatAmountWithLocale(10000.0, 'KRW', locale); - final body = '테스트 구독 • $amountText'; + final body = loc?.testSubscriptionBody(amountText) ?? + 'Test subscription • $amountText'; await _notifications.show( DateTime.now().millisecondsSinceEpoch.remainder(1 << 31), @@ -880,7 +886,11 @@ class NotificationService { } static String getNotificationBody(String serviceName, double amount) { - return '$serviceName 구독료 ${amount.toStringAsFixed(0)}원이 결제되었습니다.'; + final ctx = navigatorKey.currentContext; + final loc = ctx != null ? AppLocalizations.of(ctx) : null; + final amountText = amount.toStringAsFixed(0); + return loc?.paymentChargeNotification(serviceName, amountText) ?? + '$serviceName subscription charge $amountText was completed.'; } static Future _buildPaymentBody( @@ -925,6 +935,10 @@ class NotificationService { } static String _paymentReminderTitle(String locale) { + final ctx = navigatorKey.currentContext; + if (ctx != null) { + return AppLocalizations.of(ctx).paymentReminder; + } switch (locale) { case 'ko': return '결제 예정 알림'; diff --git a/lib/services/sms_scanner.dart b/lib/services/sms_scanner.dart index 443e06b..c2353a4 100644 --- a/lib/services/sms_scanner.dart +++ b/lib/services/sms_scanner.dart @@ -10,6 +10,8 @@ import '../utils/platform_helper.dart'; import '../utils/business_day_util.dart'; import '../services/sms_scan/sms_scan_result.dart'; import '../models/payment_card_suggestion.dart'; +import '../l10n/app_localizations.dart'; +import '../navigator_key.dart'; class SmsScanner { final SmsQuery _query = SmsQuery(); @@ -82,7 +84,9 @@ class SmsScanner { return subscriptions; } catch (e) { Log.e('SmsScanner: 예외 발생', e); - throw Exception('SMS 스캔 중 오류 발생: $e'); + final loc = _loc(); + throw Exception(loc?.smsScanErrorWithMessage(e.toString()) ?? + 'Error occurred during SMS scan: $e'); } } @@ -116,7 +120,13 @@ class SmsScanner { SmsScanResult? _parseSms(Map sms, int repeatCount) { try { - final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스'; + final loc = _loc(); + final unknownLabel = loc?.unknownService ?? 'Unknown service'; + final serviceNameRaw = sms['serviceName'] as String?; + final serviceName = + (serviceNameRaw == null || serviceNameRaw.trim().isEmpty) + ? unknownLabel + : serviceNameRaw; final monthlyCost = (sms['monthlyCost'] as num?)?.toDouble() ?? 0.0; final billingCycle = SubscriptionModel.normalizeBillingCycle( sms['billingCycle'] as String? ?? 'monthly'); @@ -196,8 +206,9 @@ class SmsScanner { if (issuer == null && last4 == null) { return null; } + final loc = _loc(); return PaymentCardSuggestion( - issuerName: issuer ?? '결제수단', + issuerName: issuer ?? loc?.paymentCard ?? 'Payment card', last4: last4, source: 'sms', ); @@ -366,6 +377,12 @@ class SmsScanner { // 기본값은 원화 return 'KRW'; } + + AppLocalizations? _loc() { + final ctx = navigatorKey.currentContext; + if (ctx == null) return null; + return AppLocalizations.of(ctx); + } } const List _paymentLikeKeywords = [ @@ -501,7 +518,7 @@ String _isoExtractServiceName(String body, String sender) { String _isoExtractServiceNameFromSender(String sender) { if (RegExp(r'^\d+$').hasMatch(sender)) { - return '알 수 없는 서비스'; + return _unknownServiceLabel(); } return sender.replaceAll(RegExp(r'[^\w\s가-힣]'), '').trim(); } @@ -576,13 +593,14 @@ Map>> _groupMessagesByIdentifier( final address = (sms['address'] as String?)?.trim(); final sender = (sms['sender'] as String?)?.trim(); + final unknownLabel = _unknownServiceLabel(); String key = (serviceName != null && serviceName.isNotEmpty && - serviceName != '알 수 없는 서비스') + serviceName != unknownLabel) ? serviceName : (address?.isNotEmpty == true ? address! - : (sender?.isNotEmpty == true ? sender! : 'unknown')); + : (sender?.isNotEmpty == true ? sender! : unknownLabel)); groups.putIfAbsent(key, () => []).add(sms); } @@ -602,6 +620,12 @@ class _RepeatDetectionResult { enum _MatchType { none, monthly, yearly, identical } +String _unknownServiceLabel() { + final ctx = navigatorKey.currentContext; + if (ctx == null) return 'Unknown service'; + return AppLocalizations.of(ctx).unknownService; +} + class _MatchedPair { _MatchedPair(this.first, this.second, this.type); diff --git a/lib/services/sms_service.dart b/lib/services/sms_service.dart index 7922783..b49bb28 100644 --- a/lib/services/sms_service.dart +++ b/lib/services/sms_service.dart @@ -2,6 +2,8 @@ import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/services.dart'; import 'package:permission_handler/permission_handler.dart' as permission; import '../utils/platform_helper.dart'; +import '../l10n/app_localizations.dart'; +import '../navigator_key.dart'; class SMSService { static const platform = MethodChannel('com.submanager/sms'); @@ -37,14 +39,24 @@ class SMSService { try { if (!await hasSMSPermission()) { - throw Exception('SMS 권한이 없습니다.'); + final loc = _loc(); + throw Exception( + loc?.smsPermissionRequired ?? 'SMS permission required.'); } final List result = await platform.invokeMethod('scanSubscriptions'); return result.map((item) => item as Map).toList(); } on PlatformException catch (e) { - throw Exception('SMS 스캔 중 오류 발생: ${e.message}'); + final loc = _loc(); + throw Exception(loc?.smsScanErrorWithMessage(e.message ?? '') ?? + 'Error occurred during SMS scan: ${e.message}'); } } + + static AppLocalizations? _loc() { + final ctx = navigatorKey.currentContext; + if (ctx == null) return null; + return AppLocalizations.of(ctx); + } } diff --git a/lib/theme/ui_constants.dart b/lib/theme/ui_constants.dart index 58524b5..a90a1a7 100644 --- a/lib/theme/ui_constants.dart +++ b/lib/theme/ui_constants.dart @@ -1,7 +1,10 @@ class UIConstants { static const double pageHorizontalPadding = 16; static const double adVerticalPadding = 12; - static const double adCardHeight = 88; + static const double nativeAdWidth = 320; + static const double nativeAdHeight = 300; + static const double nativeAdAspectRatio = nativeAdWidth / nativeAdHeight; + static const double pageTopPadding = 40; static const double cardRadius = 16; static const double cardOutlineAlpha = 0.5; // for outline color alpha } diff --git a/lib/utils/sms_scan/date_formatter.dart b/lib/utils/sms_scan/date_formatter.dart index f8adec9..ff6ee48 100644 --- a/lib/utils/sms_scan/date_formatter.dart +++ b/lib/utils/sms_scan/date_formatter.dart @@ -35,7 +35,7 @@ class SmsDateFormatter { ); } - return '다음 결제일 확인 필요 (과거 날짜)'; + return AppLocalizations.of(context).nextBillingDatePastRequired; } // 미래 날짜 처리 diff --git a/lib/widgets/add_subscription/add_subscription_event_section.dart b/lib/widgets/add_subscription/add_subscription_event_section.dart index 4346e4d..e4cfcb5 100644 --- a/lib/widgets/add_subscription/add_subscription_event_section.dart +++ b/lib/widgets/add_subscription/add_subscription_event_section.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../../controllers/add_subscription_controller.dart'; +import '../../l10n/app_localizations.dart'; import '../common/form_fields/currency_input_field.dart'; import '../common/form_fields/date_picker_field.dart'; // import '../../theme/app_colors.dart'; @@ -72,23 +73,9 @@ class AddSubscriptionEventSection extends StatelessWidget { const SizedBox(width: 12), Builder( builder: (context) { - final locale = Localizations.localeOf(context); - String titleText; - switch (locale.languageCode) { - case 'ko': - titleText = '이벤트 가격'; - break; - case 'ja': - titleText = 'イベント価格'; - break; - case 'zh': - titleText = '活动价格'; - break; - default: - titleText = 'Event Price'; - } + final loc = AppLocalizations.of(context); return Text( - titleText, + loc.eventPrice, style: TextStyle( fontSize: 18, fontWeight: FontWeight.w700, @@ -157,23 +144,8 @@ class AddSubscriptionEventSection extends StatelessWidget { Expanded( child: Builder( builder: (context) { - final locale = - Localizations.localeOf(context); - String infoText; - switch (locale.languageCode) { - case 'ko': - infoText = '할인 또는 프로모션 가격을 설정하세요'; - break; - case 'ja': - infoText = '割引またはプロモーション価格を設定してください'; - break; - case 'zh': - infoText = '设置折扣或促销价格'; - break; - default: - infoText = - 'Set up discount or promotion price'; - } + final loc = AppLocalizations.of(context); + final infoText = loc.eventPriceHint; return Text( infoText, style: TextStyle( @@ -195,26 +167,9 @@ class AddSubscriptionEventSection extends StatelessWidget { // 이벤트 기간 Builder( builder: (context) { - final locale = Localizations.localeOf(context); - String startLabel; - String endLabel; - switch (locale.languageCode) { - case 'ko': - startLabel = '시작일'; - endLabel = '종료일'; - break; - case 'ja': - startLabel = '開始日'; - endLabel = '終了日'; - break; - case 'zh': - startLabel = '开始日期'; - endLabel = '结束日期'; - break; - default: - startLabel = 'Start Date'; - endLabel = 'End Date'; - } + final loc = AppLocalizations.of(context); + final startLabel = loc.startDate; + final endLabel = loc.endDate; return DateRangePickerField( startDate: controller.eventStartDate, endDate: controller.eventEndDate, @@ -245,37 +200,13 @@ class AddSubscriptionEventSection extends StatelessWidget { // 이벤트 가격 Builder( builder: (BuildContext innerContext) { - // 현재 로케일 확인 - final currentLocale = - Localizations.localeOf(innerContext); - - // 로케일에 따라 직접 텍스트 설정 - String eventPriceLabel; - String eventPriceHint; - - switch (currentLocale.languageCode) { - case 'ko': - eventPriceLabel = '이벤트 가격'; - eventPriceHint = '할인된 가격을 입력하세요'; - break; - case 'ja': - eventPriceLabel = 'イベント価格'; - eventPriceHint = '割引価格を入力してください'; - break; - case 'zh': - eventPriceLabel = '活动价格'; - eventPriceHint = '输入折扣价格'; - break; - default: - eventPriceLabel = 'Event Price'; - eventPriceHint = 'Enter discounted price'; - } + final loc = AppLocalizations.of(innerContext); return CurrencyInputField( controller: controller.eventPriceController, currency: controller.currency, - label: eventPriceLabel, - hintText: eventPriceHint, + label: loc.eventPrice, + hintText: loc.eventPriceHint, enabled: controller.isEventActive, // 이벤트 비활성화 시 검증을 건너뛰어 저장이 막히지 않도록 처리 validator: diff --git a/lib/widgets/home_content.dart b/lib/widgets/home_content.dart index d270162..d5d3deb 100644 --- a/lib/widgets/home_content.dart +++ b/lib/widgets/home_content.dart @@ -9,7 +9,7 @@ import '../providers/subscription_provider.dart'; import '../utils/subscription_grouping_helper.dart'; import '../widgets/empty_state_widget.dart'; import '../widgets/main_summary_card.dart'; -import '../widgets/native_ad_widget.dart'; +import '../theme/ui_constants.dart'; import '../widgets/subscription_list_widget.dart'; class HomeContent extends StatefulWidget { @@ -115,13 +115,8 @@ class _HomeContentState extends State { controller: widget.scrollController, physics: const BouncingScrollPhysics(), slivers: [ - SliverToBoxAdapter( - child: SizedBox( - height: kToolbarHeight + MediaQuery.of(context).padding.top, - ), - ), const SliverToBoxAdapter( - child: NativeAdWidget(key: ValueKey('home_ad')), + child: SizedBox(height: UIConstants.pageTopPadding), ), SliverToBoxAdapter( child: SlideTransition( diff --git a/lib/widgets/native_ad_widget.dart b/lib/widgets/native_ad_widget.dart index 6407457..47cc9a6 100644 --- a/lib/widgets/native_ad_widget.dart +++ b/lib/widgets/native_ad_widget.dart @@ -11,7 +11,16 @@ import '../theme/ui_constants.dart'; /// SRP에 따라 광고 전용 위젯으로 분리 class NativeAdWidget extends StatefulWidget { final bool useOuterPadding; // true이면 외부에서 페이지 패딩을 제공 - const NativeAdWidget({super.key, this.useOuterPadding = false}); + final TemplateType? templateTypeOverride; + final double? aspectRatioOverride; + final MediaAspectRatio? mediaAspectRatioOverride; + const NativeAdWidget({ + super.key, + this.useOuterPadding = false, + this.templateTypeOverride, + this.aspectRatioOverride, + this.mediaAspectRatioOverride, + }); @override State createState() => _NativeAdWidgetState(); @@ -58,10 +67,14 @@ class _NativeAdWidgetState extends State { adUnitId: _testAdUnitId(), // 실제 광고 단위 ID // 네이티브 템플릿을 사용하면 NativeAdFactory 등록 없이도 동작합니다. nativeTemplateStyle: NativeTemplateStyle( - templateType: TemplateType.small, + templateType: widget.templateTypeOverride ?? TemplateType.medium, mainBackgroundColor: const Color(0x00000000), cornerRadius: 12, ), + nativeAdOptions: NativeAdOptions( + mediaAspectRatio: + widget.mediaAspectRatioOverride ?? MediaAspectRatio.square, + ), request: const AdRequest(), listener: NativeAdListener( onAdLoaded: (ad) { @@ -129,12 +142,19 @@ class _NativeAdWidgetState extends State { super.dispose(); } + double _adSlotHeight(double availableWidth) { + final safeWidth = + availableWidth > 0 ? availableWidth : UIConstants.nativeAdWidth; + final aspectRatio = + widget.aspectRatioOverride ?? UIConstants.nativeAdAspectRatio; + return safeWidth / aspectRatio; + } + /// 웹용 광고 플레이스홀더 위젯 - Widget _buildWebPlaceholder() { + Widget _buildWebPlaceholder(double slotHeight, double horizontalPadding) { return Padding( padding: EdgeInsets.symmetric( - horizontal: - widget.useOuterPadding ? 0 : UIConstants.pageHorizontalPadding, + horizontal: horizontalPadding, vertical: UIConstants.adVerticalPadding, ), child: Card( @@ -143,7 +163,7 @@ class _NativeAdWidgetState extends State { borderRadius: BorderRadius.zero, ), child: Container( - height: UIConstants.adCardHeight, + height: slotHeight, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( children: [ @@ -232,43 +252,54 @@ class _NativeAdWidgetState extends State { return const SizedBox.shrink(); } - // 웹 환경인 경우 플레이스홀더 표시 - if (kIsWeb) { - return _buildWebPlaceholder(); - } + return LayoutBuilder( + builder: (context, constraints) { + final double horizontalPadding = + widget.useOuterPadding ? 0.0 : UIConstants.pageHorizontalPadding; + final availableWidth = (constraints.maxWidth.isFinite + ? constraints.maxWidth + : MediaQuery.of(context).size.width) - + (horizontalPadding * 2); + final double slotHeight = _adSlotHeight(availableWidth); - // Android/iOS가 아닌 경우 광고 위젯을 렌더링하지 않음 - if (!(Platform.isAndroid || Platform.isIOS)) { - return const SizedBox.shrink(); - } + // 웹 환경인 경우 플레이스홀더 표시 + if (kIsWeb) { + return _buildWebPlaceholder(slotHeight, horizontalPadding); + } - if (_error != null) { - // 실패 시에도 동일 높이의 플레이스홀더를 유지하여 레이아웃 점프 방지 - return _buildWebPlaceholder(); - } + // Android/iOS가 아닌 경우 광고 위젯을 렌더링하지 않음 + if (!(Platform.isAndroid || Platform.isIOS)) { + return const SizedBox.shrink(); + } - if (!_isLoaded) { - // 로딩 중에도 실제 광고와 동일한 높이의 스켈레톤을 유지 - return _buildWebPlaceholder(); - } + if (_error != null) { + // 실패 시에도 동일 높이의 플레이스홀더를 유지하여 레이아웃 점프 방지 + return _buildWebPlaceholder(slotHeight, horizontalPadding); + } - // 광고 정상 노출 - return Padding( - padding: EdgeInsets.symmetric( - horizontal: - widget.useOuterPadding ? 0 : UIConstants.pageHorizontalPadding, - vertical: UIConstants.adVerticalPadding, - ), - child: Card( - elevation: 1, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.zero, - ), - child: SizedBox( - height: UIConstants.adCardHeight, - child: AdWidget(ad: _nativeAd!), - ), - ), + if (!_isLoaded) { + // 로딩 중에도 실제 광고와 동일한 높이의 스켈레톤을 유지 + return _buildWebPlaceholder(slotHeight, horizontalPadding); + } + + // 광고 정상 노출 + return Padding( + padding: EdgeInsets.symmetric( + horizontal: horizontalPadding, + vertical: UIConstants.adVerticalPadding, + ), + child: Card( + elevation: 1, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.zero, + ), + child: SizedBox( + height: slotHeight, + child: AdWidget(ad: _nativeAd!), + ), + ), + ); + }, ); } } diff --git a/lib/widgets/sms_scan/scan_initial_widget.dart b/lib/widgets/sms_scan/scan_initial_widget.dart index 169ff54..221903a 100644 --- a/lib/widgets/sms_scan/scan_initial_widget.dart +++ b/lib/widgets/sms_scan/scan_initial_widget.dart @@ -19,9 +19,6 @@ class ScanInitialWidget extends StatelessWidget { Widget build(BuildContext context) { return Column( children: [ - // 광고 위젯 추가 - const NativeAdWidget(key: ValueKey('sms_scan_start_ad')), - const SizedBox(height: 48), Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Column( @@ -64,6 +61,8 @@ class ScanInitialWidget extends StatelessWidget { ], ), ), + const SizedBox(height: 32), + const NativeAdWidget(key: ValueKey('sms_scan_start_ad')), ], ); } diff --git a/lib/widgets/sms_scan/scan_loading_widget.dart b/lib/widgets/sms_scan/scan_loading_widget.dart index d381748..5b71705 100644 --- a/lib/widgets/sms_scan/scan_loading_widget.dart +++ b/lib/widgets/sms_scan/scan_loading_widget.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import '../../widgets/native_ad_widget.dart'; import '../../widgets/themed_text.dart'; import '../../l10n/app_localizations.dart'; @@ -8,33 +7,32 @@ class ScanLoadingWidget extends StatelessWidget { @override Widget build(BuildContext context) { - return Column( - children: [ - const NativeAdWidget(key: ValueKey('sms_scan_loading_ad')), - const SizedBox(height: 48), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CircularProgressIndicator( - color: Theme.of(context).colorScheme.primary, - ), - const SizedBox(height: 16), - ThemedText( - AppLocalizations.of(context).scanningMessages, - forceDark: true, - ), - const SizedBox(height: 8), - ThemedText( - AppLocalizations.of(context).findingSubscriptions, - opacity: 0.7, - forceDark: true, - ), - ], - ), + return SizedBox.expand( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + CircularProgressIndicator( + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 16), + ThemedText( + AppLocalizations.of(context).scanningMessages, + forceDark: true, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + ThemedText( + AppLocalizations.of(context).findingSubscriptions, + opacity: 0.7, + forceDark: true, + textAlign: TextAlign.center, + ), + ], ), - ], + ), ); } } diff --git a/lib/widgets/sms_scan/subscription_card_widget.dart b/lib/widgets/sms_scan/subscription_card_widget.dart index 0fdef4a..fd0acea 100644 --- a/lib/widgets/sms_scan/subscription_card_widget.dart +++ b/lib/widgets/sms_scan/subscription_card_widget.dart @@ -10,7 +10,6 @@ import '../../widgets/common/buttons/secondary_button.dart'; import '../../widgets/common/form_fields/base_text_field.dart'; import '../../widgets/common/form_fields/category_selector.dart'; import '../../widgets/common/snackbar/app_snackbar.dart'; -import '../../widgets/native_ad_widget.dart'; import '../../widgets/payment_card/payment_card_selector.dart'; import '../../services/currency_util.dart'; import '../../utils/sms_scan/date_formatter.dart'; @@ -87,9 +86,6 @@ class _SubscriptionCardWidgetState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // 광고 위젯 추가 - const NativeAdWidget(key: ValueKey('sms_scan_result_ad')), - const SizedBox(height: 16), if (_hasRawSmsMessage) Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), diff --git a/lib/widgets/subscription_list_widget.dart b/lib/widgets/subscription_list_widget.dart index 123dfec..3517ee8 100644 --- a/lib/widgets/subscription_list_widget.dart +++ b/lib/widgets/subscription_list_widget.dart @@ -13,6 +13,8 @@ import './common/snackbar/app_snackbar.dart'; import '../l10n/app_localizations.dart'; import '../utils/logger.dart'; import '../utils/subscription_grouping_helper.dart'; +import 'native_ad_widget.dart'; +import 'package:google_mobile_ads/google_mobile_ads.dart'; /// 카테고리별로 구독 목록을 표시하는 위젯 class SubscriptionListWidget extends StatelessWidget { @@ -28,134 +30,132 @@ class SubscriptionListWidget extends StatelessWidget { @override Widget build(BuildContext context) { final sections = groups; + int itemCounter = 0; + final List children = []; - return SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - final group = sections[index]; - final subscriptions = group.subscriptions; + for (final group in sections) { + final subscriptions = group.subscriptions; + final List subscriptionItems = []; - return Padding( - padding: const EdgeInsets.only(bottom: 8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SubscriptionGroupHeader( - group: group, - subscriptionCount: subscriptions.length, - totalCostUSD: _calculateTotalByCurrency(subscriptions, 'USD'), - totalCostKRW: _calculateTotalByCurrency(subscriptions, 'KRW'), - totalCostJPY: _calculateTotalByCurrency(subscriptions, 'JPY'), - totalCostCNY: _calculateTotalByCurrency(subscriptions, 'CNY'), - ), - // 카테고리별 구독 목록 - FadeTransition( - opacity: Tween(begin: 0.0, end: 1.0).animate( - CurvedAnimation( - parent: fadeController, curve: Curves.easeIn)), - child: ListView.builder( - physics: const NeverScrollableScrollPhysics(), - shrinkWrap: true, - padding: const EdgeInsets.symmetric(horizontal: 16), - cacheExtent: 500, - itemCount: subscriptions.length, - itemBuilder: (context, subIndex) { - // 각 구독의 지연값 계산 (순차적으로 나타나도록) - final delay = 0.05 * subIndex; - const animationBegin = 0.2; - const animationEnd = 1.0; - final intervalStart = delay; - final intervalEnd = intervalStart + 0.4; + for (var subIndex = 0; subIndex < subscriptions.length; subIndex++) { + // 각 구독의 지연값 계산 (순차적으로 나타나도록) + final delay = 0.05 * subIndex; + const animationBegin = 0.2; + const animationEnd = 1.0; + final intervalStart = delay; + final intervalEnd = intervalStart + 0.4; - // 간격 계산 (0.0~1.0 사이의 값으로 정규화) - final intervalStartNormalized = - intervalStart.clamp(0.0, 0.9); - final intervalEndNormalized = intervalEnd.clamp(0.1, 1.0); + // 간격 계산 (0.0~1.0 사이의 값으로 정규화) + final intervalStartNormalized = intervalStart.clamp(0.0, 0.9); + final intervalEndNormalized = intervalEnd.clamp(0.1, 1.0); - return FadeTransition( - opacity: Tween( - begin: animationBegin, end: animationEnd) - .animate(CurvedAnimation( - parent: fadeController, - curve: Interval(intervalStartNormalized, - intervalEndNormalized, - curve: Curves.easeOut))), - child: Padding( - padding: const EdgeInsets.only(bottom: 12.0), - child: StaggeredAnimationItem( - index: subIndex, - delay: const Duration(milliseconds: 50), - child: RepaintBoundary( - child: SwipeableSubscriptionCard( - subscription: subscriptions[subIndex], - keepAlive: true, - onTap: () { - Log.d( - '[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨'); - AppNavigator.toDetail( - context, subscriptions[subIndex]); - }, - onDelete: () async { - // 현재 로케일에 맞는 서비스명 가져오기 - final localeProvider = - Provider.of( - context, - listen: false, - ); - final locale = - localeProvider.locale.languageCode; - final displayName = - await SubscriptionUrlMatcher - .getServiceDisplayName( - serviceName: - subscriptions[subIndex].serviceName, - locale: locale, - ); - - // 삭제 확인 다이얼로그 표시 - if (!context.mounted) return; - final shouldDelete = - await DeleteConfirmationDialog.show( - context: context, - serviceName: displayName, - ); - if (!context.mounted) return; - - if (shouldDelete) { - // 사용자가 확인한 경우에만 삭제 진행 - 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), - icon: Icons.delete_forever_rounded, - ); - } - } - }, - ), - ), - ), - ), + subscriptionItems.add( + FadeTransition( + opacity: Tween(begin: animationBegin, end: animationEnd) + .animate(CurvedAnimation( + parent: fadeController, + curve: Interval( + intervalStartNormalized, intervalEndNormalized, + curve: Curves.easeOut))), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 6.0), + child: StaggeredAnimationItem( + index: subIndex, + delay: const Duration(milliseconds: 50), + child: RepaintBoundary( + child: SwipeableSubscriptionCard( + subscription: subscriptions[subIndex], + keepAlive: true, + onTap: () { + Log.d( + '[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨'); + AppNavigator.toDetail(context, subscriptions[subIndex]); + }, + onDelete: () async { + // 현재 로케일에 맞는 서비스명 가져오기 + final localeProvider = Provider.of( + context, + listen: false, ); + final locale = localeProvider.locale.languageCode; + final displayName = + await SubscriptionUrlMatcher.getServiceDisplayName( + serviceName: subscriptions[subIndex].serviceName, + locale: locale, + ); + + // 삭제 확인 다이얼로그 표시 + if (!context.mounted) return; + final shouldDelete = await DeleteConfirmationDialog.show( + context: context, + serviceName: displayName, + ); + if (!context.mounted) return; + + if (shouldDelete) { + // 사용자가 확인한 경우에만 삭제 진행 + 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), + icon: Icons.delete_forever_rounded, + ); + } + } }, ), ), - ], + ), + ), + ), + ); + + itemCounter++; + if ((itemCounter - 1) % 10 == 0) { + subscriptionItems.add( + NativeAdWidget( + key: ValueKey('home_list_ad_$itemCounter'), + aspectRatioOverride: 320 / 80, + mediaAspectRatioOverride: MediaAspectRatio.landscape, + templateTypeOverride: TemplateType.small, ), ); - }, - childCount: sections.length, - ), + } + } + + children.add( + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SubscriptionGroupHeader( + group: group, + subscriptionCount: subscriptions.length, + totalCostUSD: _calculateTotalByCurrency(subscriptions, 'USD'), + totalCostKRW: _calculateTotalByCurrency(subscriptions, 'KRW'), + totalCostJPY: _calculateTotalByCurrency(subscriptions, 'JPY'), + totalCostCNY: _calculateTotalByCurrency(subscriptions, 'CNY'), + ), + ...subscriptionItems, + ], + ), + ), + ); + } + + return SliverList( + delegate: SliverChildListDelegate(children), ); }