From d37f66d52629130868eb934be0c33607af192b7a Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Sun, 7 Sep 2025 21:32:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(settings):=20SMS=20=EC=9D=BD=EA=B8=B0=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=EC=83=81=ED=83=9C/=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=EC=9C=84=EC=A0=AF=20=EC=B6=94=EA=B0=80=20(Android)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 설정 화면에 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 권한 온보딩/설정 문구 현지화 키 추가 --- android/app/src/main/AndroidManifest.xml | 1 - assets/data/text.json | 24 +++- .../add_subscription_controller.dart | 3 +- lib/controllers/detail_screen_controller.dart | 5 +- lib/controllers/sms_scan_controller.dart | 39 +++---- lib/l10n/app_localizations.dart | 34 +++++- lib/main.dart | 5 +- lib/providers/subscription_provider.dart | 22 ++-- lib/screens/analysis_screen.dart | 4 +- lib/screens/app_lock_screen.dart | 8 +- lib/screens/category_management_screen.dart | 24 ++-- lib/screens/detail_screen.dart | 2 +- lib/screens/settings_screen.dart | 104 +++++++++++------- lib/screens/sms_permission_screen.dart | 34 +++--- lib/screens/sms_scan_screen.dart | 1 - lib/screens/splash_screen.dart | 6 +- lib/services/exchange_rate_service.dart | 33 ++++-- lib/services/notification_service.dart | 17 ++- .../sms_scan/subscription_converter.dart | 3 + .../sms_scan/subscription_filter.dart | 5 +- lib/services/sms_scanner.dart | 39 +++---- .../data/service_data_repository.dart | 5 +- .../services/category_mapper_service.dart | 27 +++-- .../services/url_matcher_service.dart | 25 +++-- lib/temp/test_sms_data.dart | 3 +- lib/theme/adaptive_theme.dart | 5 +- lib/theme/app_theme.dart | 33 +++--- lib/utils/logger.dart | 27 +++++ lib/utils/memory_manager.dart | 7 +- lib/utils/performance_optimizer.dart | 19 ++-- lib/utils/subscription_category_helper.dart | 1 - .../add_subscription_event_section.dart | 9 +- lib/widgets/analysis/analysis_badge.dart | 3 +- lib/widgets/app_navigator.dart | 7 +- .../common/buttons/secondary_button.dart | 1 - .../common/dialogs/loading_overlay.dart | 4 +- .../common/form_fields/base_text_field.dart | 6 +- .../common/form_fields/date_picker_field.dart | 4 +- lib/widgets/common/snackbar/app_snackbar.dart | 2 +- lib/widgets/detail/detail_event_section.dart | 12 +- lib/widgets/detail/detail_form_section.dart | 4 +- lib/widgets/detail/detail_header_section.dart | 1 - lib/widgets/detail/detail_url_section.dart | 16 +-- lib/widgets/empty_state_widget.dart | 2 +- lib/widgets/glassmorphic_scaffold.dart | 2 +- lib/widgets/glassmorphism_card.dart | 7 +- lib/widgets/main_summary_card.dart | 10 +- lib/widgets/sms_scan/scan_loading_widget.dart | 2 +- lib/widgets/subscription_card.dart | 7 +- lib/widgets/subscription_list_widget.dart | 7 +- lib/widgets/swipeable_subscription_card.dart | 23 +--- test/exchange_rate_service_test.dart | 12 ++ test/url_matcher_test.dart | 19 ++++ 53 files changed, 435 insertions(+), 290 deletions(-) create mode 100644 lib/utils/logger.dart create mode 100644 test/exchange_rate_service_test.dart create mode 100644 test/url_matcher_test.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 8ae03d5..c6eaf9c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,5 @@ - (context, listen: false); final existingSubscriptions = provider.subscriptions; - print('기존 구독: ${existingSubscriptions.length}개'); + Log.d('기존 구독: ${existingSubscriptions.length}개'); // 중복 구독 필터링 final filteredSubscriptions = _filter.filterDuplicates(repeatSubscriptions, existingSubscriptions); - print('중복 제거 후 구독: ${filteredSubscriptions.length}개'); + Log.d('중복 제거 후 구독: ${filteredSubscriptions.length}개'); if (filteredSubscriptions.isNotEmpty) { - print( + Log.d( '첫 번째 필터링된 구독: ${filteredSubscriptions[0].serviceName}, 반복 횟수: ${filteredSubscriptions[0].repeatCount}'); } // 중복 제거 후 신규 구독이 없는 경우 if (filteredSubscriptions.isEmpty) { - print('중복 제거 후 신규 구독이 없음'); + Log.i('중복 제거 후 신규 구독이 없음'); _isLoading = false; notifyListeners(); return; @@ -129,7 +129,7 @@ class SmsScanController extends ChangeNotifier { websiteUrlController.text = ''; // URL 입력 필드 초기화 notifyListeners(); } catch (e) { - print('SMS 스캔 중 오류 발생: $e'); + Log.e('SMS 스캔 중 오류 발생', e); if (context.mounted) { _errorMessage = AppLocalizations.of(context).smsScanErrorWithMessage(e.toString()); @@ -159,7 +159,7 @@ class SmsScanController extends ChangeNotifier { ? websiteUrlController.text.trim() : subscription.websiteUrl; - print( + Log.d( '구독 추가 시도: ${subscription.serviceName}, 카테고리: $finalCategoryId, URL: $websiteUrl'); // addSubscription 호출 @@ -176,19 +176,20 @@ class SmsScanController extends ChangeNotifier { currency: subscription.currency, ); - print('구독 추가 성공: ${subscription.serviceName}'); - + Log.i('구독 추가 성공: ${subscription.serviceName}'); + if (!context.mounted) return; moveToNextSubscription(context); } catch (e) { - print('구독 추가 중 오류 발생: $e'); + Log.e('구독 추가 중 오류 발생', e); // 오류가 있어도 다음 구독으로 이동 + if (!context.mounted) return; moveToNextSubscription(context); } } void skipCurrentSubscription(BuildContext context) { final subscription = _scannedSubscriptions[_currentIndex]; - print('구독 건너뛰기: ${subscription.serviceName}'); + Log.i('구독 건너뛰기: ${subscription.serviceName}'); moveToNextSubscription(context); } @@ -224,7 +225,7 @@ class SmsScanController extends ChangeNotifier { (cat) => cat.name == 'other', orElse: () => categoryProvider.categories.first, ); - print('기본 카테고리 설정: ${otherCategory.name} (ID: ${otherCategory.id})'); + Log.d('기본 카테고리 설정: ${otherCategory.name} (ID: ${otherCategory.id})'); return otherCategory.id; } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 49ae593..8ea4702 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -63,6 +63,28 @@ class AppLocalizations { String get notifications => _localizedStrings['notifications'] ?? 'Notifications'; 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 => _localizedStrings['notificationPermission'] ?? 'Notification Permission'; @@ -308,11 +330,11 @@ class AppLocalizations { String subscriptionCount(int count) { if (locale.languageCode == 'ko') { - return '${count}개'; + return '$count개'; } else if (locale.languageCode == 'ja') { - return '${count}個'; + return '$count個'; } else if (locale.languageCode == 'zh') { - return '${count}个'; + return '$count个'; } else { return count.toString(); } @@ -444,11 +466,11 @@ class AppLocalizations { String servicesInProgress(int count) { if (locale.languageCode == 'ko') { - return '${count}개 진행중'; + return '$count개 진행중'; } else if (locale.languageCode == 'ja') { - return '${count}個進行中'; + return '$count個進行中'; } else if (locale.languageCode == 'zh') { - return '${count}个进行中'; + return '$count个进行中'; } else { return '$count in progress'; } diff --git a/lib/main.dart b/lib/main.dart index 9303428..164e17f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -22,6 +22,7 @@ import 'package:google_mobile_ads/google_mobile_ads.dart'; import 'dart:io' show Platform; import 'dart:async' show unawaited; import 'utils/memory_manager.dart'; +import 'utils/logger.dart'; import 'utils/performance_optimizer.dart'; import 'navigator_key.dart'; @@ -48,12 +49,12 @@ Future main() async { await DefaultCacheManager().emptyCache(); if (kDebugMode) { - print('이미지 캐시 관리 초기화 완료'); + Log.d('이미지 캐시 관리 초기화 완료'); PerformanceOptimizer.checkConstOptimization(); } } catch (e) { if (kDebugMode) { - print('캐시 초기화 오류: $e'); + Log.e('캐시 초기화 오류', e); } } diff --git a/lib/providers/subscription_provider.dart b/lib/providers/subscription_provider.dart index 897e410..7efc654 100644 --- a/lib/providers/subscription_provider.dart +++ b/lib/providers/subscription_provider.dart @@ -28,7 +28,7 @@ class SubscriptionProvider extends ChangeNotifier { final price = subscription.currentPrice; if (subscription.currency == 'USD') { debugPrint('[SubscriptionProvider] ${subscription.serviceName}: ' - '\$${price} × ₩$rate = ₩${price * rate}'); + '\$$price × ₩$rate = ₩${price * rate}'); return sum + (price * rate); } debugPrint( @@ -264,7 +264,7 @@ class SubscriptionProvider extends ChangeNotifier { for (final subscription in _subscriptions) { final currentPrice = subscription.currentPrice; debugPrint('[calculateTotalExpense] ${subscription.serviceName}: ' - '${currentPrice} ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})'); + '$currentPrice ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})'); final converted = await ExchangeRateService().convertBetweenCurrencies( currentPrice, @@ -310,7 +310,7 @@ class SubscriptionProvider extends ChangeNotifier { final cost = subscription.currentPrice; debugPrint( '[getMonthlyExpenseData] 현재 월 - ${subscription.serviceName}: ' - '${cost} ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})'); + '$cost ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})'); // 통화 변환 final converted = @@ -508,19 +508,17 @@ class SubscriptionProvider extends ChangeNotifier { .id; } - if (categoryId != null) { - subscription.categoryId = categoryId; - await subscription.save(); - migratedCount++; - final categoryName = - categories.firstWhere((cat) => cat.id == categoryId).name; - debugPrint('✅ ${subscription.serviceName} → $categoryName'); - } + subscription.categoryId = categoryId; + await subscription.save(); + migratedCount++; + final categoryName = + categories.firstWhere((cat) => cat.id == categoryId).name; + debugPrint('✅ ${subscription.serviceName} → $categoryName'); } } if (migratedCount > 0) { - debugPrint('❎ 총 ${migratedCount}개의 구독에 categoryId 할당 완료'); + debugPrint('❎ 총 $migratedCount개의 구독에 categoryId 할당 완료'); await refreshSubscriptions(); } else { debugPrint('❎ 모든 구독이 이미 categoryId를 가지고 있습니다'); diff --git a/lib/screens/analysis_screen.dart b/lib/screens/analysis_screen.dart index a8ad53b..8d3ae18 100644 --- a/lib/screens/analysis_screen.dart +++ b/lib/screens/analysis_screen.dart @@ -169,7 +169,7 @@ class _AnalysisScreenState extends State // 2. 총 지출 요약 카드 TotalExpenseSummaryCard( - key: ValueKey('total_expense_${_lastDataHash}'), + key: ValueKey('total_expense_$_lastDataHash'), subscriptions: subscriptions, totalExpense: _totalExpense, animationController: _animationController, @@ -179,7 +179,7 @@ class _AnalysisScreenState extends State // 3. 월별 지출 차트 MonthlyExpenseChartCard( - key: ValueKey('monthly_expense_${_lastDataHash}'), + key: ValueKey('monthly_expense_$_lastDataHash'), monthlyData: _monthlyData, animationController: _animationController, ), diff --git a/lib/screens/app_lock_screen.dart b/lib/screens/app_lock_screen.dart index 1e139a7..db8bd89 100644 --- a/lib/screens/app_lock_screen.dart +++ b/lib/screens/app_lock_screen.dart @@ -13,13 +13,13 @@ class AppLockScreen extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( + const Icon( Icons.lock_outline, size: 80, color: AppColors.navyGray, ), const SizedBox(height: 24), - Text( + const Text( '앱이 잠겨 있습니다', style: TextStyle( fontSize: 24, @@ -28,7 +28,7 @@ class AppLockScreen extends StatelessWidget { ), ), const SizedBox(height: 16), - Text( + const Text( '생체 인증으로 잠금을 해제하세요', style: TextStyle( fontSize: 16, @@ -42,7 +42,7 @@ class AppLockScreen extends StatelessWidget { final success = await appLock.authenticate(); if (!success && context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( + const SnackBar( content: Text( '인증에 실패했습니다. 다시 시도해주세요.', style: TextStyle( diff --git a/lib/screens/category_management_screen.dart b/lib/screens/category_management_screen.dart index ad8bbb4..f998ec9 100644 --- a/lib/screens/category_management_screen.dart +++ b/lib/screens/category_management_screen.dart @@ -43,7 +43,7 @@ class _CategoryManagementScreenState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: Text( + title: const Text( '카테고리 관리', style: TextStyle( color: AppColors.pureWhite, @@ -66,7 +66,7 @@ class _CategoryManagementScreenState extends State { children: [ TextFormField( controller: _nameController, - decoration: InputDecoration( + decoration: const InputDecoration( labelText: '카테고리 이름', labelStyle: TextStyle( color: AppColors.navyGray, @@ -82,7 +82,7 @@ class _CategoryManagementScreenState extends State { const SizedBox(height: 16), DropdownButtonFormField( value: _selectedColor, - decoration: InputDecoration( + decoration: const InputDecoration( labelText: '색상 선택', labelStyle: TextStyle( color: AppColors.navyGray, @@ -94,31 +94,31 @@ class _CategoryManagementScreenState extends State { child: Text( AppLocalizations.of(context).colorBlue, style: - TextStyle(color: AppColors.darkNavy))), + const TextStyle(color: AppColors.darkNavy))), DropdownMenuItem( value: '#4CAF50', child: Text( AppLocalizations.of(context).colorGreen, style: - TextStyle(color: AppColors.darkNavy))), + const TextStyle(color: AppColors.darkNavy))), DropdownMenuItem( value: '#FF9800', child: Text( AppLocalizations.of(context).colorOrange, style: - TextStyle(color: AppColors.darkNavy))), + const TextStyle(color: AppColors.darkNavy))), DropdownMenuItem( value: '#F44336', child: Text( AppLocalizations.of(context).colorRed, style: - TextStyle(color: AppColors.darkNavy))), + const TextStyle(color: AppColors.darkNavy))), DropdownMenuItem( value: '#9C27B0', child: Text( AppLocalizations.of(context).colorPurple, style: - TextStyle(color: AppColors.darkNavy))), + const TextStyle(color: AppColors.darkNavy))), ], onChanged: (value) { setState(() { @@ -129,13 +129,13 @@ class _CategoryManagementScreenState extends State { const SizedBox(height: 16), DropdownButtonFormField( value: _selectedIcon, - decoration: InputDecoration( + decoration: const InputDecoration( labelText: '아이콘 선택', labelStyle: TextStyle( color: AppColors.navyGray, ), ), - items: [ + items: const [ DropdownMenuItem( value: 'subscriptions', child: Text('구독', @@ -171,7 +171,7 @@ class _CategoryManagementScreenState extends State { const SizedBox(height: 16), ElevatedButton( onPressed: _addCategory, - child: Text( + child: const Text( '카테고리 추가', style: TextStyle( color: AppColors.pureWhite, @@ -201,7 +201,7 @@ class _CategoryManagementScreenState extends State { title: Text( provider.getLocalizedCategoryName( context, category.name), - style: TextStyle( + style: const TextStyle( color: AppColors.darkNavy, ), ), diff --git a/lib/screens/detail_screen.dart b/lib/screens/detail_screen.dart index 2cad294..7dab203 100644 --- a/lib/screens/detail_screen.dart +++ b/lib/screens/detail_screen.dart @@ -111,7 +111,7 @@ class _DetailScreenState extends State Text( AppLocalizations.of(context) .changesAppliedAfterSave, - style: TextStyle( + style: const TextStyle( fontSize: 14, color: AppColors.darkNavy, ), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index bace00b..4fff2ee 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -5,7 +5,6 @@ import '../providers/notification_provider.dart'; import 'dart:io'; import '../services/notification_service.dart'; import 'package:url_launcher/url_launcher.dart'; -import '../theme/adaptive_theme.dart'; import '../widgets/glassmorphism_card.dart'; import '../theme/app_colors.dart'; import '../widgets/native_ad_widget.dart'; @@ -230,6 +229,7 @@ class SettingsScreen extends StatelessWidget { if (granted) { await provider.setEnabled(true); } else { + if (!context.mounted) return; AppSnackBar.showError( context: context, message: AppLocalizations.of(context) @@ -273,7 +273,7 @@ class SettingsScreen extends StatelessWidget { elevation: 0, color: Theme.of(context) .colorScheme - .surfaceVariant + .surfaceContainerHighest .withValues(alpha: 0.3), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), @@ -414,7 +414,7 @@ class SettingsScreen extends StatelessWidget { decoration: BoxDecoration( color: Theme.of(context) .colorScheme - .surfaceVariant + .surfaceContainerHighest .withValues(alpha: 0.3), borderRadius: BorderRadius.circular(8), @@ -484,49 +484,77 @@ class SettingsScreen extends StatelessWidget { margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.all(8), - child: FutureBuilder( - future: SMSService.hasSMSPermission(), + child: FutureBuilder( + future: permission.Permission.sms.status, 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( leading: const Icon( Icons.sms, color: AppColors.textSecondary, ), - title: const Text( - 'SMS 권한', - style: TextStyle(color: AppColors.textPrimary), + title: Text( + AppLocalizations.of(context).smsPermissionLabel, + style: const TextStyle(color: AppColors.textPrimary), ), - subtitle: Text( - AppLocalizations.of(context).smsPermissionRequired, - style: - const TextStyle(color: AppColors.textSecondary), - ), - trailing: hasPermission - ? const Padding( - padding: EdgeInsets.symmetric(horizontal: 8.0), - child: Icon(Icons.check_circle, - color: Colors.green), + subtitle: !hasPermission + ? Text( + isPermanentlyDenied + ? AppLocalizations.of(context) + .permanentlyDeniedMessage + : AppLocalizations.of(context) + .smsPermissionRequired, + style: const TextStyle( + color: AppColors.textSecondary), ) - : ElevatedButton( - onPressed: () async { - final granted = - await SMSService.requestSMSPermission(); - if (!granted) { - final status = - await permission.Permission.sms.status; - if (status.isPermanentlyDenied) { - await permission.openAppSettings(); - } - } - if (context.mounted) { - // 상태 갱신을 위해 다시 build 트리거 - (context as Element).markNeedsBuild(); - } - }, - child: Text(AppLocalizations.of(context) - .requestPermission), - ), + : null, + trailing: isLoading + ? const SizedBox( + width: 20, + height: 20, + child: + CircularProgressIndicator(strokeWidth: 2), + ) + : hasPermission + ? const Padding( + padding: + EdgeInsets.symmetric(horizontal: 8.0), + child: Icon(Icons.check_circle, + color: Colors.green), + ) + : isPermanentlyDenied + ? TextButton( + 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), + ), ); }, ), diff --git a/lib/screens/sms_permission_screen.dart b/lib/screens/sms_permission_screen.dart index b1dd8c3..01486e4 100644 --- a/lib/screens/sms_permission_screen.dart +++ b/lib/screens/sms_permission_screen.dart @@ -63,18 +63,18 @@ class _SmsPermissionScreenState extends State { context: context, builder: (_) => AlertDialog( title: Text(loc.smsPermissionRequired), - content: const Text('권한이 영구적으로 거부되었습니다. 설정에서 권한을 허용해주세요.'), + content: Text(loc.permanentlyDeniedMessage), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), - child: const Text('닫기'), + child: Text(AppLocalizations.of(context).cancel), ), TextButton( onPressed: () async { await permission.openAppSettings(); if (mounted) Navigator.of(context).pop(); }, - child: const Text('설정 열기'), + child: Text(loc.openSettings), ), ], ), @@ -95,7 +95,7 @@ class _SmsPermissionScreenState extends State { const Icon(Icons.sms, size: 64, color: AppColors.primaryColor), const SizedBox(height: 16), Text( - 'SMS 권한 요청', + loc.smsPermissionTitle, style: Theme.of(context).textTheme.headlineSmall?.copyWith( color: AppColors.textPrimary, fontWeight: FontWeight.bold, @@ -112,16 +112,16 @@ class _SmsPermissionScreenState extends State { padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: const [ - Text('이유:', - style: TextStyle(fontWeight: FontWeight.bold)), - SizedBox(height: 8), - Text('문자 메시지에서 반복 결제 내역을 분석해 구독 서비스를 자동으로 탐지합니다.'), - SizedBox(height: 12), - Text('수집 범위:', - style: TextStyle(fontWeight: FontWeight.bold)), - SizedBox(height: 8), - Text('결제 관련 문자 메시지(서비스명/금액/날짜 패턴)를 로컬에서만 처리합니다.'), + children: [ + Text(loc.smsPermissionReasonTitle, + style: const TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Text(loc.smsPermissionReasonBody), + const SizedBox(height: 12), + Text(loc.smsPermissionScopeTitle, + style: const TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Text(loc.smsPermissionScopeBody), ], ), ), @@ -131,15 +131,15 @@ class _SmsPermissionScreenState extends State { child: ElevatedButton.icon( onPressed: _requesting ? null : _handleRequest, icon: const Icon(Icons.lock_open), - label: - Text(_requesting ? '요청 중...' : loc.requestPermission), + label: Text( + _requesting ? loc.requesting : loc.requestPermission), ), ), const SizedBox(height: 8), TextButton( onPressed: () => Navigator.of(context) .pushReplacementNamed(AppRoutes.main), - child: const Text('나중에 하기'), + child: Text(loc.later), ) ], ), diff --git a/lib/screens/sms_scan_screen.dart b/lib/screens/sms_scan_screen.dart index 4b8ce06..d3c1f7a 100644 --- a/lib/screens/sms_scan_screen.dart +++ b/lib/screens/sms_scan_screen.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; import '../controllers/sms_scan_controller.dart'; import '../widgets/sms_scan/scan_loading_widget.dart'; import '../widgets/sms_scan/scan_initial_widget.dart'; diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart index cea021a..77ba8de 100644 --- a/lib/screens/splash_screen.dart +++ b/lib/screens/splash_screen.dart @@ -277,13 +277,13 @@ class _SplashScreenState extends State .withValues(alpha: 0.3), width: 1.5, ), - boxShadow: [ + boxShadow: const [ BoxShadow( color: AppColors.shadowBlack, spreadRadius: 0, blurRadius: 30, - offset: const Offset(0, 10), + offset: Offset(0, 10), ), ], ), @@ -398,7 +398,7 @@ class _SplashScreenState extends State width: 1, ), ), - child: CircularProgressIndicator( + child: const CircularProgressIndicator( valueColor: AlwaysStoppedAnimation( AppColors.pureWhite), strokeWidth: 3, diff --git a/lib/services/exchange_rate_service.dart b/lib/services/exchange_rate_service.dart index 2a61a72..eb3e9bc 100644 --- a/lib/services/exchange_rate_service.dart +++ b/lib/services/exchange_rate_service.dart @@ -1,6 +1,7 @@ import 'package:http/http.dart' as http; import 'dart:convert'; import 'package:intl/intl.dart'; +import '../utils/logger.dart'; /// 환율 정보 서비스 클래스 class ExchangeRateService { @@ -21,12 +22,20 @@ class ExchangeRateService { double? _usdToCnyRate; DateTime? _lastUpdated; - // API 요청 URL (ExchangeRate-API 사용) - final String _apiUrl = 'https://api.exchangerate-api.com/v4/latest/USD'; + // API 요청 URL (ExchangeRate-API 등) - 빌드 타임 오버라이드 가능 + 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; + // ignore: constant_identifier_names static const double DEFAULT_USD_TO_JPY_RATE = 150.0; + // ignore: constant_identifier_names static const double DEFAULT_USD_TO_CNY_RATE = 7.2; // 캐싱된 환율 반환 (동기적) @@ -44,18 +53,26 @@ class ExchangeRateService { } try { - // API 요청 + // API 요청 (네트워크 불가 환경에서는 예외 발생 가능) final response = await http.get(Uri.parse(_apiUrl)); if (response.statusCode == 200) { final data = json.decode(response.body); - _usdToKrwRate = data['rates']['KRW']?.toDouble(); - _usdToJpyRate = data['rates']['JPY']?.toDouble(); - _usdToCnyRate = data['rates']['CNY']?.toDouble(); + _usdToKrwRate = (data['rates']['KRW'] as num?)?.toDouble(); + _usdToJpyRate = (data['rates']['JPY'] as num?)?.toDouble(); + _usdToCnyRate = (data['rates']['CNY'] as num?)?.toDouble(); _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); } } diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 59ff18b..d4be196 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -1,7 +1,6 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:timezone/timezone.dart' as tz; import 'package:timezone/data/latest_all.dart' as tz; -import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; import 'dart:io' show Platform; import '../models/subscription_model.dart'; @@ -10,7 +9,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; class NotificationService { static final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin(); - static final _secureStorage = const FlutterSecureStorage(); + static const _secureStorage = FlutterSecureStorage(); static const _notificationEnabledKey = 'notification_enabled'; static const _paymentNotificationEnabledKey = 'payment_notification_enabled'; @@ -241,7 +240,7 @@ class NotificationService { priority: Priority.high, ); - final iosDetails = const DarwinNotificationDetails(); + const iosDetails = DarwinNotificationDetails(); // tz.local 초기화 확인 및 재시도 tz.Location location; @@ -266,10 +265,10 @@ class NotificationService { title, body, tz.TZDateTime.from(scheduledDate, location), - NotificationDetails(android: androidDetails, iOS: iosDetails), - androidAllowWhileIdle: true, + const NotificationDetails(android: androidDetails, iOS: iosDetails), uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation.absoluteTime, + androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, ); } catch (e) { debugPrint('알림 예약 중 오류 발생: $e'); @@ -351,9 +350,9 @@ class NotificationService { '${subscription.serviceName} 구독이 ${subscription.nextBillingDate.day}일 만료됩니다.', tz.TZDateTime.from(subscription.nextBillingDate, location), notificationDetails, - androidAllowWhileIdle: true, uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation.absoluteTime, + androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, ); } catch (e) { debugPrint('구독 알림 예약 중 오류 발생: $e'); @@ -416,9 +415,9 @@ class NotificationService { priority: Priority.high, ), ), - androidAllowWhileIdle: true, uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation.absoluteTime, + androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, ); } catch (e) { debugPrint('결제 알림 예약 중 오류 발생: $e'); @@ -456,7 +455,7 @@ class NotificationService { } await _notifications.zonedSchedule( - (subscription.id + '_expiration').hashCode, + ('${subscription.id}_expiration').hashCode, '구독 만료 예정 알림', '${subscription.serviceName} 구독이 7일 후 만료됩니다.', tz.TZDateTime.from(reminderDate, location), @@ -469,9 +468,9 @@ class NotificationService { priority: Priority.high, ), ), - androidAllowWhileIdle: true, uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation.absoluteTime, + androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, ); } catch (e) { debugPrint('만료 알림 예약 중 오류 발생: $e'); diff --git a/lib/services/sms_scan/subscription_converter.dart b/lib/services/sms_scan/subscription_converter.dart index 595d894..153045a 100644 --- a/lib/services/sms_scan/subscription_converter.dart +++ b/lib/services/sms_scan/subscription_converter.dart @@ -12,9 +12,12 @@ class SubscriptionConverter { final subscription = _convertSingle(model); result.add(subscription); + // 개발 편의를 위한 디버그 로그 + // ignore: avoid_print print( '모델 변환 성공: ${model.serviceName}, 카테고리ID: ${model.categoryId}, URL: ${model.websiteUrl}, 통화: ${model.currency}'); } catch (e) { + // ignore: avoid_print print('모델 변환 중 오류 발생: $e'); } } diff --git a/lib/services/sms_scan/subscription_filter.dart b/lib/services/sms_scan/subscription_filter.dart index 9a946b0..25f311f 100644 --- a/lib/services/sms_scan/subscription_filter.dart +++ b/lib/services/sms_scan/subscription_filter.dart @@ -1,11 +1,12 @@ import '../../models/subscription.dart'; import '../../models/subscription_model.dart'; +import '../../utils/logger.dart'; class SubscriptionFilter { // 중복 구독 필터링 (서비스명과 금액이 같으면 중복으로 간주) List filterDuplicates( List scanned, List existing) { - print( + Log.d( '_filterDuplicates: 스캔된 구독 ${scanned.length}개, 기존 구독 ${existing.length}개'); // 중복되지 않은 구독만 필터링 @@ -17,7 +18,7 @@ class SubscriptionFilter { final isSameCost = existingSub.monthlyCost == scannedSub.monthlyCost; if (isSameName && isSameCost) { - print( + Log.d( '중복 발견: ${scannedSub.serviceName} (${scannedSub.monthlyCost}원)'); return true; } diff --git a/lib/services/sms_scanner.dart b/lib/services/sms_scanner.dart index 723ce03..06282ac 100644 --- a/lib/services/sms_scanner.dart +++ b/lib/services/sms_scanner.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter_sms_inbox/flutter_sms_inbox.dart'; import '../models/subscription_model.dart'; +import '../utils/logger.dart'; import '../temp/test_sms_data.dart'; import '../services/subscription_url_matcher.dart'; import '../utils/platform_helper.dart'; @@ -11,26 +12,26 @@ class SmsScanner { Future> scanForSubscriptions() async { try { List smsList; - print('SmsScanner: 스캔 시작'); + Log.d('SmsScanner: 스캔 시작'); // 플랫폼별 분기 처리 if (kIsWeb) { // 웹 환경: 테스트 데이터 사용 - print('SmsScanner: 웹 환경에서 테스트 데이터 사용'); + Log.i('SmsScanner: 웹 환경에서 테스트 데이터 사용'); smsList = TestSmsData.getTestData(); - print('SmsScanner: 테스트 데이터 개수: ${smsList.length}'); + Log.d('SmsScanner: 테스트 데이터 개수: ${smsList.length}'); } else if (PlatformHelper.isIOS) { // iOS: SMS 접근 불가, 빈 리스트 반환 - print('SmsScanner: iOS에서는 SMS 스캔 불가'); + Log.w('SmsScanner: iOS에서는 SMS 스캔 불가'); return []; } else if (PlatformHelper.isAndroid) { // Android: flutter_sms_inbox 사용 - print('SmsScanner: Android에서 실제 SMS 스캔'); + Log.i('SmsScanner: Android에서 실제 SMS 스캔'); smsList = await _scanAndroidSms(); - print('SmsScanner: 스캔된 SMS 개수: ${smsList.length}'); + Log.d('SmsScanner: 스캔된 SMS 개수: ${smsList.length}'); } else { // 기타 플랫폼 - print('SmsScanner: 지원하지 않는 플랫폼'); + Log.w('SmsScanner: 지원하지 않는 플랫폼'); return []; } @@ -47,32 +48,32 @@ class SmsScanner { serviceGroups[serviceName]!.add(sms); } - print('SmsScanner: 그룹화된 서비스 수: ${serviceGroups.length}'); + Log.d('SmsScanner: 그룹화된 서비스 수: ${serviceGroups.length}'); // 그룹화된 데이터로 구독 분석 for (final entry in serviceGroups.entries) { - print('SmsScanner: 서비스 "${entry.key}" - 메시지 개수: ${entry.value.length}'); + Log.d('SmsScanner: 서비스 "${entry.key}" - 메시지 개수: ${entry.value.length}'); // 2회 이상 반복된 서비스만 구독으로 간주 if (entry.value.length >= 2) { final serviceSms = entry.value[0]; // 가장 최근 SMS 사용 final subscription = _parseSms(serviceSms, entry.value.length); if (subscription != null) { - print( + Log.i( 'SmsScanner: 구독 추가: ${subscription.serviceName}, 반복 횟수: ${subscription.repeatCount}'); subscriptions.add(subscription); } else { - print('SmsScanner: 구독 파싱 실패: ${entry.key}'); + Log.w('SmsScanner: 구독 파싱 실패: ${entry.key}'); } } else { - print('SmsScanner: 반복 횟수 부족, 구독으로 간주하지 않음: ${entry.key}'); + Log.d('SmsScanner: 반복 횟수 부족, 구독으로 간주하지 않음: ${entry.key}'); } } - print('SmsScanner: 최종 구독 개수: ${subscriptions.length}'); + Log.d('SmsScanner: 최종 구독 개수: ${subscriptions.length}'); return subscriptions; } catch (e) { - print('SmsScanner: 예외 발생: $e'); + Log.e('SmsScanner: 예외 발생', e); throw Exception('SMS 스캔 중 오류 발생: $e'); } } @@ -93,7 +94,7 @@ class SmsScanner { return smsList; } catch (e) { - print('SmsScanner: Android SMS 스캔 실패: $e'); + Log.e('SmsScanner: Android SMS 스캔 실패', e); return []; } } @@ -160,7 +161,7 @@ class SmsScanner { 'previousPaymentDate': date.toIso8601String(), }; } catch (e) { - print('SmsScanner: SMS 파싱 실패: $e'); + Log.e('SmsScanner: SMS 파싱 실패', e); return null; } } @@ -281,7 +282,7 @@ class SmsScanner { 'Spotify Premium' ]; if (dollarServices.any((service) => serviceName.contains(service))) { - print('서비스명 $serviceName으로 USD 통화 단위 확정'); + Log.d('서비스명 $serviceName으로 USD 통화 단위 확정'); currency = 'USD'; } @@ -411,7 +412,7 @@ class SmsScanner { // 서비스명 기반 통화 단위 확인 for (final service in serviceCurrencyMap.keys) { if (message.contains(service)) { - print('_detectCurrency: ${service}는 USD 서비스로 판별됨'); + Log.d('_detectCurrency: $service는 USD 서비스로 판별됨'); return 'USD'; } } @@ -419,7 +420,7 @@ class SmsScanner { // 메시지에 달러 관련 키워드가 있는지 확인 for (final keyword in dollarKeywords) { if (message.toLowerCase().contains(keyword.toLowerCase())) { - print('_detectCurrency: USD 키워드 발견: $keyword'); + Log.d('_detectCurrency: USD 키워드 발견: $keyword'); return 'USD'; } } diff --git a/lib/services/url_matcher/data/service_data_repository.dart b/lib/services/url_matcher/data/service_data_repository.dart index 25ab7f9..43a51e2 100644 --- a/lib/services/url_matcher/data/service_data_repository.dart +++ b/lib/services/url_matcher/data/service_data_repository.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'package:flutter/services.dart'; +import '../../../utils/logger.dart'; /// 서비스 데이터를 관리하는 저장소 클래스 class ServiceDataRepository { @@ -15,9 +16,9 @@ class ServiceDataRepository { await rootBundle.loadString('assets/data/subscription_services.json'); _servicesData = json.decode(jsonString); _isInitialized = true; - print('ServiceDataRepository: JSON 데이터 로드 완료'); + Log.i('ServiceDataRepository: JSON 데이터 로드 완료'); } catch (e) { - print('ServiceDataRepository: JSON 로드 실패 - $e'); + Log.w('ServiceDataRepository: JSON 로드 실패 - $e'); // 로드 실패시 기존 하드코딩 데이터 사용 _isInitialized = true; } diff --git a/lib/services/url_matcher/services/category_mapper_service.dart b/lib/services/url_matcher/services/category_mapper_service.dart index 83c3de4..c407697 100644 --- a/lib/services/url_matcher/services/category_mapper_service.dart +++ b/lib/services/url_matcher/services/category_mapper_service.dart @@ -75,24 +75,33 @@ class CategoryMapperService { String getCategoryForLegacyService(String serviceName) { final lowerName = serviceName.toLowerCase(); - if (LegacyServiceData.ottServices.containsKey(lowerName)) + if (LegacyServiceData.ottServices.containsKey(lowerName)) { return 'ott_services'; - if (LegacyServiceData.musicServices.containsKey(lowerName)) + } + if (LegacyServiceData.musicServices.containsKey(lowerName)) { return 'music_streaming'; - if (LegacyServiceData.storageServices.containsKey(lowerName)) + } + if (LegacyServiceData.storageServices.containsKey(lowerName)) { return 'cloud_storage'; - if (LegacyServiceData.aiServices.containsKey(lowerName)) + } + if (LegacyServiceData.aiServices.containsKey(lowerName)) { return 'ai_services'; - if (LegacyServiceData.programmingServices.containsKey(lowerName)) + } + if (LegacyServiceData.programmingServices.containsKey(lowerName)) { return 'dev_tools'; - if (LegacyServiceData.officeTools.containsKey(lowerName)) + } + if (LegacyServiceData.officeTools.containsKey(lowerName)) { return 'office_tools'; - if (LegacyServiceData.lifestyleServices.containsKey(lowerName)) + } + if (LegacyServiceData.lifestyleServices.containsKey(lowerName)) { return 'lifestyle'; - if (LegacyServiceData.shoppingServices.containsKey(lowerName)) + } + if (LegacyServiceData.shoppingServices.containsKey(lowerName)) { return 'shopping'; - if (LegacyServiceData.telecomServices.containsKey(lowerName)) + } + if (LegacyServiceData.telecomServices.containsKey(lowerName)) { return 'telecom'; + } return 'other'; } diff --git a/lib/services/url_matcher/services/url_matcher_service.dart b/lib/services/url_matcher/services/url_matcher_service.dart index 6df9b7c..8282994 100644 --- a/lib/services/url_matcher/services/url_matcher_service.dart +++ b/lib/services/url_matcher/services/url_matcher_service.dart @@ -2,6 +2,7 @@ import '../models/service_info.dart'; import '../data/service_data_repository.dart'; import '../data/legacy_service_data.dart'; import 'category_mapper_service.dart'; +import '../../../utils/logger.dart'; /// URL 매칭 관련 기능을 제공하는 서비스 클래스 class UrlMatcherService { @@ -35,7 +36,7 @@ class UrlMatcherService { return null; } catch (e) { - print('UrlMatcherService: 도메인 추출 실패 - $e'); + Log.e('UrlMatcherService: 도메인 추출 실패', e); return null; } } @@ -107,7 +108,7 @@ class UrlMatcherService { /// 서비스명으로 URL 찾기 String? suggestUrl(String serviceName) { if (serviceName.isEmpty) { - print('UrlMatcherService: 빈 serviceName'); + Log.w('UrlMatcherService: 빈 serviceName'); return null; } @@ -118,7 +119,7 @@ class UrlMatcherService { // 정확한 매칭을 먼저 시도 for (final entry in LegacyServiceData.allServices.entries) { if (lowerName.contains(entry.key.toLowerCase())) { - print('UrlMatcherService: 정확한 매칭 - $lowerName -> ${entry.key}'); + Log.d('UrlMatcherService: 정확한 매칭 - $lowerName -> ${entry.key}'); return entry.value; } } @@ -126,7 +127,7 @@ class UrlMatcherService { // OTT 서비스 검사 for (final entry in LegacyServiceData.ottServices.entries) { if (lowerName.contains(entry.key.toLowerCase())) { - print('UrlMatcherService: OTT 서비스 매칭 - $lowerName -> ${entry.key}'); + Log.d('UrlMatcherService: OTT 서비스 매칭 - $lowerName -> ${entry.key}'); return entry.value; } } @@ -134,7 +135,7 @@ class UrlMatcherService { // 음악 서비스 검사 for (final entry in LegacyServiceData.musicServices.entries) { if (lowerName.contains(entry.key.toLowerCase())) { - print('UrlMatcherService: 음악 서비스 매칭 - $lowerName -> ${entry.key}'); + Log.d('UrlMatcherService: 음악 서비스 매칭 - $lowerName -> ${entry.key}'); return entry.value; } } @@ -142,7 +143,7 @@ class UrlMatcherService { // AI 서비스 검사 for (final entry in LegacyServiceData.aiServices.entries) { if (lowerName.contains(entry.key.toLowerCase())) { - print('UrlMatcherService: AI 서비스 매칭 - $lowerName -> ${entry.key}'); + Log.d('UrlMatcherService: AI 서비스 매칭 - $lowerName -> ${entry.key}'); return entry.value; } } @@ -150,7 +151,7 @@ class UrlMatcherService { // 프로그래밍 서비스 검사 for (final entry in LegacyServiceData.programmingServices.entries) { if (lowerName.contains(entry.key.toLowerCase())) { - print('UrlMatcherService: 프로그래밍 서비스 매칭 - $lowerName -> ${entry.key}'); + Log.d('UrlMatcherService: 프로그래밍 서비스 매칭 - $lowerName -> ${entry.key}'); return entry.value; } } @@ -158,7 +159,7 @@ class UrlMatcherService { // 오피스 툴 검사 for (final entry in LegacyServiceData.officeTools.entries) { if (lowerName.contains(entry.key.toLowerCase())) { - print('UrlMatcherService: 오피스 툴 매칭 - $lowerName -> ${entry.key}'); + Log.d('UrlMatcherService: 오피스 툴 매칭 - $lowerName -> ${entry.key}'); return entry.value; } } @@ -166,7 +167,7 @@ class UrlMatcherService { // 기타 서비스 검사 for (final entry in LegacyServiceData.otherServices.entries) { if (lowerName.contains(entry.key.toLowerCase())) { - print('UrlMatcherService: 기타 서비스 매칭 - $lowerName -> ${entry.key}'); + Log.d('UrlMatcherService: 기타 서비스 매칭 - $lowerName -> ${entry.key}'); return entry.value; } } @@ -175,15 +176,15 @@ class UrlMatcherService { for (final entry in LegacyServiceData.allServices.entries) { final key = entry.key.toLowerCase(); if (key.contains(lowerName) || lowerName.contains(key)) { - print('UrlMatcherService: 부분 매칭 - $lowerName -> ${entry.key}'); + Log.d('UrlMatcherService: 부분 매칭 - $lowerName -> ${entry.key}'); return entry.value; } } - print('UrlMatcherService: 매칭 실패 - $lowerName'); + Log.d('UrlMatcherService: 매칭 실패 - $lowerName'); return null; } catch (e) { - print('UrlMatcherService: suggestUrl 에러 - $e'); + Log.e('UrlMatcherService: suggestUrl 에러', e); return null; } } diff --git a/lib/temp/test_sms_data.dart b/lib/temp/test_sms_data.dart index 73a9706..146338e 100644 --- a/lib/temp/test_sms_data.dart +++ b/lib/temp/test_sms_data.dart @@ -210,6 +210,7 @@ class TestSmsData { } } + // ignore: avoid_print print('TestSmsData: 생성된 테스트 메시지 수: ${resultData.length}개'); return resultData; } @@ -233,7 +234,7 @@ class TestSmsData { ]; // Microsoft 365는 연간 구독이므로 월별 비용으로 환산 (1년에 1번만 결제) - final microsoftMonthlyCost = 12800.0 / 12; + const microsoftMonthlyCost = 12800.0 / 12; // 최근 6개월 데이터 생성 for (int i = 0; i < 6; i++) { diff --git a/lib/theme/adaptive_theme.dart b/lib/theme/adaptive_theme.dart index 74aaa6c..a705569 100644 --- a/lib/theme/adaptive_theme.dart +++ b/lib/theme/adaptive_theme.dart @@ -19,8 +19,7 @@ class AdaptiveTheme { secondary: AppColors.secondaryColor, tertiary: AppColors.infoColor, error: AppColors.dangerColor, - background: const Color(0xFF121212), - surface: const Color(0xFF1E1E1E), + surface: Color(0xFF1E1E1E), ), scaffoldBackgroundColor: const Color(0xFF121212), cardTheme: CardThemeData( @@ -175,7 +174,6 @@ class AdaptiveTheme { return darkTheme.copyWith( scaffoldBackgroundColor: Colors.black, colorScheme: darkTheme.colorScheme.copyWith( - background: Colors.black, surface: const Color(0xFF0A0A0A), ), cardTheme: darkTheme.cardTheme.copyWith( @@ -200,7 +198,6 @@ class AdaptiveTheme { secondary: Colors.black87, tertiary: Colors.black54, error: Colors.red, - background: Colors.white, surface: Colors.white, ), textTheme: const TextTheme( diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index cfcb80e..df42009 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -10,7 +10,6 @@ class AppTheme { secondary: AppColors.secondaryColor, tertiary: AppColors.infoColor, error: AppColors.dangerColor, - background: AppColors.backgroundColor, surface: AppColors.surfaceColor, ), @@ -36,13 +35,13 @@ class AppTheme { foregroundColor: AppColors.textPrimary, elevation: 0, centerTitle: false, - titleTextStyle: const TextStyle( + titleTextStyle: TextStyle( color: AppColors.textPrimary, fontSize: 22, fontWeight: FontWeight.w600, letterSpacing: -0.2, ), - iconTheme: const IconThemeData( + iconTheme: IconThemeData( color: AppColors.primaryColor, size: 24, ), @@ -51,21 +50,21 @@ class AppTheme { // 타이포그래피 - Metronic Tailwind 스타일 textTheme: const TextTheme( // 헤드라인 - 페이지 제목 - headlineLarge: const TextStyle( + headlineLarge: TextStyle( color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 fontSize: 32, fontWeight: FontWeight.w700, letterSpacing: -0.5, height: 1.2, ), - headlineMedium: const TextStyle( + headlineMedium: TextStyle( color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 fontSize: 28, fontWeight: FontWeight.w700, letterSpacing: -0.5, height: 1.2, ), - headlineSmall: const TextStyle( + headlineSmall: TextStyle( color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 fontSize: 24, fontWeight: FontWeight.w600, @@ -74,7 +73,7 @@ class AppTheme { ), // 타이틀 - 카드, 섹션 제목 - titleLarge: const TextStyle( + titleLarge: TextStyle( color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 fontSize: 20, fontWeight: FontWeight.w600, @@ -257,14 +256,14 @@ class AppTheme { // 스위치 스타일 switchTheme: SwitchThemeData( - thumbColor: MaterialStateProperty.resolveWith((states) { - if (states.contains(MaterialState.selected)) { + thumbColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { return AppColors.primaryColor; } return Colors.white; }), - trackColor: MaterialStateProperty.resolveWith((states) { - if (states.contains(MaterialState.selected)) { + trackColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { return AppColors.secondaryColor.withValues(alpha: 0.5); } return AppColors.borderColor; @@ -273,8 +272,8 @@ class AppTheme { // 체크박스 스타일 checkboxTheme: CheckboxThemeData( - fillColor: MaterialStateProperty.resolveWith((states) { - if (states.contains(MaterialState.selected)) { + fillColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { return AppColors.primaryColor; } return Colors.transparent; @@ -287,8 +286,8 @@ class AppTheme { // 라디오 버튼 스타일 radioTheme: RadioThemeData( - fillColor: MaterialStateProperty.resolveWith((states) { - if (states.contains(MaterialState.selected)) { + fillColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { return AppColors.primaryColor; } return AppColors.textSecondary; @@ -311,12 +310,12 @@ class AppTheme { labelColor: AppColors.primaryColor, unselectedLabelColor: AppColors.textSecondary, indicatorColor: AppColors.primaryColor, - labelStyle: const TextStyle( + labelStyle: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, letterSpacing: 0.1, ), - unselectedLabelStyle: const TextStyle( + unselectedLabelStyle: TextStyle( fontSize: 14, fontWeight: FontWeight.w500, letterSpacing: 0.1, diff --git a/lib/utils/logger.dart b/lib/utils/logger.dart new file mode 100644 index 0000000..c391d02 --- /dev/null +++ b/lib/utils/logger.dart @@ -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()); + } +} diff --git a/lib/utils/memory_manager.dart b/lib/utils/memory_manager.dart index ab4dc6b..fc9b17e 100644 --- a/lib/utils/memory_manager.dart +++ b/lib/utils/memory_manager.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; +import 'logger.dart'; import 'dart:async'; /// 메모리 관리를 위한 헬퍼 클래스 @@ -57,7 +58,7 @@ class MemoryManager { void clearCache() { _cache.clear(); if (kDebugMode) { - print('🧹 메모리 캐시가 비워졌습니다.'); + Log.d('🧹 메모리 캐시가 비워졌습니다.'); } } @@ -122,7 +123,7 @@ class MemoryManager { PaintingBinding.instance.imageCache.clear(); PaintingBinding.instance.imageCache.clearLiveImages(); if (kDebugMode) { - print('🖼️ 이미지 캐시가 비워졌습니다.'); + Log.d('🖼️ 이미지 캐시가 비워졌습니다.'); } } @@ -155,7 +156,7 @@ class MemoryManager { imageCache.maximumSizeBytes = maxImageCacheSize ~/ 2; if (kDebugMode) { - print('⚠️ 메모리 압박 대응: 캐시 크기 감소'); + Log.w('메모리 압박 대응: 캐시 크기 감소'); } } diff --git a/lib/utils/performance_optimizer.dart b/lib/utils/performance_optimizer.dart index a6ff576..5d2c4d0 100644 --- a/lib/utils/performance_optimizer.dart +++ b/lib/utils/performance_optimizer.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'logger.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'dart:async'; @@ -141,12 +142,12 @@ class PerformanceOptimizer { /// 빌드 최적화를 위한 const 위젯 권장사항 체크 static void checkConstOptimization() { if (kDebugMode) { - print('💡 성능 최적화 팁:'); - print('1. 가능한 모든 위젯에 const 사용'); - print('2. StatelessWidget 대신 const 생성자 사용'); - print('3. 큰 리스트는 ListView.builder 사용'); - print('4. 이미지는 캐싱과 함께 적절한 크기로 로드'); - print('5. 애니메이션은 AnimatedBuilder 사용'); + Log.i('💡 성능 최적화 팁:\n' + '1. 가능한 모든 위젯에 const 사용\n' + '2. StatelessWidget 대신 const 생성자 사용\n' + '3. 큰 리스트는 ListView.builder 사용\n' + '4. 이미지는 캐싱과 함께 적절한 크기로 로드\n' + '5. 애니메이션은 AnimatedBuilder 사용'); } } @@ -161,7 +162,7 @@ class PerformanceOptimizer { // 위젯이 비정상적으로 많이 생성되면 경고 if ((_widgetCounts[widgetName] ?? 0) > 100) { - print('⚠️ 경고: $widgetName 위젯이 100개 이상 생성됨. 메모리 누수 가능성!'); + Log.w('경고: $widgetName 위젯이 100개 이상 생성됨. 메모리 누수 가능성!'); } } } @@ -196,11 +197,11 @@ class PerformanceMeasure { try { final result = await operation(); stopwatch.stop(); - print('✅ $name 완료: ${stopwatch.elapsedMilliseconds}ms'); + Log.d('✅ $name 완료: ${stopwatch.elapsedMilliseconds}ms'); return result; } catch (e) { stopwatch.stop(); - print('❌ $name 실패: ${stopwatch.elapsedMilliseconds}ms - $e'); + Log.e('❌ $name 실패: ${stopwatch.elapsedMilliseconds}ms', e); rethrow; } } diff --git a/lib/utils/subscription_category_helper.dart b/lib/utils/subscription_category_helper.dart index b5b57fa..78060e8 100644 --- a/lib/utils/subscription_category_helper.dart +++ b/lib/utils/subscription_category_helper.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import '../models/subscription_model.dart'; import '../providers/category_provider.dart'; -import '../services/subscription_url_matcher.dart'; import '../services/url_matcher/data/legacy_service_data.dart'; /// 구독 서비스를 카테고리별로 구분하는 도우미 클래스 diff --git a/lib/widgets/add_subscription/add_subscription_event_section.dart b/lib/widgets/add_subscription/add_subscription_event_section.dart index 45b875a..b1cf6c5 100644 --- a/lib/widgets/add_subscription/add_subscription_event_section.dart +++ b/lib/widgets/add_subscription/add_subscription_event_section.dart @@ -3,7 +3,6 @@ import '../../controllers/add_subscription_controller.dart'; import '../common/form_fields/currency_input_field.dart'; import '../common/form_fields/date_picker_field.dart'; import '../../theme/app_colors.dart'; -import '../../l10n/app_localizations.dart'; /// 구독 추가 화면의 이벤트/할인 섹션 class AddSubscriptionEventSection extends StatelessWidget { @@ -47,11 +46,11 @@ class AddSubscriptionEventSection extends StatelessWidget { color: AppColors.glassBorder.withValues(alpha: 0.1), width: 1, ), - boxShadow: [ + boxShadow: const [ BoxShadow( color: AppColors.shadowBlack, blurRadius: 10, - offset: const Offset(0, 4), + offset: Offset(0, 4), ), ], ), @@ -147,7 +146,7 @@ class AddSubscriptionEventSection extends StatelessWidget { ), child: Row( children: [ - Icon( + const Icon( Icons.info_outline_rounded, color: AppColors.infoColor, size: 20, @@ -175,7 +174,7 @@ class AddSubscriptionEventSection extends StatelessWidget { } return Text( infoText, - style: TextStyle( + style: const TextStyle( fontSize: 14, color: AppColors.darkNavy, fontWeight: FontWeight.w500, diff --git a/lib/widgets/analysis/analysis_badge.dart b/lib/widgets/analysis/analysis_badge.dart index d3deaef..0317577 100644 --- a/lib/widgets/analysis/analysis_badge.dart +++ b/lib/widgets/analysis/analysis_badge.dart @@ -5,7 +5,6 @@ import '../../models/subscription_model.dart'; import '../../services/currency_util.dart'; import '../../providers/locale_provider.dart'; import '../../theme/app_colors.dart'; -import '../../l10n/app_localizations.dart'; /// 파이 차트에서 선택된 섹션에 표시되는 배지 위젯 class AnalysisBadge extends StatelessWidget { @@ -33,7 +32,7 @@ class AnalysisBadge extends StatelessWidget { color: borderColor, width: 2, ), - boxShadow: [ + boxShadow: const [ BoxShadow( color: AppColors.shadowBlack, blurRadius: 10, diff --git a/lib/widgets/app_navigator.dart b/lib/widgets/app_navigator.dart index aa1a876..9ea1917 100644 --- a/lib/widgets/app_navigator.dart +++ b/lib/widgets/app_navigator.dart @@ -6,6 +6,7 @@ import '../screens/app_lock_screen.dart'; import '../models/subscription_model.dart'; import '../providers/navigation_provider.dart'; import '../routes/app_routes.dart'; +import '../utils/logger.dart'; import 'animated_page_transitions.dart'; import '../l10n/app_localizations.dart'; @@ -44,7 +45,7 @@ class AppNavigator { /// 구독 상세 화면으로 네비게이션 static Future toDetail( BuildContext context, SubscriptionModel subscription) async { - print('AppNavigator.toDetail 호출됨: ${subscription.serviceName}'); + Log.d('AppNavigator.toDetail 호출됨: ${subscription.serviceName}'); HapticFeedback.lightImpact(); try { @@ -52,9 +53,9 @@ class AppNavigator { AppRoutes.subscriptionDetail, arguments: subscription, ); - print('DetailScreen 네비게이션 성공'); + Log.d('DetailScreen 네비게이션 성공'); } catch (e) { - print('DetailScreen 네비게이션 오류: $e'); + Log.e('DetailScreen 네비게이션 오류', e); } } diff --git a/lib/widgets/common/buttons/secondary_button.dart b/lib/widgets/common/buttons/secondary_button.dart index 5aab3fb..3f31271 100644 --- a/lib/widgets/common/buttons/secondary_button.dart +++ b/lib/widgets/common/buttons/secondary_button.dart @@ -42,7 +42,6 @@ class _SecondaryButtonState extends State { @override Widget build(BuildContext context) { - final theme = Theme.of(context); final effectiveBorderColor = widget.borderColor ?? AppColors.secondaryColor; final effectiveTextColor = widget.textColor ?? AppColors.primaryColor; diff --git a/lib/widgets/common/dialogs/loading_overlay.dart b/lib/widgets/common/dialogs/loading_overlay.dart index 0c7a451..033cd95 100644 --- a/lib/widgets/common/dialogs/loading_overlay.dart +++ b/lib/widgets/common/dialogs/loading_overlay.dart @@ -81,8 +81,8 @@ class LoadingDialog { context: context, barrierDismissible: barrierDismissible, barrierColor: barrierColor ?? Colors.black54, - builder: (context) => WillPopScope( - onWillPop: () async => barrierDismissible, + builder: (context) => PopScope( + canPop: barrierDismissible, child: Center( child: Container( padding: const EdgeInsets.all(24), diff --git a/lib/widgets/common/form_fields/base_text_field.dart b/lib/widgets/common/form_fields/base_text_field.dart index 63d010e..63d685a 100644 --- a/lib/widgets/common/form_fields/base_text_field.dart +++ b/lib/widgets/common/form_fields/base_text_field.dart @@ -66,7 +66,7 @@ class BaseTextField extends StatelessWidget { if (label != null) ...[ Text( label!, - style: TextStyle( + style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textSecondary, @@ -91,13 +91,13 @@ class BaseTextField extends StatelessWidget { readOnly: readOnly, cursorColor: cursorColor ?? theme.primaryColor, style: style ?? - TextStyle( + const TextStyle( fontSize: 16, color: AppColors.textPrimary, ), decoration: InputDecoration( hintText: hintText, - hintStyle: TextStyle( + hintStyle: const TextStyle( color: AppColors.textMuted, ), prefixIcon: prefixIcon, diff --git a/lib/widgets/common/form_fields/date_picker_field.dart b/lib/widgets/common/form_fields/date_picker_field.dart index 8a88da8..b5ee04d 100644 --- a/lib/widgets/common/form_fields/date_picker_field.dart +++ b/lib/widgets/common/form_fields/date_picker_field.dart @@ -48,7 +48,7 @@ class DatePickerField extends StatelessWidget { children: [ Text( label, - style: TextStyle( + style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.darkNavy, @@ -249,7 +249,7 @@ class _DateRangeItem extends StatelessWidget { children: [ Text( label, - style: TextStyle( + style: const TextStyle( fontSize: 12, color: AppColors.textSecondary, ), diff --git a/lib/widgets/common/snackbar/app_snackbar.dart b/lib/widgets/common/snackbar/app_snackbar.dart index 0bff6b0..81b47e8 100644 --- a/lib/widgets/common/snackbar/app_snackbar.dart +++ b/lib/widgets/common/snackbar/app_snackbar.dart @@ -200,7 +200,7 @@ class AppSnackBar { width: 24, height: 24, margin: const EdgeInsets.only(right: 12), - child: CircularProgressIndicator( + child: const CircularProgressIndicator( strokeWidth: 2.5, color: AppColors.pureWhite, ), diff --git a/lib/widgets/detail/detail_event_section.dart b/lib/widgets/detail/detail_event_section.dart index ea09925..65e360b 100644 --- a/lib/widgets/detail/detail_event_section.dart +++ b/lib/widgets/detail/detail_event_section.dart @@ -44,11 +44,11 @@ class DetailEventSection extends StatelessWidget { color: AppColors.glassBorder.withValues(alpha: 0.1), width: 1, ), - boxShadow: [ + boxShadow: const [ BoxShadow( color: AppColors.shadowBlack, blurRadius: 10, - offset: const Offset(0, 4), + offset: Offset(0, 4), ), ], ), @@ -118,7 +118,7 @@ class DetailEventSection extends StatelessWidget { ), child: Row( children: [ - Icon( + const Icon( Icons.info_outline_rounded, color: AppColors.infoColor, size: 20, @@ -127,7 +127,7 @@ class DetailEventSection extends StatelessWidget { Expanded( child: Text( AppLocalizations.of(context).eventPriceHint, - style: TextStyle( + style: const TextStyle( fontSize: 14, color: AppColors.darkNavy, fontWeight: FontWeight.w500, @@ -253,8 +253,8 @@ class _DiscountBadge extends StatelessWidget { const SizedBox(width: 12), Text( _getLocalizedDiscountAmount(context, currency, discountAmount), - style: TextStyle( - color: const Color(0xFF15803D), + style: const TextStyle( + color: Color(0xFF15803D), fontSize: 14, fontWeight: FontWeight.w500, ), diff --git a/lib/widgets/detail/detail_form_section.dart b/lib/widgets/detail/detail_form_section.dart index e647f4d..ffaef54 100644 --- a/lib/widgets/detail/detail_form_section.dart +++ b/lib/widgets/detail/detail_form_section.dart @@ -49,11 +49,11 @@ class DetailFormSection extends StatelessWidget { color: AppColors.glassBorder.withValues(alpha: 0.1), width: 1, ), - boxShadow: [ + boxShadow: const [ BoxShadow( color: AppColors.shadowBlack, blurRadius: 10, - offset: const Offset(0, 4), + offset: Offset(0, 4), ), ], ), diff --git a/lib/widgets/detail/detail_header_section.dart b/lib/widgets/detail/detail_header_section.dart index 55c3e3c..ebc20c0 100644 --- a/lib/widgets/detail/detail_header_section.dart +++ b/lib/widgets/detail/detail_header_section.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:intl/intl.dart'; import '../../models/subscription_model.dart'; import '../../controllers/detail_screen_controller.dart'; import '../../providers/locale_provider.dart'; diff --git a/lib/widgets/detail/detail_url_section.dart b/lib/widgets/detail/detail_url_section.dart index 770d213..9f9c4fa 100644 --- a/lib/widgets/detail/detail_url_section.dart +++ b/lib/widgets/detail/detail_url_section.dart @@ -41,11 +41,11 @@ class DetailUrlSection extends StatelessWidget { color: AppColors.glassBorder.withValues(alpha: 0.1), width: 1, ), - boxShadow: [ + boxShadow: const [ BoxShadow( color: AppColors.shadowBlack, blurRadius: 10, - offset: const Offset(0, 4), + offset: Offset(0, 4), ), ], ), @@ -89,7 +89,7 @@ class DetailUrlSection extends StatelessWidget { label: AppLocalizations.of(context).websiteUrl, hintText: AppLocalizations.of(context).urlExample, keyboardType: TextInputType.url, - prefixIcon: Icon( + prefixIcon: const Icon( Icons.link_rounded, color: AppColors.navyGray, ), @@ -114,7 +114,7 @@ class DetailUrlSection extends StatelessWidget { children: [ Row( children: [ - Icon( + const Icon( Icons.info_outline_rounded, color: AppColors.warningColor, size: 20, @@ -122,7 +122,7 @@ class DetailUrlSection extends StatelessWidget { const SizedBox(width: 8), Text( AppLocalizations.of(context).cancelGuide, - style: TextStyle( + style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.darkNavy, @@ -133,7 +133,7 @@ class DetailUrlSection extends StatelessWidget { const SizedBox(height: 8), Text( AppLocalizations.of(context).cancelServiceGuide, - style: TextStyle( + style: const TextStyle( fontSize: 14, color: AppColors.darkNavy, fontWeight: FontWeight.w500, @@ -167,7 +167,7 @@ class DetailUrlSection extends StatelessWidget { ), child: Row( children: [ - Icon( + const Icon( Icons.auto_fix_high_rounded, color: AppColors.infoColor, size: 20, @@ -176,7 +176,7 @@ class DetailUrlSection extends StatelessWidget { Expanded( child: Text( AppLocalizations.of(context).urlAutoMatchInfo, - style: TextStyle( + style: const TextStyle( fontSize: 14, color: AppColors.darkNavy, fontWeight: FontWeight.w500, diff --git a/lib/widgets/empty_state_widget.dart b/lib/widgets/empty_state_widget.dart index 1fc4f0e..e049e15 100644 --- a/lib/widgets/empty_state_widget.dart +++ b/lib/widgets/empty_state_widget.dart @@ -111,7 +111,7 @@ class EmptyStateWidget extends StatelessWidget { }, child: Text( AppLocalizations.of(context).addSubscription, - style: TextStyle( + style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, letterSpacing: 0.5, diff --git a/lib/widgets/glassmorphic_scaffold.dart b/lib/widgets/glassmorphic_scaffold.dart index a78a0d1..10bfd60 100644 --- a/lib/widgets/glassmorphic_scaffold.dart +++ b/lib/widgets/glassmorphic_scaffold.dart @@ -163,7 +163,7 @@ class _GlassmorphicScaffoldState extends State begin: Alignment.topLeft, end: Alignment.bottomRight, colors: gradientColors - .map((color) => color.withOpacity(0.3)) + .map((color) => color.withValues(alpha: 0.3)) .toList(), ), ), diff --git a/lib/widgets/glassmorphism_card.dart b/lib/widgets/glassmorphism_card.dart index 2ec769c..8fa8868 100644 --- a/lib/widgets/glassmorphism_card.dart +++ b/lib/widgets/glassmorphism_card.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../utils/logger.dart'; import 'dart:ui'; import '../theme/app_colors.dart'; import 'themed_text.dart'; @@ -74,12 +75,12 @@ class GlassmorphismCard extends StatelessWidget { ), boxShadow: boxShadow ?? [ - BoxShadow( + const BoxShadow( color: AppColors .shadowBlack, // color.md 가이드: rgba(0,0,0,0.08) blurRadius: 20, spreadRadius: -5, - offset: const Offset(0, 10), + offset: Offset(0, 10), ), ], ), @@ -200,7 +201,7 @@ class _AnimatedGlassmorphismCardState extends State _handleTapUp(details); // onTap 콜백 실행 if (widget.onTap != null) { - print('[AnimatedGlassmorphismCard] onTap 콜백 실행'); + Log.d('[AnimatedGlassmorphismCard] onTap 콜백 실행'); widget.onTap!(); } }, diff --git a/lib/widgets/main_summary_card.dart b/lib/widgets/main_summary_card.dart index c404c62..49a8be8 100644 --- a/lib/widgets/main_summary_card.dart +++ b/lib/widgets/main_summary_card.dart @@ -90,7 +90,7 @@ class MainScreenSummaryCard extends StatelessWidget { Text( AppLocalizations.of(context) .monthlyTotalSubscriptionCost, - style: TextStyle( + style: const TextStyle( color: AppColors .darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트 fontSize: 15, @@ -215,7 +215,7 @@ class MainScreenSummaryCard extends StatelessWidget { context, title: AppLocalizations.of(context) .estimatedAnnualCost, - value: '${NumberFormat.currency( + value: NumberFormat.currency( locale: defaultCurrency == 'KRW' ? 'ko_KR' : defaultCurrency == 'JPY' @@ -225,7 +225,7 @@ class MainScreenSummaryCard extends StatelessWidget { : 'en_US', symbol: currencySymbol, decimalDigits: decimals, - ).format(yearlyCost)}', + ).format(yearlyCost), ), const SizedBox(width: 16), _buildInfoBox( @@ -282,7 +282,7 @@ class MainScreenSummaryCard extends StatelessWidget { Text( AppLocalizations.of(context) .eventDiscountActive, - style: TextStyle( + style: const TextStyle( color: AppColors .darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트 fontSize: 11, @@ -373,7 +373,7 @@ class MainScreenSummaryCard extends StatelessWidget { children: [ Text( title, - style: TextStyle( + style: const TextStyle( color: AppColors.navyGray, // color.md 가이드: 밝은 배경 위 서브 텍스트 fontSize: 12, fontWeight: FontWeight.w500, diff --git a/lib/widgets/sms_scan/scan_loading_widget.dart b/lib/widgets/sms_scan/scan_loading_widget.dart index dd4873c..153ecd5 100644 --- a/lib/widgets/sms_scan/scan_loading_widget.dart +++ b/lib/widgets/sms_scan/scan_loading_widget.dart @@ -14,7 +14,7 @@ class ScanLoadingWidget extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - CircularProgressIndicator( + const CircularProgressIndicator( valueColor: AlwaysStoppedAnimation(AppColors.primaryColor), ), const SizedBox(height: 16), diff --git a/lib/widgets/subscription_card.dart b/lib/widgets/subscription_card.dart index a9d44a7..a70b832 100644 --- a/lib/widgets/subscription_card.dart +++ b/lib/widgets/subscription_card.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import '../models/subscription_model.dart'; import '../providers/category_provider.dart'; @@ -202,8 +201,9 @@ class _SubscriptionCardState extends State daysUntilNext = 7; // 다음 주 같은 요일 } - if (daysUntilNext == 0) + if (daysUntilNext == 0) { return AppLocalizations.of(context).paymentDueToday; + } return AppLocalizations.of(context).paymentDueInDays(daysUntilNext); } @@ -303,8 +303,7 @@ class _SubscriptionCardState extends State width: double.infinity, // 전체 너비를 차지하도록 설정 onTap: widget.onTap ?? () async { - print( - '[SubscriptionCard] AnimatedGlassmorphismCard onTap 호출됨 - ${widget.subscription.serviceName}'); + // ignore: use_build_context_synchronously await AppNavigator.toDetail(context, widget.subscription); }, child: Column( diff --git a/lib/widgets/subscription_list_widget.dart b/lib/widgets/subscription_list_widget.dart index e22c23e..ade668c 100644 --- a/lib/widgets/subscription_list_widget.dart +++ b/lib/widgets/subscription_list_widget.dart @@ -12,6 +12,7 @@ import '../services/subscription_url_matcher.dart'; import './dialogs/delete_confirmation_dialog.dart'; import './common/snackbar/app_snackbar.dart'; import '../l10n/app_localizations.dart'; +import '../utils/logger.dart'; /// 카테고리별로 구독 목록을 표시하는 위젯 class SubscriptionListWidget extends StatelessWidget { @@ -100,7 +101,7 @@ class SubscriptionListWidget extends StatelessWidget { child: SwipeableSubscriptionCard( subscription: subscriptions[subIndex], onTap: () { - print( + Log.d( '[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨'); AppNavigator.toDetail( context, subscriptions[subIndex]); @@ -122,13 +123,15 @@ class SubscriptionListWidget extends StatelessWidget { ); // 삭제 확인 다이얼로그 표시 + if (!context.mounted) return; final shouldDelete = await DeleteConfirmationDialog.show( context: context, serviceName: displayName, ); + if (!context.mounted) return; - if (shouldDelete && context.mounted) { + if (shouldDelete) { // 사용자가 확인한 경우에만 삭제 진행 final provider = Provider.of( diff --git a/lib/widgets/swipeable_subscription_card.dart b/lib/widgets/swipeable_subscription_card.dart index 6bb9cfe..b0934b0 100644 --- a/lib/widgets/swipeable_subscription_card.dart +++ b/lib/widgets/swipeable_subscription_card.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/gestures.dart'; import '../models/subscription_model.dart'; import '../utils/haptic_feedback_helper.dart'; import 'subscription_card.dart'; @@ -29,7 +28,6 @@ class _SwipeableSubscriptionCardState extends State static const double _tapTolerance = 20.0; // 탭 허용 범위 static const double _actionThresholdPercent = 0.15; static const double _deleteThresholdPercent = 0.40; - static const int _tapDurationMs = 500; static const double _velocityThreshold = 800.0; // static const double _animationDuration = 300.0; @@ -39,8 +37,7 @@ class _SwipeableSubscriptionCardState extends State // 제스처 추적 Offset? _startPosition; - DateTime? _startTime; - bool _isValidTap = true; + // 제스처 관련 보조 변수(간소화) // 상태 관리 double _currentOffset = 0; @@ -95,8 +92,6 @@ class _SwipeableSubscriptionCardState extends State // 제스처 핸들러 void _handlePanStart(DragStartDetails details) { _startPosition = details.localPosition; - _startTime = DateTime.now(); - _isValidTap = true; _hapticTriggered = false; _controller.stop(); } @@ -104,12 +99,7 @@ class _SwipeableSubscriptionCardState extends State void _handlePanUpdate(DragUpdateDetails details) { final currentPosition = details.localPosition; final delta = currentPosition.dx - _startPosition!.dx; - final distance = (currentPosition - _startPosition!).distance; - - // 탭 유효성 검사 - 거리가 허용 범위를 벗어나면 스와이프로 간주 - if (distance > _tapTolerance) { - _isValidTap = false; - } + // 탭/스와이프 판별 거리는 외부에서 사용하지 않아 제거 // 카드 이동 setState(() { @@ -129,14 +119,7 @@ class _SwipeableSubscriptionCardState extends State } // 헬퍼 메서드 - void _processTap() { - print('[SwipeableSubscriptionCard] _processTap 호출됨'); - if (widget.onTap != null) { - print('[SwipeableSubscriptionCard] onTap 콜백 실행'); - widget.onTap!(); - } - _animateToOffset(0); - } + // 탭 처리는 SubscriptionCard에서 수행 void _processSwipe(double velocity) { final extent = _currentOffset.abs(); diff --git a/test/exchange_rate_service_test.dart b/test/exchange_rate_service_test.dart new file mode 100644 index 0000000..ce3e644 --- /dev/null +++ b/test/exchange_rate_service_test.dart @@ -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)); + }); +} diff --git a/test/url_matcher_test.dart b/test/url_matcher_test.dart new file mode 100644 index 0000000..9096757 --- /dev/null +++ b/test/url_matcher_test.dart @@ -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); + }); +}