i8n과 광고 수정
This commit is contained in:
@@ -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": "@ 订阅费用 @ 已扣款。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -525,7 +525,7 @@ class AddSubscriptionController {
|
||||
if (context.mounted) {
|
||||
AppSnackBar.showInfo(
|
||||
context: context,
|
||||
message: '다음 결제 예정일로 저장됨',
|
||||
message: AppLocalizations.of(context).nextBillingDateAdjusted,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,7 +454,7 @@ class DetailScreenController extends ChangeNotifier {
|
||||
if (adjustedNext.isAfter(originalDateOnly)) {
|
||||
AppSnackBar.showInfo(
|
||||
context: context,
|
||||
message: '다음 결제 예정일로 저장됨',
|
||||
message: AppLocalizations.of(context).nextBillingDateAdjusted,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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 {
|
||||
_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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<bool> _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,
|
||||
|
||||
@@ -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<SubscriptionModel> _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,
|
||||
);
|
||||
|
||||
@@ -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<dynamic> _errorRoute() {
|
||||
return MaterialPageRoute(
|
||||
builder: (_) => const Scaffold(
|
||||
builder: (context) => Scaffold(
|
||||
body: Center(
|
||||
child: Text('페이지를 찾을 수 없습니다'),
|
||||
child: Text(AppLocalizations.of(context).pageNotFound),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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<AnalysisScreen>
|
||||
controller: _scrollController,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
slivers: <Widget>[
|
||||
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<AnalysisScreen>
|
||||
|
||||
const AnalysisScreenSpacer(),
|
||||
|
||||
// 네이티브 광고 위젯 (구독 비율 차트 하단)
|
||||
SliverToBoxAdapter(
|
||||
child: _buildAnimatedAd(),
|
||||
),
|
||||
|
||||
const AnalysisScreenSpacer(),
|
||||
|
||||
// 2. 총 지출 요약 카드
|
||||
TotalExpenseSummaryCard(
|
||||
key: ValueKey('total_expense_$_lastDataHash'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -41,10 +41,11 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
||||
|
||||
@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<CategoryManagementScreen> {
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: '카테고리 이름',
|
||||
labelText: loc.categoryName,
|
||||
labelStyle: TextStyle(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
@@ -76,7 +77,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '카테고리 이름을 입력하세요';
|
||||
return loc.categoryNameRequired;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
@@ -85,7 +86,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _selectedColor,
|
||||
decoration: InputDecoration(
|
||||
labelText: '색상 선택',
|
||||
labelText: loc.selectColor,
|
||||
labelStyle: TextStyle(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
@@ -144,7 +145,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _selectedIcon,
|
||||
decoration: InputDecoration(
|
||||
labelText: '아이콘 선택',
|
||||
labelText: loc.selectIcon,
|
||||
labelStyle: TextStyle(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
@@ -154,35 +155,35 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
||||
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<CategoryManagementScreen> {
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _addCategory,
|
||||
child: const Text('카테고리 추가'),
|
||||
child: Text(loc.addCategory),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<SmsScanScreen> {
|
||||
|
||||
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<SmsScanScreen> {
|
||||
}
|
||||
});
|
||||
return ScanInitialWidget(
|
||||
onScanPressed: () => _controller.scanSms(context),
|
||||
onScanPressed: () => _controller.startScan(context),
|
||||
errorMessage: _controller.errorMessage,
|
||||
);
|
||||
}
|
||||
@@ -104,7 +105,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
||||
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<SmsScanScreen> {
|
||||
|
||||
@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<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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> _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 '결제 예정 알림';
|
||||
|
||||
@@ -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<String, dynamic> 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<String> _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<String, List<Map<String, dynamic>>> _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);
|
||||
|
||||
|
||||
@@ -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<dynamic> result =
|
||||
await platform.invokeMethod('scanSubscriptions');
|
||||
return result.map((item) => item as Map<String, dynamic>).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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ class SmsDateFormatter {
|
||||
);
|
||||
}
|
||||
|
||||
return '다음 결제일 확인 필요 (과거 날짜)';
|
||||
return AppLocalizations.of(context).nextBillingDatePastRequired;
|
||||
}
|
||||
|
||||
// 미래 날짜 처리
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<HomeContent> {
|
||||
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(
|
||||
|
||||
@@ -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<NativeAdWidget> createState() => _NativeAdWidgetState();
|
||||
@@ -58,10 +67,14 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
|
||||
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<NativeAdWidget> {
|
||||
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<NativeAdWidget> {
|
||||
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<NativeAdWidget> {
|
||||
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!),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SubscriptionCardWidget> {
|
||||
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),
|
||||
|
||||
@@ -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<Widget> 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<Widget> 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<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;
|
||||
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<double>(
|
||||
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<LocaleProvider>(
|
||||
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<SubscriptionProvider>(
|
||||
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<double>(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<LocaleProvider>(
|
||||
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<SubscriptionProvider>(
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user