feat: add payment card grouping and analysis

This commit is contained in:
JiWoong Sul
2025-11-14 16:53:41 +09:00
parent cba7d082bd
commit 132ae758de
40 changed files with 2846 additions and 522 deletions

View File

@@ -1,8 +1,10 @@
import 'package:flutter/material.dart';
import '../services/sms_scanner.dart';
import '../models/subscription.dart';
import '../models/payment_card_suggestion.dart';
import '../services/sms_scan/subscription_converter.dart';
import '../services/sms_scan/subscription_filter.dart';
import '../services/sms_scan/sms_scan_result.dart';
import '../providers/subscription_provider.dart';
import 'package:provider/provider.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
@@ -11,6 +13,7 @@ import '../utils/logger.dart';
import '../providers/navigation_provider.dart';
import '../providers/category_provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/payment_card_provider.dart';
class SmsScanController extends ChangeNotifier {
// 상태 관리
@@ -22,12 +25,18 @@ class SmsScanController extends ChangeNotifier {
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();
@@ -47,6 +56,14 @@ class SmsScanController extends ChangeNotifier {
notifyListeners();
}
void setSelectedPaymentCardId(String? paymentCardId) {
_selectedPaymentCardId = paymentCardId;
if (paymentCardId != null) {
_shouldSuggestCardCreation = false;
}
notifyListeners();
}
void resetWebsiteUrl() {
websiteUrlController.text = '';
}
@@ -88,18 +105,18 @@ class SmsScanController extends ChangeNotifier {
// SMS 스캔 실행
Log.i('SMS 스캔 시작');
final scannedSubscriptionModels =
final List<SmsScanResult> scanResults =
await _smsScanner.scanForSubscriptions();
Log.d('스캔된 구독: ${scannedSubscriptionModels.length}');
Log.d('스캔된 구독: ${scanResults.length}');
if (scannedSubscriptionModels.isNotEmpty) {
if (scanResults.isNotEmpty) {
Log.d(
'첫 번째 구독: ${scannedSubscriptionModels[0].serviceName}, 반복 횟수: ${scannedSubscriptionModels[0].repeatCount}');
'첫 번째 구독: ${scanResults[0].model.serviceName}, 반복 횟수: ${scanResults[0].model.repeatCount}');
}
if (!context.mounted) return;
if (scannedSubscriptionModels.isEmpty) {
if (scanResults.isEmpty) {
Log.i('스캔된 구독이 없음');
_errorMessage = AppLocalizations.of(context).subscriptionNotFound;
_isLoading = false;
@@ -109,7 +126,7 @@ class SmsScanController extends ChangeNotifier {
// SubscriptionModel을 Subscription으로 변환
final scannedSubscriptions =
_converter.convertModelsToSubscriptions(scannedSubscriptionModels);
_converter.convertResultsToSubscriptions(scanResults);
// 2회 이상 반복 결제된 구독만 필터링
final repeatSubscriptions =
@@ -155,7 +172,9 @@ class SmsScanController extends ChangeNotifier {
_scannedSubscriptions = filteredSubscriptions;
_isLoading = false;
websiteUrlController.text = ''; // URL 입력 필드 초기화
websiteUrlController.text = '';
_currentSuggestion = null;
_prepareCurrentSelection(context);
notifyListeners();
} catch (e) {
Log.e('SMS 스캔 중 오류 발생', e);
@@ -202,10 +221,14 @@ class SmsScanController extends ChangeNotifier {
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
@@ -226,6 +249,7 @@ class SmsScanController extends ChangeNotifier {
repeatCount: subscription.repeatCount,
lastPaymentDate: subscription.lastPaymentDate,
categoryId: finalCategoryId,
paymentCardId: finalPaymentCardId,
currency: subscription.currency,
);
@@ -248,8 +272,9 @@ class SmsScanController extends ChangeNotifier {
void moveToNextSubscription(BuildContext context) {
_currentIndex++;
websiteUrlController.text = ''; // URL 입력 필드 초기화
_selectedCategoryId = null; // 카테고리 선택 초기화
websiteUrlController.text = '';
_selectedCategoryId = null;
_prepareCurrentSelection(context);
// 모든 구독을 처리했으면 홈 화면으로 이동
if (_currentIndex >= _scannedSubscriptions.length) {
@@ -270,6 +295,9 @@ class SmsScanController extends ChangeNotifier {
_scannedSubscriptions = [];
_currentIndex = 0;
_errorMessage = null;
_selectedPaymentCardId = null;
_currentSuggestion = null;
_shouldSuggestCardCreation = false;
notifyListeners();
}
@@ -290,4 +318,78 @@ class SmsScanController extends ChangeNotifier {
}
}
}
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;
return;
}
final current = _scannedSubscriptions[_currentIndex];
// 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;
}
}