feat(settings): SMS 읽기 권한 상태/요청 위젯 추가 (Android)

- 설정 화면에 SMS 권한 카드 추가: 상태 표시(허용/미허용/영구 거부), 권한 요청/설정 이동 지원\n- 기존 알림 권한 카드 스타일과 일관성 유지

feat(permissions): 최초 실행 시 SMS 권한 온보딩 화면 추가 및 Splash에서 라우팅 (Android)

- 권한 필요 이유/수집 범위 현지화 문구 추가\n- 거부/영구거부 케이스 처리 및 설정 이동

chore(codex): AGENTS.md/체크 스크립트/CI/프롬프트 템플릿 추가

- AGENTS.md, scripts/check.sh, scripts/fix.sh, .github/workflows/flutter_ci.yml, .claude/agents/codex.md, 문서 템플릿 추가

refactor(logging): 경로별 print 제거 후 경량 로거(Log) 도입

- SMS 스캐너/컨트롤러, URL 매처, 데이터 리포지토리, 내비게이션, 메모리/성능 유틸 등 핵심 경로 치환

feat(exchange): 환율 API URL을 --dart-define로 오버라이드 가능 + 폴백 로깅 강화

test: URL 매처/환율 스모크 테스트 추가

chore(android): RECEIVE_SMS 권한 제거 (READ_SMS만 유지)

fix(lints): dart fix + 수동 정리로 경고 대폭 감소, 비동기 context(mounted) 보강

fix(deprecations):\n- flutter_local_notifications의 androidAllowWhileIdle → androidScheduleMode 전환\n- WillPopScope → PopScope 교체

i18n: SMS 권한 온보딩/설정 문구 현지화 키 추가
This commit is contained in:
JiWoong Sul
2025-09-07 21:32:16 +09:00
parent d1a6cb9fe3
commit d37f66d526
53 changed files with 435 additions and 290 deletions

View File

@@ -1,6 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.READ_SMS" /> <uses-permission android:name="android.permission.READ_SMS" />
<uses-permission android:name="android.permission.RECEIVE_SMS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
android:label="구독 관리" android:label="구독 관리"

View File

@@ -217,6 +217,17 @@
"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"
,
"smsPermissionTitle": "Request SMS Permission",
"smsPermissionReasonTitle": "Why",
"smsPermissionReasonBody": "We analyze payment-related SMS to auto-detect subscriptions. Processing happens locally only.",
"smsPermissionScopeTitle": "Scope",
"smsPermissionScopeBody": "We scan only payment-related SMS patterns (service/amount/date) locally; no data leaves your device.",
"permanentlyDeniedMessage": "Permission is permanently denied. Enable it in Settings.",
"openSettings": "Open Settings",
"later": "Later",
"requesting": "Requesting...",
"smsPermissionLabel": "SMS Permission"
}, },
"ko": { "ko": {
"appTitle": "디지털 월세 관리자", "appTitle": "디지털 월세 관리자",
@@ -436,6 +447,17 @@
"enterAmount": "금액을 입력하세요", "enterAmount": "금액을 입력하세요",
"invalidAmount": "올바른 금액을 입력해주세요", "invalidAmount": "올바른 금액을 입력해주세요",
"featureComingSoon": "이 기능은 곧 출시됩니다" "featureComingSoon": "이 기능은 곧 출시됩니다"
,
"smsPermissionTitle": "SMS 권한 요청",
"smsPermissionReasonTitle": "이유",
"smsPermissionReasonBody": "문자 메시지에서 반복 결제 내역을 분석해 구독 서비스를 자동으로 탐지합니다. 모든 처리는 기기 내에서만 이루어집니다.",
"smsPermissionScopeTitle": "수집 범위",
"smsPermissionScopeBody": "결제 관련 문자 메시지의 패턴(서비스명/금액/날짜)만 로컬에서 처리하며, 외부로 전송하지 않습니다.",
"permanentlyDeniedMessage": "권한이 영구적으로 거부되었습니다. 설정에서 권한을 허용해주세요.",
"openSettings": "설정 열기",
"later": "나중에 하기",
"requesting": "요청 중...",
"smsPermissionLabel": "SMS 권한"
}, },
"ja": { "ja": {
"appTitle": "デジタル月額管理者", "appTitle": "デジタル月額管理者",
@@ -875,4 +897,4 @@
"invalidAmount": "请输入有效的金额", "invalidAmount": "请输入有效的金额",
"featureComingSoon": "此功能即将推出" "featureComingSoon": "此功能即将推出"
} }
} }

View File

@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode; import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode;
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../models/subscription_model.dart';
import '../providers/subscription_provider.dart'; import '../providers/subscription_provider.dart';
import '../providers/category_provider.dart'; import '../providers/category_provider.dart';
import '../services/sms_service.dart'; import '../services/sms_service.dart';
@@ -183,6 +182,7 @@ class AddSubscriptionController {
} }
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
// ignore: avoid_print
print('AddSubscriptionController: URL 자동 매칭 중 오류 - $e'); print('AddSubscriptionController: URL 자동 매칭 중 오류 - $e');
} }
} }
@@ -320,6 +320,7 @@ class AddSubscriptionController {
await SubscriptionUrlMatcher.extractServiceFromSms(smsContent); await SubscriptionUrlMatcher.extractServiceFromSms(smsContent);
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
// ignore: avoid_print
print('AddSubscriptionController: SMS 서비스 추출 실패 - $e'); print('AddSubscriptionController: SMS 서비스 추출 실패 - $e');
} }
} }

View File

@@ -401,7 +401,7 @@ class DetailScreenController extends ChangeNotifier {
debugPrint('[DetailScreenController] 구독 업데이트 시작: ' debugPrint('[DetailScreenController] 구독 업데이트 시작: '
'${subscription.serviceName}${serviceNameController.text}, ' '${subscription.serviceName}${serviceNameController.text}, '
'금액: ${subscription.monthlyCost}$monthlyCost ${_currency}'); '금액: $subscription.monthlyCost → $monthlyCost $_currency');
subscription.serviceName = serviceNameController.text; subscription.serviceName = serviceNameController.text;
subscription.monthlyCost = monthlyCost; subscription.monthlyCost = monthlyCost;
@@ -460,12 +460,14 @@ class DetailScreenController extends ChangeNotifier {
serviceName: subscription.serviceName, serviceName: subscription.serviceName,
locale: locale, locale: locale,
); );
if (!context.mounted) return;
// 삭제 확인 다이얼로그 표시 // 삭제 확인 다이얼로그 표시
final shouldDelete = await DeleteConfirmationDialog.show( final shouldDelete = await DeleteConfirmationDialog.show(
context: context, context: context,
serviceName: displayName, serviceName: displayName,
); );
if (!context.mounted) return;
if (!shouldDelete) return; if (!shouldDelete) return;
@@ -529,6 +531,7 @@ class DetailScreenController extends ChangeNotifier {
} }
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
// ignore: avoid_print
print('DetailScreenController: 해지 페이지 열기 실패 - $e'); print('DetailScreenController: 해지 페이지 열기 실패 - $e');
} }

View File

@@ -1,11 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../services/sms_scanner.dart'; import '../services/sms_scanner.dart';
import '../models/subscription.dart'; import '../models/subscription.dart';
import '../models/subscription_model.dart';
import '../services/sms_scan/subscription_converter.dart'; import '../services/sms_scan/subscription_converter.dart';
import '../services/sms_scan/subscription_filter.dart'; import '../services/sms_scan/subscription_filter.dart';
import '../providers/subscription_provider.dart'; import '../providers/subscription_provider.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../utils/logger.dart';
import '../providers/navigation_provider.dart'; 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';
@@ -58,20 +58,20 @@ class SmsScanController extends ChangeNotifier {
try { try {
// SMS 스캔 실행 // SMS 스캔 실행
print('SMS 스캔 시작'); Log.i('SMS 스캔 시작');
final scannedSubscriptionModels = final scannedSubscriptionModels =
await _smsScanner.scanForSubscriptions(); await _smsScanner.scanForSubscriptions();
print('스캔된 구독: ${scannedSubscriptionModels.length}'); Log.d('스캔된 구독: ${scannedSubscriptionModels.length}');
if (scannedSubscriptionModels.isNotEmpty) { if (scannedSubscriptionModels.isNotEmpty) {
print( Log.d(
'첫 번째 구독: ${scannedSubscriptionModels[0].serviceName}, 반복 횟수: ${scannedSubscriptionModels[0].repeatCount}'); '첫 번째 구독: ${scannedSubscriptionModels[0].serviceName}, 반복 횟수: ${scannedSubscriptionModels[0].repeatCount}');
} }
if (!context.mounted) return; if (!context.mounted) return;
if (scannedSubscriptionModels.isEmpty) { if (scannedSubscriptionModels.isEmpty) {
print('스캔된 구독이 없음'); Log.i('스캔된 구독이 없음');
_errorMessage = AppLocalizations.of(context).subscriptionNotFound; _errorMessage = AppLocalizations.of(context).subscriptionNotFound;
_isLoading = false; _isLoading = false;
notifyListeners(); notifyListeners();
@@ -85,15 +85,15 @@ class SmsScanController extends ChangeNotifier {
// 2회 이상 반복 결제된 구독만 필터링 // 2회 이상 반복 결제된 구독만 필터링
final repeatSubscriptions = final repeatSubscriptions =
_filter.filterByRepeatCount(scannedSubscriptions, 2); _filter.filterByRepeatCount(scannedSubscriptions, 2);
print('반복 결제된 구독: ${repeatSubscriptions.length}'); Log.d('반복 결제된 구독: ${repeatSubscriptions.length}');
if (repeatSubscriptions.isNotEmpty) { if (repeatSubscriptions.isNotEmpty) {
print( Log.d(
'첫 번째 반복 구독: ${repeatSubscriptions[0].serviceName}, 반복 횟수: ${repeatSubscriptions[0].repeatCount}'); '첫 번째 반복 구독: ${repeatSubscriptions[0].serviceName}, 반복 횟수: ${repeatSubscriptions[0].repeatCount}');
} }
if (repeatSubscriptions.isEmpty) { if (repeatSubscriptions.isEmpty) {
print('반복 결제된 구독이 없음'); Log.i('반복 결제된 구독이 없음');
_errorMessage = AppLocalizations.of(context).repeatSubscriptionNotFound; _errorMessage = AppLocalizations.of(context).repeatSubscriptionNotFound;
_isLoading = false; _isLoading = false;
notifyListeners(); notifyListeners();
@@ -104,21 +104,21 @@ class SmsScanController extends ChangeNotifier {
final provider = final provider =
Provider.of<SubscriptionProvider>(context, listen: false); Provider.of<SubscriptionProvider>(context, listen: false);
final existingSubscriptions = provider.subscriptions; final existingSubscriptions = provider.subscriptions;
print('기존 구독: ${existingSubscriptions.length}'); Log.d('기존 구독: ${existingSubscriptions.length}');
// 중복 구독 필터링 // 중복 구독 필터링
final filteredSubscriptions = final filteredSubscriptions =
_filter.filterDuplicates(repeatSubscriptions, existingSubscriptions); _filter.filterDuplicates(repeatSubscriptions, existingSubscriptions);
print('중복 제거 후 구독: ${filteredSubscriptions.length}'); Log.d('중복 제거 후 구독: ${filteredSubscriptions.length}');
if (filteredSubscriptions.isNotEmpty) { if (filteredSubscriptions.isNotEmpty) {
print( Log.d(
'첫 번째 필터링된 구독: ${filteredSubscriptions[0].serviceName}, 반복 횟수: ${filteredSubscriptions[0].repeatCount}'); '첫 번째 필터링된 구독: ${filteredSubscriptions[0].serviceName}, 반복 횟수: ${filteredSubscriptions[0].repeatCount}');
} }
// 중복 제거 후 신규 구독이 없는 경우 // 중복 제거 후 신규 구독이 없는 경우
if (filteredSubscriptions.isEmpty) { if (filteredSubscriptions.isEmpty) {
print('중복 제거 후 신규 구독이 없음'); Log.i('중복 제거 후 신규 구독이 없음');
_isLoading = false; _isLoading = false;
notifyListeners(); notifyListeners();
return; return;
@@ -129,7 +129,7 @@ class SmsScanController extends ChangeNotifier {
websiteUrlController.text = ''; // URL 입력 필드 초기화 websiteUrlController.text = ''; // URL 입력 필드 초기화
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
print('SMS 스캔 중 오류 발생: $e'); Log.e('SMS 스캔 중 오류 발생', e);
if (context.mounted) { if (context.mounted) {
_errorMessage = _errorMessage =
AppLocalizations.of(context).smsScanErrorWithMessage(e.toString()); AppLocalizations.of(context).smsScanErrorWithMessage(e.toString());
@@ -159,7 +159,7 @@ class SmsScanController extends ChangeNotifier {
? websiteUrlController.text.trim() ? websiteUrlController.text.trim()
: subscription.websiteUrl; : subscription.websiteUrl;
print( Log.d(
'구독 추가 시도: ${subscription.serviceName}, 카테고리: $finalCategoryId, URL: $websiteUrl'); '구독 추가 시도: ${subscription.serviceName}, 카테고리: $finalCategoryId, URL: $websiteUrl');
// addSubscription 호출 // addSubscription 호출
@@ -176,19 +176,20 @@ class SmsScanController extends ChangeNotifier {
currency: subscription.currency, currency: subscription.currency,
); );
print('구독 추가 성공: ${subscription.serviceName}'); Log.i('구독 추가 성공: ${subscription.serviceName}');
if (!context.mounted) return;
moveToNextSubscription(context); moveToNextSubscription(context);
} catch (e) { } catch (e) {
print('구독 추가 중 오류 발생: $e'); Log.e('구독 추가 중 오류 발생', e);
// 오류가 있어도 다음 구독으로 이동 // 오류가 있어도 다음 구독으로 이동
if (!context.mounted) return;
moveToNextSubscription(context); moveToNextSubscription(context);
} }
} }
void skipCurrentSubscription(BuildContext context) { void skipCurrentSubscription(BuildContext context) {
final subscription = _scannedSubscriptions[_currentIndex]; final subscription = _scannedSubscriptions[_currentIndex];
print('구독 건너뛰기: ${subscription.serviceName}'); Log.i('구독 건너뛰기: ${subscription.serviceName}');
moveToNextSubscription(context); moveToNextSubscription(context);
} }
@@ -224,7 +225,7 @@ class SmsScanController extends ChangeNotifier {
(cat) => cat.name == 'other', (cat) => cat.name == 'other',
orElse: () => categoryProvider.categories.first, orElse: () => categoryProvider.categories.first,
); );
print('기본 카테고리 설정: ${otherCategory.name} (ID: ${otherCategory.id})'); Log.d('기본 카테고리 설정: ${otherCategory.name} (ID: ${otherCategory.id})');
return otherCategory.id; return otherCategory.id;
} }

View File

@@ -63,6 +63,28 @@ class AppLocalizations {
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';
// SMS 권한 온보딩/설정
String get smsPermissionTitle =>
_localizedStrings['smsPermissionTitle'] ?? 'Request SMS Permission';
String get smsPermissionReasonTitle =>
_localizedStrings['smsPermissionReasonTitle'] ?? 'Why';
String get smsPermissionReasonBody =>
_localizedStrings['smsPermissionReasonBody'] ??
'We analyze payment-related SMS to auto-detect subscriptions. Processing happens locally only.';
String get smsPermissionScopeTitle =>
_localizedStrings['smsPermissionScopeTitle'] ?? 'Scope';
String get smsPermissionScopeBody =>
_localizedStrings['smsPermissionScopeBody'] ??
'We scan only payment-related SMS patterns (service/amount/date) locally; no data leaves your device.';
String get permanentlyDeniedMessage =>
_localizedStrings['permanentlyDeniedMessage'] ??
'Permission is permanently denied. Enable it in Settings.';
String get openSettings =>
_localizedStrings['openSettings'] ?? 'Open Settings';
String get later => _localizedStrings['later'] ?? 'Later';
String get requesting => _localizedStrings['requesting'] ?? 'Requesting...';
String get smsPermissionLabel =>
_localizedStrings['smsPermissionLabel'] ?? 'SMS Permission';
// 알림 설정 // 알림 설정
String get notificationPermission => String get notificationPermission =>
_localizedStrings['notificationPermission'] ?? 'Notification Permission'; _localizedStrings['notificationPermission'] ?? 'Notification Permission';
@@ -308,11 +330,11 @@ class AppLocalizations {
String subscriptionCount(int count) { String subscriptionCount(int count) {
if (locale.languageCode == 'ko') { if (locale.languageCode == 'ko') {
return '${count}'; return '$count개';
} else if (locale.languageCode == 'ja') { } else if (locale.languageCode == 'ja') {
return '${count}'; return '$count個';
} else if (locale.languageCode == 'zh') { } else if (locale.languageCode == 'zh') {
return '${count}'; return '$count个';
} else { } else {
return count.toString(); return count.toString();
} }
@@ -444,11 +466,11 @@ class AppLocalizations {
String servicesInProgress(int count) { String servicesInProgress(int count) {
if (locale.languageCode == 'ko') { if (locale.languageCode == 'ko') {
return '${count} 진행중'; return '$count 진행중';
} else if (locale.languageCode == 'ja') { } else if (locale.languageCode == 'ja') {
return '${count}個進行中'; return '$count個進行中';
} else if (locale.languageCode == 'zh') { } else if (locale.languageCode == 'zh') {
return '${count}个进行中'; return '$count个进行中';
} else { } else {
return '$count in progress'; return '$count in progress';
} }

View File

@@ -22,6 +22,7 @@ import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'dart:io' show Platform; import 'dart:io' show Platform;
import 'dart:async' show unawaited; import 'dart:async' show unawaited;
import 'utils/memory_manager.dart'; import 'utils/memory_manager.dart';
import 'utils/logger.dart';
import 'utils/performance_optimizer.dart'; import 'utils/performance_optimizer.dart';
import 'navigator_key.dart'; import 'navigator_key.dart';
@@ -48,12 +49,12 @@ Future<void> main() async {
await DefaultCacheManager().emptyCache(); await DefaultCacheManager().emptyCache();
if (kDebugMode) { if (kDebugMode) {
print('이미지 캐시 관리 초기화 완료'); Log.d('이미지 캐시 관리 초기화 완료');
PerformanceOptimizer.checkConstOptimization(); PerformanceOptimizer.checkConstOptimization();
} }
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('캐시 초기화 오류: $e'); Log.e('캐시 초기화 오류', e);
} }
} }

View File

@@ -28,7 +28,7 @@ class SubscriptionProvider extends ChangeNotifier {
final price = subscription.currentPrice; final price = subscription.currentPrice;
if (subscription.currency == 'USD') { if (subscription.currency == 'USD') {
debugPrint('[SubscriptionProvider] ${subscription.serviceName}: ' debugPrint('[SubscriptionProvider] ${subscription.serviceName}: '
'\$${price} ×$rate = ₩${price * rate}'); '\$$price ×$rate = ₩${price * rate}');
return sum + (price * rate); return sum + (price * rate);
} }
debugPrint( debugPrint(
@@ -264,7 +264,7 @@ class SubscriptionProvider extends ChangeNotifier {
for (final subscription in _subscriptions) { for (final subscription in _subscriptions) {
final currentPrice = subscription.currentPrice; final currentPrice = subscription.currentPrice;
debugPrint('[calculateTotalExpense] ${subscription.serviceName}: ' debugPrint('[calculateTotalExpense] ${subscription.serviceName}: '
'${currentPrice} ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})'); '$currentPrice ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})');
final converted = await ExchangeRateService().convertBetweenCurrencies( final converted = await ExchangeRateService().convertBetweenCurrencies(
currentPrice, currentPrice,
@@ -310,7 +310,7 @@ class SubscriptionProvider extends ChangeNotifier {
final cost = subscription.currentPrice; final cost = subscription.currentPrice;
debugPrint( debugPrint(
'[getMonthlyExpenseData] 현재 월 - ${subscription.serviceName}: ' '[getMonthlyExpenseData] 현재 월 - ${subscription.serviceName}: '
'${cost} ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})'); '$cost ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})');
// 통화 변환 // 통화 변환
final converted = final converted =
@@ -508,19 +508,17 @@ class SubscriptionProvider extends ChangeNotifier {
.id; .id;
} }
if (categoryId != null) { subscription.categoryId = categoryId;
subscription.categoryId = categoryId; await subscription.save();
await subscription.save(); migratedCount++;
migratedCount++; final categoryName =
final categoryName = categories.firstWhere((cat) => cat.id == categoryId).name;
categories.firstWhere((cat) => cat.id == categoryId).name; debugPrint('${subscription.serviceName}$categoryName');
debugPrint('${subscription.serviceName}$categoryName');
}
} }
} }
if (migratedCount > 0) { if (migratedCount > 0) {
debugPrint('❎ 총 ${migratedCount}개의 구독에 categoryId 할당 완료'); debugPrint('❎ 총 $migratedCount개의 구독에 categoryId 할당 완료');
await refreshSubscriptions(); await refreshSubscriptions();
} else { } else {
debugPrint('❎ 모든 구독이 이미 categoryId를 가지고 있습니다'); debugPrint('❎ 모든 구독이 이미 categoryId를 가지고 있습니다');

View File

@@ -169,7 +169,7 @@ class _AnalysisScreenState extends State<AnalysisScreen>
// 2. 총 지출 요약 카드 // 2. 총 지출 요약 카드
TotalExpenseSummaryCard( TotalExpenseSummaryCard(
key: ValueKey('total_expense_${_lastDataHash}'), key: ValueKey('total_expense_$_lastDataHash'),
subscriptions: subscriptions, subscriptions: subscriptions,
totalExpense: _totalExpense, totalExpense: _totalExpense,
animationController: _animationController, animationController: _animationController,
@@ -179,7 +179,7 @@ class _AnalysisScreenState extends State<AnalysisScreen>
// 3. 월별 지출 차트 // 3. 월별 지출 차트
MonthlyExpenseChartCard( MonthlyExpenseChartCard(
key: ValueKey('monthly_expense_${_lastDataHash}'), key: ValueKey('monthly_expense_$_lastDataHash'),
monthlyData: _monthlyData, monthlyData: _monthlyData,
animationController: _animationController, animationController: _animationController,
), ),

View File

@@ -13,13 +13,13 @@ class AppLockScreen extends StatelessWidget {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( const Icon(
Icons.lock_outline, Icons.lock_outline,
size: 80, size: 80,
color: AppColors.navyGray, color: AppColors.navyGray,
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
Text( const Text(
'앱이 잠겨 있습니다', '앱이 잠겨 있습니다',
style: TextStyle( style: TextStyle(
fontSize: 24, fontSize: 24,
@@ -28,7 +28,7 @@ class AppLockScreen extends StatelessWidget {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( const Text(
'생체 인증으로 잠금을 해제하세요', '생체 인증으로 잠금을 해제하세요',
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
@@ -42,7 +42,7 @@ class AppLockScreen extends StatelessWidget {
final success = await appLock.authenticate(); final success = await appLock.authenticate();
if (!success && context.mounted) { if (!success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( const SnackBar(
content: Text( content: Text(
'인증에 실패했습니다. 다시 시도해주세요.', '인증에 실패했습니다. 다시 시도해주세요.',
style: TextStyle( style: TextStyle(

View File

@@ -43,7 +43,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text( title: const Text(
'카테고리 관리', '카테고리 관리',
style: TextStyle( style: TextStyle(
color: AppColors.pureWhite, color: AppColors.pureWhite,
@@ -66,7 +66,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
children: [ children: [
TextFormField( TextFormField(
controller: _nameController, controller: _nameController,
decoration: InputDecoration( decoration: const InputDecoration(
labelText: '카테고리 이름', labelText: '카테고리 이름',
labelStyle: TextStyle( labelStyle: TextStyle(
color: AppColors.navyGray, color: AppColors.navyGray,
@@ -82,7 +82,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
value: _selectedColor, value: _selectedColor,
decoration: InputDecoration( decoration: const InputDecoration(
labelText: '색상 선택', labelText: '색상 선택',
labelStyle: TextStyle( labelStyle: TextStyle(
color: AppColors.navyGray, color: AppColors.navyGray,
@@ -94,31 +94,31 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
child: Text( child: Text(
AppLocalizations.of(context).colorBlue, AppLocalizations.of(context).colorBlue,
style: style:
TextStyle(color: AppColors.darkNavy))), const TextStyle(color: AppColors.darkNavy))),
DropdownMenuItem( DropdownMenuItem(
value: '#4CAF50', value: '#4CAF50',
child: Text( child: Text(
AppLocalizations.of(context).colorGreen, AppLocalizations.of(context).colorGreen,
style: style:
TextStyle(color: AppColors.darkNavy))), const TextStyle(color: AppColors.darkNavy))),
DropdownMenuItem( DropdownMenuItem(
value: '#FF9800', value: '#FF9800',
child: Text( child: Text(
AppLocalizations.of(context).colorOrange, AppLocalizations.of(context).colorOrange,
style: style:
TextStyle(color: AppColors.darkNavy))), const TextStyle(color: AppColors.darkNavy))),
DropdownMenuItem( DropdownMenuItem(
value: '#F44336', value: '#F44336',
child: Text( child: Text(
AppLocalizations.of(context).colorRed, AppLocalizations.of(context).colorRed,
style: style:
TextStyle(color: AppColors.darkNavy))), const TextStyle(color: AppColors.darkNavy))),
DropdownMenuItem( DropdownMenuItem(
value: '#9C27B0', value: '#9C27B0',
child: Text( child: Text(
AppLocalizations.of(context).colorPurple, AppLocalizations.of(context).colorPurple,
style: style:
TextStyle(color: AppColors.darkNavy))), const TextStyle(color: AppColors.darkNavy))),
], ],
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
@@ -129,13 +129,13 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
value: _selectedIcon, value: _selectedIcon,
decoration: InputDecoration( decoration: const InputDecoration(
labelText: '아이콘 선택', labelText: '아이콘 선택',
labelStyle: TextStyle( labelStyle: TextStyle(
color: AppColors.navyGray, color: AppColors.navyGray,
), ),
), ),
items: [ items: const [
DropdownMenuItem( DropdownMenuItem(
value: 'subscriptions', value: 'subscriptions',
child: Text('구독', child: Text('구독',
@@ -171,7 +171,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
ElevatedButton( ElevatedButton(
onPressed: _addCategory, onPressed: _addCategory,
child: Text( child: const Text(
'카테고리 추가', '카테고리 추가',
style: TextStyle( style: TextStyle(
color: AppColors.pureWhite, color: AppColors.pureWhite,
@@ -201,7 +201,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
title: Text( title: Text(
provider.getLocalizedCategoryName( provider.getLocalizedCategoryName(
context, category.name), context, category.name),
style: TextStyle( style: const TextStyle(
color: AppColors.darkNavy, color: AppColors.darkNavy,
), ),
), ),

View File

@@ -111,7 +111,7 @@ class _DetailScreenState extends State<DetailScreen>
Text( Text(
AppLocalizations.of(context) AppLocalizations.of(context)
.changesAppliedAfterSave, .changesAppliedAfterSave,
style: TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
color: AppColors.darkNavy, color: AppColors.darkNavy,
), ),

View File

@@ -5,7 +5,6 @@ import '../providers/notification_provider.dart';
import 'dart:io'; import 'dart:io';
import '../services/notification_service.dart'; import '../services/notification_service.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../theme/adaptive_theme.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/native_ad_widget.dart';
@@ -230,6 +229,7 @@ class SettingsScreen extends StatelessWidget {
if (granted) { if (granted) {
await provider.setEnabled(true); await provider.setEnabled(true);
} else { } else {
if (!context.mounted) return;
AppSnackBar.showError( AppSnackBar.showError(
context: context, context: context,
message: AppLocalizations.of(context) message: AppLocalizations.of(context)
@@ -273,7 +273,7 @@ class SettingsScreen extends StatelessWidget {
elevation: 0, elevation: 0,
color: Theme.of(context) color: Theme.of(context)
.colorScheme .colorScheme
.surfaceVariant .surfaceContainerHighest
.withValues(alpha: 0.3), .withValues(alpha: 0.3),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@@ -414,7 +414,7 @@ class SettingsScreen extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context) color: Theme.of(context)
.colorScheme .colorScheme
.surfaceVariant .surfaceContainerHighest
.withValues(alpha: 0.3), .withValues(alpha: 0.3),
borderRadius: borderRadius:
BorderRadius.circular(8), BorderRadius.circular(8),
@@ -484,49 +484,77 @@ class SettingsScreen extends StatelessWidget {
margin: margin:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8), const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: FutureBuilder<bool>( child: FutureBuilder<permission.PermissionStatus>(
future: SMSService.hasSMSPermission(), future: permission.Permission.sms.status,
builder: (context, snapshot) { builder: (context, snapshot) {
final hasPermission = snapshot.data ?? false; final isLoading =
snapshot.connectionState == ConnectionState.waiting;
final status = snapshot.data;
final hasPermission = status?.isGranted ?? false;
final isPermanentlyDenied =
status?.isPermanentlyDenied ?? false;
return ListTile( return ListTile(
leading: const Icon( leading: const Icon(
Icons.sms, Icons.sms,
color: AppColors.textSecondary, color: AppColors.textSecondary,
), ),
title: const Text( title: Text(
'SMS 권한', AppLocalizations.of(context).smsPermissionLabel,
style: TextStyle(color: AppColors.textPrimary), style: const TextStyle(color: AppColors.textPrimary),
), ),
subtitle: Text( subtitle: !hasPermission
AppLocalizations.of(context).smsPermissionRequired, ? Text(
style: isPermanentlyDenied
const TextStyle(color: AppColors.textSecondary), ? AppLocalizations.of(context)
), .permanentlyDeniedMessage
trailing: hasPermission : AppLocalizations.of(context)
? const Padding( .smsPermissionRequired,
padding: EdgeInsets.symmetric(horizontal: 8.0), style: const TextStyle(
child: Icon(Icons.check_circle, color: AppColors.textSecondary),
color: Colors.green),
) )
: ElevatedButton( : null,
onPressed: () async { trailing: isLoading
final granted = ? const SizedBox(
await SMSService.requestSMSPermission(); width: 20,
if (!granted) { height: 20,
final status = child:
await permission.Permission.sms.status; CircularProgressIndicator(strokeWidth: 2),
if (status.isPermanentlyDenied) { )
await permission.openAppSettings(); : hasPermission
} ? const Padding(
} padding:
if (context.mounted) { EdgeInsets.symmetric(horizontal: 8.0),
// 상태 갱신을 위해 다시 build 트리거 child: Icon(Icons.check_circle,
(context as Element).markNeedsBuild(); color: Colors.green),
} )
}, : isPermanentlyDenied
child: Text(AppLocalizations.of(context) ? TextButton(
.requestPermission), onPressed: () async {
), await permission.openAppSettings();
},
child: Text(
AppLocalizations.of(context)
.openSettings),
)
: ElevatedButton(
onPressed: () async {
final granted = await SMSService
.requestSMSPermission();
if (!granted) {
final newStatus = await permission
.Permission.sms.status;
if (newStatus.isPermanentlyDenied) {
await permission.openAppSettings();
}
}
if (context.mounted) {
(context as Element).markNeedsBuild();
}
},
child: Text(AppLocalizations.of(context)
.requestPermission),
),
); );
}, },
), ),

View File

@@ -63,18 +63,18 @@ class _SmsPermissionScreenState extends State<SmsPermissionScreen> {
context: context, context: context,
builder: (_) => AlertDialog( builder: (_) => AlertDialog(
title: Text(loc.smsPermissionRequired), title: Text(loc.smsPermissionRequired),
content: const Text('권한이 영구적으로 거부되었습니다. 설정에서 권한을 허용해주세요.'), content: Text(loc.permanentlyDeniedMessage),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
child: const Text('닫기'), child: Text(AppLocalizations.of(context).cancel),
), ),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
await permission.openAppSettings(); await permission.openAppSettings();
if (mounted) Navigator.of(context).pop(); if (mounted) Navigator.of(context).pop();
}, },
child: const Text('설정 열기'), child: Text(loc.openSettings),
), ),
], ],
), ),
@@ -95,7 +95,7 @@ class _SmsPermissionScreenState extends State<SmsPermissionScreen> {
const Icon(Icons.sms, size: 64, color: AppColors.primaryColor), const Icon(Icons.sms, size: 64, color: AppColors.primaryColor),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'SMS 권한 요청', loc.smsPermissionTitle,
style: Theme.of(context).textTheme.headlineSmall?.copyWith( style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: AppColors.textPrimary, color: AppColors.textPrimary,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -112,16 +112,16 @@ class _SmsPermissionScreenState extends State<SmsPermissionScreen> {
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: const [ children: [
Text('이유:', Text(loc.smsPermissionReasonTitle,
style: TextStyle(fontWeight: FontWeight.bold)), style: const TextStyle(fontWeight: FontWeight.bold)),
SizedBox(height: 8), const SizedBox(height: 8),
Text('문자 메시지에서 반복 결제 내역을 분석해 구독 서비스를 자동으로 탐지합니다.'), Text(loc.smsPermissionReasonBody),
SizedBox(height: 12), const SizedBox(height: 12),
Text('수집 범위:', Text(loc.smsPermissionScopeTitle,
style: TextStyle(fontWeight: FontWeight.bold)), style: const TextStyle(fontWeight: FontWeight.bold)),
SizedBox(height: 8), const SizedBox(height: 8),
Text('결제 관련 문자 메시지(서비스명/금액/날짜 패턴)를 로컬에서만 처리합니다.'), Text(loc.smsPermissionScopeBody),
], ],
), ),
), ),
@@ -131,15 +131,15 @@ class _SmsPermissionScreenState extends State<SmsPermissionScreen> {
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: _requesting ? null : _handleRequest, onPressed: _requesting ? null : _handleRequest,
icon: const Icon(Icons.lock_open), icon: const Icon(Icons.lock_open),
label: label: Text(
Text(_requesting ? '요청 중...' : loc.requestPermission), _requesting ? loc.requesting : loc.requestPermission),
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
TextButton( TextButton(
onPressed: () => Navigator.of(context) onPressed: () => Navigator.of(context)
.pushReplacementNamed(AppRoutes.main), .pushReplacementNamed(AppRoutes.main),
child: const Text('나중에 하기'), child: Text(loc.later),
) )
], ],
), ),

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../controllers/sms_scan_controller.dart'; import '../controllers/sms_scan_controller.dart';
import '../widgets/sms_scan/scan_loading_widget.dart'; import '../widgets/sms_scan/scan_loading_widget.dart';
import '../widgets/sms_scan/scan_initial_widget.dart'; import '../widgets/sms_scan/scan_initial_widget.dart';

View File

@@ -277,13 +277,13 @@ class _SplashScreenState extends State<SplashScreen>
.withValues(alpha: 0.3), .withValues(alpha: 0.3),
width: 1.5, width: 1.5,
), ),
boxShadow: [ boxShadow: const [
BoxShadow( BoxShadow(
color: color:
AppColors.shadowBlack, AppColors.shadowBlack,
spreadRadius: 0, spreadRadius: 0,
blurRadius: 30, blurRadius: 30,
offset: const Offset(0, 10), offset: Offset(0, 10),
), ),
], ],
), ),
@@ -398,7 +398,7 @@ class _SplashScreenState extends State<SplashScreen>
width: 1, width: 1,
), ),
), ),
child: CircularProgressIndicator( child: const CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>( valueColor: AlwaysStoppedAnimation<Color>(
AppColors.pureWhite), AppColors.pureWhite),
strokeWidth: 3, strokeWidth: 3,

View File

@@ -1,6 +1,7 @@
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'dart:convert'; import 'dart:convert';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../utils/logger.dart';
/// 환율 정보 서비스 클래스 /// 환율 정보 서비스 클래스
class ExchangeRateService { class ExchangeRateService {
@@ -21,12 +22,20 @@ class ExchangeRateService {
double? _usdToCnyRate; double? _usdToCnyRate;
DateTime? _lastUpdated; DateTime? _lastUpdated;
// API 요청 URL (ExchangeRate-API 사용) // API 요청 URL (ExchangeRate-API 등) - 빌드 타임 오버라이드 가능
final String _apiUrl = 'https://api.exchangerate-api.com/v4/latest/USD'; static const String _defaultApiUrl =
'https://api.exchangerate-api.com/v4/latest/USD';
final String _apiUrl = const String.fromEnvironment(
'EXCHANGE_RATE_API_URL',
defaultValue: _defaultApiUrl,
);
// 기본 환율 상수 // 기본 환율 상수
// ignore: constant_identifier_names
static const double DEFAULT_USD_TO_KRW_RATE = 1350.0; static const double DEFAULT_USD_TO_KRW_RATE = 1350.0;
// ignore: constant_identifier_names
static const double DEFAULT_USD_TO_JPY_RATE = 150.0; static const double DEFAULT_USD_TO_JPY_RATE = 150.0;
// ignore: constant_identifier_names
static const double DEFAULT_USD_TO_CNY_RATE = 7.2; static const double DEFAULT_USD_TO_CNY_RATE = 7.2;
// 캐싱된 환율 반환 (동기적) // 캐싱된 환율 반환 (동기적)
@@ -44,18 +53,26 @@ class ExchangeRateService {
} }
try { try {
// API 요청 // API 요청 (네트워크 불가 환경에서는 예외 발생 가능)
final response = await http.get(Uri.parse(_apiUrl)); final response = await http.get(Uri.parse(_apiUrl));
if (response.statusCode == 200) { if (response.statusCode == 200) {
final data = json.decode(response.body); final data = json.decode(response.body);
_usdToKrwRate = data['rates']['KRW']?.toDouble(); _usdToKrwRate = (data['rates']['KRW'] as num?)?.toDouble();
_usdToJpyRate = data['rates']['JPY']?.toDouble(); _usdToJpyRate = (data['rates']['JPY'] as num?)?.toDouble();
_usdToCnyRate = data['rates']['CNY']?.toDouble(); _usdToCnyRate = (data['rates']['CNY'] as num?)?.toDouble();
_lastUpdated = DateTime.now(); _lastUpdated = DateTime.now();
Log.d(
'환율 갱신 완료: USD→KRW=$_usdToKrwRate, JPY=$_usdToJpyRate, CNY=$_usdToCnyRate');
return;
} else {
Log.w(
'환율 API 응답 코드: ${response.statusCode} (${response.reasonPhrase})');
} }
} catch (e) { } catch (e, st) {
// 오류 발생 시 기본값 사용 // 네트워크 실패 시 캐시/기본값 폴백
Log.w('환율 API 요청 실패. 캐시/기본값 사용');
Log.e('환율 API 에러', e, st);
} }
} }

View File

@@ -1,7 +1,6 @@
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/timezone.dart' as tz; import 'package:timezone/timezone.dart' as tz;
import 'package:timezone/data/latest_all.dart' as tz; import 'package:timezone/data/latest_all.dart' as tz;
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'dart:io' show Platform; import 'dart:io' show Platform;
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';
@@ -10,7 +9,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class NotificationService { class NotificationService {
static final FlutterLocalNotificationsPlugin _notifications = static final FlutterLocalNotificationsPlugin _notifications =
FlutterLocalNotificationsPlugin(); FlutterLocalNotificationsPlugin();
static final _secureStorage = const FlutterSecureStorage(); static const _secureStorage = FlutterSecureStorage();
static const _notificationEnabledKey = 'notification_enabled'; static const _notificationEnabledKey = 'notification_enabled';
static const _paymentNotificationEnabledKey = 'payment_notification_enabled'; static const _paymentNotificationEnabledKey = 'payment_notification_enabled';
@@ -241,7 +240,7 @@ class NotificationService {
priority: Priority.high, priority: Priority.high,
); );
final iosDetails = const DarwinNotificationDetails(); const iosDetails = DarwinNotificationDetails();
// tz.local 초기화 확인 및 재시도 // tz.local 초기화 확인 및 재시도
tz.Location location; tz.Location location;
@@ -266,10 +265,10 @@ class NotificationService {
title, title,
body, body,
tz.TZDateTime.from(scheduledDate, location), tz.TZDateTime.from(scheduledDate, location),
NotificationDetails(android: androidDetails, iOS: iosDetails), const NotificationDetails(android: androidDetails, iOS: iosDetails),
androidAllowWhileIdle: true,
uiLocalNotificationDateInterpretation: uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime, UILocalNotificationDateInterpretation.absoluteTime,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
); );
} catch (e) { } catch (e) {
debugPrint('알림 예약 중 오류 발생: $e'); debugPrint('알림 예약 중 오류 발생: $e');
@@ -351,9 +350,9 @@ class NotificationService {
'${subscription.serviceName} 구독이 ${subscription.nextBillingDate.day}일 만료됩니다.', '${subscription.serviceName} 구독이 ${subscription.nextBillingDate.day}일 만료됩니다.',
tz.TZDateTime.from(subscription.nextBillingDate, location), tz.TZDateTime.from(subscription.nextBillingDate, location),
notificationDetails, notificationDetails,
androidAllowWhileIdle: true,
uiLocalNotificationDateInterpretation: uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime, UILocalNotificationDateInterpretation.absoluteTime,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
); );
} catch (e) { } catch (e) {
debugPrint('구독 알림 예약 중 오류 발생: $e'); debugPrint('구독 알림 예약 중 오류 발생: $e');
@@ -416,9 +415,9 @@ class NotificationService {
priority: Priority.high, priority: Priority.high,
), ),
), ),
androidAllowWhileIdle: true,
uiLocalNotificationDateInterpretation: uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime, UILocalNotificationDateInterpretation.absoluteTime,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
); );
} catch (e) { } catch (e) {
debugPrint('결제 알림 예약 중 오류 발생: $e'); debugPrint('결제 알림 예약 중 오류 발생: $e');
@@ -456,7 +455,7 @@ class NotificationService {
} }
await _notifications.zonedSchedule( await _notifications.zonedSchedule(
(subscription.id + '_expiration').hashCode, ('${subscription.id}_expiration').hashCode,
'구독 만료 예정 알림', '구독 만료 예정 알림',
'${subscription.serviceName} 구독이 7일 후 만료됩니다.', '${subscription.serviceName} 구독이 7일 후 만료됩니다.',
tz.TZDateTime.from(reminderDate, location), tz.TZDateTime.from(reminderDate, location),
@@ -469,9 +468,9 @@ class NotificationService {
priority: Priority.high, priority: Priority.high,
), ),
), ),
androidAllowWhileIdle: true,
uiLocalNotificationDateInterpretation: uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime, UILocalNotificationDateInterpretation.absoluteTime,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
); );
} catch (e) { } catch (e) {
debugPrint('만료 알림 예약 중 오류 발생: $e'); debugPrint('만료 알림 예약 중 오류 발생: $e');

View File

@@ -12,9 +12,12 @@ class SubscriptionConverter {
final subscription = _convertSingle(model); final subscription = _convertSingle(model);
result.add(subscription); result.add(subscription);
// 개발 편의를 위한 디버그 로그
// ignore: avoid_print
print( print(
'모델 변환 성공: ${model.serviceName}, 카테고리ID: ${model.categoryId}, URL: ${model.websiteUrl}, 통화: ${model.currency}'); '모델 변환 성공: ${model.serviceName}, 카테고리ID: ${model.categoryId}, URL: ${model.websiteUrl}, 통화: ${model.currency}');
} catch (e) { } catch (e) {
// ignore: avoid_print
print('모델 변환 중 오류 발생: $e'); print('모델 변환 중 오류 발생: $e');
} }
} }

View File

@@ -1,11 +1,12 @@
import '../../models/subscription.dart'; import '../../models/subscription.dart';
import '../../models/subscription_model.dart'; import '../../models/subscription_model.dart';
import '../../utils/logger.dart';
class SubscriptionFilter { class SubscriptionFilter {
// 중복 구독 필터링 (서비스명과 금액이 같으면 중복으로 간주) // 중복 구독 필터링 (서비스명과 금액이 같으면 중복으로 간주)
List<Subscription> filterDuplicates( List<Subscription> filterDuplicates(
List<Subscription> scanned, List<SubscriptionModel> existing) { List<Subscription> scanned, List<SubscriptionModel> existing) {
print( Log.d(
'_filterDuplicates: 스캔된 구독 ${scanned.length}개, 기존 구독 ${existing.length}'); '_filterDuplicates: 스캔된 구독 ${scanned.length}개, 기존 구독 ${existing.length}');
// 중복되지 않은 구독만 필터링 // 중복되지 않은 구독만 필터링
@@ -17,7 +18,7 @@ class SubscriptionFilter {
final isSameCost = existingSub.monthlyCost == scannedSub.monthlyCost; final isSameCost = existingSub.monthlyCost == scannedSub.monthlyCost;
if (isSameName && isSameCost) { if (isSameName && isSameCost) {
print( Log.d(
'중복 발견: ${scannedSub.serviceName} (${scannedSub.monthlyCost}원)'); '중복 발견: ${scannedSub.serviceName} (${scannedSub.monthlyCost}원)');
return true; return true;
} }

View File

@@ -1,6 +1,7 @@
import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter_sms_inbox/flutter_sms_inbox.dart'; import 'package:flutter_sms_inbox/flutter_sms_inbox.dart';
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';
import '../utils/logger.dart';
import '../temp/test_sms_data.dart'; import '../temp/test_sms_data.dart';
import '../services/subscription_url_matcher.dart'; import '../services/subscription_url_matcher.dart';
import '../utils/platform_helper.dart'; import '../utils/platform_helper.dart';
@@ -11,26 +12,26 @@ class SmsScanner {
Future<List<SubscriptionModel>> scanForSubscriptions() async { Future<List<SubscriptionModel>> scanForSubscriptions() async {
try { try {
List<dynamic> smsList; List<dynamic> smsList;
print('SmsScanner: 스캔 시작'); Log.d('SmsScanner: 스캔 시작');
// 플랫폼별 분기 처리 // 플랫폼별 분기 처리
if (kIsWeb) { if (kIsWeb) {
// 웹 환경: 테스트 데이터 사용 // 웹 환경: 테스트 데이터 사용
print('SmsScanner: 웹 환경에서 테스트 데이터 사용'); Log.i('SmsScanner: 웹 환경에서 테스트 데이터 사용');
smsList = TestSmsData.getTestData(); smsList = TestSmsData.getTestData();
print('SmsScanner: 테스트 데이터 개수: ${smsList.length}'); Log.d('SmsScanner: 테스트 데이터 개수: ${smsList.length}');
} else if (PlatformHelper.isIOS) { } else if (PlatformHelper.isIOS) {
// iOS: SMS 접근 불가, 빈 리스트 반환 // iOS: SMS 접근 불가, 빈 리스트 반환
print('SmsScanner: iOS에서는 SMS 스캔 불가'); Log.w('SmsScanner: iOS에서는 SMS 스캔 불가');
return []; return [];
} else if (PlatformHelper.isAndroid) { } else if (PlatformHelper.isAndroid) {
// Android: flutter_sms_inbox 사용 // Android: flutter_sms_inbox 사용
print('SmsScanner: Android에서 실제 SMS 스캔'); Log.i('SmsScanner: Android에서 실제 SMS 스캔');
smsList = await _scanAndroidSms(); smsList = await _scanAndroidSms();
print('SmsScanner: 스캔된 SMS 개수: ${smsList.length}'); Log.d('SmsScanner: 스캔된 SMS 개수: ${smsList.length}');
} else { } else {
// 기타 플랫폼 // 기타 플랫폼
print('SmsScanner: 지원하지 않는 플랫폼'); Log.w('SmsScanner: 지원하지 않는 플랫폼');
return []; return [];
} }
@@ -47,32 +48,32 @@ class SmsScanner {
serviceGroups[serviceName]!.add(sms); serviceGroups[serviceName]!.add(sms);
} }
print('SmsScanner: 그룹화된 서비스 수: ${serviceGroups.length}'); Log.d('SmsScanner: 그룹화된 서비스 수: ${serviceGroups.length}');
// 그룹화된 데이터로 구독 분석 // 그룹화된 데이터로 구독 분석
for (final entry in serviceGroups.entries) { for (final entry in serviceGroups.entries) {
print('SmsScanner: 서비스 "${entry.key}" - 메시지 개수: ${entry.value.length}'); Log.d('SmsScanner: 서비스 "${entry.key}" - 메시지 개수: ${entry.value.length}');
// 2회 이상 반복된 서비스만 구독으로 간주 // 2회 이상 반복된 서비스만 구독으로 간주
if (entry.value.length >= 2) { if (entry.value.length >= 2) {
final serviceSms = entry.value[0]; // 가장 최근 SMS 사용 final serviceSms = entry.value[0]; // 가장 최근 SMS 사용
final subscription = _parseSms(serviceSms, entry.value.length); final subscription = _parseSms(serviceSms, entry.value.length);
if (subscription != null) { if (subscription != null) {
print( Log.i(
'SmsScanner: 구독 추가: ${subscription.serviceName}, 반복 횟수: ${subscription.repeatCount}'); 'SmsScanner: 구독 추가: ${subscription.serviceName}, 반복 횟수: ${subscription.repeatCount}');
subscriptions.add(subscription); subscriptions.add(subscription);
} else { } else {
print('SmsScanner: 구독 파싱 실패: ${entry.key}'); Log.w('SmsScanner: 구독 파싱 실패: ${entry.key}');
} }
} else { } else {
print('SmsScanner: 반복 횟수 부족, 구독으로 간주하지 않음: ${entry.key}'); Log.d('SmsScanner: 반복 횟수 부족, 구독으로 간주하지 않음: ${entry.key}');
} }
} }
print('SmsScanner: 최종 구독 개수: ${subscriptions.length}'); Log.d('SmsScanner: 최종 구독 개수: ${subscriptions.length}');
return subscriptions; return subscriptions;
} catch (e) { } catch (e) {
print('SmsScanner: 예외 발생: $e'); Log.e('SmsScanner: 예외 발생', e);
throw Exception('SMS 스캔 중 오류 발생: $e'); throw Exception('SMS 스캔 중 오류 발생: $e');
} }
} }
@@ -93,7 +94,7 @@ class SmsScanner {
return smsList; return smsList;
} catch (e) { } catch (e) {
print('SmsScanner: Android SMS 스캔 실패: $e'); Log.e('SmsScanner: Android SMS 스캔 실패', e);
return []; return [];
} }
} }
@@ -160,7 +161,7 @@ class SmsScanner {
'previousPaymentDate': date.toIso8601String(), 'previousPaymentDate': date.toIso8601String(),
}; };
} catch (e) { } catch (e) {
print('SmsScanner: SMS 파싱 실패: $e'); Log.e('SmsScanner: SMS 파싱 실패', e);
return null; return null;
} }
} }
@@ -281,7 +282,7 @@ class SmsScanner {
'Spotify Premium' 'Spotify Premium'
]; ];
if (dollarServices.any((service) => serviceName.contains(service))) { if (dollarServices.any((service) => serviceName.contains(service))) {
print('서비스명 $serviceName으로 USD 통화 단위 확정'); Log.d('서비스명 $serviceName으로 USD 통화 단위 확정');
currency = 'USD'; currency = 'USD';
} }
@@ -411,7 +412,7 @@ class SmsScanner {
// 서비스명 기반 통화 단위 확인 // 서비스명 기반 통화 단위 확인
for (final service in serviceCurrencyMap.keys) { for (final service in serviceCurrencyMap.keys) {
if (message.contains(service)) { if (message.contains(service)) {
print('_detectCurrency: ${service} USD 서비스로 판별됨'); Log.d('_detectCurrency: $service USD 서비스로 판별됨');
return 'USD'; return 'USD';
} }
} }
@@ -419,7 +420,7 @@ class SmsScanner {
// 메시지에 달러 관련 키워드가 있는지 확인 // 메시지에 달러 관련 키워드가 있는지 확인
for (final keyword in dollarKeywords) { for (final keyword in dollarKeywords) {
if (message.toLowerCase().contains(keyword.toLowerCase())) { if (message.toLowerCase().contains(keyword.toLowerCase())) {
print('_detectCurrency: USD 키워드 발견: $keyword'); Log.d('_detectCurrency: USD 키워드 발견: $keyword');
return 'USD'; return 'USD';
} }
} }

View File

@@ -1,5 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../../../utils/logger.dart';
/// 서비스 데이터를 관리하는 저장소 클래스 /// 서비스 데이터를 관리하는 저장소 클래스
class ServiceDataRepository { class ServiceDataRepository {
@@ -15,9 +16,9 @@ class ServiceDataRepository {
await rootBundle.loadString('assets/data/subscription_services.json'); await rootBundle.loadString('assets/data/subscription_services.json');
_servicesData = json.decode(jsonString); _servicesData = json.decode(jsonString);
_isInitialized = true; _isInitialized = true;
print('ServiceDataRepository: JSON 데이터 로드 완료'); Log.i('ServiceDataRepository: JSON 데이터 로드 완료');
} catch (e) { } catch (e) {
print('ServiceDataRepository: JSON 로드 실패 - $e'); Log.w('ServiceDataRepository: JSON 로드 실패 - $e');
// 로드 실패시 기존 하드코딩 데이터 사용 // 로드 실패시 기존 하드코딩 데이터 사용
_isInitialized = true; _isInitialized = true;
} }

View File

@@ -75,24 +75,33 @@ class CategoryMapperService {
String getCategoryForLegacyService(String serviceName) { String getCategoryForLegacyService(String serviceName) {
final lowerName = serviceName.toLowerCase(); final lowerName = serviceName.toLowerCase();
if (LegacyServiceData.ottServices.containsKey(lowerName)) if (LegacyServiceData.ottServices.containsKey(lowerName)) {
return 'ott_services'; return 'ott_services';
if (LegacyServiceData.musicServices.containsKey(lowerName)) }
if (LegacyServiceData.musicServices.containsKey(lowerName)) {
return 'music_streaming'; return 'music_streaming';
if (LegacyServiceData.storageServices.containsKey(lowerName)) }
if (LegacyServiceData.storageServices.containsKey(lowerName)) {
return 'cloud_storage'; return 'cloud_storage';
if (LegacyServiceData.aiServices.containsKey(lowerName)) }
if (LegacyServiceData.aiServices.containsKey(lowerName)) {
return 'ai_services'; return 'ai_services';
if (LegacyServiceData.programmingServices.containsKey(lowerName)) }
if (LegacyServiceData.programmingServices.containsKey(lowerName)) {
return 'dev_tools'; return 'dev_tools';
if (LegacyServiceData.officeTools.containsKey(lowerName)) }
if (LegacyServiceData.officeTools.containsKey(lowerName)) {
return 'office_tools'; return 'office_tools';
if (LegacyServiceData.lifestyleServices.containsKey(lowerName)) }
if (LegacyServiceData.lifestyleServices.containsKey(lowerName)) {
return 'lifestyle'; return 'lifestyle';
if (LegacyServiceData.shoppingServices.containsKey(lowerName)) }
if (LegacyServiceData.shoppingServices.containsKey(lowerName)) {
return 'shopping'; return 'shopping';
if (LegacyServiceData.telecomServices.containsKey(lowerName)) }
if (LegacyServiceData.telecomServices.containsKey(lowerName)) {
return 'telecom'; return 'telecom';
}
return 'other'; return 'other';
} }

View File

@@ -2,6 +2,7 @@ import '../models/service_info.dart';
import '../data/service_data_repository.dart'; import '../data/service_data_repository.dart';
import '../data/legacy_service_data.dart'; import '../data/legacy_service_data.dart';
import 'category_mapper_service.dart'; import 'category_mapper_service.dart';
import '../../../utils/logger.dart';
/// URL 매칭 관련 기능을 제공하는 서비스 클래스 /// URL 매칭 관련 기능을 제공하는 서비스 클래스
class UrlMatcherService { class UrlMatcherService {
@@ -35,7 +36,7 @@ class UrlMatcherService {
return null; return null;
} catch (e) { } catch (e) {
print('UrlMatcherService: 도메인 추출 실패 - $e'); Log.e('UrlMatcherService: 도메인 추출 실패', e);
return null; return null;
} }
} }
@@ -107,7 +108,7 @@ class UrlMatcherService {
/// 서비스명으로 URL 찾기 /// 서비스명으로 URL 찾기
String? suggestUrl(String serviceName) { String? suggestUrl(String serviceName) {
if (serviceName.isEmpty) { if (serviceName.isEmpty) {
print('UrlMatcherService: 빈 serviceName'); Log.w('UrlMatcherService: 빈 serviceName');
return null; return null;
} }
@@ -118,7 +119,7 @@ class UrlMatcherService {
// 정확한 매칭을 먼저 시도 // 정확한 매칭을 먼저 시도
for (final entry in LegacyServiceData.allServices.entries) { for (final entry in LegacyServiceData.allServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) { if (lowerName.contains(entry.key.toLowerCase())) {
print('UrlMatcherService: 정확한 매칭 - $lowerName -> ${entry.key}'); Log.d('UrlMatcherService: 정확한 매칭 - $lowerName -> ${entry.key}');
return entry.value; return entry.value;
} }
} }
@@ -126,7 +127,7 @@ class UrlMatcherService {
// OTT 서비스 검사 // OTT 서비스 검사
for (final entry in LegacyServiceData.ottServices.entries) { for (final entry in LegacyServiceData.ottServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) { if (lowerName.contains(entry.key.toLowerCase())) {
print('UrlMatcherService: OTT 서비스 매칭 - $lowerName -> ${entry.key}'); Log.d('UrlMatcherService: OTT 서비스 매칭 - $lowerName -> ${entry.key}');
return entry.value; return entry.value;
} }
} }
@@ -134,7 +135,7 @@ class UrlMatcherService {
// 음악 서비스 검사 // 음악 서비스 검사
for (final entry in LegacyServiceData.musicServices.entries) { for (final entry in LegacyServiceData.musicServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) { if (lowerName.contains(entry.key.toLowerCase())) {
print('UrlMatcherService: 음악 서비스 매칭 - $lowerName -> ${entry.key}'); Log.d('UrlMatcherService: 음악 서비스 매칭 - $lowerName -> ${entry.key}');
return entry.value; return entry.value;
} }
} }
@@ -142,7 +143,7 @@ class UrlMatcherService {
// AI 서비스 검사 // AI 서비스 검사
for (final entry in LegacyServiceData.aiServices.entries) { for (final entry in LegacyServiceData.aiServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) { if (lowerName.contains(entry.key.toLowerCase())) {
print('UrlMatcherService: AI 서비스 매칭 - $lowerName -> ${entry.key}'); Log.d('UrlMatcherService: AI 서비스 매칭 - $lowerName -> ${entry.key}');
return entry.value; return entry.value;
} }
} }
@@ -150,7 +151,7 @@ class UrlMatcherService {
// 프로그래밍 서비스 검사 // 프로그래밍 서비스 검사
for (final entry in LegacyServiceData.programmingServices.entries) { for (final entry in LegacyServiceData.programmingServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) { if (lowerName.contains(entry.key.toLowerCase())) {
print('UrlMatcherService: 프로그래밍 서비스 매칭 - $lowerName -> ${entry.key}'); Log.d('UrlMatcherService: 프로그래밍 서비스 매칭 - $lowerName -> ${entry.key}');
return entry.value; return entry.value;
} }
} }
@@ -158,7 +159,7 @@ class UrlMatcherService {
// 오피스 툴 검사 // 오피스 툴 검사
for (final entry in LegacyServiceData.officeTools.entries) { for (final entry in LegacyServiceData.officeTools.entries) {
if (lowerName.contains(entry.key.toLowerCase())) { if (lowerName.contains(entry.key.toLowerCase())) {
print('UrlMatcherService: 오피스 툴 매칭 - $lowerName -> ${entry.key}'); Log.d('UrlMatcherService: 오피스 툴 매칭 - $lowerName -> ${entry.key}');
return entry.value; return entry.value;
} }
} }
@@ -166,7 +167,7 @@ class UrlMatcherService {
// 기타 서비스 검사 // 기타 서비스 검사
for (final entry in LegacyServiceData.otherServices.entries) { for (final entry in LegacyServiceData.otherServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) { if (lowerName.contains(entry.key.toLowerCase())) {
print('UrlMatcherService: 기타 서비스 매칭 - $lowerName -> ${entry.key}'); Log.d('UrlMatcherService: 기타 서비스 매칭 - $lowerName -> ${entry.key}');
return entry.value; return entry.value;
} }
} }
@@ -175,15 +176,15 @@ class UrlMatcherService {
for (final entry in LegacyServiceData.allServices.entries) { for (final entry in LegacyServiceData.allServices.entries) {
final key = entry.key.toLowerCase(); final key = entry.key.toLowerCase();
if (key.contains(lowerName) || lowerName.contains(key)) { if (key.contains(lowerName) || lowerName.contains(key)) {
print('UrlMatcherService: 부분 매칭 - $lowerName -> ${entry.key}'); Log.d('UrlMatcherService: 부분 매칭 - $lowerName -> ${entry.key}');
return entry.value; return entry.value;
} }
} }
print('UrlMatcherService: 매칭 실패 - $lowerName'); Log.d('UrlMatcherService: 매칭 실패 - $lowerName');
return null; return null;
} catch (e) { } catch (e) {
print('UrlMatcherService: suggestUrl 에러 - $e'); Log.e('UrlMatcherService: suggestUrl 에러', e);
return null; return null;
} }
} }

View File

@@ -210,6 +210,7 @@ class TestSmsData {
} }
} }
// ignore: avoid_print
print('TestSmsData: 생성된 테스트 메시지 수: ${resultData.length}'); print('TestSmsData: 생성된 테스트 메시지 수: ${resultData.length}');
return resultData; return resultData;
} }
@@ -233,7 +234,7 @@ class TestSmsData {
]; ];
// Microsoft 365는 연간 구독이므로 월별 비용으로 환산 (1년에 1번만 결제) // Microsoft 365는 연간 구독이므로 월별 비용으로 환산 (1년에 1번만 결제)
final microsoftMonthlyCost = 12800.0 / 12; const microsoftMonthlyCost = 12800.0 / 12;
// 최근 6개월 데이터 생성 // 최근 6개월 데이터 생성
for (int i = 0; i < 6; i++) { for (int i = 0; i < 6; i++) {

View File

@@ -19,8 +19,7 @@ class AdaptiveTheme {
secondary: AppColors.secondaryColor, secondary: AppColors.secondaryColor,
tertiary: AppColors.infoColor, tertiary: AppColors.infoColor,
error: AppColors.dangerColor, error: AppColors.dangerColor,
background: const Color(0xFF121212), surface: Color(0xFF1E1E1E),
surface: const Color(0xFF1E1E1E),
), ),
scaffoldBackgroundColor: const Color(0xFF121212), scaffoldBackgroundColor: const Color(0xFF121212),
cardTheme: CardThemeData( cardTheme: CardThemeData(
@@ -175,7 +174,6 @@ class AdaptiveTheme {
return darkTheme.copyWith( return darkTheme.copyWith(
scaffoldBackgroundColor: Colors.black, scaffoldBackgroundColor: Colors.black,
colorScheme: darkTheme.colorScheme.copyWith( colorScheme: darkTheme.colorScheme.copyWith(
background: Colors.black,
surface: const Color(0xFF0A0A0A), surface: const Color(0xFF0A0A0A),
), ),
cardTheme: darkTheme.cardTheme.copyWith( cardTheme: darkTheme.cardTheme.copyWith(
@@ -200,7 +198,6 @@ class AdaptiveTheme {
secondary: Colors.black87, secondary: Colors.black87,
tertiary: Colors.black54, tertiary: Colors.black54,
error: Colors.red, error: Colors.red,
background: Colors.white,
surface: Colors.white, surface: Colors.white,
), ),
textTheme: const TextTheme( textTheme: const TextTheme(

View File

@@ -10,7 +10,6 @@ class AppTheme {
secondary: AppColors.secondaryColor, secondary: AppColors.secondaryColor,
tertiary: AppColors.infoColor, tertiary: AppColors.infoColor,
error: AppColors.dangerColor, error: AppColors.dangerColor,
background: AppColors.backgroundColor,
surface: AppColors.surfaceColor, surface: AppColors.surfaceColor,
), ),
@@ -36,13 +35,13 @@ class AppTheme {
foregroundColor: AppColors.textPrimary, foregroundColor: AppColors.textPrimary,
elevation: 0, elevation: 0,
centerTitle: false, centerTitle: false,
titleTextStyle: const TextStyle( titleTextStyle: TextStyle(
color: AppColors.textPrimary, color: AppColors.textPrimary,
fontSize: 22, fontSize: 22,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: -0.2, letterSpacing: -0.2,
), ),
iconTheme: const IconThemeData( iconTheme: IconThemeData(
color: AppColors.primaryColor, color: AppColors.primaryColor,
size: 24, size: 24,
), ),
@@ -51,21 +50,21 @@ class AppTheme {
// 타이포그래피 - Metronic Tailwind 스타일 // 타이포그래피 - Metronic Tailwind 스타일
textTheme: const TextTheme( textTheme: const TextTheme(
// 헤드라인 - 페이지 제목 // 헤드라인 - 페이지 제목
headlineLarge: const TextStyle( headlineLarge: TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 32, fontSize: 32,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: -0.5, letterSpacing: -0.5,
height: 1.2, height: 1.2,
), ),
headlineMedium: const TextStyle( headlineMedium: TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 28, fontSize: 28,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: -0.5, letterSpacing: -0.5,
height: 1.2, height: 1.2,
), ),
headlineSmall: const TextStyle( headlineSmall: TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 24, fontSize: 24,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -74,7 +73,7 @@ class AppTheme {
), ),
// 타이틀 - 카드, 섹션 제목 // 타이틀 - 카드, 섹션 제목
titleLarge: const TextStyle( titleLarge: TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -257,14 +256,14 @@ class AppTheme {
// 스위치 스타일 // 스위치 스타일
switchTheme: SwitchThemeData( switchTheme: SwitchThemeData(
thumbColor: MaterialStateProperty.resolveWith<Color>((states) { thumbColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(MaterialState.selected)) { if (states.contains(WidgetState.selected)) {
return AppColors.primaryColor; return AppColors.primaryColor;
} }
return Colors.white; return Colors.white;
}), }),
trackColor: MaterialStateProperty.resolveWith<Color>((states) { trackColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(MaterialState.selected)) { if (states.contains(WidgetState.selected)) {
return AppColors.secondaryColor.withValues(alpha: 0.5); return AppColors.secondaryColor.withValues(alpha: 0.5);
} }
return AppColors.borderColor; return AppColors.borderColor;
@@ -273,8 +272,8 @@ class AppTheme {
// 체크박스 스타일 // 체크박스 스타일
checkboxTheme: CheckboxThemeData( checkboxTheme: CheckboxThemeData(
fillColor: MaterialStateProperty.resolveWith<Color>((states) { fillColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(MaterialState.selected)) { if (states.contains(WidgetState.selected)) {
return AppColors.primaryColor; return AppColors.primaryColor;
} }
return Colors.transparent; return Colors.transparent;
@@ -287,8 +286,8 @@ class AppTheme {
// 라디오 버튼 스타일 // 라디오 버튼 스타일
radioTheme: RadioThemeData( radioTheme: RadioThemeData(
fillColor: MaterialStateProperty.resolveWith<Color>((states) { fillColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(MaterialState.selected)) { if (states.contains(WidgetState.selected)) {
return AppColors.primaryColor; return AppColors.primaryColor;
} }
return AppColors.textSecondary; return AppColors.textSecondary;
@@ -311,12 +310,12 @@ class AppTheme {
labelColor: AppColors.primaryColor, labelColor: AppColors.primaryColor,
unselectedLabelColor: AppColors.textSecondary, unselectedLabelColor: AppColors.textSecondary,
indicatorColor: AppColors.primaryColor, indicatorColor: AppColors.primaryColor,
labelStyle: const TextStyle( labelStyle: TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: 0.1, letterSpacing: 0.1,
), ),
unselectedLabelStyle: const TextStyle( unselectedLabelStyle: TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
letterSpacing: 0.1, letterSpacing: 0.1,

27
lib/utils/logger.dart Normal file
View File

@@ -0,0 +1,27 @@
import 'package:flutter/foundation.dart';
/// 단순 로거 헬퍼
/// - 디버그/프로파일 모드에서만 상세 로그 출력
/// - 릴리스 모드에서는 중요한 경고/에러만 축약 출력
class Log {
static bool get _verbose => !kReleaseMode;
static void d(String message) {
if (_verbose) debugPrint(message);
}
static void i(String message) {
if (_verbose) debugPrint(' $message');
}
static void w(String message) {
// 경고는 릴리스에서도 간단히 남김
debugPrint('⚠️ $message');
}
static void e(String message, [Object? error, StackTrace? stack]) {
final suffix = error != null ? ' | $error' : '';
debugPrint('$message$suffix');
if (_verbose && stack != null) debugPrint(stack.toString());
}
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'logger.dart';
import 'dart:async'; import 'dart:async';
/// 메모리 관리를 위한 헬퍼 클래스 /// 메모리 관리를 위한 헬퍼 클래스
@@ -57,7 +58,7 @@ class MemoryManager {
void clearCache() { void clearCache() {
_cache.clear(); _cache.clear();
if (kDebugMode) { if (kDebugMode) {
print('🧹 메모리 캐시가 비워졌습니다.'); Log.d('🧹 메모리 캐시가 비워졌습니다.');
} }
} }
@@ -122,7 +123,7 @@ class MemoryManager {
PaintingBinding.instance.imageCache.clear(); PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages(); PaintingBinding.instance.imageCache.clearLiveImages();
if (kDebugMode) { if (kDebugMode) {
print('🖼️ 이미지 캐시가 비워졌습니다.'); Log.d('🖼️ 이미지 캐시가 비워졌습니다.');
} }
} }
@@ -155,7 +156,7 @@ class MemoryManager {
imageCache.maximumSizeBytes = maxImageCacheSize ~/ 2; imageCache.maximumSizeBytes = maxImageCacheSize ~/ 2;
if (kDebugMode) { if (kDebugMode) {
print('⚠️ 메모리 압박 대응: 캐시 크기 감소'); Log.w('메모리 압박 대응: 캐시 크기 감소');
} }
} }

View File

@@ -1,4 +1,5 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'logger.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'dart:async'; import 'dart:async';
@@ -141,12 +142,12 @@ class PerformanceOptimizer {
/// 빌드 최적화를 위한 const 위젯 권장사항 체크 /// 빌드 최적화를 위한 const 위젯 권장사항 체크
static void checkConstOptimization() { static void checkConstOptimization() {
if (kDebugMode) { if (kDebugMode) {
print('💡 성능 최적화 팁:'); Log.i('💡 성능 최적화 팁:\n'
print('1. 가능한 모든 위젯에 const 사용'); '1. 가능한 모든 위젯에 const 사용\n'
print('2. StatelessWidget 대신 const 생성자 사용'); '2. StatelessWidget 대신 const 생성자 사용\n'
print('3. 큰 리스트는 ListView.builder 사용'); '3. 큰 리스트는 ListView.builder 사용\n'
print('4. 이미지는 캐싱과 함께 적절한 크기로 로드'); '4. 이미지는 캐싱과 함께 적절한 크기로 로드\n'
print('5. 애니메이션은 AnimatedBuilder 사용'); '5. 애니메이션은 AnimatedBuilder 사용');
} }
} }
@@ -161,7 +162,7 @@ class PerformanceOptimizer {
// 위젯이 비정상적으로 많이 생성되면 경고 // 위젯이 비정상적으로 많이 생성되면 경고
if ((_widgetCounts[widgetName] ?? 0) > 100) { if ((_widgetCounts[widgetName] ?? 0) > 100) {
print('⚠️ 경고: $widgetName 위젯이 100개 이상 생성됨. 메모리 누수 가능성!'); Log.w('경고: $widgetName 위젯이 100개 이상 생성됨. 메모리 누수 가능성!');
} }
} }
} }
@@ -196,11 +197,11 @@ class PerformanceMeasure {
try { try {
final result = await operation(); final result = await operation();
stopwatch.stop(); stopwatch.stop();
print('$name 완료: ${stopwatch.elapsedMilliseconds}ms'); Log.d('$name 완료: ${stopwatch.elapsedMilliseconds}ms');
return result; return result;
} catch (e) { } catch (e) {
stopwatch.stop(); stopwatch.stop();
print('$name 실패: ${stopwatch.elapsedMilliseconds}ms - $e'); Log.e('$name 실패: ${stopwatch.elapsedMilliseconds}ms', e);
rethrow; rethrow;
} }
} }

View File

@@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';
import '../providers/category_provider.dart'; import '../providers/category_provider.dart';
import '../services/subscription_url_matcher.dart';
import '../services/url_matcher/data/legacy_service_data.dart'; import '../services/url_matcher/data/legacy_service_data.dart';
/// 구독 서비스를 카테고리별로 구분하는 도우미 클래스 /// 구독 서비스를 카테고리별로 구분하는 도우미 클래스

View File

@@ -3,7 +3,6 @@ import '../../controllers/add_subscription_controller.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';
import '../../l10n/app_localizations.dart';
/// 구독 추가 화면의 이벤트/할인 섹션 /// 구독 추가 화면의 이벤트/할인 섹션
class AddSubscriptionEventSection extends StatelessWidget { class AddSubscriptionEventSection extends StatelessWidget {
@@ -47,11 +46,11 @@ class AddSubscriptionEventSection extends StatelessWidget {
color: AppColors.glassBorder.withValues(alpha: 0.1), color: AppColors.glassBorder.withValues(alpha: 0.1),
width: 1, width: 1,
), ),
boxShadow: [ boxShadow: const [
BoxShadow( BoxShadow(
color: AppColors.shadowBlack, color: AppColors.shadowBlack,
blurRadius: 10, blurRadius: 10,
offset: const Offset(0, 4), offset: Offset(0, 4),
), ),
], ],
), ),
@@ -147,7 +146,7 @@ class AddSubscriptionEventSection extends StatelessWidget {
), ),
child: Row( child: Row(
children: [ children: [
Icon( const Icon(
Icons.info_outline_rounded, Icons.info_outline_rounded,
color: AppColors.infoColor, color: AppColors.infoColor,
size: 20, size: 20,
@@ -175,7 +174,7 @@ class AddSubscriptionEventSection extends StatelessWidget {
} }
return Text( return Text(
infoText, infoText,
style: TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
color: AppColors.darkNavy, color: AppColors.darkNavy,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,

View File

@@ -5,7 +5,6 @@ import '../../models/subscription_model.dart';
import '../../services/currency_util.dart'; import '../../services/currency_util.dart';
import '../../providers/locale_provider.dart'; import '../../providers/locale_provider.dart';
import '../../theme/app_colors.dart'; import '../../theme/app_colors.dart';
import '../../l10n/app_localizations.dart';
/// 파이 차트에서 선택된 섹션에 표시되는 배지 위젯 /// 파이 차트에서 선택된 섹션에 표시되는 배지 위젯
class AnalysisBadge extends StatelessWidget { class AnalysisBadge extends StatelessWidget {
@@ -33,7 +32,7 @@ class AnalysisBadge extends StatelessWidget {
color: borderColor, color: borderColor,
width: 2, width: 2,
), ),
boxShadow: [ boxShadow: const [
BoxShadow( BoxShadow(
color: AppColors.shadowBlack, color: AppColors.shadowBlack,
blurRadius: 10, blurRadius: 10,

View File

@@ -6,6 +6,7 @@ import '../screens/app_lock_screen.dart';
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';
import '../providers/navigation_provider.dart'; import '../providers/navigation_provider.dart';
import '../routes/app_routes.dart'; import '../routes/app_routes.dart';
import '../utils/logger.dart';
import 'animated_page_transitions.dart'; import 'animated_page_transitions.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
@@ -44,7 +45,7 @@ class AppNavigator {
/// 구독 상세 화면으로 네비게이션 /// 구독 상세 화면으로 네비게이션
static Future<void> toDetail( static Future<void> toDetail(
BuildContext context, SubscriptionModel subscription) async { BuildContext context, SubscriptionModel subscription) async {
print('AppNavigator.toDetail 호출됨: ${subscription.serviceName}'); Log.d('AppNavigator.toDetail 호출됨: ${subscription.serviceName}');
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
try { try {
@@ -52,9 +53,9 @@ class AppNavigator {
AppRoutes.subscriptionDetail, AppRoutes.subscriptionDetail,
arguments: subscription, arguments: subscription,
); );
print('DetailScreen 네비게이션 성공'); Log.d('DetailScreen 네비게이션 성공');
} catch (e) { } catch (e) {
print('DetailScreen 네비게이션 오류: $e'); Log.e('DetailScreen 네비게이션 오류', e);
} }
} }

View File

@@ -42,7 +42,6 @@ class _SecondaryButtonState extends State<SecondaryButton> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
final effectiveBorderColor = widget.borderColor ?? AppColors.secondaryColor; final effectiveBorderColor = widget.borderColor ?? AppColors.secondaryColor;
final effectiveTextColor = widget.textColor ?? AppColors.primaryColor; final effectiveTextColor = widget.textColor ?? AppColors.primaryColor;

View File

@@ -81,8 +81,8 @@ class LoadingDialog {
context: context, context: context,
barrierDismissible: barrierDismissible, barrierDismissible: barrierDismissible,
barrierColor: barrierColor ?? Colors.black54, barrierColor: barrierColor ?? Colors.black54,
builder: (context) => WillPopScope( builder: (context) => PopScope(
onWillPop: () async => barrierDismissible, canPop: barrierDismissible,
child: Center( child: Center(
child: Container( child: Container(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),

View File

@@ -66,7 +66,7 @@ class BaseTextField extends StatelessWidget {
if (label != null) ...[ if (label != null) ...[
Text( Text(
label!, label!,
style: TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.textSecondary, color: AppColors.textSecondary,
@@ -91,13 +91,13 @@ class BaseTextField extends StatelessWidget {
readOnly: readOnly, readOnly: readOnly,
cursorColor: cursorColor ?? theme.primaryColor, cursorColor: cursorColor ?? theme.primaryColor,
style: style ?? style: style ??
TextStyle( const TextStyle(
fontSize: 16, fontSize: 16,
color: AppColors.textPrimary, color: AppColors.textPrimary,
), ),
decoration: InputDecoration( decoration: InputDecoration(
hintText: hintText, hintText: hintText,
hintStyle: TextStyle( hintStyle: const TextStyle(
color: AppColors.textMuted, color: AppColors.textMuted,
), ),
prefixIcon: prefixIcon, prefixIcon: prefixIcon,

View File

@@ -48,7 +48,7 @@ class DatePickerField extends StatelessWidget {
children: [ children: [
Text( Text(
label, label,
style: TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.darkNavy, color: AppColors.darkNavy,
@@ -249,7 +249,7 @@ class _DateRangeItem extends StatelessWidget {
children: [ children: [
Text( Text(
label, label,
style: TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
color: AppColors.textSecondary, color: AppColors.textSecondary,
), ),

View File

@@ -200,7 +200,7 @@ class AppSnackBar {
width: 24, width: 24,
height: 24, height: 24,
margin: const EdgeInsets.only(right: 12), margin: const EdgeInsets.only(right: 12),
child: CircularProgressIndicator( child: const CircularProgressIndicator(
strokeWidth: 2.5, strokeWidth: 2.5,
color: AppColors.pureWhite, color: AppColors.pureWhite,
), ),

View File

@@ -44,11 +44,11 @@ class DetailEventSection extends StatelessWidget {
color: AppColors.glassBorder.withValues(alpha: 0.1), color: AppColors.glassBorder.withValues(alpha: 0.1),
width: 1, width: 1,
), ),
boxShadow: [ boxShadow: const [
BoxShadow( BoxShadow(
color: AppColors.shadowBlack, color: AppColors.shadowBlack,
blurRadius: 10, blurRadius: 10,
offset: const Offset(0, 4), offset: Offset(0, 4),
), ),
], ],
), ),
@@ -118,7 +118,7 @@ class DetailEventSection extends StatelessWidget {
), ),
child: Row( child: Row(
children: [ children: [
Icon( const Icon(
Icons.info_outline_rounded, Icons.info_outline_rounded,
color: AppColors.infoColor, color: AppColors.infoColor,
size: 20, size: 20,
@@ -127,7 +127,7 @@ class DetailEventSection extends StatelessWidget {
Expanded( Expanded(
child: Text( child: Text(
AppLocalizations.of(context).eventPriceHint, AppLocalizations.of(context).eventPriceHint,
style: TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
color: AppColors.darkNavy, color: AppColors.darkNavy,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -253,8 +253,8 @@ class _DiscountBadge extends StatelessWidget {
const SizedBox(width: 12), const SizedBox(width: 12),
Text( Text(
_getLocalizedDiscountAmount(context, currency, discountAmount), _getLocalizedDiscountAmount(context, currency, discountAmount),
style: TextStyle( style: const TextStyle(
color: const Color(0xFF15803D), color: Color(0xFF15803D),
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),

View File

@@ -49,11 +49,11 @@ class DetailFormSection extends StatelessWidget {
color: AppColors.glassBorder.withValues(alpha: 0.1), color: AppColors.glassBorder.withValues(alpha: 0.1),
width: 1, width: 1,
), ),
boxShadow: [ boxShadow: const [
BoxShadow( BoxShadow(
color: AppColors.shadowBlack, color: AppColors.shadowBlack,
blurRadius: 10, blurRadius: 10,
offset: const Offset(0, 4), offset: Offset(0, 4),
), ),
], ],
), ),

View File

@@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../../models/subscription_model.dart'; import '../../models/subscription_model.dart';
import '../../controllers/detail_screen_controller.dart'; import '../../controllers/detail_screen_controller.dart';
import '../../providers/locale_provider.dart'; import '../../providers/locale_provider.dart';

View File

@@ -41,11 +41,11 @@ class DetailUrlSection extends StatelessWidget {
color: AppColors.glassBorder.withValues(alpha: 0.1), color: AppColors.glassBorder.withValues(alpha: 0.1),
width: 1, width: 1,
), ),
boxShadow: [ boxShadow: const [
BoxShadow( BoxShadow(
color: AppColors.shadowBlack, color: AppColors.shadowBlack,
blurRadius: 10, blurRadius: 10,
offset: const Offset(0, 4), offset: Offset(0, 4),
), ),
], ],
), ),
@@ -89,7 +89,7 @@ class DetailUrlSection extends StatelessWidget {
label: AppLocalizations.of(context).websiteUrl, label: AppLocalizations.of(context).websiteUrl,
hintText: AppLocalizations.of(context).urlExample, hintText: AppLocalizations.of(context).urlExample,
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
prefixIcon: Icon( prefixIcon: const Icon(
Icons.link_rounded, Icons.link_rounded,
color: AppColors.navyGray, color: AppColors.navyGray,
), ),
@@ -114,7 +114,7 @@ class DetailUrlSection extends StatelessWidget {
children: [ children: [
Row( Row(
children: [ children: [
Icon( const Icon(
Icons.info_outline_rounded, Icons.info_outline_rounded,
color: AppColors.warningColor, color: AppColors.warningColor,
size: 20, size: 20,
@@ -122,7 +122,7 @@ class DetailUrlSection extends StatelessWidget {
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
AppLocalizations.of(context).cancelGuide, AppLocalizations.of(context).cancelGuide,
style: TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.darkNavy, color: AppColors.darkNavy,
@@ -133,7 +133,7 @@ class DetailUrlSection extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
AppLocalizations.of(context).cancelServiceGuide, AppLocalizations.of(context).cancelServiceGuide,
style: TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
color: AppColors.darkNavy, color: AppColors.darkNavy,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -167,7 +167,7 @@ class DetailUrlSection extends StatelessWidget {
), ),
child: Row( child: Row(
children: [ children: [
Icon( const Icon(
Icons.auto_fix_high_rounded, Icons.auto_fix_high_rounded,
color: AppColors.infoColor, color: AppColors.infoColor,
size: 20, size: 20,
@@ -176,7 +176,7 @@ class DetailUrlSection extends StatelessWidget {
Expanded( Expanded(
child: Text( child: Text(
AppLocalizations.of(context).urlAutoMatchInfo, AppLocalizations.of(context).urlAutoMatchInfo,
style: TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
color: AppColors.darkNavy, color: AppColors.darkNavy,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,

View File

@@ -111,7 +111,7 @@ class EmptyStateWidget extends StatelessWidget {
}, },
child: Text( child: Text(
AppLocalizations.of(context).addSubscription, AppLocalizations.of(context).addSubscription,
style: TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: 0.5, letterSpacing: 0.5,

View File

@@ -163,7 +163,7 @@ class _GlassmorphicScaffoldState extends State<GlassmorphicScaffold>
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: gradientColors colors: gradientColors
.map((color) => color.withOpacity(0.3)) .map((color) => color.withValues(alpha: 0.3))
.toList(), .toList(),
), ),
), ),

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../utils/logger.dart';
import 'dart:ui'; import 'dart:ui';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import 'themed_text.dart'; import 'themed_text.dart';
@@ -74,12 +75,12 @@ class GlassmorphismCard extends StatelessWidget {
), ),
boxShadow: boxShadow ?? boxShadow: boxShadow ??
[ [
BoxShadow( const BoxShadow(
color: AppColors color: AppColors
.shadowBlack, // color.md 가이드: rgba(0,0,0,0.08) .shadowBlack, // color.md 가이드: rgba(0,0,0,0.08)
blurRadius: 20, blurRadius: 20,
spreadRadius: -5, spreadRadius: -5,
offset: const Offset(0, 10), offset: Offset(0, 10),
), ),
], ],
), ),
@@ -200,7 +201,7 @@ class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
_handleTapUp(details); _handleTapUp(details);
// onTap 콜백 실행 // onTap 콜백 실행
if (widget.onTap != null) { if (widget.onTap != null) {
print('[AnimatedGlassmorphismCard] onTap 콜백 실행'); Log.d('[AnimatedGlassmorphismCard] onTap 콜백 실행');
widget.onTap!(); widget.onTap!();
} }
}, },

View File

@@ -90,7 +90,7 @@ class MainScreenSummaryCard extends StatelessWidget {
Text( Text(
AppLocalizations.of(context) AppLocalizations.of(context)
.monthlyTotalSubscriptionCost, .monthlyTotalSubscriptionCost,
style: TextStyle( style: const TextStyle(
color: AppColors color: AppColors
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트 .darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
fontSize: 15, fontSize: 15,
@@ -215,7 +215,7 @@ class MainScreenSummaryCard extends StatelessWidget {
context, context,
title: AppLocalizations.of(context) title: AppLocalizations.of(context)
.estimatedAnnualCost, .estimatedAnnualCost,
value: '${NumberFormat.currency( value: NumberFormat.currency(
locale: defaultCurrency == 'KRW' locale: defaultCurrency == 'KRW'
? 'ko_KR' ? 'ko_KR'
: defaultCurrency == 'JPY' : defaultCurrency == 'JPY'
@@ -225,7 +225,7 @@ class MainScreenSummaryCard extends StatelessWidget {
: 'en_US', : 'en_US',
symbol: currencySymbol, symbol: currencySymbol,
decimalDigits: decimals, decimalDigits: decimals,
).format(yearlyCost)}', ).format(yearlyCost),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
_buildInfoBox( _buildInfoBox(
@@ -282,7 +282,7 @@ class MainScreenSummaryCard extends StatelessWidget {
Text( Text(
AppLocalizations.of(context) AppLocalizations.of(context)
.eventDiscountActive, .eventDiscountActive,
style: TextStyle( style: const TextStyle(
color: AppColors color: AppColors
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트 .darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
fontSize: 11, fontSize: 11,
@@ -373,7 +373,7 @@ class MainScreenSummaryCard extends StatelessWidget {
children: [ children: [
Text( Text(
title, title,
style: TextStyle( style: const TextStyle(
color: AppColors.navyGray, // color.md 가이드: 밝은 배경 위 서브 텍스트 color: AppColors.navyGray, // color.md 가이드: 밝은 배경 위 서브 텍스트
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,

View File

@@ -14,7 +14,7 @@ class ScanLoadingWidget extends StatelessWidget {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
CircularProgressIndicator( const CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(AppColors.primaryColor), valueColor: AlwaysStoppedAnimation<Color>(AppColors.primaryColor),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';
import '../providers/category_provider.dart'; import '../providers/category_provider.dart';
@@ -202,8 +201,9 @@ class _SubscriptionCardState extends State<SubscriptionCard>
daysUntilNext = 7; // 다음 주 같은 요일 daysUntilNext = 7; // 다음 주 같은 요일
} }
if (daysUntilNext == 0) if (daysUntilNext == 0) {
return AppLocalizations.of(context).paymentDueToday; return AppLocalizations.of(context).paymentDueToday;
}
return AppLocalizations.of(context).paymentDueInDays(daysUntilNext); return AppLocalizations.of(context).paymentDueInDays(daysUntilNext);
} }
@@ -303,8 +303,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
width: double.infinity, // 전체 너비를 차지하도록 설정 width: double.infinity, // 전체 너비를 차지하도록 설정
onTap: widget.onTap ?? onTap: widget.onTap ??
() async { () async {
print( // ignore: use_build_context_synchronously
'[SubscriptionCard] AnimatedGlassmorphismCard onTap 호출됨 - ${widget.subscription.serviceName}');
await AppNavigator.toDetail(context, widget.subscription); await AppNavigator.toDetail(context, widget.subscription);
}, },
child: Column( child: Column(

View File

@@ -12,6 +12,7 @@ import '../services/subscription_url_matcher.dart';
import './dialogs/delete_confirmation_dialog.dart'; import './dialogs/delete_confirmation_dialog.dart';
import './common/snackbar/app_snackbar.dart'; import './common/snackbar/app_snackbar.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../utils/logger.dart';
/// 카테고리별로 구독 목록을 표시하는 위젯 /// 카테고리별로 구독 목록을 표시하는 위젯
class SubscriptionListWidget extends StatelessWidget { class SubscriptionListWidget extends StatelessWidget {
@@ -100,7 +101,7 @@ class SubscriptionListWidget extends StatelessWidget {
child: SwipeableSubscriptionCard( child: SwipeableSubscriptionCard(
subscription: subscriptions[subIndex], subscription: subscriptions[subIndex],
onTap: () { onTap: () {
print( Log.d(
'[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨'); '[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨');
AppNavigator.toDetail( AppNavigator.toDetail(
context, subscriptions[subIndex]); context, subscriptions[subIndex]);
@@ -122,13 +123,15 @@ class SubscriptionListWidget extends StatelessWidget {
); );
// 삭제 확인 다이얼로그 표시 // 삭제 확인 다이얼로그 표시
if (!context.mounted) return;
final shouldDelete = final shouldDelete =
await DeleteConfirmationDialog.show( await DeleteConfirmationDialog.show(
context: context, context: context,
serviceName: displayName, serviceName: displayName,
); );
if (!context.mounted) return;
if (shouldDelete && context.mounted) { if (shouldDelete) {
// 사용자가 확인한 경우에만 삭제 진행 // 사용자가 확인한 경우에만 삭제 진행
final provider = final provider =
Provider.of<SubscriptionProvider>( Provider.of<SubscriptionProvider>(

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';
import '../utils/haptic_feedback_helper.dart'; import '../utils/haptic_feedback_helper.dart';
import 'subscription_card.dart'; import 'subscription_card.dart';
@@ -29,7 +28,6 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
static const double _tapTolerance = 20.0; // 탭 허용 범위 static const double _tapTolerance = 20.0; // 탭 허용 범위
static const double _actionThresholdPercent = 0.15; static const double _actionThresholdPercent = 0.15;
static const double _deleteThresholdPercent = 0.40; static const double _deleteThresholdPercent = 0.40;
static const int _tapDurationMs = 500;
static const double _velocityThreshold = 800.0; static const double _velocityThreshold = 800.0;
// static const double _animationDuration = 300.0; // static const double _animationDuration = 300.0;
@@ -39,8 +37,7 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
// 제스처 추적 // 제스처 추적
Offset? _startPosition; Offset? _startPosition;
DateTime? _startTime; // 제스처 관련 보조 변수(간소화)
bool _isValidTap = true;
// 상태 관리 // 상태 관리
double _currentOffset = 0; double _currentOffset = 0;
@@ -95,8 +92,6 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
// 제스처 핸들러 // 제스처 핸들러
void _handlePanStart(DragStartDetails details) { void _handlePanStart(DragStartDetails details) {
_startPosition = details.localPosition; _startPosition = details.localPosition;
_startTime = DateTime.now();
_isValidTap = true;
_hapticTriggered = false; _hapticTriggered = false;
_controller.stop(); _controller.stop();
} }
@@ -104,12 +99,7 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
void _handlePanUpdate(DragUpdateDetails details) { void _handlePanUpdate(DragUpdateDetails details) {
final currentPosition = details.localPosition; final currentPosition = details.localPosition;
final delta = currentPosition.dx - _startPosition!.dx; final delta = currentPosition.dx - _startPosition!.dx;
final distance = (currentPosition - _startPosition!).distance; // 탭/스와이프 판별 거리는 외부에서 사용하지 않아 제거
// 탭 유효성 검사 - 거리가 허용 범위를 벗어나면 스와이프로 간주
if (distance > _tapTolerance) {
_isValidTap = false;
}
// 카드 이동 // 카드 이동
setState(() { setState(() {
@@ -129,14 +119,7 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
} }
// 헬퍼 메서드 // 헬퍼 메서드
void _processTap() { // 탭 처리는 SubscriptionCard에서 수행
print('[SwipeableSubscriptionCard] _processTap 호출됨');
if (widget.onTap != null) {
print('[SwipeableSubscriptionCard] onTap 콜백 실행');
widget.onTap!();
}
_animateToOffset(0);
}
void _processSwipe(double velocity) { void _processSwipe(double velocity) {
final extent = _currentOffset.abs(); final extent = _currentOffset.abs();

View File

@@ -0,0 +1,12 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:submanager/services/exchange_rate_service.dart';
void main() {
test('USD -> KRW conversion returns non-null using defaults when offline',
() async {
final service = ExchangeRateService();
final krw = await service.convertUsdToTarget(1.0, 'KRW');
expect(krw, isNotNull);
expect(krw, greaterThan(0));
});
}

View File

@@ -0,0 +1,19 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:submanager/services/subscription_url_matcher.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('extractDomain parses host correctly', () async {
await SubscriptionUrlMatcher.initialize();
final domain =
SubscriptionUrlMatcher.extractDomain('https://www.netflix.com/kr');
expect(domain, 'netflix');
});
test('findMatchingUrl finds known service', () async {
await SubscriptionUrlMatcher.initialize();
final url = SubscriptionUrlMatcher.findMatchingUrl('넷플릭스');
expect(url, isNotNull);
});
}