i8n과 광고 수정

This commit is contained in:
JiWoong Sul
2025-12-07 21:14:54 +09:00
parent 64da0c5fd3
commit bac4acf9a3
25 changed files with 640 additions and 382 deletions

View File

@@ -28,10 +28,12 @@
"selectIcon": "Select Icon", "selectIcon": "Select Icon",
"addCategory": "Add Category", "addCategory": "Add Category",
"settings": "Settings", "settings": "Settings",
"theme": "Theme",
"darkMode": "Dark Mode", "darkMode": "Dark Mode",
"language": "Language", "language": "Language",
"notifications": "Notifications", "notifications": "Notifications",
"appLock": "App Lock", "appLock": "App Lock",
"appLocked": "App is locked",
"paymentCard": "Payment Card", "paymentCard": "Payment Card",
"paymentCardManagement": "Payment Card Management", "paymentCardManagement": "Payment Card Management",
"paymentCardManagementDescription": "Manage saved cards for subscriptions", "paymentCardManagementDescription": "Manage saved cards for subscriptions",
@@ -67,6 +69,7 @@
"dailyReminderEnabled": "Receive daily notifications until payment date", "dailyReminderEnabled": "Receive daily notifications until payment date",
"dailyReminderDisabled": "Receive notification @ day(s) before payment", "dailyReminderDisabled": "Receive notification @ day(s) before payment",
"notificationPermissionDenied": "Notification permission denied", "notificationPermissionDenied": "Notification permission denied",
"permissionGranted": "Permission granted.",
"appInfo": "App Info", "appInfo": "App Info",
"version": "Version", "version": "Version",
"appDescription": "Digital Rent Management App", "appDescription": "Digital Rent Management App",
@@ -86,6 +89,7 @@
"twoDaysBefore": "2 days before", "twoDaysBefore": "2 days before",
"threeDaysBefore": "3 days before", "threeDaysBefore": "3 days before",
"requiredFieldsError": "Please fill in all required fields", "requiredFieldsError": "Please fill in all required fields",
"categoryNameRequired": "Please enter category name",
"subscriptionUpdated": "Subscription information has been updated", "subscriptionUpdated": "Subscription information has been updated",
"subscriptionDeleted": "@ subscription has been deleted", "subscriptionDeleted": "@ subscription has been deleted",
"officialCancelPageNotFound": "Official cancellation page not found. Redirecting to Google search.", "officialCancelPageNotFound": "Official cancellation page not found. Redirecting to Google search.",
@@ -114,6 +118,7 @@
"appLockDesc": "App lock with biometric authentication", "appLockDesc": "App lock with biometric authentication",
"unlockWithBiometric": "Unlock with biometric authentication", "unlockWithBiometric": "Unlock with biometric authentication",
"authenticationFailed": "Authentication failed. Please try again.", "authenticationFailed": "Authentication failed. Please try again.",
"nextBillingDateAdjusted": "Saved as the next billing date",
"totalExpenseCopied": "Total expense copied: @", "totalExpenseCopied": "Total expense copied: @",
"smsPermissionRequired": "SMS permission required", "smsPermissionRequired": "SMS permission required",
"noSubscriptionSmsFound": "No subscription related SMS found", "noSubscriptionSmsFound": "No subscription related SMS found",
@@ -158,6 +163,7 @@
"latestSmsMessage": "Latest SMS message", "latestSmsMessage": "Latest SMS message",
"smsDetectedDate": "Detected on @", "smsDetectedDate": "Detected on @",
"serviceName": "Service Name", "serviceName": "Service Name",
"unknownService": "Unknown service",
"nextBillingDateLabel": "Next Billing Date", "nextBillingDateLabel": "Next Billing Date",
"category": "Category", "category": "Category",
"websiteUrlAuto": "Website URL (Auto-extracted)", "websiteUrlAuto": "Website URL (Auto-extracted)",
@@ -245,8 +251,12 @@
"subscriptionDetail": "Subscription Detail", "subscriptionDetail": "Subscription Detail",
"enterAmount": "Enter amount", "enterAmount": "Enter amount",
"invalidAmount": "Please enter a valid 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", "smsPermissionTitle": "Request SMS Permission",
"smsPermissionReasonTitle": "Why", "smsPermissionReasonTitle": "Why",
"smsPermissionReasonBody": "We analyze payment-related SMS to auto-detect subscriptions. Processing happens locally only.", "smsPermissionReasonBody": "We analyze payment-related SMS to auto-detect subscriptions. Processing happens locally only.",
@@ -256,7 +266,11 @@
"openSettings": "Open Settings", "openSettings": "Open Settings",
"later": "Later", "later": "Later",
"requesting": "Requesting...", "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": { "ko": {
"appTitle": "디지털 월세 관리자", "appTitle": "디지털 월세 관리자",
@@ -287,10 +301,12 @@
"selectIcon": "아이콘 선택", "selectIcon": "아이콘 선택",
"addCategory": "카테고리 추가", "addCategory": "카테고리 추가",
"settings": "설정", "settings": "설정",
"theme": "테마",
"darkMode": "다크 모드", "darkMode": "다크 모드",
"language": "언어", "language": "언어",
"notifications": "알림", "notifications": "알림",
"appLock": "앱 잠금", "appLock": "앱 잠금",
"appLocked": "앱이 잠겨 있습니다",
"paymentCard": "결제수단", "paymentCard": "결제수단",
"paymentCardManagement": "결제수단 관리", "paymentCardManagement": "결제수단 관리",
"paymentCardManagementDescription": "저장된 결제수단을 추가·편집·삭제합니다", "paymentCardManagementDescription": "저장된 결제수단을 추가·편집·삭제합니다",
@@ -326,6 +342,7 @@
"dailyReminderEnabled": "결제일까지 매일 알림을 받습니다", "dailyReminderEnabled": "결제일까지 매일 알림을 받습니다",
"dailyReminderDisabled": "결제 @일 전에 알림을 받습니다", "dailyReminderDisabled": "결제 @일 전에 알림을 받습니다",
"notificationPermissionDenied": "알림 권한이 거부되었습니다", "notificationPermissionDenied": "알림 권한이 거부되었습니다",
"permissionGranted": "권한이 허용되었습니다.",
"appInfo": "앱 정보", "appInfo": "앱 정보",
"version": "버전", "version": "버전",
"appDescription": "디지털 월세 관리 앱", "appDescription": "디지털 월세 관리 앱",
@@ -345,6 +362,7 @@
"twoDaysBefore": "2일 전", "twoDaysBefore": "2일 전",
"threeDaysBefore": "3일 전", "threeDaysBefore": "3일 전",
"requiredFieldsError": "필수 항목을 모두 입력해주세요", "requiredFieldsError": "필수 항목을 모두 입력해주세요",
"categoryNameRequired": "카테고리 이름을 입력하세요",
"subscriptionUpdated": "구독 정보가 업데이트되었습니다.", "subscriptionUpdated": "구독 정보가 업데이트되었습니다.",
"subscriptionDeleted": "@ 구독이 삭제되었습니다.", "subscriptionDeleted": "@ 구독이 삭제되었습니다.",
"officialCancelPageNotFound": "공식 해지 페이지를 찾을 수 없어 구글 검색으로 연결합니다.", "officialCancelPageNotFound": "공식 해지 페이지를 찾을 수 없어 구글 검색으로 연결합니다.",
@@ -373,6 +391,7 @@
"appLockDesc": "생체 인증으로 앱 잠금", "appLockDesc": "생체 인증으로 앱 잠금",
"unlockWithBiometric": "생체 인증으로 잠금 해제", "unlockWithBiometric": "생체 인증으로 잠금 해제",
"authenticationFailed": "인증에 실패했습니다. 다시 시도해주세요.", "authenticationFailed": "인증에 실패했습니다. 다시 시도해주세요.",
"nextBillingDateAdjusted": "다음 결제 예정일로 저장됨",
"totalExpenseCopied": "총 지출액이 복사되었습니다: @", "totalExpenseCopied": "총 지출액이 복사되었습니다: @",
"smsPermissionRequired": "SMS 권한이 필요합니다.", "smsPermissionRequired": "SMS 권한이 필요합니다.",
"noSubscriptionSmsFound": "구독 관련 SMS를 찾을 수 없습니다.", "noSubscriptionSmsFound": "구독 관련 SMS를 찾을 수 없습니다.",
@@ -417,6 +436,7 @@
"latestSmsMessage": "최신 SMS 메시지", "latestSmsMessage": "최신 SMS 메시지",
"smsDetectedDate": "SMS 수신일: @", "smsDetectedDate": "SMS 수신일: @",
"serviceName": "서비스명", "serviceName": "서비스명",
"unknownService": "알 수 없는 서비스",
"nextBillingDateLabel": "다음 결제일", "nextBillingDateLabel": "다음 결제일",
"category": "카테고리", "category": "카테고리",
"websiteUrlAuto": "웹사이트 URL (자동 추출됨)", "websiteUrlAuto": "웹사이트 URL (자동 추출됨)",
@@ -504,8 +524,12 @@
"subscriptionDetail": "구독 상세", "subscriptionDetail": "구독 상세",
"enterAmount": "금액을 입력하세요", "enterAmount": "금액을 입력하세요",
"invalidAmount": "올바른 금액을 입력해주세요", "invalidAmount": "올바른 금액을 입력해주세요",
"featureComingSoon": "이 기능은 곧 출시됩니다" "featureComingSoon": "이 기능은 곧 출시됩니다",
, "exactAlarmPermission": "정확 알람 권한(알람 및 리마인더)",
"exactAlarmPermissionDesc": "정확한 시각에 알림을 보장하려면 권한이 필요합니다.",
"allowAlarmsInSettings": "설정에서 \"알람 및 리마인더\"를 허용해 주세요.",
"testNotification": "테스트 알림",
"testSubscriptionBody": "테스트 구독 • @",
"smsPermissionTitle": "SMS 권한 요청", "smsPermissionTitle": "SMS 권한 요청",
"smsPermissionReasonTitle": "이유", "smsPermissionReasonTitle": "이유",
"smsPermissionReasonBody": "문자 메시지에서 반복 결제 내역을 분석해 구독 서비스를 자동으로 탐지합니다. 모든 처리는 기기 내에서만 이루어집니다.", "smsPermissionReasonBody": "문자 메시지에서 반복 결제 내역을 분석해 구독 서비스를 자동으로 탐지합니다. 모든 처리는 기기 내에서만 이루어집니다.",
@@ -515,7 +539,11 @@
"openSettings": "설정 열기", "openSettings": "설정 열기",
"later": "나중에 하기", "later": "나중에 하기",
"requesting": "요청 중...", "requesting": "요청 중...",
"smsPermissionLabel": "SMS 권한" "smsPermissionLabel": "SMS 권한",
"expirationReminderBody": "@ 구독이 #일 후 만료됩니다.",
"eventEndNotificationTitle": "이벤트 종료 알림",
"eventEndNotificationBody": "@의 할인 이벤트가 종료되었습니다.",
"paymentChargeNotification": "@ 구독료 @이 결제되었습니다."
}, },
"ja": { "ja": {
"appTitle": "デジタル月額管理者", "appTitle": "デジタル月額管理者",
@@ -546,10 +574,12 @@
"selectIcon": "アイコンを選択", "selectIcon": "アイコンを選択",
"addCategory": "カテゴリー追加", "addCategory": "カテゴリー追加",
"settings": "設定", "settings": "設定",
"theme": "テーマ",
"darkMode": "ダークモード", "darkMode": "ダークモード",
"language": "言語", "language": "言語",
"notifications": "通知", "notifications": "通知",
"appLock": "アプリロック", "appLock": "アプリロック",
"appLocked": "アプリがロックされています",
"paymentCard": "支払いカード", "paymentCard": "支払いカード",
"paymentCardManagement": "支払いカード管理", "paymentCardManagement": "支払いカード管理",
"paymentCardManagementDescription": "保存済みのカードを追加・編集・削除します", "paymentCardManagementDescription": "保存済みのカードを追加・編集・削除します",
@@ -585,6 +615,7 @@
"dailyReminderEnabled": "支払い日まで毎日通知を受け取ります", "dailyReminderEnabled": "支払い日まで毎日通知を受け取ります",
"dailyReminderDisabled": "支払い@日前に通知を受け取ります", "dailyReminderDisabled": "支払い@日前に通知を受け取ります",
"notificationPermissionDenied": "通知権限が拒否されました", "notificationPermissionDenied": "通知権限が拒否されました",
"permissionGranted": "権限が許可されました。",
"appInfo": "アプリ情報", "appInfo": "アプリ情報",
"version": "バージョン", "version": "バージョン",
"appDescription": "デジタル月額管理アプリ", "appDescription": "デジタル月額管理アプリ",
@@ -604,6 +635,7 @@
"twoDaysBefore": "2日前", "twoDaysBefore": "2日前",
"threeDaysBefore": "3日前", "threeDaysBefore": "3日前",
"requiredFieldsError": "すべての必須項目を入力してください", "requiredFieldsError": "すべての必須項目を入力してください",
"categoryNameRequired": "カテゴリ名を入力してください",
"subscriptionUpdated": "サブスクリプション情報が更新されました", "subscriptionUpdated": "サブスクリプション情報が更新されました",
"subscriptionDeleted": "@サブスクリプションが削除されました", "subscriptionDeleted": "@サブスクリプションが削除されました",
"officialCancelPageNotFound": "公式解約ページが見つかりません。Google検索にリダイレクトします。", "officialCancelPageNotFound": "公式解約ページが見つかりません。Google検索にリダイレクトします。",
@@ -632,6 +664,7 @@
"appLockDesc": "生体認証でアプリをロック", "appLockDesc": "生体認証でアプリをロック",
"unlockWithBiometric": "生体認証でロック解除", "unlockWithBiometric": "生体認証でロック解除",
"authenticationFailed": "認証に失敗しました。もう一度お試しください。", "authenticationFailed": "認証に失敗しました。もう一度お試しください。",
"nextBillingDateAdjusted": "次回請求日に保存しました",
"totalExpenseCopied": "総支出がコピーされました:@", "totalExpenseCopied": "総支出がコピーされました:@",
"smsPermissionRequired": "SMS権限が必要です", "smsPermissionRequired": "SMS権限が必要です",
"noSubscriptionSmsFound": "サブスクリプション関連のSMSが見つかりません", "noSubscriptionSmsFound": "サブスクリプション関連のSMSが見つかりません",
@@ -676,6 +709,7 @@
"latestSmsMessage": "最新のSMSメッセージ", "latestSmsMessage": "最新のSMSメッセージ",
"smsDetectedDate": "SMS受信日: @", "smsDetectedDate": "SMS受信日: @",
"serviceName": "サービス名", "serviceName": "サービス名",
"unknownService": "不明なサービス",
"nextBillingDateLabel": "次回請求日", "nextBillingDateLabel": "次回請求日",
"category": "カテゴリー", "category": "カテゴリー",
"websiteUrlAuto": "ウェブサイトURL自動抽出", "websiteUrlAuto": "ウェブサイトURL自動抽出",
@@ -763,7 +797,16 @@
"subscriptionDetail": "サブスクリプション詳細", "subscriptionDetail": "サブスクリプション詳細",
"enterAmount": "金額を入力してください", "enterAmount": "金額を入力してください",
"invalidAmount": "正しい金額を入力してください", "invalidAmount": "正しい金額を入力してください",
"featureComingSoon": "この機能は近日公開予定です" "featureComingSoon": "この機能は近日公開予定です",
"exactAlarmPermission": "正確なアラーム権限(アラームとリマインダー)",
"exactAlarmPermissionDesc": "正確な時刻に通知するには権限が必要です。",
"allowAlarmsInSettings": "設定で「アラームとリマインダー」を許可してください。",
"testNotification": "テスト通知",
"testSubscriptionBody": "テストサブスクリプション • @",
"expirationReminderBody": "@ のサブスクリプションは #日後に期限切れになります。",
"eventEndNotificationTitle": "イベント終了通知",
"eventEndNotificationBody": "@ の割引イベントが終了しました。",
"paymentChargeNotification": "@ の購読料 @ が請求されました。"
}, },
"zh": { "zh": {
"appTitle": "数字月租管理器", "appTitle": "数字月租管理器",
@@ -794,10 +837,12 @@
"selectIcon": "选择图标", "selectIcon": "选择图标",
"addCategory": "添加分类", "addCategory": "添加分类",
"settings": "设置", "settings": "设置",
"theme": "主题",
"darkMode": "深色模式", "darkMode": "深色模式",
"language": "语言", "language": "语言",
"notifications": "通知", "notifications": "通知",
"appLock": "应用锁定", "appLock": "应用锁定",
"appLocked": "应用已锁定",
"paymentCard": "支付卡", "paymentCard": "支付卡",
"paymentCardManagement": "支付卡管理", "paymentCardManagement": "支付卡管理",
"paymentCardManagementDescription": "管理已保存的支付卡(新增/编辑/删除)", "paymentCardManagementDescription": "管理已保存的支付卡(新增/编辑/删除)",
@@ -833,6 +878,7 @@
"dailyReminderEnabled": "直到付款日期每天接收通知", "dailyReminderEnabled": "直到付款日期每天接收通知",
"dailyReminderDisabled": "在付款@天前接收通知", "dailyReminderDisabled": "在付款@天前接收通知",
"notificationPermissionDenied": "通知权限被拒绝", "notificationPermissionDenied": "通知权限被拒绝",
"permissionGranted": "已获得权限。",
"appInfo": "应用信息", "appInfo": "应用信息",
"version": "版本", "version": "版本",
"appDescription": "数字月租管理应用", "appDescription": "数字月租管理应用",
@@ -852,6 +898,7 @@
"twoDaysBefore": "2天前", "twoDaysBefore": "2天前",
"threeDaysBefore": "3天前", "threeDaysBefore": "3天前",
"requiredFieldsError": "请填写所有必填项", "requiredFieldsError": "请填写所有必填项",
"categoryNameRequired": "请输入分类名称",
"subscriptionUpdated": "订阅信息已更新", "subscriptionUpdated": "订阅信息已更新",
"subscriptionDeleted": "@订阅已删除", "subscriptionDeleted": "@订阅已删除",
"officialCancelPageNotFound": "找不到官方取消页面。重定向到Google搜索。", "officialCancelPageNotFound": "找不到官方取消页面。重定向到Google搜索。",
@@ -880,6 +927,7 @@
"appLockDesc": "使用生物识别锁定应用", "appLockDesc": "使用生物识别锁定应用",
"unlockWithBiometric": "使用生物识别解锁", "unlockWithBiometric": "使用生物识别解锁",
"authenticationFailed": "认证失败。请重试。", "authenticationFailed": "认证失败。请重试。",
"nextBillingDateAdjusted": "已保存为下一次账单日",
"totalExpenseCopied": "总支出已复制:@", "totalExpenseCopied": "总支出已复制:@",
"smsPermissionRequired": "需要短信权限", "smsPermissionRequired": "需要短信权限",
"noSubscriptionSmsFound": "未找到订阅相关的短信", "noSubscriptionSmsFound": "未找到订阅相关的短信",
@@ -924,6 +972,7 @@
"latestSmsMessage": "最新短信内容", "latestSmsMessage": "最新短信内容",
"smsDetectedDate": "短信接收日期:@", "smsDetectedDate": "短信接收日期:@",
"serviceName": "服务名称", "serviceName": "服务名称",
"unknownService": "未知服务",
"nextBillingDateLabel": "下次付款日期", "nextBillingDateLabel": "下次付款日期",
"category": "类别", "category": "类别",
"websiteUrlAuto": "网站URL自动提取", "websiteUrlAuto": "网站URL自动提取",
@@ -1011,6 +1060,15 @@
"subscriptionDetail": "订阅详情", "subscriptionDetail": "订阅详情",
"enterAmount": "请输入金额", "enterAmount": "请输入金额",
"invalidAmount": "请输入有效的金额", "invalidAmount": "请输入有效的金额",
"featureComingSoon": "此功能即将推出" "featureComingSoon": "此功能即将推出",
"exactAlarmPermission": "精确闹钟权限(闹钟和提醒)",
"exactAlarmPermissionDesc": "需要权限以确保在准确时间发送提醒。",
"allowAlarmsInSettings": "请在设置中允许“闹钟和提醒”。",
"testNotification": "测试通知",
"testSubscriptionBody": "测试订阅 • @",
"expirationReminderBody": "@ 订阅将在 # 天后到期。",
"eventEndNotificationTitle": "活动结束通知",
"eventEndNotificationBody": "@ 的优惠活动已结束。",
"paymentChargeNotification": "@ 订阅费用 @ 已扣款。"
} }
} }

View File

@@ -525,7 +525,7 @@ class AddSubscriptionController {
if (context.mounted) { if (context.mounted) {
AppSnackBar.showInfo( AppSnackBar.showInfo(
context: context, context: context,
message: '다음 결제 예정일로 저장됨', message: AppLocalizations.of(context).nextBillingDateAdjusted,
); );
} }
} }

View File

@@ -454,7 +454,7 @@ class DetailScreenController extends ChangeNotifier {
if (adjustedNext.isAfter(originalDateOnly)) { if (adjustedNext.isAfter(originalDateOnly)) {
AppSnackBar.showInfo( AppSnackBar.showInfo(
context: context, context: context,
message: '다음 결제 예정일로 저장됨', message: AppLocalizations.of(context).nextBillingDateAdjusted,
); );
} }

View File

@@ -14,6 +14,8 @@ import '../providers/navigation_provider.dart';
import '../providers/category_provider.dart'; import '../providers/category_provider.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../providers/payment_card_provider.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 { class SmsScanController extends ChangeNotifier {
// 상태 관리 // 상태 관리
@@ -47,6 +49,8 @@ class SmsScanController extends ChangeNotifier {
final SubscriptionFilter _filter = SubscriptionFilter(); final SubscriptionFilter _filter = SubscriptionFilter();
bool _forceServiceNameEditing = false; bool _forceServiceNameEditing = false;
bool get isServiceNameEditable => _forceServiceNameEditing; bool get isServiceNameEditable => _forceServiceNameEditing;
bool _isAdInProgress = false;
bool get isAdInProgress => _isAdInProgress;
@override @override
void dispose() { void dispose() {
@@ -73,15 +77,79 @@ class SmsScanController extends ChangeNotifier {
serviceNameController.text = ''; serviceNameController.text = '';
} }
void updateCurrentServiceName(String value) { void updateCurrentServiceName(BuildContext context, String value) {
if (_currentIndex >= _scannedSubscriptions.length) return; if (_currentIndex >= _scannedSubscriptions.length) return;
final trimmed = value.trim(); final trimmed = value.trim();
final unknownLabel = _unknownServiceLabel(context);
final updated = _scannedSubscriptions[_currentIndex] final updated = _scannedSubscriptions[_currentIndex]
.copyWith(serviceName: trimmed.isEmpty ? '알 수 없는 서비스' : trimmed); .copyWith(serviceName: trimmed.isEmpty ? unknownLabel : trimmed);
_scannedSubscriptions[_currentIndex] = updated; _scannedSubscriptions[_currentIndex] = updated;
notifyListeners(); notifyListeners();
} }
Future<void> 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<void> _startSmsScanIfMounted(BuildContext context) async {
if (!context.mounted) return;
_isAdInProgress = false;
notifyListeners();
await scanSms(context);
}
Future<void> _fallbackAfterDelay(BuildContext context) async {
await Future.delayed(const Duration(seconds: 5));
if (!context.mounted) return;
await _startSmsScanIfMounted(context);
}
Future<void> scanSms(BuildContext context) async { Future<void> scanSms(BuildContext context) async {
_isLoading = true; _isLoading = true;
_errorMessage = null; _errorMessage = null;
@@ -366,8 +434,10 @@ class SmsScanController extends ChangeNotifier {
} }
final current = _scannedSubscriptions[_currentIndex]; final current = _scannedSubscriptions[_currentIndex];
_forceServiceNameEditing = _shouldEnableServiceNameEditing(current); final unknownLabel = _unknownServiceLabel(context);
if (_forceServiceNameEditing && current.serviceName == '알 수 없는 서비스') { _forceServiceNameEditing =
_shouldEnableServiceNameEditing(current, unknownLabel);
if (_forceServiceNameEditing && current.serviceName == unknownLabel) {
serviceNameController.clear(); serviceNameController.clear();
} else { } else {
serviceNameController.text = current.serviceName; serviceNameController.text = current.serviceName;
@@ -429,8 +499,13 @@ class SmsScanController extends ChangeNotifier {
return null; return null;
} }
bool _shouldEnableServiceNameEditing(Subscription subscription) { bool _shouldEnableServiceNameEditing(
Subscription subscription, String unknownLabel) {
final name = subscription.serviceName.trim(); final name = subscription.serviceName.trim();
return name.isEmpty || name == '알 수 없는 서비스'; return name.isEmpty || name == unknownLabel;
}
String _unknownServiceLabel(BuildContext context) {
return AppLocalizations.of(context).unknownService;
} }
} }

View File

@@ -68,11 +68,13 @@ class AppLocalizations {
String get selectIcon => _localizedStrings['selectIcon'] ?? 'Select Icon'; String get selectIcon => _localizedStrings['selectIcon'] ?? 'Select Icon';
String get addCategory => _localizedStrings['addCategory'] ?? 'Add Category'; String get addCategory => _localizedStrings['addCategory'] ?? 'Add Category';
String get settings => _localizedStrings['settings'] ?? 'Settings'; String get settings => _localizedStrings['settings'] ?? 'Settings';
String get theme => _localizedStrings['theme'] ?? 'Theme';
String get darkMode => _localizedStrings['darkMode'] ?? 'Dark Mode'; String get darkMode => _localizedStrings['darkMode'] ?? 'Dark Mode';
String get language => _localizedStrings['language'] ?? 'Language'; String get language => _localizedStrings['language'] ?? 'Language';
String get notifications => String get notifications =>
_localizedStrings['notifications'] ?? 'Notifications'; _localizedStrings['notifications'] ?? 'Notifications';
String get appLock => _localizedStrings['appLock'] ?? 'App Lock'; String get appLock => _localizedStrings['appLock'] ?? 'App Lock';
String get appLocked => _localizedStrings['appLocked'] ?? 'App is locked';
String get paymentCard => _localizedStrings['paymentCard'] ?? 'Payment Card'; String get paymentCard => _localizedStrings['paymentCard'] ?? 'Payment Card';
String get paymentCardManagement => String get paymentCardManagement =>
_localizedStrings['paymentCardManagement'] ?? 'Payment Card Management'; _localizedStrings['paymentCardManagement'] ?? 'Payment Card Management';
@@ -173,6 +175,8 @@ class AppLocalizations {
String get notificationPermissionDenied => String get notificationPermissionDenied =>
_localizedStrings['notificationPermissionDenied'] ?? _localizedStrings['notificationPermissionDenied'] ??
'Notification permission denied'; 'Notification permission denied';
String get permissionGranted =>
_localizedStrings['permissionGranted'] ?? 'Permission granted.';
// 앱 정보 // 앱 정보
String get appInfo => _localizedStrings['appInfo'] ?? 'App Info'; String get appInfo => _localizedStrings['appInfo'] ?? 'App Info';
String get version => _localizedStrings['version'] ?? 'Version'; String get version => _localizedStrings['version'] ?? 'Version';
@@ -207,6 +211,8 @@ class AppLocalizations {
String get requiredFieldsError => String get requiredFieldsError =>
_localizedStrings['requiredFieldsError'] ?? _localizedStrings['requiredFieldsError'] ??
'Please fill in all required fields'; 'Please fill in all required fields';
String get categoryNameRequired =>
_localizedStrings['categoryNameRequired'] ?? 'Please enter category name';
String get subscriptionUpdated => String get subscriptionUpdated =>
_localizedStrings['subscriptionUpdated'] ?? _localizedStrings['subscriptionUpdated'] ??
'Subscription information has been updated'; 'Subscription information has been updated';
@@ -259,6 +265,9 @@ class AppLocalizations {
String get authenticationFailed => String get authenticationFailed =>
_localizedStrings['authenticationFailed'] ?? _localizedStrings['authenticationFailed'] ??
'Authentication failed. Please try again.'; 'Authentication failed. Please try again.';
String get nextBillingDateAdjusted =>
_localizedStrings['nextBillingDateAdjusted'] ??
'Saved as the next billing date';
String get smsPermissionRequired => String get smsPermissionRequired =>
_localizedStrings['smsPermissionRequired'] ?? 'SMS permission required'; _localizedStrings['smsPermissionRequired'] ?? 'SMS permission required';
String get noSubscriptionSmsFound => String get noSubscriptionSmsFound =>
@@ -467,6 +476,8 @@ class AppLocalizations {
String get foundSubscription => String get foundSubscription =>
_localizedStrings['foundSubscription'] ?? 'Found subscription'; _localizedStrings['foundSubscription'] ?? 'Found subscription';
String get serviceName => _localizedStrings['serviceName'] ?? 'Service Name'; String get serviceName => _localizedStrings['serviceName'] ?? 'Service Name';
String get unknownService =>
_localizedStrings['unknownService'] ?? 'Unknown service';
String get latestSmsMessage => String get latestSmsMessage =>
_localizedStrings['latestSmsMessage'] ?? 'Latest SMS message'; _localizedStrings['latestSmsMessage'] ?? 'Latest SMS message';
String smsDetectedDate(String date) { String smsDetectedDate(String date) {
@@ -669,6 +680,49 @@ class AppLocalizations {
_localizedStrings['invalidAmount'] ?? 'Please enter a valid amount'; _localizedStrings['invalidAmount'] ?? 'Please enter a valid amount';
String get featureComingSoon => String get featureComingSoon =>
_localizedStrings['featureComingSoon'] ?? 'This feature is coming soon'; _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) { String getBillingCycleName(String billingCycleKey) {

View File

@@ -6,6 +6,8 @@ import '../services/notification_service.dart';
import '../providers/subscription_provider.dart'; import '../providers/subscription_provider.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/foundation.dart' show kIsWeb;
import '../l10n/app_localizations.dart';
import '../navigator_key.dart';
class AppLockProvider extends ChangeNotifier { class AppLockProvider extends ChangeNotifier {
final Box<bool> _appLockBox; final Box<bool> _appLockBox;
@@ -72,8 +74,11 @@ class AppLockProvider extends ChangeNotifier {
return true; return true;
} }
final ctx = navigatorKey.currentContext;
final loc = ctx != null ? AppLocalizations.of(ctx) : null;
final authenticated = await _localAuth.authenticate( final authenticated = await _localAuth.authenticate(
localizedReason: '생체 인증을 사용하여 앱 잠금을 해제하세요.', localizedReason:
loc?.unlockWithBiometric ?? 'Unlock with biometric authentication.',
options: const AuthenticationOptions( options: const AuthenticationOptions(
stickyAuth: true, stickyAuth: true,
biometricOnly: true, biometricOnly: true,

View File

@@ -8,6 +8,8 @@ import '../services/notification_service.dart';
import '../services/exchange_rate_service.dart'; import '../services/exchange_rate_service.dart';
import '../services/currency_util.dart'; import '../services/currency_util.dart';
import 'category_provider.dart'; import 'category_provider.dart';
import '../l10n/app_localizations.dart';
import '../navigator_key.dart';
class SubscriptionProvider extends ChangeNotifier { class SubscriptionProvider extends ChangeNotifier {
late Box<SubscriptionModel> _subscriptionBox; late Box<SubscriptionModel> _subscriptionBox;
@@ -239,10 +241,13 @@ class SubscriptionProvider extends ChangeNotifier {
SubscriptionModel subscription) async { SubscriptionModel subscription) async {
if (subscription.eventEndDate != null && if (subscription.eventEndDate != null &&
subscription.eventEndDate!.isAfter(DateTime.now())) { subscription.eventEndDate!.isAfter(DateTime.now())) {
final ctx = navigatorKey.currentContext;
final loc = ctx != null ? AppLocalizations.of(ctx) : null;
await NotificationService.scheduleNotification( await NotificationService.scheduleNotification(
id: '${subscription.id}_event_end'.hashCode, id: '${subscription.id}_event_end'.hashCode,
title: '이벤트 종료 알림', title: loc?.eventEndNotificationTitle ?? 'Event end notification',
body: '${subscription.serviceName}의 할인 이벤트가 종료되었습니다.', body: loc?.eventEndNotificationBody(subscription.serviceName) ??
"${subscription.serviceName}'s discount event has ended.",
scheduledDate: subscription.eventEndDate!, scheduledDate: subscription.eventEndDate!,
channelId: NotificationService.expirationChannelId, channelId: NotificationService.expirationChannelId,
); );

View File

@@ -9,6 +9,7 @@ import 'package:submanager/screens/splash_screen.dart';
import 'package:submanager/screens/sms_permission_screen.dart'; import 'package:submanager/screens/sms_permission_screen.dart';
import 'package:submanager/models/subscription_model.dart'; import 'package:submanager/models/subscription_model.dart';
import 'package:submanager/screens/payment_card_management_screen.dart'; import 'package:submanager/screens/payment_card_management_screen.dart';
import '../l10n/app_localizations.dart';
class AppRoutes { class AppRoutes {
static const String splash = '/splash'; static const String splash = '/splash';
@@ -81,9 +82,9 @@ class AppRoutes {
static Route<dynamic> _errorRoute() { static Route<dynamic> _errorRoute() {
return MaterialPageRoute( return MaterialPageRoute(
builder: (_) => const Scaffold( builder: (context) => Scaffold(
body: Center( body: Center(
child: Text('페이지를 찾을 수 없습니다'), child: Text(AppLocalizations.of(context).pageNotFound),
), ),
), ),
); );

View File

@@ -13,6 +13,7 @@ import '../widgets/analysis/subscription_pie_chart_card.dart';
import '../widgets/analysis/total_expense_summary_card.dart'; import '../widgets/analysis/total_expense_summary_card.dart';
import '../widgets/analysis/monthly_expense_chart_card.dart'; import '../widgets/analysis/monthly_expense_chart_card.dart';
import '../widgets/analysis/event_analysis_card.dart'; import '../widgets/analysis/event_analysis_card.dart';
import '../theme/ui_constants.dart';
enum AnalysisCardFilterType { all, unassigned, card } enum AnalysisCardFilterType { all, unassigned, card }
@@ -324,20 +325,10 @@ class _AnalysisScreenState extends State<AnalysisScreen>
controller: _scrollController, controller: _scrollController,
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
slivers: <Widget>[ slivers: <Widget>[
SliverToBoxAdapter( SliverPadding(
child: SizedBox( padding: const EdgeInsets.only(top: UIConstants.pageTopPadding),
height: kToolbarHeight + MediaQuery.of(context).padding.top, sliver: _buildCardFilterSection(context, cardProvider),
), ),
),
// 네이티브 광고 위젯
SliverToBoxAdapter(
child: _buildAnimatedAd(),
),
const AnalysisScreenSpacer(),
_buildCardFilterSection(context, cardProvider),
const AnalysisScreenSpacer(), const AnalysisScreenSpacer(),
@@ -349,6 +340,13 @@ class _AnalysisScreenState extends State<AnalysisScreen>
const AnalysisScreenSpacer(), const AnalysisScreenSpacer(),
// 네이티브 광고 위젯 (구독 비율 차트 하단)
SliverToBoxAdapter(
child: _buildAnimatedAd(),
),
const AnalysisScreenSpacer(),
// 2. 총 지출 요약 카드 // 2. 총 지출 요약 카드
TotalExpenseSummaryCard( TotalExpenseSummaryCard(
key: ValueKey('total_expense_$_lastDataHash'), key: ValueKey('total_expense_$_lastDataHash'),

View File

@@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/app_lock_provider.dart'; import '../providers/app_lock_provider.dart';
// import '../theme/app_colors.dart'; // import '../theme/app_colors.dart';
@@ -8,6 +10,7 @@ class AppLockScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final loc = AppLocalizations.of(context);
return Scaffold( return Scaffold(
body: Center( body: Center(
child: Column( child: Column(
@@ -20,7 +23,7 @@ class AppLockScreen extends StatelessWidget {
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
Text( Text(
'앱이 잠겨 있습니다', loc.appLocked,
style: TextStyle( style: TextStyle(
fontSize: 24, fontSize: 24,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -29,7 +32,7 @@ class AppLockScreen extends StatelessWidget {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'생체 인증으로 잠금을 해제하세요', loc.appLockDesc,
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant, color: Theme.of(context).colorScheme.onSurfaceVariant,
@@ -45,7 +48,7 @@ class AppLockScreen extends StatelessWidget {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
'인증에 실패했습니다. 다시 시도해주세요.', loc.authenticationFailed,
style: TextStyle( style: TextStyle(
color: cs.onPrimary, color: cs.onPrimary,
), ),
@@ -56,7 +59,7 @@ class AppLockScreen extends StatelessWidget {
} }
}, },
icon: const Icon(Icons.fingerprint), icon: const Icon(Icons.fingerprint),
label: const Text('생체 인증으로 잠금 해제'), label: Text(loc.unlockWithBiometric),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 24, horizontal: 24,

View File

@@ -41,10 +41,11 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final loc = AppLocalizations.of(context);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text( title: Text(
'카테고리 관리', loc.categoryManagement,
style: Theme.of(context).textTheme.titleLarge?.copyWith( style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Theme.of(context).colorScheme.onPrimary, color: Theme.of(context).colorScheme.onPrimary,
), ),
@@ -67,7 +68,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
TextFormField( TextFormField(
controller: _nameController, controller: _nameController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: '카테고리 이름', labelText: loc.categoryName,
labelStyle: TextStyle( labelStyle: TextStyle(
color: Theme.of(context) color: Theme.of(context)
.colorScheme .colorScheme
@@ -76,7 +77,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
), ),
validator: (value) { validator: (value) {
if (value == null || value.isEmpty) { if (value == null || value.isEmpty) {
return '카테고리 이름을 입력하세요'; return loc.categoryNameRequired;
} }
return null; return null;
}, },
@@ -85,7 +86,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
initialValue: _selectedColor, initialValue: _selectedColor,
decoration: InputDecoration( decoration: InputDecoration(
labelText: '색상 선택', labelText: loc.selectColor,
labelStyle: TextStyle( labelStyle: TextStyle(
color: Theme.of(context) color: Theme.of(context)
.colorScheme .colorScheme
@@ -144,7 +145,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
initialValue: _selectedIcon, initialValue: _selectedIcon,
decoration: InputDecoration( decoration: InputDecoration(
labelText: '아이콘 선택', labelText: loc.selectIcon,
labelStyle: TextStyle( labelStyle: TextStyle(
color: Theme.of(context) color: Theme.of(context)
.colorScheme .colorScheme
@@ -154,35 +155,35 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
items: [ items: [
DropdownMenuItem( DropdownMenuItem(
value: 'subscriptions', value: 'subscriptions',
child: Text('구독', child: Text(loc.subscription,
style: TextStyle( style: TextStyle(
color: Theme.of(context) color: Theme.of(context)
.colorScheme .colorScheme
.onSurface))), .onSurface))),
DropdownMenuItem( DropdownMenuItem(
value: 'movie', value: 'movie',
child: Text('영화', child: Text(loc.movie,
style: TextStyle( style: TextStyle(
color: Theme.of(context) color: Theme.of(context)
.colorScheme .colorScheme
.onSurface))), .onSurface))),
DropdownMenuItem( DropdownMenuItem(
value: 'music_note', value: 'music_note',
child: Text('음악', child: Text(loc.music,
style: TextStyle( style: TextStyle(
color: Theme.of(context) color: Theme.of(context)
.colorScheme .colorScheme
.onSurface))), .onSurface))),
DropdownMenuItem( DropdownMenuItem(
value: 'fitness_center', value: 'fitness_center',
child: Text('운동', child: Text(loc.exercise,
style: TextStyle( style: TextStyle(
color: Theme.of(context) color: Theme.of(context)
.colorScheme .colorScheme
.onSurface))), .onSurface))),
DropdownMenuItem( DropdownMenuItem(
value: 'shopping_cart', value: 'shopping_cart',
child: Text('쇼핑', child: Text(loc.shopping,
style: TextStyle( style: TextStyle(
color: Theme.of(context) color: Theme.of(context)
.colorScheme .colorScheme
@@ -197,7 +198,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
ElevatedButton( ElevatedButton(
onPressed: _addCategory, onPressed: _addCategory,
child: const Text('카테고리 추가'), child: Text(loc.addCategory),
), ),
], ],
), ),

View File

@@ -6,7 +6,6 @@ import 'dart:io';
import '../services/notification_service.dart'; import '../services/notification_service.dart';
// import '../widgets/glassmorphism_card.dart'; // import '../widgets/glassmorphism_card.dart';
// import '../theme/app_colors.dart'; // import '../theme/app_colors.dart';
import '../widgets/native_ad_widget.dart';
import '../widgets/common/snackbar/app_snackbar.dart'; import '../widgets/common/snackbar/app_snackbar.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../providers/locale_provider.dart'; import '../providers/locale_provider.dart';
@@ -17,6 +16,7 @@ import '../theme/adaptive_theme.dart';
import '../widgets/common/layout/page_container.dart'; import '../widgets/common/layout/page_container.dart';
import '../theme/color_scheme_ext.dart'; import '../theme/color_scheme_ext.dart';
import '../widgets/app_navigator.dart'; import '../widgets/app_navigator.dart';
import '../theme/ui_constants.dart';
class SettingsScreen extends StatelessWidget { class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key}); const SettingsScreen({super.key});
@@ -86,23 +86,16 @@ class SettingsScreen extends StatelessWidget {
child: PageContainer( child: PageContainer(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
child: ListView( child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.fromLTRB(
16,
UIConstants.pageTopPadding,
16,
0,
),
children: [ children: [
// toolbar 높이 추가
SizedBox(
height: kToolbarHeight + MediaQuery.of(context).padding.top,
),
// 광고 위젯 추가
const NativeAdWidget(
key: ValueKey('settings_ad'),
useOuterPadding: true,
),
const SizedBox(height: 16),
// 테마 모드 설정 // 테마 모드 설정
Card( Card(
margin: margin: const EdgeInsets.fromLTRB(16, 0, 16, 8),
const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
elevation: 1, elevation: 1,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
@@ -191,7 +184,7 @@ class SettingsScreen extends StatelessWidget {
leading: Icon(Icons.color_lens, leading: Icon(Icons.color_lens,
color: cs.onSurfaceVariant), color: cs.onSurfaceVariant),
title: Text( title: Text(
'테마', loc.theme,
style: TextStyle(color: cs.onSurface), style: TextStyle(color: cs.onSurface),
), ),
), ),
@@ -360,14 +353,14 @@ class SettingsScreen extends StatelessWidget {
.colorScheme .colorScheme
.onSurfaceVariant), .onSurfaceVariant),
title: Text( title: Text(
'정확 알람 권한(알람 및 리마인더)', loc.exactAlarmPermission,
style: TextStyle( style: TextStyle(
color: Theme.of(context) color: Theme.of(context)
.colorScheme .colorScheme
.onSurface), .onSurface),
), ),
subtitle: Text( subtitle: Text(
'정확한 시각에 알림을 보장하려면 권한이 필요합니다.', loc.exactAlarmPermissionDesc,
style: TextStyle( style: TextStyle(
color: Theme.of(context) color: Theme.of(context)
.colorScheme .colorScheme
@@ -385,19 +378,19 @@ class SettingsScreen extends StatelessWidget {
if (ok || recheck) { if (ok || recheck) {
AppSnackBar.showSuccess( AppSnackBar.showSuccess(
context: context, context: context,
message: '권한이 허용되었습니다.', message: loc.permissionGranted,
); );
} else { } else {
AppSnackBar.showInfo( AppSnackBar.showInfo(
context: context, context: context,
message: message:
'설정에서 "알람 및 리마인더"를 허용해 주세요.', loc.allowAlarmsInSettings,
); );
} }
(context as Element).markNeedsBuild(); (context as Element).markNeedsBuild();
} }
}, },
child: const Text('허용 요청'), child: Text(loc.requestPermission),
), ),
); );
}, },
@@ -747,8 +740,8 @@ class SettingsScreen extends StatelessWidget {
child: OutlinedButton.icon( child: OutlinedButton.icon(
icon: const Icon(Icons icon: const Icon(Icons
.notifications_active), .notifications_active),
label: label: Text(
const Text('테스트 알림'), loc.testNotification),
onPressed: () { onPressed: () {
NotificationService NotificationService
.showTestPaymentNotification(); .showTestPaymentNotification();

View File

@@ -9,6 +9,7 @@ import '../l10n/app_localizations.dart';
import '../widgets/payment_card/payment_card_form_sheet.dart'; import '../widgets/payment_card/payment_card_form_sheet.dart';
import '../routes/app_routes.dart'; import '../routes/app_routes.dart';
import '../models/payment_card_suggestion.dart'; import '../models/payment_card_suggestion.dart';
import '../theme/ui_constants.dart';
class SmsScanScreen extends StatefulWidget { class SmsScanScreen extends StatefulWidget {
const SmsScanScreen({super.key}); const SmsScanScreen({super.key});
@@ -56,7 +57,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
if (_controller.scannedSubscriptions.isEmpty) { if (_controller.scannedSubscriptions.isEmpty) {
return ScanInitialWidget( return ScanInitialWidget(
onScanPressed: () => _controller.scanSms(context), onScanPressed: () => _controller.startScan(context),
errorMessage: _controller.errorMessage, errorMessage: _controller.errorMessage,
); );
} }
@@ -75,7 +76,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
} }
}); });
return ScanInitialWidget( return ScanInitialWidget(
onScanPressed: () => _controller.scanSms(context), onScanPressed: () => _controller.startScan(context),
errorMessage: _controller.errorMessage, errorMessage: _controller.errorMessage,
); );
} }
@@ -104,7 +105,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
onPaymentCardChanged: _controller.setSelectedPaymentCardId, onPaymentCardChanged: _controller.setSelectedPaymentCardId,
enableServiceNameEditing: _controller.isServiceNameEditable, enableServiceNameEditing: _controller.isServiceNameEditable,
onServiceNameChanged: _controller.isServiceNameEditable onServiceNameChanged: _controller.isServiceNameEditable
? _controller.updateCurrentServiceName ? (value) => _controller.updateCurrentServiceName(context, value)
: null, : null,
onAddCard: () async { onAddCard: () async {
final newCardId = await PaymentCardFormSheet.show(context); final newCardId = await PaymentCardFormSheet.show(context);
@@ -160,15 +161,15 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SingleChildScrollView( return Stack(
children: [
SingleChildScrollView(
controller: _scrollController, controller: _scrollController,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.only(top: UIConstants.pageTopPadding),
child: Column( child: Column(
children: [ children: [
// toolbar 높이 추가
SizedBox(
height: kToolbarHeight + MediaQuery.of(context).padding.top,
),
_buildContent(), _buildContent(),
// FloatingNavigationBar를 위한 충분한 하단 여백 // FloatingNavigationBar를 위한 충분한 하단 여백
SizedBox( SizedBox(
@@ -176,6 +177,67 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
), ),
], ],
), ),
),
),
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<Color>(
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,
),
],
),
),
),
),
],
),
),
),
],
); );
} }
} }

View File

@@ -635,6 +635,8 @@ class NotificationService {
try { try {
final expirationDate = subscription.nextBillingDate; final expirationDate = subscription.nextBillingDate;
final reminderDate = expirationDate.subtract(const Duration(days: 7)); final reminderDate = expirationDate.subtract(const Duration(days: 7));
final ctx = navigatorKey.currentContext;
final loc = ctx != null ? AppLocalizations.of(ctx) : null;
// tz.local 초기화 확인 및 재시도 // tz.local 초기화 확인 및 재시도
tz.Location location; tz.Location location;
@@ -656,8 +658,9 @@ class NotificationService {
await _notifications.zonedSchedule( await _notifications.zonedSchedule(
('${subscription.id}_expiration').hashCode, ('${subscription.id}_expiration').hashCode,
'구독 만료 예정 알림', loc?.expirationReminder ?? _paymentReminderTitle(_getLocaleCode()),
'${subscription.serviceName} 구독이 7일 후 만료됩니다.', loc?.expirationReminderBody(subscription.serviceName, 7) ??
'${subscription.serviceName} subscription expires in 7 days.',
tz.TZDateTime.from(reminderDate, location), tz.TZDateTime.from(reminderDate, location),
const NotificationDetails( const NotificationDetails(
android: AndroidNotificationDetails( android: AndroidNotificationDetails(
@@ -849,11 +852,14 @@ class NotificationService {
if (_isWeb || !_initialized) return; if (_isWeb || !_initialized) return;
try { try {
final locale = _getLocaleCode(); 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 = final amountText =
await CurrencyUtil.formatAmountWithLocale(10000.0, 'KRW', locale); await CurrencyUtil.formatAmountWithLocale(10000.0, 'KRW', locale);
final body = '테스트 구독 • $amountText'; final body = loc?.testSubscriptionBody(amountText) ??
'Test subscription • $amountText';
await _notifications.show( await _notifications.show(
DateTime.now().millisecondsSinceEpoch.remainder(1 << 31), DateTime.now().millisecondsSinceEpoch.remainder(1 << 31),
@@ -880,7 +886,11 @@ class NotificationService {
} }
static String getNotificationBody(String serviceName, double amount) { 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<String> _buildPaymentBody( static Future<String> _buildPaymentBody(
@@ -925,6 +935,10 @@ class NotificationService {
} }
static String _paymentReminderTitle(String locale) { static String _paymentReminderTitle(String locale) {
final ctx = navigatorKey.currentContext;
if (ctx != null) {
return AppLocalizations.of(ctx).paymentReminder;
}
switch (locale) { switch (locale) {
case 'ko': case 'ko':
return '결제 예정 알림'; return '결제 예정 알림';

View File

@@ -10,6 +10,8 @@ import '../utils/platform_helper.dart';
import '../utils/business_day_util.dart'; import '../utils/business_day_util.dart';
import '../services/sms_scan/sms_scan_result.dart'; import '../services/sms_scan/sms_scan_result.dart';
import '../models/payment_card_suggestion.dart'; import '../models/payment_card_suggestion.dart';
import '../l10n/app_localizations.dart';
import '../navigator_key.dart';
class SmsScanner { class SmsScanner {
final SmsQuery _query = SmsQuery(); final SmsQuery _query = SmsQuery();
@@ -82,7 +84,9 @@ class SmsScanner {
return subscriptions; return subscriptions;
} catch (e) { } catch (e) {
Log.e('SmsScanner: 예외 발생', 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<String, dynamic> sms, int repeatCount) { SmsScanResult? _parseSms(Map<String, dynamic> sms, int repeatCount) {
try { 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 monthlyCost = (sms['monthlyCost'] as num?)?.toDouble() ?? 0.0;
final billingCycle = SubscriptionModel.normalizeBillingCycle( final billingCycle = SubscriptionModel.normalizeBillingCycle(
sms['billingCycle'] as String? ?? 'monthly'); sms['billingCycle'] as String? ?? 'monthly');
@@ -196,8 +206,9 @@ class SmsScanner {
if (issuer == null && last4 == null) { if (issuer == null && last4 == null) {
return null; return null;
} }
final loc = _loc();
return PaymentCardSuggestion( return PaymentCardSuggestion(
issuerName: issuer ?? '결제수단', issuerName: issuer ?? loc?.paymentCard ?? 'Payment card',
last4: last4, last4: last4,
source: 'sms', source: 'sms',
); );
@@ -366,6 +377,12 @@ class SmsScanner {
// 기본값은 원화 // 기본값은 원화
return 'KRW'; return 'KRW';
} }
AppLocalizations? _loc() {
final ctx = navigatorKey.currentContext;
if (ctx == null) return null;
return AppLocalizations.of(ctx);
}
} }
const List<String> _paymentLikeKeywords = [ const List<String> _paymentLikeKeywords = [
@@ -501,7 +518,7 @@ String _isoExtractServiceName(String body, String sender) {
String _isoExtractServiceNameFromSender(String sender) { String _isoExtractServiceNameFromSender(String sender) {
if (RegExp(r'^\d+$').hasMatch(sender)) { if (RegExp(r'^\d+$').hasMatch(sender)) {
return '알 수 없는 서비스'; return _unknownServiceLabel();
} }
return sender.replaceAll(RegExp(r'[^\w\s가-힣]'), '').trim(); return sender.replaceAll(RegExp(r'[^\w\s가-힣]'), '').trim();
} }
@@ -576,13 +593,14 @@ Map<String, List<Map<String, dynamic>>> _groupMessagesByIdentifier(
final address = (sms['address'] as String?)?.trim(); final address = (sms['address'] as String?)?.trim();
final sender = (sms['sender'] as String?)?.trim(); final sender = (sms['sender'] as String?)?.trim();
final unknownLabel = _unknownServiceLabel();
String key = (serviceName != null && String key = (serviceName != null &&
serviceName.isNotEmpty && serviceName.isNotEmpty &&
serviceName != '알 수 없는 서비스') serviceName != unknownLabel)
? serviceName ? serviceName
: (address?.isNotEmpty == true : (address?.isNotEmpty == true
? address! ? address!
: (sender?.isNotEmpty == true ? sender! : 'unknown')); : (sender?.isNotEmpty == true ? sender! : unknownLabel));
groups.putIfAbsent(key, () => []).add(sms); groups.putIfAbsent(key, () => []).add(sms);
} }
@@ -602,6 +620,12 @@ class _RepeatDetectionResult {
enum _MatchType { none, monthly, yearly, identical } 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 { class _MatchedPair {
_MatchedPair(this.first, this.second, this.type); _MatchedPair(this.first, this.second, this.type);

View File

@@ -2,6 +2,8 @@ import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:permission_handler/permission_handler.dart' as permission; import 'package:permission_handler/permission_handler.dart' as permission;
import '../utils/platform_helper.dart'; import '../utils/platform_helper.dart';
import '../l10n/app_localizations.dart';
import '../navigator_key.dart';
class SMSService { class SMSService {
static const platform = MethodChannel('com.submanager/sms'); static const platform = MethodChannel('com.submanager/sms');
@@ -37,14 +39,24 @@ class SMSService {
try { try {
if (!await hasSMSPermission()) { if (!await hasSMSPermission()) {
throw Exception('SMS 권한이 없습니다.'); final loc = _loc();
throw Exception(
loc?.smsPermissionRequired ?? 'SMS permission required.');
} }
final List<dynamic> result = final List<dynamic> result =
await platform.invokeMethod('scanSubscriptions'); await platform.invokeMethod('scanSubscriptions');
return result.map((item) => item as Map<String, dynamic>).toList(); return result.map((item) => item as Map<String, dynamic>).toList();
} on PlatformException catch (e) { } 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);
}
} }

View File

@@ -1,7 +1,10 @@
class UIConstants { class UIConstants {
static const double pageHorizontalPadding = 16; static const double pageHorizontalPadding = 16;
static const double adVerticalPadding = 12; 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 cardRadius = 16;
static const double cardOutlineAlpha = 0.5; // for outline color alpha static const double cardOutlineAlpha = 0.5; // for outline color alpha
} }

View File

@@ -35,7 +35,7 @@ class SmsDateFormatter {
); );
} }
return '다음 결제일 확인 필요 (과거 날짜)'; return AppLocalizations.of(context).nextBillingDatePastRequired;
} }
// 미래 날짜 처리 // 미래 날짜 처리

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../controllers/add_subscription_controller.dart'; import '../../controllers/add_subscription_controller.dart';
import '../../l10n/app_localizations.dart';
import '../common/form_fields/currency_input_field.dart'; import '../common/form_fields/currency_input_field.dart';
import '../common/form_fields/date_picker_field.dart'; import '../common/form_fields/date_picker_field.dart';
// import '../../theme/app_colors.dart'; // import '../../theme/app_colors.dart';
@@ -72,23 +73,9 @@ class AddSubscriptionEventSection extends StatelessWidget {
const SizedBox(width: 12), const SizedBox(width: 12),
Builder( Builder(
builder: (context) { builder: (context) {
final locale = Localizations.localeOf(context); final loc = AppLocalizations.of(context);
String titleText;
switch (locale.languageCode) {
case 'ko':
titleText = '이벤트 가격';
break;
case 'ja':
titleText = 'イベント価格';
break;
case 'zh':
titleText = '活动价格';
break;
default:
titleText = 'Event Price';
}
return Text( return Text(
titleText, loc.eventPrice,
style: TextStyle( style: TextStyle(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
@@ -157,23 +144,8 @@ class AddSubscriptionEventSection extends StatelessWidget {
Expanded( Expanded(
child: Builder( child: Builder(
builder: (context) { builder: (context) {
final locale = final loc = AppLocalizations.of(context);
Localizations.localeOf(context); final infoText = loc.eventPriceHint;
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';
}
return Text( return Text(
infoText, infoText,
style: TextStyle( style: TextStyle(
@@ -195,26 +167,9 @@ class AddSubscriptionEventSection extends StatelessWidget {
// 이벤트 기간 // 이벤트 기간
Builder( Builder(
builder: (context) { builder: (context) {
final locale = Localizations.localeOf(context); final loc = AppLocalizations.of(context);
String startLabel; final startLabel = loc.startDate;
String endLabel; final endLabel = loc.endDate;
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';
}
return DateRangePickerField( return DateRangePickerField(
startDate: controller.eventStartDate, startDate: controller.eventStartDate,
endDate: controller.eventEndDate, endDate: controller.eventEndDate,
@@ -245,37 +200,13 @@ class AddSubscriptionEventSection extends StatelessWidget {
// 이벤트 가격 // 이벤트 가격
Builder( Builder(
builder: (BuildContext innerContext) { builder: (BuildContext innerContext) {
// 현재 로케일 확인 final loc = AppLocalizations.of(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';
}
return CurrencyInputField( return CurrencyInputField(
controller: controller.eventPriceController, controller: controller.eventPriceController,
currency: controller.currency, currency: controller.currency,
label: eventPriceLabel, label: loc.eventPrice,
hintText: eventPriceHint, hintText: loc.eventPriceHint,
enabled: controller.isEventActive, enabled: controller.isEventActive,
// 이벤트 비활성화 시 검증을 건너뛰어 저장이 막히지 않도록 처리 // 이벤트 비활성화 시 검증을 건너뛰어 저장이 막히지 않도록 처리
validator: validator:

View File

@@ -9,7 +9,7 @@ import '../providers/subscription_provider.dart';
import '../utils/subscription_grouping_helper.dart'; import '../utils/subscription_grouping_helper.dart';
import '../widgets/empty_state_widget.dart'; import '../widgets/empty_state_widget.dart';
import '../widgets/main_summary_card.dart'; import '../widgets/main_summary_card.dart';
import '../widgets/native_ad_widget.dart'; import '../theme/ui_constants.dart';
import '../widgets/subscription_list_widget.dart'; import '../widgets/subscription_list_widget.dart';
class HomeContent extends StatefulWidget { class HomeContent extends StatefulWidget {
@@ -115,13 +115,8 @@ class _HomeContentState extends State<HomeContent> {
controller: widget.scrollController, controller: widget.scrollController,
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
slivers: [ slivers: [
SliverToBoxAdapter(
child: SizedBox(
height: kToolbarHeight + MediaQuery.of(context).padding.top,
),
),
const SliverToBoxAdapter( const SliverToBoxAdapter(
child: NativeAdWidget(key: ValueKey('home_ad')), child: SizedBox(height: UIConstants.pageTopPadding),
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
child: SlideTransition( child: SlideTransition(

View File

@@ -11,7 +11,16 @@ import '../theme/ui_constants.dart';
/// SRP에 따라 광고 전용 위젯으로 분리 /// SRP에 따라 광고 전용 위젯으로 분리
class NativeAdWidget extends StatefulWidget { class NativeAdWidget extends StatefulWidget {
final bool useOuterPadding; // true이면 외부에서 페이지 패딩을 제공 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 @override
State<NativeAdWidget> createState() => _NativeAdWidgetState(); State<NativeAdWidget> createState() => _NativeAdWidgetState();
@@ -58,10 +67,14 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
adUnitId: _testAdUnitId(), // 실제 광고 단위 ID adUnitId: _testAdUnitId(), // 실제 광고 단위 ID
// 네이티브 템플릿을 사용하면 NativeAdFactory 등록 없이도 동작합니다. // 네이티브 템플릿을 사용하면 NativeAdFactory 등록 없이도 동작합니다.
nativeTemplateStyle: NativeTemplateStyle( nativeTemplateStyle: NativeTemplateStyle(
templateType: TemplateType.small, templateType: widget.templateTypeOverride ?? TemplateType.medium,
mainBackgroundColor: const Color(0x00000000), mainBackgroundColor: const Color(0x00000000),
cornerRadius: 12, cornerRadius: 12,
), ),
nativeAdOptions: NativeAdOptions(
mediaAspectRatio:
widget.mediaAspectRatioOverride ?? MediaAspectRatio.square,
),
request: const AdRequest(), request: const AdRequest(),
listener: NativeAdListener( listener: NativeAdListener(
onAdLoaded: (ad) { onAdLoaded: (ad) {
@@ -129,12 +142,19 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
super.dispose(); 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( return Padding(
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
horizontal: horizontal: horizontalPadding,
widget.useOuterPadding ? 0 : UIConstants.pageHorizontalPadding,
vertical: UIConstants.adVerticalPadding, vertical: UIConstants.adVerticalPadding,
), ),
child: Card( child: Card(
@@ -143,7 +163,7 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
borderRadius: BorderRadius.zero, borderRadius: BorderRadius.zero,
), ),
child: Container( child: Container(
height: UIConstants.adCardHeight, height: slotHeight,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row( child: Row(
children: [ children: [
@@ -232,9 +252,19 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
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);
// 웹 환경인 경우 플레이스홀더 표시 // 웹 환경인 경우 플레이스홀더 표시
if (kIsWeb) { if (kIsWeb) {
return _buildWebPlaceholder(); return _buildWebPlaceholder(slotHeight, horizontalPadding);
} }
// Android/iOS가 아닌 경우 광고 위젯을 렌더링하지 않음 // Android/iOS가 아닌 경우 광고 위젯을 렌더링하지 않음
@@ -244,19 +274,18 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
if (_error != null) { if (_error != null) {
// 실패 시에도 동일 높이의 플레이스홀더를 유지하여 레이아웃 점프 방지 // 실패 시에도 동일 높이의 플레이스홀더를 유지하여 레이아웃 점프 방지
return _buildWebPlaceholder(); return _buildWebPlaceholder(slotHeight, horizontalPadding);
} }
if (!_isLoaded) { if (!_isLoaded) {
// 로딩 중에도 실제 광고와 동일한 높이의 스켈레톤을 유지 // 로딩 중에도 실제 광고와 동일한 높이의 스켈레톤을 유지
return _buildWebPlaceholder(); return _buildWebPlaceholder(slotHeight, horizontalPadding);
} }
// 광고 정상 노출 // 광고 정상 노출
return Padding( return Padding(
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
horizontal: horizontal: horizontalPadding,
widget.useOuterPadding ? 0 : UIConstants.pageHorizontalPadding,
vertical: UIConstants.adVerticalPadding, vertical: UIConstants.adVerticalPadding,
), ),
child: Card( child: Card(
@@ -265,10 +294,12 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
borderRadius: BorderRadius.zero, borderRadius: BorderRadius.zero,
), ),
child: SizedBox( child: SizedBox(
height: UIConstants.adCardHeight, height: slotHeight,
child: AdWidget(ad: _nativeAd!), child: AdWidget(ad: _nativeAd!),
), ),
), ),
); );
},
);
} }
} }

View File

@@ -19,9 +19,6 @@ class ScanInitialWidget extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
children: [ children: [
// 광고 위젯 추가
const NativeAdWidget(key: ValueKey('sms_scan_start_ad')),
const SizedBox(height: 48),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0), padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column( child: Column(
@@ -64,6 +61,8 @@ class ScanInitialWidget extends StatelessWidget {
], ],
), ),
), ),
const SizedBox(height: 32),
const NativeAdWidget(key: ValueKey('sms_scan_start_ad')),
], ],
); );
} }

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../widgets/native_ad_widget.dart';
import '../../widgets/themed_text.dart'; import '../../widgets/themed_text.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
@@ -8,14 +7,12 @@ class ScanLoadingWidget extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return SizedBox.expand(
children: [ child: Padding(
const NativeAdWidget(key: ValueKey('sms_scan_loading_ad')),
const SizedBox(height: 48),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0), padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
CircularProgressIndicator( CircularProgressIndicator(
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
@@ -24,17 +21,18 @@ class ScanLoadingWidget extends StatelessWidget {
ThemedText( ThemedText(
AppLocalizations.of(context).scanningMessages, AppLocalizations.of(context).scanningMessages,
forceDark: true, forceDark: true,
textAlign: TextAlign.center,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
ThemedText( ThemedText(
AppLocalizations.of(context).findingSubscriptions, AppLocalizations.of(context).findingSubscriptions,
opacity: 0.7, opacity: 0.7,
forceDark: true, forceDark: true,
textAlign: TextAlign.center,
), ),
], ],
), ),
), ),
],
); );
} }
} }

View File

@@ -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/base_text_field.dart';
import '../../widgets/common/form_fields/category_selector.dart'; import '../../widgets/common/form_fields/category_selector.dart';
import '../../widgets/common/snackbar/app_snackbar.dart'; import '../../widgets/common/snackbar/app_snackbar.dart';
import '../../widgets/native_ad_widget.dart';
import '../../widgets/payment_card/payment_card_selector.dart'; import '../../widgets/payment_card/payment_card_selector.dart';
import '../../services/currency_util.dart'; import '../../services/currency_util.dart';
import '../../utils/sms_scan/date_formatter.dart'; import '../../utils/sms_scan/date_formatter.dart';
@@ -87,9 +86,6 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// 광고 위젯 추가
const NativeAdWidget(key: ValueKey('sms_scan_result_ad')),
const SizedBox(height: 16),
if (_hasRawSmsMessage) if (_hasRawSmsMessage)
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0), padding: const EdgeInsets.symmetric(horizontal: 16.0),

View File

@@ -13,6 +13,8 @@ import './common/snackbar/app_snackbar.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../utils/logger.dart'; import '../utils/logger.dart';
import '../utils/subscription_grouping_helper.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 { class SubscriptionListWidget extends StatelessWidget {
@@ -28,38 +30,14 @@ class SubscriptionListWidget extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final sections = groups; final sections = groups;
int itemCounter = 0;
final List<Widget> children = [];
return SliverList( for (final group in sections) {
delegate: SliverChildBuilderDelegate(
(context, index) {
final group = sections[index];
final subscriptions = group.subscriptions; final subscriptions = group.subscriptions;
final List<Widget> subscriptionItems = [];
return Padding( for (var subIndex = 0; subIndex < subscriptions.length; subIndex++) {
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<double>(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; final delay = 0.05 * subIndex;
const animationBegin = 0.2; const animationBegin = 0.2;
@@ -68,20 +46,20 @@ class SubscriptionListWidget extends StatelessWidget {
final intervalEnd = intervalStart + 0.4; final intervalEnd = intervalStart + 0.4;
// 간격 계산 (0.0~1.0 사이의 값으로 정규화) // 간격 계산 (0.0~1.0 사이의 값으로 정규화)
final intervalStartNormalized = final intervalStartNormalized = intervalStart.clamp(0.0, 0.9);
intervalStart.clamp(0.0, 0.9);
final intervalEndNormalized = intervalEnd.clamp(0.1, 1.0); final intervalEndNormalized = intervalEnd.clamp(0.1, 1.0);
return FadeTransition( subscriptionItems.add(
opacity: Tween<double>( FadeTransition(
begin: animationBegin, end: animationEnd) opacity: Tween<double>(begin: animationBegin, end: animationEnd)
.animate(CurvedAnimation( .animate(CurvedAnimation(
parent: fadeController, parent: fadeController,
curve: Interval(intervalStartNormalized, curve: Interval(
intervalEndNormalized, intervalStartNormalized, intervalEndNormalized,
curve: Curves.easeOut))), curve: Curves.easeOut))),
child: Padding( child: Padding(
padding: const EdgeInsets.only(bottom: 12.0), padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 6.0),
child: StaggeredAnimationItem( child: StaggeredAnimationItem(
index: subIndex, index: subIndex,
delay: const Duration(milliseconds: 50), delay: const Duration(milliseconds: 50),
@@ -92,30 +70,24 @@ class SubscriptionListWidget extends StatelessWidget {
onTap: () { onTap: () {
Log.d( Log.d(
'[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨'); '[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨');
AppNavigator.toDetail( AppNavigator.toDetail(context, subscriptions[subIndex]);
context, subscriptions[subIndex]);
}, },
onDelete: () async { onDelete: () async {
// 현재 로케일에 맞는 서비스명 가져오기 // 현재 로케일에 맞는 서비스명 가져오기
final localeProvider = final localeProvider = Provider.of<LocaleProvider>(
Provider.of<LocaleProvider>(
context, context,
listen: false, listen: false,
); );
final locale = final locale = localeProvider.locale.languageCode;
localeProvider.locale.languageCode;
final displayName = final displayName =
await SubscriptionUrlMatcher await SubscriptionUrlMatcher.getServiceDisplayName(
.getServiceDisplayName( serviceName: subscriptions[subIndex].serviceName,
serviceName:
subscriptions[subIndex].serviceName,
locale: locale, locale: locale,
); );
// 삭제 확인 다이얼로그 표시 // 삭제 확인 다이얼로그 표시
if (!context.mounted) return; if (!context.mounted) return;
final shouldDelete = final shouldDelete = await DeleteConfirmationDialog.show(
await DeleteConfirmationDialog.show(
context: context, context: context,
serviceName: displayName, serviceName: displayName,
); );
@@ -123,8 +95,7 @@ class SubscriptionListWidget extends StatelessWidget {
if (shouldDelete) { if (shouldDelete) {
// 사용자가 확인한 경우에만 삭제 진행 // 사용자가 확인한 경우에만 삭제 진행
final provider = final provider = Provider.of<SubscriptionProvider>(
Provider.of<SubscriptionProvider>(
context, context,
listen: false, listen: false,
); );
@@ -146,19 +117,48 @@ class SubscriptionListWidget extends StatelessWidget {
), ),
), ),
), ),
),
); );
},
itemCounter++;
if ((itemCounter - 1) % 10 == 0) {
subscriptionItems.add(
NativeAdWidget(
key: ValueKey('home_list_ad_$itemCounter'),
aspectRatioOverride: 320 / 80,
mediaAspectRatioOverride: MediaAspectRatio.landscape,
templateTypeOverride: TemplateType.small,
), ),
);
}
}
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,
], ],
), ),
);
},
childCount: sections.length,
), ),
); );
} }
return SliverList(
delegate: SliverChildListDelegate(children),
);
}
/// 특정 통화의 총 합계를 계산합니다. /// 특정 통화의 총 합계를 계산합니다.
double _calculateTotalByCurrency( double _calculateTotalByCurrency(
List<SubscriptionModel> subscriptions, String currency) { List<SubscriptionModel> subscriptions, String currency) {