## 전면 광고 (AdService) - AdService 클래스 신규 생성 (lunchpick 패턴 참조) - Completer 패턴으로 광고 완료 대기 구현 - 로딩 오버레이로 앱 foreground 상태 유지 - 몰입형 모드 (immersiveSticky) 적용 - iOS 테스트 광고 ID 설정 ## SMS 스캔 버그 수정 - Isolate 내 Flutter 바인딩 접근 오류 해결 - _isoExtractServiceNameFromSender()에서 하드코딩 사용 - 로딩 위젯 화면 정중앙 배치 수정 ## 문서 및 설정 - CLAUDE.md 최적화 (글로벌 규칙 중복 제거) - Claude Code Skills 5개 추가 - flutter-build: 빌드/분석 - hive-model: Hive 모델 관리 - release-deploy: 릴리즈 배포 - sms-scanner: SMS 스캔 디버깅 - admob: 광고 구현 ## 버전 - 1.0.1+2 → 1.0.1+3
475 lines
16 KiB
Dart
475 lines
16 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
|
import 'package:provider/provider.dart';
|
|
import 'package:permission_handler/permission_handler.dart' as permission;
|
|
import 'dart:io' show Platform;
|
|
|
|
import '../services/sms_scanner.dart';
|
|
import '../services/ad_service.dart';
|
|
import '../services/sms_scan/subscription_converter.dart';
|
|
import '../services/sms_scan/subscription_filter.dart';
|
|
import '../services/sms_scan/sms_scan_result.dart';
|
|
import '../models/subscription.dart';
|
|
import '../models/payment_card_suggestion.dart';
|
|
import '../providers/subscription_provider.dart';
|
|
import '../providers/navigation_provider.dart';
|
|
import '../providers/category_provider.dart';
|
|
import '../providers/payment_card_provider.dart';
|
|
import '../l10n/app_localizations.dart';
|
|
import '../utils/logger.dart';
|
|
|
|
class SmsScanController extends ChangeNotifier {
|
|
// 상태 관리
|
|
bool _isLoading = false;
|
|
bool get isLoading => _isLoading;
|
|
|
|
String? _errorMessage;
|
|
String? get errorMessage => _errorMessage;
|
|
|
|
List<Subscription> _scannedSubscriptions = [];
|
|
List<Subscription> get scannedSubscriptions => _scannedSubscriptions;
|
|
PaymentCardSuggestion? _currentSuggestion;
|
|
PaymentCardSuggestion? get currentSuggestion => _currentSuggestion;
|
|
bool _shouldSuggestCardCreation = false;
|
|
bool get shouldSuggestCardCreation => _shouldSuggestCardCreation;
|
|
|
|
int _currentIndex = 0;
|
|
int get currentIndex => _currentIndex;
|
|
|
|
String? _selectedCategoryId;
|
|
String? get selectedCategoryId => _selectedCategoryId;
|
|
String? _selectedPaymentCardId;
|
|
String? get selectedPaymentCardId => _selectedPaymentCardId;
|
|
|
|
final TextEditingController websiteUrlController = TextEditingController();
|
|
final TextEditingController serviceNameController = TextEditingController();
|
|
|
|
// 의존성
|
|
final SmsScanner _smsScanner = SmsScanner();
|
|
final SubscriptionConverter _converter = SubscriptionConverter();
|
|
final SubscriptionFilter _filter = SubscriptionFilter();
|
|
final AdService _adService = AdService();
|
|
bool _forceServiceNameEditing = false;
|
|
bool get isServiceNameEditable => _forceServiceNameEditing;
|
|
|
|
@override
|
|
void dispose() {
|
|
serviceNameController.dispose();
|
|
websiteUrlController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void setSelectedCategoryId(String? categoryId) {
|
|
_selectedCategoryId = categoryId;
|
|
notifyListeners();
|
|
}
|
|
|
|
void setSelectedPaymentCardId(String? paymentCardId) {
|
|
_selectedPaymentCardId = paymentCardId;
|
|
if (paymentCardId != null) {
|
|
_shouldSuggestCardCreation = false;
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
void resetWebsiteUrl() {
|
|
websiteUrlController.text = '';
|
|
serviceNameController.text = '';
|
|
}
|
|
|
|
void updateCurrentServiceName(BuildContext context, String value) {
|
|
if (_currentIndex >= _scannedSubscriptions.length) return;
|
|
final trimmed = value.trim();
|
|
final unknownLabel = _unknownServiceLabel(context);
|
|
final updated = _scannedSubscriptions[_currentIndex]
|
|
.copyWith(serviceName: trimmed.isEmpty ? unknownLabel : trimmed);
|
|
_scannedSubscriptions[_currentIndex] = updated;
|
|
notifyListeners();
|
|
}
|
|
|
|
/// SMS 스캔 시작 (전면 광고 표시 후 스캔 진행)
|
|
Future<void> startScan(BuildContext context) async {
|
|
if (_isLoading) return;
|
|
|
|
// 웹/비지원 플랫폼은 바로 스캔
|
|
if (kIsWeb || !(Platform.isAndroid || Platform.isIOS)) {
|
|
await scanSms(context);
|
|
return;
|
|
}
|
|
|
|
// 광고 표시 (완료까지 대기)
|
|
// 광고 실패해도 스캔 진행 (사용자 경험 우선)
|
|
await _adService.showInterstitialAd(context);
|
|
|
|
if (!context.mounted) return;
|
|
|
|
// 광고 완료 후 SMS 스캔 실행
|
|
await scanSms(context);
|
|
}
|
|
|
|
Future<void> scanSms(BuildContext context) async {
|
|
_isLoading = true;
|
|
_errorMessage = null;
|
|
_scannedSubscriptions = [];
|
|
_currentIndex = 0;
|
|
notifyListeners();
|
|
|
|
await _performSmsScan(context);
|
|
}
|
|
|
|
/// 실제 SMS 스캔 수행 (로딩 상태는 이미 설정되어 있음)
|
|
Future<void> _performSmsScan(BuildContext context) async {
|
|
try {
|
|
// Android에서 SMS 권한 확인 및 요청
|
|
final ctx = context;
|
|
if (!kIsWeb) {
|
|
final smsStatus = await permission.Permission.sms.status;
|
|
if (!smsStatus.isGranted) {
|
|
if (smsStatus.isPermanentlyDenied) {
|
|
// 설정 유도 다이얼로그 표시
|
|
if (!ctx.mounted) return;
|
|
await _showPermissionSettingsDialog(ctx);
|
|
_isLoading = false;
|
|
notifyListeners();
|
|
return;
|
|
}
|
|
|
|
final req = await permission.Permission.sms.request();
|
|
if (!ctx.mounted) return;
|
|
if (!req.isGranted) {
|
|
// 거부됨: 안내 후 종료
|
|
if (!ctx.mounted) return;
|
|
_errorMessage = AppLocalizations.of(ctx).smsPermissionRequired;
|
|
_isLoading = false;
|
|
notifyListeners();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// SMS 스캔 실행
|
|
Log.i('SMS 스캔 시작');
|
|
final List<SmsScanResult> scanResults =
|
|
await _smsScanner.scanForSubscriptions();
|
|
Log.d('스캔된 구독: ${scanResults.length}개');
|
|
|
|
if (scanResults.isNotEmpty) {
|
|
Log.d(
|
|
'첫 번째 구독: ${scanResults[0].model.serviceName}, 반복 횟수: ${scanResults[0].model.repeatCount}');
|
|
}
|
|
|
|
if (!context.mounted) return;
|
|
|
|
if (scanResults.isEmpty) {
|
|
Log.i('스캔된 구독이 없음');
|
|
_errorMessage = AppLocalizations.of(context).subscriptionNotFound;
|
|
_isLoading = false;
|
|
notifyListeners();
|
|
return;
|
|
}
|
|
|
|
// SubscriptionModel을 Subscription으로 변환
|
|
final scannedSubscriptions =
|
|
_converter.convertResultsToSubscriptions(scanResults);
|
|
|
|
// 2회 이상 반복 결제된 구독만 필터링
|
|
final repeatSubscriptions =
|
|
_filter.filterByRepeatCount(scannedSubscriptions, 2);
|
|
Log.d('반복 결제된 구독: ${repeatSubscriptions.length}개');
|
|
|
|
if (repeatSubscriptions.isNotEmpty) {
|
|
Log.d(
|
|
'첫 번째 반복 구독: ${repeatSubscriptions[0].serviceName}, 반복 횟수: ${repeatSubscriptions[0].repeatCount}');
|
|
}
|
|
|
|
if (repeatSubscriptions.isEmpty) {
|
|
Log.i('반복 결제된 구독이 없음');
|
|
_errorMessage = AppLocalizations.of(context).repeatSubscriptionNotFound;
|
|
_isLoading = false;
|
|
notifyListeners();
|
|
return;
|
|
}
|
|
|
|
// 구독 목록 가져오기
|
|
final provider =
|
|
Provider.of<SubscriptionProvider>(context, listen: false);
|
|
final existingSubscriptions = provider.subscriptions;
|
|
Log.d('기존 구독: ${existingSubscriptions.length}개');
|
|
|
|
// 중복 구독 필터링
|
|
final filteredSubscriptions =
|
|
_filter.filterDuplicates(repeatSubscriptions, existingSubscriptions);
|
|
Log.d('중복 제거 후 구독: ${filteredSubscriptions.length}개');
|
|
|
|
if (filteredSubscriptions.isNotEmpty) {
|
|
Log.d(
|
|
'첫 번째 필터링된 구독: ${filteredSubscriptions[0].serviceName}, 반복 횟수: ${filteredSubscriptions[0].repeatCount}');
|
|
}
|
|
|
|
// 중복 제거 후 신규 구독이 없는 경우
|
|
if (filteredSubscriptions.isEmpty) {
|
|
Log.i('중복 제거 후 신규 구독이 없음');
|
|
_isLoading = false;
|
|
notifyListeners();
|
|
return;
|
|
}
|
|
|
|
_scannedSubscriptions = filteredSubscriptions;
|
|
_isLoading = false;
|
|
websiteUrlController.text = '';
|
|
_currentSuggestion = null;
|
|
_prepareCurrentSelection(context);
|
|
notifyListeners();
|
|
} catch (e) {
|
|
Log.e('SMS 스캔 중 오류 발생', e);
|
|
if (context.mounted) {
|
|
_errorMessage =
|
|
AppLocalizations.of(context).smsScanErrorWithMessage(e.toString());
|
|
_isLoading = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _showPermissionSettingsDialog(BuildContext context) async {
|
|
final loc = AppLocalizations.of(context);
|
|
await showDialog(
|
|
context: context,
|
|
builder: (_) => AlertDialog(
|
|
title: Text(loc.smsPermissionRequired),
|
|
content: Text(loc.permanentlyDeniedMessage),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: Text(loc.cancel),
|
|
),
|
|
TextButton(
|
|
onPressed: () async {
|
|
await permission.openAppSettings();
|
|
if (context.mounted) Navigator.of(context).pop();
|
|
},
|
|
child: Text(loc.openSettings),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> addCurrentSubscription(BuildContext context) async {
|
|
if (_currentIndex >= _scannedSubscriptions.length) return;
|
|
|
|
final subscription = _scannedSubscriptions[_currentIndex];
|
|
final inputName = serviceNameController.text.trim();
|
|
final resolvedServiceName =
|
|
inputName.isNotEmpty ? inputName : subscription.serviceName;
|
|
|
|
try {
|
|
final provider =
|
|
Provider.of<SubscriptionProvider>(context, listen: false);
|
|
final categoryProvider =
|
|
Provider.of<CategoryProvider>(context, listen: false);
|
|
final paymentCardProvider =
|
|
Provider.of<PaymentCardProvider>(context, listen: false);
|
|
|
|
final finalCategoryId = _selectedCategoryId ??
|
|
subscription.category ??
|
|
getDefaultCategoryId(categoryProvider);
|
|
final finalPaymentCardId =
|
|
_selectedPaymentCardId ?? paymentCardProvider.defaultCard?.id;
|
|
|
|
// websiteUrl 처리
|
|
final websiteUrl = websiteUrlController.text.trim().isNotEmpty
|
|
? websiteUrlController.text.trim()
|
|
: subscription.websiteUrl;
|
|
|
|
Log.d(
|
|
'구독 추가 시도: ${subscription.serviceName}, 카테고리: $finalCategoryId, URL: $websiteUrl');
|
|
|
|
// addSubscription 호출
|
|
await provider.addSubscription(
|
|
serviceName: resolvedServiceName,
|
|
monthlyCost: subscription.monthlyCost,
|
|
billingCycle: subscription.billingCycle,
|
|
nextBillingDate: subscription.nextBillingDate,
|
|
websiteUrl: websiteUrl,
|
|
isAutoDetected: true,
|
|
repeatCount: subscription.repeatCount,
|
|
lastPaymentDate: subscription.lastPaymentDate,
|
|
categoryId: finalCategoryId,
|
|
paymentCardId: finalPaymentCardId,
|
|
currency: subscription.currency,
|
|
);
|
|
|
|
Log.i('구독 추가 성공: ${subscription.serviceName}');
|
|
if (!context.mounted) return;
|
|
moveToNextSubscription(context);
|
|
} catch (e) {
|
|
Log.e('구독 추가 중 오류 발생', e);
|
|
// 오류가 있어도 다음 구독으로 이동
|
|
if (!context.mounted) return;
|
|
moveToNextSubscription(context);
|
|
}
|
|
}
|
|
|
|
void skipCurrentSubscription(BuildContext context) {
|
|
final subscription = _scannedSubscriptions[_currentIndex];
|
|
Log.i('구독 건너뛰기: ${subscription.serviceName}');
|
|
moveToNextSubscription(context);
|
|
}
|
|
|
|
void moveToNextSubscription(BuildContext context) {
|
|
_currentIndex++;
|
|
websiteUrlController.text = '';
|
|
serviceNameController.text = '';
|
|
_selectedCategoryId = null;
|
|
_forceServiceNameEditing = false;
|
|
_prepareCurrentSelection(context);
|
|
|
|
// 모든 구독을 처리했으면 홈 화면으로 이동
|
|
if (_currentIndex >= _scannedSubscriptions.length) {
|
|
navigateToHome(context);
|
|
}
|
|
|
|
notifyListeners();
|
|
}
|
|
|
|
void navigateToHome(BuildContext context) {
|
|
// NavigationProvider를 사용하여 홈 화면으로 이동
|
|
final navigationProvider =
|
|
Provider.of<NavigationProvider>(context, listen: false);
|
|
navigationProvider.updateCurrentIndex(0);
|
|
}
|
|
|
|
void resetState() {
|
|
_scannedSubscriptions = [];
|
|
_currentIndex = 0;
|
|
_errorMessage = null;
|
|
_selectedPaymentCardId = null;
|
|
_currentSuggestion = null;
|
|
_shouldSuggestCardCreation = false;
|
|
serviceNameController.clear();
|
|
_forceServiceNameEditing = false;
|
|
notifyListeners();
|
|
}
|
|
|
|
String getDefaultCategoryId(CategoryProvider categoryProvider) {
|
|
final otherCategory = categoryProvider.categories.firstWhere(
|
|
(cat) => cat.name == 'other',
|
|
orElse: () => categoryProvider.categories.first,
|
|
);
|
|
Log.d('기본 카테고리 설정: ${otherCategory.name} (ID: ${otherCategory.id})');
|
|
return otherCategory.id;
|
|
}
|
|
|
|
void initializeWebsiteUrl(BuildContext context) {
|
|
if (_currentIndex < _scannedSubscriptions.length) {
|
|
final currentSub = _scannedSubscriptions[_currentIndex];
|
|
if (websiteUrlController.text.isEmpty && currentSub.websiteUrl != null) {
|
|
websiteUrlController.text = currentSub.websiteUrl!;
|
|
}
|
|
final unknownLabel = _unknownServiceLabel(context);
|
|
if (_shouldEnableServiceNameEditing(currentSub, unknownLabel)) {
|
|
if (serviceNameController.text != currentSub.serviceName) {
|
|
serviceNameController.clear();
|
|
}
|
|
} else {
|
|
serviceNameController.text = currentSub.serviceName;
|
|
}
|
|
}
|
|
}
|
|
|
|
String? _getDefaultPaymentCardId(BuildContext context) {
|
|
try {
|
|
final provider = Provider.of<PaymentCardProvider>(context, listen: false);
|
|
return provider.defaultCard?.id;
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
void _prepareCurrentSelection(BuildContext context) {
|
|
if (_currentIndex >= _scannedSubscriptions.length) {
|
|
_selectedPaymentCardId = null;
|
|
_currentSuggestion = null;
|
|
_forceServiceNameEditing = false;
|
|
serviceNameController.clear();
|
|
return;
|
|
}
|
|
|
|
final current = _scannedSubscriptions[_currentIndex];
|
|
final unknownLabel = _unknownServiceLabel(context);
|
|
_forceServiceNameEditing =
|
|
_shouldEnableServiceNameEditing(current, unknownLabel);
|
|
if (_forceServiceNameEditing && current.serviceName == unknownLabel) {
|
|
serviceNameController.clear();
|
|
} else {
|
|
serviceNameController.text = current.serviceName;
|
|
}
|
|
|
|
// URL 기본값
|
|
if (current.websiteUrl != null && current.websiteUrl!.isNotEmpty) {
|
|
websiteUrlController.text = current.websiteUrl!;
|
|
} else {
|
|
websiteUrlController.clear();
|
|
}
|
|
|
|
_currentSuggestion = current.paymentCardSuggestion;
|
|
|
|
final matchedCardId = _matchCardWithSuggestion(context, _currentSuggestion);
|
|
_shouldSuggestCardCreation =
|
|
_currentSuggestion != null && matchedCardId == null;
|
|
if (matchedCardId != null) {
|
|
_selectedPaymentCardId = matchedCardId;
|
|
return;
|
|
}
|
|
|
|
// 모델에 직접 카드 정보가 존재하면 우선 사용
|
|
if (current.paymentCardId != null) {
|
|
_selectedPaymentCardId = current.paymentCardId;
|
|
return;
|
|
}
|
|
|
|
_selectedPaymentCardId = _getDefaultPaymentCardId(context);
|
|
}
|
|
|
|
String? _matchCardWithSuggestion(
|
|
BuildContext context, PaymentCardSuggestion? suggestion) {
|
|
if (suggestion == null) return null;
|
|
try {
|
|
final provider = Provider.of<PaymentCardProvider>(context, listen: false);
|
|
final cards = provider.cards;
|
|
if (cards.isEmpty) return null;
|
|
|
|
if (suggestion.hasLast4) {
|
|
for (final card in cards) {
|
|
if (card.last4 == suggestion.last4) {
|
|
return card.id;
|
|
}
|
|
}
|
|
}
|
|
|
|
final normalizedIssuer = suggestion.issuerName.toLowerCase();
|
|
for (final card in cards) {
|
|
final issuer = card.issuerName.toLowerCase();
|
|
if (issuer.contains(normalizedIssuer) ||
|
|
normalizedIssuer.contains(issuer)) {
|
|
return card.id;
|
|
}
|
|
}
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
bool _shouldEnableServiceNameEditing(
|
|
Subscription subscription, String unknownLabel) {
|
|
final name = subscription.serviceName.trim();
|
|
return name.isEmpty || name == unknownLabel;
|
|
}
|
|
|
|
String _unknownServiceLabel(BuildContext context) {
|
|
return AppLocalizations.of(context).unknownService;
|
|
}
|
|
}
|