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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -1,6 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<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="구독 관리"

View File

@@ -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": "デジタル月額管理者",

View File

@@ -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');
}
}

View File

@@ -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');
}

View File

@@ -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;
}

View File

@@ -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';
}

View File

@@ -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);
}
}

View File

@@ -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를 가지고 있습니다');

View File

@@ -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,
),

View File

@@ -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(

View File

@@ -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,
),
),

View File

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

View File

@@ -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<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
? 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),
),
);
},
),

View File

@@ -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),
)
],
),

View File

@@ -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';

View File

@@ -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,

View File

@@ -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);
}
}

View File

@@ -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');

View File

@@ -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');
}
}

View File

@@ -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;
}

View File

@@ -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';
}
}

View File

@@ -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;
}

View File

@@ -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';
}

View File

@@ -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;
}
}

View File

@@ -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++) {

View File

@@ -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(

View File

@@ -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
View File

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

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/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('메모리 압박 대응: 캐시 크기 감소');
}
}

View File

@@ -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;
}
}

View File

@@ -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';
/// 구독 서비스를 카테고리별로 구분하는 도우미 클래스

View File

@@ -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,

View File

@@ -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,

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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),

View File

@@ -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,

View File

@@ -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,
),

View File

@@ -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,
),

View File

@@ -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,
),

View File

@@ -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),
),
],
),

View File

@@ -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';

View File

@@ -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,

View File

@@ -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,

View File

@@ -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(),
),
),

View File

@@ -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!();
}
},

View File

@@ -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,

View File

@@ -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),

View File

@@ -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(

View File

@@ -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>(

View File

@@ -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();

View File

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

View File

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