feat: improve sms scan review and detail layouts

This commit is contained in:
JiWoong Sul
2025-11-14 19:33:32 +09:00
parent a9f42f6f01
commit 2cd46a303e
13 changed files with 455 additions and 115 deletions

View File

@@ -152,6 +152,8 @@
"scanTextMessages": "Scan text messages to automatically find repeatedly paid subscriptions.\nService names and amounts can be extracted for easy subscription addition.\nThis auto-detection feature is still under development, and might miss or misidentify some subscriptions.\nPlease review the detected results and add or edit subscriptions manually if needed.", "scanTextMessages": "Scan text messages to automatically find repeatedly paid subscriptions.\nService names and amounts can be extracted for easy subscription addition.\nThis auto-detection feature is still under development, and might miss or misidentify some subscriptions.\nPlease review the detected results and add or edit subscriptions manually if needed.",
"startScanning": "Start Scanning", "startScanning": "Start Scanning",
"foundSubscription": "Found subscription", "foundSubscription": "Found subscription",
"latestSmsMessage": "Latest SMS message",
"smsDetectedDate": "Detected on @",
"serviceName": "Service Name", "serviceName": "Service Name",
"nextBillingDateLabel": "Next Billing Date", "nextBillingDateLabel": "Next Billing Date",
"category": "Category", "category": "Category",
@@ -406,6 +408,8 @@
"scanTextMessages": "문자 메시지를 스캔하여 반복적으로 결제된 구독 서비스를 자동으로 찾습니다.\n서비스명과 금액을 추출하여 쉽게 구독을 추가할 수 있습니다.\n이 자동 감지 기능은 일부 구독 서비스를 놓치거나 잘못 인식할 수 있습니다.\n감지 결과를 확인하신 후 필요에 따라 수동으로 추가하거나 수정해 주세요.", "scanTextMessages": "문자 메시지를 스캔하여 반복적으로 결제된 구독 서비스를 자동으로 찾습니다.\n서비스명과 금액을 추출하여 쉽게 구독을 추가할 수 있습니다.\n이 자동 감지 기능은 일부 구독 서비스를 놓치거나 잘못 인식할 수 있습니다.\n감지 결과를 확인하신 후 필요에 따라 수동으로 추가하거나 수정해 주세요.",
"startScanning": "스캔 시작하기", "startScanning": "스캔 시작하기",
"foundSubscription": "다음 구독을 찾았습니다", "foundSubscription": "다음 구독을 찾았습니다",
"latestSmsMessage": "최신 SMS 메시지",
"smsDetectedDate": "SMS 수신일: @",
"serviceName": "서비스명", "serviceName": "서비스명",
"nextBillingDateLabel": "다음 결제일", "nextBillingDateLabel": "다음 결제일",
"category": "카테고리", "category": "카테고리",
@@ -660,6 +664,8 @@
"scanTextMessages": "テキストメッセージをスキャンして、繰り返し決済されたサブスクリプションを自動的に検出します。\nサービス名と金額を抽出して簡単にサブスクリプションを追加できます。\nこの自動検出機能は、一部のサブスクリプションを見落としたり誤検出する可能性があります。\n検出結果を確認し、必要に応じて手動で追加または修正してください。", "scanTextMessages": "テキストメッセージをスキャンして、繰り返し決済されたサブスクリプションを自動的に検出します。\nサービス名と金額を抽出して簡単にサブスクリプションを追加できます。\nこの自動検出機能は、一部のサブスクリプションを見落としたり誤検出する可能性があります。\n検出結果を確認し、必要に応じて手動で追加または修正してください。",
"startScanning": "スキャン開始", "startScanning": "スキャン開始",
"foundSubscription": "サブスクリプションが見つかりました", "foundSubscription": "サブスクリプションが見つかりました",
"latestSmsMessage": "最新のSMSメッセージ",
"smsDetectedDate": "SMS受信日: @",
"serviceName": "サービス名", "serviceName": "サービス名",
"nextBillingDateLabel": "次回請求日", "nextBillingDateLabel": "次回請求日",
"category": "カテゴリー", "category": "カテゴリー",
@@ -903,6 +909,8 @@
"scanTextMessages": "扫描短信以自动查找重复付款的订阅。\n可以提取服务名称和金额轻松添加订阅。\n该自动检测功能可能会遗漏或误识别某些订阅。\n请检查检测结果并在需要时手动添加或修改。", "scanTextMessages": "扫描短信以自动查找重复付款的订阅。\n可以提取服务名称和金额轻松添加订阅。\n该自动检测功能可能会遗漏或误识别某些订阅。\n请检查检测结果并在需要时手动添加或修改。",
"startScanning": "开始扫描", "startScanning": "开始扫描",
"foundSubscription": "找到订阅", "foundSubscription": "找到订阅",
"latestSmsMessage": "最新短信内容",
"smsDetectedDate": "短信接收日期:@",
"serviceName": "服务名称", "serviceName": "服务名称",
"nextBillingDateLabel": "下次付款日期", "nextBillingDateLabel": "下次付款日期",
"category": "类别", "category": "类别",

View File

@@ -39,14 +39,18 @@ class SmsScanController extends ChangeNotifier {
String? get selectedPaymentCardId => _selectedPaymentCardId; String? get selectedPaymentCardId => _selectedPaymentCardId;
final TextEditingController websiteUrlController = TextEditingController(); final TextEditingController websiteUrlController = TextEditingController();
final TextEditingController serviceNameController = TextEditingController();
// 의존성 // 의존성
final SmsScanner _smsScanner = SmsScanner(); final SmsScanner _smsScanner = SmsScanner();
final SubscriptionConverter _converter = SubscriptionConverter(); final SubscriptionConverter _converter = SubscriptionConverter();
final SubscriptionFilter _filter = SubscriptionFilter(); final SubscriptionFilter _filter = SubscriptionFilter();
bool _forceServiceNameEditing = false;
bool get isServiceNameEditable => _forceServiceNameEditing;
@override @override
void dispose() { void dispose() {
serviceNameController.dispose();
websiteUrlController.dispose(); websiteUrlController.dispose();
super.dispose(); super.dispose();
} }
@@ -66,6 +70,16 @@ class SmsScanController extends ChangeNotifier {
void resetWebsiteUrl() { void resetWebsiteUrl() {
websiteUrlController.text = ''; websiteUrlController.text = '';
serviceNameController.text = '';
}
void updateCurrentServiceName(String value) {
if (_currentIndex >= _scannedSubscriptions.length) return;
final trimmed = value.trim();
final updated = _scannedSubscriptions[_currentIndex]
.copyWith(serviceName: trimmed.isEmpty ? '알 수 없는 서비스' : trimmed);
_scannedSubscriptions[_currentIndex] = updated;
notifyListeners();
} }
Future<void> scanSms(BuildContext context) async { Future<void> scanSms(BuildContext context) async {
@@ -215,6 +229,9 @@ class SmsScanController extends ChangeNotifier {
if (_currentIndex >= _scannedSubscriptions.length) return; if (_currentIndex >= _scannedSubscriptions.length) return;
final subscription = _scannedSubscriptions[_currentIndex]; final subscription = _scannedSubscriptions[_currentIndex];
final inputName = serviceNameController.text.trim();
final resolvedServiceName =
inputName.isNotEmpty ? inputName : subscription.serviceName;
try { try {
final provider = final provider =
@@ -240,7 +257,7 @@ class SmsScanController extends ChangeNotifier {
// addSubscription 호출 // addSubscription 호출
await provider.addSubscription( await provider.addSubscription(
serviceName: subscription.serviceName, serviceName: resolvedServiceName,
monthlyCost: subscription.monthlyCost, monthlyCost: subscription.monthlyCost,
billingCycle: subscription.billingCycle, billingCycle: subscription.billingCycle,
nextBillingDate: subscription.nextBillingDate, nextBillingDate: subscription.nextBillingDate,
@@ -273,7 +290,9 @@ class SmsScanController extends ChangeNotifier {
void moveToNextSubscription(BuildContext context) { void moveToNextSubscription(BuildContext context) {
_currentIndex++; _currentIndex++;
websiteUrlController.text = ''; websiteUrlController.text = '';
serviceNameController.text = '';
_selectedCategoryId = null; _selectedCategoryId = null;
_forceServiceNameEditing = false;
_prepareCurrentSelection(context); _prepareCurrentSelection(context);
// 모든 구독을 처리했으면 홈 화면으로 이동 // 모든 구독을 처리했으면 홈 화면으로 이동
@@ -298,6 +317,8 @@ class SmsScanController extends ChangeNotifier {
_selectedPaymentCardId = null; _selectedPaymentCardId = null;
_currentSuggestion = null; _currentSuggestion = null;
_shouldSuggestCardCreation = false; _shouldSuggestCardCreation = false;
serviceNameController.clear();
_forceServiceNameEditing = false;
notifyListeners(); notifyListeners();
} }
@@ -316,6 +337,13 @@ class SmsScanController extends ChangeNotifier {
if (websiteUrlController.text.isEmpty && currentSub.websiteUrl != null) { if (websiteUrlController.text.isEmpty && currentSub.websiteUrl != null) {
websiteUrlController.text = currentSub.websiteUrl!; websiteUrlController.text = currentSub.websiteUrl!;
} }
if (_shouldEnableServiceNameEditing(currentSub)) {
if (serviceNameController.text != currentSub.serviceName) {
serviceNameController.clear();
}
} else {
serviceNameController.text = currentSub.serviceName;
}
} }
} }
@@ -332,10 +360,18 @@ class SmsScanController extends ChangeNotifier {
if (_currentIndex >= _scannedSubscriptions.length) { if (_currentIndex >= _scannedSubscriptions.length) {
_selectedPaymentCardId = null; _selectedPaymentCardId = null;
_currentSuggestion = null; _currentSuggestion = null;
_forceServiceNameEditing = false;
serviceNameController.clear();
return; return;
} }
final current = _scannedSubscriptions[_currentIndex]; final current = _scannedSubscriptions[_currentIndex];
_forceServiceNameEditing = _shouldEnableServiceNameEditing(current);
if (_forceServiceNameEditing && current.serviceName == '알 수 없는 서비스') {
serviceNameController.clear();
} else {
serviceNameController.text = current.serviceName;
}
// URL 기본값 // URL 기본값
if (current.websiteUrl != null && current.websiteUrl!.isNotEmpty) { if (current.websiteUrl != null && current.websiteUrl!.isNotEmpty) {
@@ -392,4 +428,9 @@ class SmsScanController extends ChangeNotifier {
} }
return null; return null;
} }
bool _shouldEnableServiceNameEditing(Subscription subscription) {
final name = subscription.serviceName.trim();
return name.isEmpty || name == '알 수 없는 서비스';
}
} }

View File

@@ -457,6 +457,13 @@ class AppLocalizations {
String get foundSubscription => String get foundSubscription =>
_localizedStrings['foundSubscription'] ?? 'Found subscription'; _localizedStrings['foundSubscription'] ?? 'Found subscription';
String get serviceName => _localizedStrings['serviceName'] ?? 'Service Name'; String get serviceName => _localizedStrings['serviceName'] ?? 'Service Name';
String get latestSmsMessage =>
_localizedStrings['latestSmsMessage'] ?? 'Latest SMS message';
String smsDetectedDate(String date) {
final template = _localizedStrings['smsDetectedDate'] ?? 'Detected on @';
return template.replaceAll('@', date);
}
String get nextBillingDateLabel => String get nextBillingDateLabel =>
_localizedStrings['nextBillingDateLabel'] ?? 'Next Billing Date'; _localizedStrings['nextBillingDateLabel'] ?? 'Next Billing Date';
String get category => _localizedStrings['category'] ?? 'Category'; String get category => _localizedStrings['category'] ?? 'Category';

View File

@@ -14,6 +14,7 @@ class Subscription {
final String currency; final String currency;
final String? paymentCardId; final String? paymentCardId;
final PaymentCardSuggestion? paymentCardSuggestion; final PaymentCardSuggestion? paymentCardSuggestion;
final String? rawMessage;
Subscription({ Subscription({
required this.id, required this.id,
@@ -29,8 +30,50 @@ class Subscription {
this.currency = 'KRW', this.currency = 'KRW',
this.paymentCardId, this.paymentCardId,
this.paymentCardSuggestion, this.paymentCardSuggestion,
this.rawMessage,
}); });
Subscription copyWith({
String? id,
String? serviceName,
double? monthlyCost,
String? billingCycle,
DateTime? nextBillingDate,
String? category,
String? notes,
int? repeatCount,
DateTime? lastPaymentDate,
String? websiteUrl,
String? currency,
String? paymentCardId,
PaymentCardSuggestion? paymentCardSuggestion,
String? rawMessage,
}) {
return Subscription(
id: id ?? this.id,
serviceName: serviceName ?? this.serviceName,
monthlyCost: monthlyCost ?? this.monthlyCost,
billingCycle: billingCycle ?? this.billingCycle,
nextBillingDate: nextBillingDate ?? this.nextBillingDate,
category: category ?? this.category,
notes: notes ?? this.notes,
repeatCount: repeatCount ?? this.repeatCount,
lastPaymentDate: lastPaymentDate ?? this.lastPaymentDate,
websiteUrl: websiteUrl ?? this.websiteUrl,
currency: currency ?? this.currency,
paymentCardId: paymentCardId ?? this.paymentCardId,
paymentCardSuggestion: paymentCardSuggestion ??
(this.paymentCardSuggestion != null
? PaymentCardSuggestion(
issuerName: this.paymentCardSuggestion!.issuerName,
last4: this.paymentCardSuggestion!.last4,
source: this.paymentCardSuggestion!.source,
)
: null),
rawMessage: rawMessage ?? this.rawMessage,
);
}
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
return { return {
'id': id, 'id': id,
@@ -48,6 +91,7 @@ class Subscription {
'paymentCardSuggestionIssuer': paymentCardSuggestion?.issuerName, 'paymentCardSuggestionIssuer': paymentCardSuggestion?.issuerName,
'paymentCardSuggestionLast4': paymentCardSuggestion?.last4, 'paymentCardSuggestionLast4': paymentCardSuggestion?.last4,
'paymentCardSuggestionSource': paymentCardSuggestion?.source, 'paymentCardSuggestionSource': paymentCardSuggestion?.source,
'rawMessage': rawMessage,
}; };
} }
@@ -74,6 +118,7 @@ class Subscription {
source: map['paymentCardSuggestionSource'] as String?, source: map['paymentCardSuggestionSource'] as String?,
) )
: null, : null,
rawMessage: map['rawMessage'] as String?,
); );
} }

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/navigation_provider.dart'; import '../providers/navigation_provider.dart';
import '../routes/app_routes.dart';
class AppNavigationObserver extends NavigatorObserver { class AppNavigationObserver extends NavigatorObserver {
@override @override
@@ -47,6 +48,12 @@ class AppNavigationObserver extends NavigatorObserver {
final routeName = route.settings.name; final routeName = route.settings.name;
if (routeName == null) return; if (routeName == null) return;
// 메인 화면('/')은 하단 탭으로 상태를 관리하므로
// 모달 닫힘 등으로 인해 홈 탭으로 강제 전환하지 않도록 무시한다.
if (routeName == AppRoutes.main || routeName == '/') {
return;
}
// build 완료 후 업데이트하도록 변경 // build 완료 후 업데이트하도록 변경
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (navigator?.context == null) return; if (navigator?.context == null) return;

View File

@@ -96,11 +96,16 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
const SizedBox(height: 24), const SizedBox(height: 24),
SubscriptionCardWidget( SubscriptionCardWidget(
subscription: currentSubscription, subscription: currentSubscription,
serviceNameController: _controller.serviceNameController,
websiteUrlController: _controller.websiteUrlController, websiteUrlController: _controller.websiteUrlController,
selectedCategoryId: _controller.selectedCategoryId, selectedCategoryId: _controller.selectedCategoryId,
onCategoryChanged: _controller.setSelectedCategoryId, onCategoryChanged: _controller.setSelectedCategoryId,
selectedPaymentCardId: _controller.selectedPaymentCardId, selectedPaymentCardId: _controller.selectedPaymentCardId,
onPaymentCardChanged: _controller.setSelectedPaymentCardId, onPaymentCardChanged: _controller.setSelectedPaymentCardId,
enableServiceNameEditing: _controller.isServiceNameEditable,
onServiceNameChanged: _controller.isServiceNameEditable
? _controller.updateCurrentServiceName
: null,
onAddCard: () async { onAddCard: () async {
final newCardId = await PaymentCardFormSheet.show(context); final newCardId = await PaymentCardFormSheet.show(context);
if (newCardId != null) { if (newCardId != null) {

View File

@@ -41,6 +41,7 @@ class SubscriptionConverter {
currency: model.currency, currency: model.currency,
paymentCardId: model.paymentCardId, paymentCardId: model.paymentCardId,
paymentCardSuggestion: result.cardSuggestion, paymentCardSuggestion: result.cardSuggestion,
rawMessage: result.rawMessage,
); );
} }

View File

@@ -40,9 +40,22 @@ class SmsScanner {
return []; return [];
} }
final filteredSms = smsList
.whereType<Map<String, dynamic>>()
.where(_isEligibleSubscriptionSms)
.toList();
Log.d(
'SmsScanner: 유효 결제 SMS ${filteredSms.length}건 / 전체 ${smsList.length}');
if (filteredSms.isEmpty) {
Log.w('SmsScanner: 결제 패턴 SMS 미검출');
return [];
}
// SMS 데이터를 분석하여 반복 결제되는 구독 식별 // SMS 데이터를 분석하여 반복 결제되는 구독 식별
final List<SmsScanResult> subscriptions = []; final List<SmsScanResult> subscriptions = [];
final serviceGroups = _groupMessagesByIdentifier(smsList); final serviceGroups = _groupMessagesByIdentifier(filteredSms);
Log.d('SmsScanner: 그룹화된 키 수: ${serviceGroups.length}'); Log.d('SmsScanner: 그룹화된 키 수: ${serviceGroups.length}');
@@ -355,16 +368,80 @@ class SmsScanner {
} }
} }
const List<String> _paymentLikeKeywords = [
'승인',
'결제',
'청구',
'charged',
'charge',
'payment',
'billed',
'purchase',
];
const List<String> _blockedKeywords = [
'otp',
'인증',
'보안',
'verification',
'code',
'코드',
'password',
'pw',
'일회성',
'1회용',
'보안문자',
];
bool _containsPaymentKeyword(String message) {
if (message.isEmpty) return false;
final normalized = message.toLowerCase();
return _paymentLikeKeywords.any(
(keyword) => normalized.contains(keyword.toLowerCase()),
);
}
bool _containsBlockedKeyword(String message) {
if (message.isEmpty) return false;
final normalized = message.toLowerCase();
return _blockedKeywords.any(
(keyword) => normalized.contains(keyword.toLowerCase()),
);
}
bool _isEligibleSubscriptionSms(Map<String, dynamic> sms) {
final amount = (sms['monthlyCost'] as num?)?.toDouble();
if (amount == null || amount <= 0) {
return false;
}
final message = sms['message'] as String? ?? '';
final isPaymentLike =
(sms['isPaymentLike'] as bool?) ?? _containsPaymentKeyword(message);
final isBlocked =
(sms['isBlocked'] as bool?) ?? _containsBlockedKeyword(message);
if (!isPaymentLike || isBlocked) {
return false;
}
return true;
}
// ===== Isolate 오프로딩용 Top-level 파서 ===== // ===== Isolate 오프로딩용 Top-level 파서 =====
// 대량 SMS 파싱: plugin 객체를 직렬화한 맵 리스트를 받아 구독 후보 맵 리스트로 변환 // 대량 SMS 파싱: plugin 객체를 직렬화한 맵 리스트를 받아 구독 후보 맵 리스트로 변환
List<Map<String, dynamic>> _parseRawSmsBatch( List<Map<String, dynamic>> _parseRawSmsBatch(
List<Map<String, dynamic>> messages) { List<Map<String, dynamic>> messages) {
final amountPatterns = <RegExp>[ final amountPatterns = <RegExp>[
RegExp(r'(\d{1,3}(?:,\d{3})*)(?:원|₩)'), // 원화 RegExp(r'(\d{1,3}(?:,\d{3})*(?:\.\d{1,2})?)\s*(?:원|₩)'),
RegExp(r'\$(\d+(?:\.\d{2})?)'), // 달러 RegExp(r'(?:원|₩)\s*(\d{1,3}(?:,\d{3})*(?:\.\d{1,2})?)'),
RegExp(r'(\d+(?:\.\d{2})?)\s*USD'), // USD RegExp(r'(?:(?:US)?\$)\s*(\d+(?:\.\d{1,2})?)', caseSensitive: false),
RegExp(r'결제.*?(\d{1,3}(?:,\d{3})*)'), // 결제 금액 RegExp(r'(\d+(?:,\d{3})*(?:\.\d{1,2})?)\s*(?:USD|KRW)',
caseSensitive: false),
RegExp(r'(?:USD|KRW)\s*(\d+(?:,\d{3})*(?:\.\d{1,2})?)',
caseSensitive: false),
RegExp(r'(?:결제|승인)[^0-9]{0,12}(\d{1,3}(?:,\d{3})*(?:\.\d{1,2})?)'),
]; ];
final results = <Map<String, dynamic>>[]; final results = <Map<String, dynamic>>[];
@@ -377,6 +454,8 @@ List<Map<String, dynamic>> _parseRawSmsBatch(
final date = DateTime.fromMillisecondsSinceEpoch(dateMillis); final date = DateTime.fromMillisecondsSinceEpoch(dateMillis);
final serviceName = _isoExtractServiceName(body, sender); final serviceName = _isoExtractServiceName(body, sender);
final amount = _isoExtractAmount(body, amountPatterns); final amount = _isoExtractAmount(body, amountPatterns);
final isPaymentLike = _containsPaymentKeyword(body);
final isBlocked = _containsBlockedKeyword(body);
final billingCycle = _isoExtractBillingCycle(body); final billingCycle = _isoExtractBillingCycle(body);
final nextBillingDate = final nextBillingDate =
_isoCalculateNextBillingFromDate(date, billingCycle); _isoCalculateNextBillingFromDate(date, billingCycle);
@@ -391,6 +470,8 @@ List<Map<String, dynamic>> _parseRawSmsBatch(
'normalizedBody': normalizedBody, 'normalizedBody': normalizedBody,
'nextBillingDate': nextBillingDate.toIso8601String(), 'nextBillingDate': nextBillingDate.toIso8601String(),
'previousPaymentDate': date.toIso8601String(), 'previousPaymentDate': date.toIso8601String(),
'isPaymentLike': isPaymentLike,
'isBlocked': isBlocked,
}); });
} }
@@ -557,6 +638,10 @@ _RepeatDetectionResult? _detectRepeatingSubscriptions(
if (matchedIndices.length < 2) return null; if (matchedIndices.length < 2) return null;
final hasValidInterval = matchedPairs.any((pair) =>
pair.type == _MatchType.monthly || pair.type == _MatchType.yearly);
if (!hasValidInterval) return null;
final baseIndex = matchedIndices final baseIndex = matchedIndices
.reduce((value, element) => value < element ? value : element); .reduce((value, element) => value < element ? value : element);
final baseMessage = Map<String, dynamic>.from(sorted[baseIndex]); final baseMessage = Map<String, dynamic>.from(sorted[baseIndex]);

View File

@@ -166,6 +166,21 @@ class TestSmsData {
'message': 'message':
'[GitHub] Your Pro plan has been renewed for \$4.00 USD. View your receipt at github.com/receipt. Next bill on ${DateTime(now.year, now.month + 1, 3).day}' '[GitHub] Your Pro plan has been renewed for \$4.00 USD. View your receipt at github.com/receipt. Next bill on ${DateTime(now.year, now.month + 1, 3).day}'
}, },
{
'serviceName': 'Enterprise Cloud Suite',
'monthlyCost': 990.0,
'billingCycle': '월간',
'nextBillingDate':
'${DateTime(now.year, now.month + 1, 25).year}-${DateTime(now.year, now.month + 1, 25).month.toString().padLeft(2, '0')}-25',
'isRecurring': true,
'repeatCount': 3,
'sender': '445566',
'messageDate': formattedNow,
'previousPaymentDate':
'${DateTime(now.year, now.month - 1, 25).year}-${DateTime(now.year, now.month - 1, 25).month.toString().padLeft(2, '0')}-25',
'message':
'[Enterprise Cloud] Your enterprise tier has been renewed. \$990.00 USD charged to your card. Next billing date: ${DateTime(now.year, now.month + 1, 25).day}'
},
]; ];
// 각 서비스별로 여러 개의 메시지 생성 (그룹화를 위해) // 각 서비스별로 여러 개의 메시지 생성 (그룹화를 위해)

View File

@@ -41,7 +41,7 @@ class DetailHeaderSection extends StatelessWidget {
); );
return Container( return Container(
height: 320, constraints: const BoxConstraints(minHeight: 320),
decoration: BoxDecoration(color: baseColor), decoration: BoxDecoration(color: baseColor),
child: Stack( child: Stack(
children: [ children: [
@@ -78,6 +78,7 @@ class DetailHeaderSection extends StatelessWidget {
child: Padding( child: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// 뒤로가기 버튼 // 뒤로가기 버튼
@@ -100,7 +101,7 @@ class DetailHeaderSection extends StatelessWidget {
), ),
], ],
), ),
const Spacer(), const SizedBox(height: 16),
// 서비스 정보 // 서비스 정보
FadeTransition( FadeTransition(
opacity: fadeAnimation, opacity: fadeAnimation,
@@ -200,42 +201,47 @@ class DetailHeaderSection extends StatelessWidget {
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), ),
child: Row( child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [ children: [
_InfoColumn( Expanded(
label: AppLocalizations.of(context) child: _InfoColumn(
.nextBillingDate, label: AppLocalizations.of(context)
value: AppLocalizations.of(context) .nextBillingDate,
.formatDate( value: AppLocalizations.of(context)
controller.nextBillingDate), .formatDate(
controller.nextBillingDate),
),
), ),
FutureBuilder<String>( const SizedBox(width: 12),
future: () async { Expanded(
final locale = context child: FutureBuilder<String>(
.read<LocaleProvider>() future: () async {
.locale final locale = context
.languageCode; .read<LocaleProvider>()
final amount = double.tryParse( .locale
controller .languageCode;
.monthlyCostController.text final amount = double.tryParse(
.replaceAll(',', '')) ?? controller
0; .monthlyCostController
return CurrencyUtil .text
.formatAmountWithLocale( .replaceAll(',', '')) ??
amount, 0;
controller.currency, return CurrencyUtil
locale, .formatAmountWithLocale(
); amount,
}(), controller.currency,
builder: (context, snapshot) { locale,
return _InfoColumn( );
label: AppLocalizations.of(context) }(),
.monthlyExpense, builder: (context, snapshot) {
value: snapshot.data ?? '-', return _InfoColumn(
alignment: CrossAxisAlignment.end, label: AppLocalizations.of(context)
); .monthlyExpense,
}, value: snapshot.data ?? '-',
alignment: CrossAxisAlignment.end,
wrapValue: true,
);
},
),
), ),
], ],
), ),
@@ -387,11 +393,13 @@ class _InfoColumn extends StatelessWidget {
final String label; final String label;
final String value; final String value;
final CrossAxisAlignment alignment; final CrossAxisAlignment alignment;
final bool wrapValue;
const _InfoColumn({ const _InfoColumn({
required this.label, required this.label,
required this.value, required this.value,
this.alignment = CrossAxisAlignment.start, this.alignment = CrossAxisAlignment.start,
this.wrapValue = false,
}); });
@override @override
@@ -408,14 +416,27 @@ class _InfoColumn extends StatelessWidget {
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( if (wrapValue)
value, Text(
style: const TextStyle( value,
fontSize: 18, textAlign: TextAlign.end,
fontWeight: FontWeight.w700, maxLines: 2,
color: Colors.white, overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: Colors.white,
),
)
else
Text(
value,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: Colors.white,
),
), ),
),
], ],
); );
} }

View File

@@ -19,6 +19,7 @@ import '../../l10n/app_localizations.dart';
class SubscriptionCardWidget extends StatefulWidget { class SubscriptionCardWidget extends StatefulWidget {
final Subscription subscription; final Subscription subscription;
final TextEditingController serviceNameController;
final TextEditingController websiteUrlController; final TextEditingController websiteUrlController;
final String? selectedCategoryId; final String? selectedCategoryId;
final Function(String?) onCategoryChanged; final Function(String?) onCategoryChanged;
@@ -32,10 +33,13 @@ class SubscriptionCardWidget extends StatefulWidget {
final bool showDetectedCardShortcut; final bool showDetectedCardShortcut;
final Future<void> Function(PaymentCardSuggestion suggestion)? final Future<void> Function(PaymentCardSuggestion suggestion)?
onAddDetectedCard; onAddDetectedCard;
final bool enableServiceNameEditing;
final ValueChanged<String>? onServiceNameChanged;
const SubscriptionCardWidget({ const SubscriptionCardWidget({
super.key, super.key,
required this.subscription, required this.subscription,
required this.serviceNameController,
required this.websiteUrlController, required this.websiteUrlController,
this.selectedCategoryId, this.selectedCategoryId,
required this.onCategoryChanged, required this.onCategoryChanged,
@@ -48,6 +52,8 @@ class SubscriptionCardWidget extends StatefulWidget {
this.detectedCardSuggestion, this.detectedCardSuggestion,
this.showDetectedCardShortcut = false, this.showDetectedCardShortcut = false,
this.onAddDetectedCard, this.onAddDetectedCard,
this.enableServiceNameEditing = false,
this.onServiceNameChanged,
}); });
@override @override
@@ -84,6 +90,12 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
// 광고 위젯 추가 // 광고 위젯 추가
const NativeAdWidget(key: ValueKey('sms_scan_result_ad')), const NativeAdWidget(key: ValueKey('sms_scan_result_ad')),
const SizedBox(height: 16), const SizedBox(height: 16),
if (_hasRawSmsMessage)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: _buildSmsPreviewCard(context),
),
if (_hasRawSmsMessage) const SizedBox(height: 16),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0), padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column( child: Column(
@@ -143,6 +155,75 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
); );
} }
bool get _hasRawSmsMessage {
return widget.subscription.rawMessage != null &&
widget.subscription.rawMessage!.trim().isNotEmpty;
}
Widget _buildSmsPreviewCard(BuildContext context) {
final theme = Theme.of(context);
final loc = AppLocalizations.of(context);
final rawMessage = widget.subscription.rawMessage?.trim() ?? '';
final lastDate = widget.subscription.lastPaymentDate;
return Card(
elevation: 0,
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.sms_outlined, color: theme.colorScheme.primary),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ThemedText(
loc.latestSmsMessage,
fontWeight: FontWeight.bold,
),
if (lastDate != null)
ThemedText(
loc.smsDetectedDate(loc.formatDate(lastDate)),
opacity: 0.7,
fontSize: 13,
),
],
),
),
],
),
const SizedBox(height: 12),
Container(
width: double.infinity,
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: theme.colorScheme.outline.withValues(alpha: 0.3),
),
),
padding: const EdgeInsets.all(12),
child: SelectableText(
rawMessage,
style: TextStyle(
fontSize: 15,
height: 1.4,
color: theme.colorScheme.onSurface,
),
),
),
],
),
),
);
}
// 정보 섹션 (클릭 가능) // 정보 섹션 (클릭 가능)
Widget _buildInfoSection(CategoryProvider categoryProvider) { Widget _buildInfoSection(CategoryProvider categoryProvider) {
return Column( return Column(
@@ -162,11 +243,20 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
opacity: 0.7, opacity: 0.7,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
ThemedText( if (widget.enableServiceNameEditing)
widget.subscription.serviceName, BaseTextField(
fontSize: 22, controller: widget.serviceNameController,
fontWeight: FontWeight.bold, hintText: AppLocalizations.of(context).serviceNameRequired,
), onChanged: widget.onServiceNameChanged,
textInputAction: TextInputAction.done,
maxLines: 1,
)
else
ThemedText(
widget.subscription.serviceName,
fontSize: 22,
fontWeight: FontWeight.bold,
),
const SizedBox(height: 16), const SizedBox(height: 16),
// 금액 및 결제 주기 // 금액 및 결제 주기

View File

@@ -474,67 +474,75 @@ class _SubscriptionCardState extends State<SubscriptionCard>
// 가격 정보 // 가격 정보
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// 가격 표시 (이벤트 가격 반영) // 가격 표시 (이벤트 가격 반영)
// 가격 표시 (언어별 통화) Expanded(
FutureBuilder<String>( child: FutureBuilder<String>(
future: _getFormattedPrice(), future: _getFormattedPrice(),
builder: (context, snapshot) { builder: (context, snapshot) {
if (!snapshot.hasData) { if (!snapshot.hasData) {
return const SizedBox(); return const SizedBox();
} }
if (widget if (widget.subscription
.subscription.isCurrentlyInEvent && .isCurrentlyInEvent &&
snapshot.data!.contains('|')) { snapshot.data!.contains('|')) {
final prices = snapshot.data!.split('|'); final prices =
return Row( snapshot.data!.split('|');
children: [ return Wrap(
Text( spacing: 8,
prices[0], runSpacing: 4,
style: TextStyle( crossAxisAlignment:
fontSize: 14, WrapCrossAlignment.center,
fontWeight: FontWeight.w500, children: [
color: Theme.of(context) Text(
.colorScheme prices[0],
.onSurfaceVariant, style: TextStyle(
decoration: fontSize: 14,
TextDecoration.lineThrough, fontWeight: FontWeight.w500,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
decoration:
TextDecoration.lineThrough,
),
), ),
), Text(
const SizedBox(width: 8), prices[1],
Text( style: TextStyle(
prices[1], fontSize: 16,
style: TextStyle( fontWeight: FontWeight.w700,
fontSize: 16, color: Theme.of(context)
fontWeight: FontWeight.w700, .colorScheme
color: Theme.of(context) .error,
.colorScheme ),
.error,
), ),
],
);
} else {
return Text(
snapshot.data!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: widget.subscription
.isCurrentlyInEvent
? Theme.of(context)
.colorScheme
.error
: Theme.of(context)
.colorScheme
.primary,
), ),
], );
); }
} else { },
return Text( ),
snapshot.data!,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: widget.subscription
.isCurrentlyInEvent
? Theme.of(context)
.colorScheme
.error
: Theme.of(context)
.colorScheme
.primary,
),
);
}
},
), ),
const SizedBox(width: 12),
// 결제 예정일 정보 // 결제 예정일 정보
Container( Container(

View File

@@ -32,10 +32,11 @@ class SubscriptionGroupHeader extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( Expanded(
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (group.mode == SubscriptionGroupingMode.paymentCard && if (group.mode == SubscriptionGroupingMode.paymentCard &&
group.paymentCard != null) group.paymentCard != null)
@@ -70,12 +71,18 @@ class SubscriptionGroupHeader extends StatelessWidget {
], ],
), ),
), ),
Text( const SizedBox(width: 12),
_buildCostDisplay(context), Flexible(
style: TextStyle( child: Text(
fontSize: 12, _buildCostDisplay(context),
fontWeight: FontWeight.w500, textAlign: TextAlign.end,
color: scheme.onSurfaceVariant, maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: scheme.onSurfaceVariant,
),
), ),
), ),
], ],