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:
@@ -1,6 +1,5 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.READ_SMS" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_SMS" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<application
|
||||
android:label="구독 관리"
|
||||
|
||||
@@ -217,6 +217,17 @@
|
||||
"enterAmount": "Enter amount",
|
||||
"invalidAmount": "Please enter a valid amount",
|
||||
"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": {
|
||||
"appTitle": "디지털 월세 관리자",
|
||||
@@ -436,6 +447,17 @@
|
||||
"enterAmount": "금액을 입력하세요",
|
||||
"invalidAmount": "올바른 금액을 입력해주세요",
|
||||
"featureComingSoon": "이 기능은 곧 출시됩니다"
|
||||
,
|
||||
"smsPermissionTitle": "SMS 권한 요청",
|
||||
"smsPermissionReasonTitle": "이유",
|
||||
"smsPermissionReasonBody": "문자 메시지에서 반복 결제 내역을 분석해 구독 서비스를 자동으로 탐지합니다. 모든 처리는 기기 내에서만 이루어집니다.",
|
||||
"smsPermissionScopeTitle": "수집 범위",
|
||||
"smsPermissionScopeBody": "결제 관련 문자 메시지의 패턴(서비스명/금액/날짜)만 로컬에서 처리하며, 외부로 전송하지 않습니다.",
|
||||
"permanentlyDeniedMessage": "권한이 영구적으로 거부되었습니다. 설정에서 권한을 허용해주세요.",
|
||||
"openSettings": "설정 열기",
|
||||
"later": "나중에 하기",
|
||||
"requesting": "요청 중...",
|
||||
"smsPermissionLabel": "SMS 권한"
|
||||
},
|
||||
"ja": {
|
||||
"appTitle": "デジタル月額管理者",
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode;
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../models/subscription_model.dart';
|
||||
import '../providers/subscription_provider.dart';
|
||||
import '../providers/category_provider.dart';
|
||||
import '../services/sms_service.dart';
|
||||
@@ -183,6 +182,7 @@ class AddSubscriptionController {
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
// ignore: avoid_print
|
||||
print('AddSubscriptionController: URL 자동 매칭 중 오류 - $e');
|
||||
}
|
||||
}
|
||||
@@ -320,6 +320,7 @@ class AddSubscriptionController {
|
||||
await SubscriptionUrlMatcher.extractServiceFromSms(smsContent);
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
// ignore: avoid_print
|
||||
print('AddSubscriptionController: SMS 서비스 추출 실패 - $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,7 +401,7 @@ class DetailScreenController extends ChangeNotifier {
|
||||
|
||||
debugPrint('[DetailScreenController] 구독 업데이트 시작: '
|
||||
'${subscription.serviceName} → ${serviceNameController.text}, '
|
||||
'금액: ${subscription.monthlyCost} → $monthlyCost ${_currency}');
|
||||
'금액: $subscription.monthlyCost → $monthlyCost $_currency');
|
||||
|
||||
subscription.serviceName = serviceNameController.text;
|
||||
subscription.monthlyCost = monthlyCost;
|
||||
@@ -460,12 +460,14 @@ class DetailScreenController extends ChangeNotifier {
|
||||
serviceName: subscription.serviceName,
|
||||
locale: locale,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
|
||||
// 삭제 확인 다이얼로그 표시
|
||||
final shouldDelete = await DeleteConfirmationDialog.show(
|
||||
context: context,
|
||||
serviceName: displayName,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (!shouldDelete) return;
|
||||
|
||||
@@ -529,6 +531,7 @@ class DetailScreenController extends ChangeNotifier {
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
// ignore: avoid_print
|
||||
print('DetailScreenController: 해지 페이지 열기 실패 - $e');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/sms_scanner.dart';
|
||||
import '../models/subscription.dart';
|
||||
import '../models/subscription_model.dart';
|
||||
import '../services/sms_scan/subscription_converter.dart';
|
||||
import '../services/sms_scan/subscription_filter.dart';
|
||||
import '../providers/subscription_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../utils/logger.dart';
|
||||
import '../providers/navigation_provider.dart';
|
||||
import '../providers/category_provider.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
@@ -58,20 +58,20 @@ class SmsScanController extends ChangeNotifier {
|
||||
|
||||
try {
|
||||
// SMS 스캔 실행
|
||||
print('SMS 스캔 시작');
|
||||
Log.i('SMS 스캔 시작');
|
||||
final scannedSubscriptionModels =
|
||||
await _smsScanner.scanForSubscriptions();
|
||||
print('스캔된 구독: ${scannedSubscriptionModels.length}개');
|
||||
Log.d('스캔된 구독: ${scannedSubscriptionModels.length}개');
|
||||
|
||||
if (scannedSubscriptionModels.isNotEmpty) {
|
||||
print(
|
||||
Log.d(
|
||||
'첫 번째 구독: ${scannedSubscriptionModels[0].serviceName}, 반복 횟수: ${scannedSubscriptionModels[0].repeatCount}');
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (scannedSubscriptionModels.isEmpty) {
|
||||
print('스캔된 구독이 없음');
|
||||
Log.i('스캔된 구독이 없음');
|
||||
_errorMessage = AppLocalizations.of(context).subscriptionNotFound;
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
@@ -85,15 +85,15 @@ class SmsScanController extends ChangeNotifier {
|
||||
// 2회 이상 반복 결제된 구독만 필터링
|
||||
final repeatSubscriptions =
|
||||
_filter.filterByRepeatCount(scannedSubscriptions, 2);
|
||||
print('반복 결제된 구독: ${repeatSubscriptions.length}개');
|
||||
Log.d('반복 결제된 구독: ${repeatSubscriptions.length}개');
|
||||
|
||||
if (repeatSubscriptions.isNotEmpty) {
|
||||
print(
|
||||
Log.d(
|
||||
'첫 번째 반복 구독: ${repeatSubscriptions[0].serviceName}, 반복 횟수: ${repeatSubscriptions[0].repeatCount}');
|
||||
}
|
||||
|
||||
if (repeatSubscriptions.isEmpty) {
|
||||
print('반복 결제된 구독이 없음');
|
||||
Log.i('반복 결제된 구독이 없음');
|
||||
_errorMessage = AppLocalizations.of(context).repeatSubscriptionNotFound;
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
@@ -104,21 +104,21 @@ class SmsScanController extends ChangeNotifier {
|
||||
final provider =
|
||||
Provider.of<SubscriptionProvider>(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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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<void> main() async {
|
||||
await DefaultCacheManager().emptyCache();
|
||||
|
||||
if (kDebugMode) {
|
||||
print('이미지 캐시 관리 초기화 완료');
|
||||
Log.d('이미지 캐시 관리 초기화 완료');
|
||||
PerformanceOptimizer.checkConstOptimization();
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('캐시 초기화 오류: $e');
|
||||
Log.e('캐시 초기화 오류', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,7 +508,6 @@ class SubscriptionProvider extends ChangeNotifier {
|
||||
.id;
|
||||
}
|
||||
|
||||
if (categoryId != null) {
|
||||
subscription.categoryId = categoryId;
|
||||
await subscription.save();
|
||||
migratedCount++;
|
||||
@@ -517,10 +516,9 @@ class SubscriptionProvider extends ChangeNotifier {
|
||||
debugPrint('✅ ${subscription.serviceName} → $categoryName');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (migratedCount > 0) {
|
||||
debugPrint('❎ 총 ${migratedCount}개의 구독에 categoryId 할당 완료');
|
||||
debugPrint('❎ 총 $migratedCount개의 구독에 categoryId 할당 완료');
|
||||
await refreshSubscriptions();
|
||||
} else {
|
||||
debugPrint('❎ 모든 구독이 이미 categoryId를 가지고 있습니다');
|
||||
|
||||
@@ -169,7 +169,7 @@ class _AnalysisScreenState extends State<AnalysisScreen>
|
||||
|
||||
// 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<AnalysisScreen>
|
||||
|
||||
// 3. 월별 지출 차트
|
||||
MonthlyExpenseChartCard(
|
||||
key: ValueKey('monthly_expense_${_lastDataHash}'),
|
||||
key: ValueKey('monthly_expense_$_lastDataHash'),
|
||||
monthlyData: _monthlyData,
|
||||
animationController: _animationController,
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -43,7 +43,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
||||
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<CategoryManagementScreen> {
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: InputDecoration(
|
||||
decoration: const InputDecoration(
|
||||
labelText: '카테고리 이름',
|
||||
labelStyle: TextStyle(
|
||||
color: AppColors.navyGray,
|
||||
@@ -82,7 +82,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
value: _selectedColor,
|
||||
decoration: InputDecoration(
|
||||
decoration: const InputDecoration(
|
||||
labelText: '색상 선택',
|
||||
labelStyle: TextStyle(
|
||||
color: AppColors.navyGray,
|
||||
@@ -94,31 +94,31 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
||||
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<CategoryManagementScreen> {
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
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<CategoryManagementScreen> {
|
||||
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<CategoryManagementScreen> {
|
||||
title: Text(
|
||||
provider.getLocalizedCategoryName(
|
||||
context, category.name),
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
color: AppColors.darkNavy,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -111,7 +111,7 @@ class _DetailScreenState extends State<DetailScreen>
|
||||
Text(
|
||||
AppLocalizations.of(context)
|
||||
.changesAppliedAfterSave,
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.darkNavy,
|
||||
),
|
||||
|
||||
@@ -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,43 +484,71 @@ class SettingsScreen extends StatelessWidget {
|
||||
margin:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: FutureBuilder<bool>(
|
||||
future: SMSService.hasSMSPermission(),
|
||||
child: FutureBuilder<permission.PermissionStatus>(
|
||||
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
|
||||
subtitle: !hasPermission
|
||||
? Text(
|
||||
isPermanentlyDenied
|
||||
? AppLocalizations.of(context)
|
||||
.permanentlyDeniedMessage
|
||||
: AppLocalizations.of(context)
|
||||
.smsPermissionRequired,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary),
|
||||
)
|
||||
: null,
|
||||
trailing: isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child:
|
||||
CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: hasPermission
|
||||
? const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8.0),
|
||||
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();
|
||||
final granted = await SMSService
|
||||
.requestSMSPermission();
|
||||
if (!granted) {
|
||||
final status =
|
||||
await permission.Permission.sms.status;
|
||||
if (status.isPermanentlyDenied) {
|
||||
final newStatus = await permission
|
||||
.Permission.sms.status;
|
||||
if (newStatus.isPermanentlyDenied) {
|
||||
await permission.openAppSettings();
|
||||
}
|
||||
}
|
||||
if (context.mounted) {
|
||||
// 상태 갱신을 위해 다시 build 트리거
|
||||
(context as Element).markNeedsBuild();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -63,18 +63,18 @@ class _SmsPermissionScreenState extends State<SmsPermissionScreen> {
|
||||
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<SmsPermissionScreen> {
|
||||
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<SmsPermissionScreen> {
|
||||
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<SmsPermissionScreen> {
|
||||
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),
|
||||
)
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -277,13 +277,13 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
.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<SplashScreen>
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: CircularProgressIndicator(
|
||||
child: const CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
AppColors.pureWhite),
|
||||
strokeWidth: 3,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import '../../models/subscription.dart';
|
||||
import '../../models/subscription_model.dart';
|
||||
import '../../utils/logger.dart';
|
||||
|
||||
class SubscriptionFilter {
|
||||
// 중복 구독 필터링 (서비스명과 금액이 같으면 중복으로 간주)
|
||||
List<Subscription> filterDuplicates(
|
||||
List<Subscription> scanned, List<SubscriptionModel> 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;
|
||||
}
|
||||
|
||||
@@ -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<List<SubscriptionModel>> scanForSubscriptions() async {
|
||||
try {
|
||||
List<dynamic> 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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<Color>((states) {
|
||||
if (states.contains(MaterialState.selected)) {
|
||||
thumbColor: WidgetStateProperty.resolveWith<Color>((states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return AppColors.primaryColor;
|
||||
}
|
||||
return Colors.white;
|
||||
}),
|
||||
trackColor: MaterialStateProperty.resolveWith<Color>((states) {
|
||||
if (states.contains(MaterialState.selected)) {
|
||||
trackColor: WidgetStateProperty.resolveWith<Color>((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<Color>((states) {
|
||||
if (states.contains(MaterialState.selected)) {
|
||||
fillColor: WidgetStateProperty.resolveWith<Color>((states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return AppColors.primaryColor;
|
||||
}
|
||||
return Colors.transparent;
|
||||
@@ -287,8 +286,8 @@ class AppTheme {
|
||||
|
||||
// 라디오 버튼 스타일
|
||||
radioTheme: RadioThemeData(
|
||||
fillColor: MaterialStateProperty.resolveWith<Color>((states) {
|
||||
if (states.contains(MaterialState.selected)) {
|
||||
fillColor: WidgetStateProperty.resolveWith<Color>((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,
|
||||
|
||||
27
lib/utils/logger.dart
Normal file
27
lib/utils/logger.dart
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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('메모리 압박 대응: 캐시 크기 감소');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
/// 구독 서비스를 카테고리별로 구분하는 도우미 클래스
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,6 @@ class _SecondaryButtonState extends State<SecondaryButton> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final effectiveBorderColor = widget.borderColor ?? AppColors.secondaryColor;
|
||||
final effectiveTextColor = widget.textColor ?? AppColors.primaryColor;
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -163,7 +163,7 @@ class _GlassmorphicScaffoldState extends State<GlassmorphicScaffold>
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: gradientColors
|
||||
.map((color) => color.withOpacity(0.3))
|
||||
.map((color) => color.withValues(alpha: 0.3))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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<AnimatedGlassmorphismCard>
|
||||
_handleTapUp(details);
|
||||
// onTap 콜백 실행
|
||||
if (widget.onTap != null) {
|
||||
print('[AnimatedGlassmorphismCard] onTap 콜백 실행');
|
||||
Log.d('[AnimatedGlassmorphismCard] onTap 콜백 실행');
|
||||
widget.onTap!();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -14,7 +14,7 @@ class ScanLoadingWidget extends StatelessWidget {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
const CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(AppColors.primaryColor),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@@ -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<SubscriptionCard>
|
||||
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<SubscriptionCard>
|
||||
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(
|
||||
|
||||
@@ -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<SubscriptionProvider>(
|
||||
|
||||
@@ -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<SwipeableSubscriptionCard>
|
||||
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<SwipeableSubscriptionCard>
|
||||
|
||||
// 제스처 추적
|
||||
Offset? _startPosition;
|
||||
DateTime? _startTime;
|
||||
bool _isValidTap = true;
|
||||
// 제스처 관련 보조 변수(간소화)
|
||||
|
||||
// 상태 관리
|
||||
double _currentOffset = 0;
|
||||
@@ -95,8 +92,6 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
|
||||
// 제스처 핸들러
|
||||
void _handlePanStart(DragStartDetails details) {
|
||||
_startPosition = details.localPosition;
|
||||
_startTime = DateTime.now();
|
||||
_isValidTap = true;
|
||||
_hapticTriggered = false;
|
||||
_controller.stop();
|
||||
}
|
||||
@@ -104,12 +99,7 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
|
||||
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<SwipeableSubscriptionCard>
|
||||
}
|
||||
|
||||
// 헬퍼 메서드
|
||||
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();
|
||||
|
||||
12
test/exchange_rate_service_test.dart
Normal file
12
test/exchange_rate_service_test.dart
Normal 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));
|
||||
});
|
||||
}
|
||||
19
test/url_matcher_test.dart
Normal file
19
test/url_matcher_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user