From 2cd46a303ed38811fa43728224d4fc7bae9aa288 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Fri, 14 Nov 2025 19:33:32 +0900 Subject: [PATCH] feat: improve sms scan review and detail layouts --- assets/data/text.json | 8 ++ lib/controllers/sms_scan_controller.dart | 43 ++++++- lib/l10n/app_localizations.dart | 7 ++ lib/models/subscription.dart | 45 +++++++ lib/navigation/app_navigation_observer.dart | 7 ++ lib/screens/sms_scan_screen.dart | 5 + .../sms_scan/subscription_converter.dart | 1 + lib/services/sms_scanner.dart | 95 +++++++++++++- lib/temp/test_sms_data.dart | 15 +++ lib/widgets/detail/detail_header_section.dart | 107 +++++++++------- .../sms_scan/subscription_card_widget.dart | 100 ++++++++++++++- lib/widgets/subscription_card.dart | 116 ++++++++++-------- lib/widgets/subscription_group_header.dart | 21 ++-- 13 files changed, 455 insertions(+), 115 deletions(-) diff --git a/assets/data/text.json b/assets/data/text.json index 05d28c4..79890f4 100644 --- a/assets/data/text.json +++ b/assets/data/text.json @@ -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.", "startScanning": "Start Scanning", "foundSubscription": "Found subscription", + "latestSmsMessage": "Latest SMS message", + "smsDetectedDate": "Detected on @", "serviceName": "Service Name", "nextBillingDateLabel": "Next Billing Date", "category": "Category", @@ -406,6 +408,8 @@ "scanTextMessages": "문자 메시지를 스캔하여 반복적으로 결제된 구독 서비스를 자동으로 찾습니다.\n서비스명과 금액을 추출하여 쉽게 구독을 추가할 수 있습니다.\n이 자동 감지 기능은 일부 구독 서비스를 놓치거나 잘못 인식할 수 있습니다.\n감지 결과를 확인하신 후 필요에 따라 수동으로 추가하거나 수정해 주세요.", "startScanning": "스캔 시작하기", "foundSubscription": "다음 구독을 찾았습니다", + "latestSmsMessage": "최신 SMS 메시지", + "smsDetectedDate": "SMS 수신일: @", "serviceName": "서비스명", "nextBillingDateLabel": "다음 결제일", "category": "카테고리", @@ -660,6 +664,8 @@ "scanTextMessages": "テキストメッセージをスキャンして、繰り返し決済されたサブスクリプションを自動的に検出します。\nサービス名と金額を抽出して簡単にサブスクリプションを追加できます。\nこの自動検出機能は、一部のサブスクリプションを見落としたり誤検出する可能性があります。\n検出結果を確認し、必要に応じて手動で追加または修正してください。", "startScanning": "スキャン開始", "foundSubscription": "サブスクリプションが見つかりました", + "latestSmsMessage": "最新のSMSメッセージ", + "smsDetectedDate": "SMS受信日: @", "serviceName": "サービス名", "nextBillingDateLabel": "次回請求日", "category": "カテゴリー", @@ -903,6 +909,8 @@ "scanTextMessages": "扫描短信以自动查找重复付款的订阅。\n可以提取服务名称和金额,轻松添加订阅。\n该自动检测功能可能会遗漏或误识别某些订阅。\n请检查检测结果,并在需要时手动添加或修改。", "startScanning": "开始扫描", "foundSubscription": "找到订阅", + "latestSmsMessage": "最新短信内容", + "smsDetectedDate": "短信接收日期:@", "serviceName": "服务名称", "nextBillingDateLabel": "下次付款日期", "category": "类别", diff --git a/lib/controllers/sms_scan_controller.dart b/lib/controllers/sms_scan_controller.dart index d5d0148..6b9be65 100644 --- a/lib/controllers/sms_scan_controller.dart +++ b/lib/controllers/sms_scan_controller.dart @@ -39,14 +39,18 @@ class SmsScanController extends ChangeNotifier { String? get selectedPaymentCardId => _selectedPaymentCardId; final TextEditingController websiteUrlController = TextEditingController(); + final TextEditingController serviceNameController = TextEditingController(); // 의존성 final SmsScanner _smsScanner = SmsScanner(); final SubscriptionConverter _converter = SubscriptionConverter(); final SubscriptionFilter _filter = SubscriptionFilter(); + bool _forceServiceNameEditing = false; + bool get isServiceNameEditable => _forceServiceNameEditing; @override void dispose() { + serviceNameController.dispose(); websiteUrlController.dispose(); super.dispose(); } @@ -66,6 +70,16 @@ class SmsScanController extends ChangeNotifier { void resetWebsiteUrl() { 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 scanSms(BuildContext context) async { @@ -215,6 +229,9 @@ class SmsScanController extends ChangeNotifier { if (_currentIndex >= _scannedSubscriptions.length) return; final subscription = _scannedSubscriptions[_currentIndex]; + final inputName = serviceNameController.text.trim(); + final resolvedServiceName = + inputName.isNotEmpty ? inputName : subscription.serviceName; try { final provider = @@ -240,7 +257,7 @@ class SmsScanController extends ChangeNotifier { // addSubscription 호출 await provider.addSubscription( - serviceName: subscription.serviceName, + serviceName: resolvedServiceName, monthlyCost: subscription.monthlyCost, billingCycle: subscription.billingCycle, nextBillingDate: subscription.nextBillingDate, @@ -273,7 +290,9 @@ class SmsScanController extends ChangeNotifier { void moveToNextSubscription(BuildContext context) { _currentIndex++; websiteUrlController.text = ''; + serviceNameController.text = ''; _selectedCategoryId = null; + _forceServiceNameEditing = false; _prepareCurrentSelection(context); // 모든 구독을 처리했으면 홈 화면으로 이동 @@ -298,6 +317,8 @@ class SmsScanController extends ChangeNotifier { _selectedPaymentCardId = null; _currentSuggestion = null; _shouldSuggestCardCreation = false; + serviceNameController.clear(); + _forceServiceNameEditing = false; notifyListeners(); } @@ -316,6 +337,13 @@ class SmsScanController extends ChangeNotifier { if (websiteUrlController.text.isEmpty && currentSub.websiteUrl != null) { 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) { _selectedPaymentCardId = null; _currentSuggestion = null; + _forceServiceNameEditing = false; + serviceNameController.clear(); return; } final current = _scannedSubscriptions[_currentIndex]; + _forceServiceNameEditing = _shouldEnableServiceNameEditing(current); + if (_forceServiceNameEditing && current.serviceName == '알 수 없는 서비스') { + serviceNameController.clear(); + } else { + serviceNameController.text = current.serviceName; + } // URL 기본값 if (current.websiteUrl != null && current.websiteUrl!.isNotEmpty) { @@ -392,4 +428,9 @@ class SmsScanController extends ChangeNotifier { } return null; } + + bool _shouldEnableServiceNameEditing(Subscription subscription) { + final name = subscription.serviceName.trim(); + return name.isEmpty || name == '알 수 없는 서비스'; + } } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 942da47..2084d83 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -457,6 +457,13 @@ class AppLocalizations { String get foundSubscription => _localizedStrings['foundSubscription'] ?? 'Found subscription'; 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 => _localizedStrings['nextBillingDateLabel'] ?? 'Next Billing Date'; String get category => _localizedStrings['category'] ?? 'Category'; diff --git a/lib/models/subscription.dart b/lib/models/subscription.dart index 4ac1021..2a1e4f4 100644 --- a/lib/models/subscription.dart +++ b/lib/models/subscription.dart @@ -14,6 +14,7 @@ class Subscription { final String currency; final String? paymentCardId; final PaymentCardSuggestion? paymentCardSuggestion; + final String? rawMessage; Subscription({ required this.id, @@ -29,8 +30,50 @@ class Subscription { this.currency = 'KRW', this.paymentCardId, 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 toMap() { return { 'id': id, @@ -48,6 +91,7 @@ class Subscription { 'paymentCardSuggestionIssuer': paymentCardSuggestion?.issuerName, 'paymentCardSuggestionLast4': paymentCardSuggestion?.last4, 'paymentCardSuggestionSource': paymentCardSuggestion?.source, + 'rawMessage': rawMessage, }; } @@ -74,6 +118,7 @@ class Subscription { source: map['paymentCardSuggestionSource'] as String?, ) : null, + rawMessage: map['rawMessage'] as String?, ); } diff --git a/lib/navigation/app_navigation_observer.dart b/lib/navigation/app_navigation_observer.dart index 82db49d..39d8de6 100644 --- a/lib/navigation/app_navigation_observer.dart +++ b/lib/navigation/app_navigation_observer.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/navigation_provider.dart'; +import '../routes/app_routes.dart'; class AppNavigationObserver extends NavigatorObserver { @override @@ -47,6 +48,12 @@ class AppNavigationObserver extends NavigatorObserver { final routeName = route.settings.name; if (routeName == null) return; + // 메인 화면('/')은 하단 탭으로 상태를 관리하므로 + // 모달 닫힘 등으로 인해 홈 탭으로 강제 전환하지 않도록 무시한다. + if (routeName == AppRoutes.main || routeName == '/') { + return; + } + // build 완료 후 업데이트하도록 변경 WidgetsBinding.instance.addPostFrameCallback((_) { if (navigator?.context == null) return; diff --git a/lib/screens/sms_scan_screen.dart b/lib/screens/sms_scan_screen.dart index 23a9390..9dc4218 100644 --- a/lib/screens/sms_scan_screen.dart +++ b/lib/screens/sms_scan_screen.dart @@ -96,11 +96,16 @@ class _SmsScanScreenState extends State { const SizedBox(height: 24), SubscriptionCardWidget( subscription: currentSubscription, + serviceNameController: _controller.serviceNameController, websiteUrlController: _controller.websiteUrlController, selectedCategoryId: _controller.selectedCategoryId, onCategoryChanged: _controller.setSelectedCategoryId, selectedPaymentCardId: _controller.selectedPaymentCardId, onPaymentCardChanged: _controller.setSelectedPaymentCardId, + enableServiceNameEditing: _controller.isServiceNameEditable, + onServiceNameChanged: _controller.isServiceNameEditable + ? _controller.updateCurrentServiceName + : null, onAddCard: () async { final newCardId = await PaymentCardFormSheet.show(context); if (newCardId != null) { diff --git a/lib/services/sms_scan/subscription_converter.dart b/lib/services/sms_scan/subscription_converter.dart index 0797f64..edb9111 100644 --- a/lib/services/sms_scan/subscription_converter.dart +++ b/lib/services/sms_scan/subscription_converter.dart @@ -41,6 +41,7 @@ class SubscriptionConverter { currency: model.currency, paymentCardId: model.paymentCardId, paymentCardSuggestion: result.cardSuggestion, + rawMessage: result.rawMessage, ); } diff --git a/lib/services/sms_scanner.dart b/lib/services/sms_scanner.dart index efd693d..443e06b 100644 --- a/lib/services/sms_scanner.dart +++ b/lib/services/sms_scanner.dart @@ -40,9 +40,22 @@ class SmsScanner { return []; } + final filteredSms = smsList + .whereType>() + .where(_isEligibleSubscriptionSms) + .toList(); + + Log.d( + 'SmsScanner: 유효 결제 SMS ${filteredSms.length}건 / 전체 ${smsList.length}건'); + + if (filteredSms.isEmpty) { + Log.w('SmsScanner: 결제 패턴 SMS 미검출'); + return []; + } + // SMS 데이터를 분석하여 반복 결제되는 구독 식별 final List subscriptions = []; - final serviceGroups = _groupMessagesByIdentifier(smsList); + final serviceGroups = _groupMessagesByIdentifier(filteredSms); Log.d('SmsScanner: 그룹화된 키 수: ${serviceGroups.length}'); @@ -355,16 +368,80 @@ class SmsScanner { } } +const List _paymentLikeKeywords = [ + '승인', + '결제', + '청구', + 'charged', + 'charge', + 'payment', + 'billed', + 'purchase', +]; + +const List _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 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 파서 ===== // 대량 SMS 파싱: plugin 객체를 직렬화한 맵 리스트를 받아 구독 후보 맵 리스트로 변환 List> _parseRawSmsBatch( List> messages) { final amountPatterns = [ - RegExp(r'(\d{1,3}(?:,\d{3})*)(?:원|₩)'), // 원화 - RegExp(r'\$(\d+(?:\.\d{2})?)'), // 달러 - RegExp(r'(\d+(?:\.\d{2})?)\s*USD'), // USD - RegExp(r'결제.*?(\d{1,3}(?:,\d{3})*)'), // 결제 금액 + RegExp(r'(\d{1,3}(?:,\d{3})*(?:\.\d{1,2})?)\s*(?:원|₩)'), + RegExp(r'(?:원|₩)\s*(\d{1,3}(?:,\d{3})*(?:\.\d{1,2})?)'), + RegExp(r'(?:(?:US)?\$)\s*(\d+(?:\.\d{1,2})?)', caseSensitive: false), + 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 = >[]; @@ -377,6 +454,8 @@ List> _parseRawSmsBatch( final date = DateTime.fromMillisecondsSinceEpoch(dateMillis); final serviceName = _isoExtractServiceName(body, sender); final amount = _isoExtractAmount(body, amountPatterns); + final isPaymentLike = _containsPaymentKeyword(body); + final isBlocked = _containsBlockedKeyword(body); final billingCycle = _isoExtractBillingCycle(body); final nextBillingDate = _isoCalculateNextBillingFromDate(date, billingCycle); @@ -391,6 +470,8 @@ List> _parseRawSmsBatch( 'normalizedBody': normalizedBody, 'nextBillingDate': nextBillingDate.toIso8601String(), 'previousPaymentDate': date.toIso8601String(), + 'isPaymentLike': isPaymentLike, + 'isBlocked': isBlocked, }); } @@ -557,6 +638,10 @@ _RepeatDetectionResult? _detectRepeatingSubscriptions( 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 .reduce((value, element) => value < element ? value : element); final baseMessage = Map.from(sorted[baseIndex]); diff --git a/lib/temp/test_sms_data.dart b/lib/temp/test_sms_data.dart index 146338e..b1848d8 100644 --- a/lib/temp/test_sms_data.dart +++ b/lib/temp/test_sms_data.dart @@ -166,6 +166,21 @@ class TestSmsData { '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}' }, + { + '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}' + }, ]; // 각 서비스별로 여러 개의 메시지 생성 (그룹화를 위해) diff --git a/lib/widgets/detail/detail_header_section.dart b/lib/widgets/detail/detail_header_section.dart index d533890..b423c31 100644 --- a/lib/widgets/detail/detail_header_section.dart +++ b/lib/widgets/detail/detail_header_section.dart @@ -41,7 +41,7 @@ class DetailHeaderSection extends StatelessWidget { ); return Container( - height: 320, + constraints: const BoxConstraints(minHeight: 320), decoration: BoxDecoration(color: baseColor), child: Stack( children: [ @@ -78,6 +78,7 @@ class DetailHeaderSection extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(24), child: Column( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ // 뒤로가기 버튼 @@ -100,7 +101,7 @@ class DetailHeaderSection extends StatelessWidget { ), ], ), - const Spacer(), + const SizedBox(height: 16), // 서비스 정보 FadeTransition( opacity: fadeAnimation, @@ -200,42 +201,47 @@ class DetailHeaderSection extends StatelessWidget { borderRadius: BorderRadius.circular(16), ), child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, children: [ - _InfoColumn( - label: AppLocalizations.of(context) - .nextBillingDate, - value: AppLocalizations.of(context) - .formatDate( - controller.nextBillingDate), + Expanded( + child: _InfoColumn( + label: AppLocalizations.of(context) + .nextBillingDate, + value: AppLocalizations.of(context) + .formatDate( + controller.nextBillingDate), + ), ), - FutureBuilder( - future: () async { - final locale = context - .read() - .locale - .languageCode; - final amount = double.tryParse( - controller - .monthlyCostController.text - .replaceAll(',', '')) ?? - 0; - return CurrencyUtil - .formatAmountWithLocale( - amount, - controller.currency, - locale, - ); - }(), - builder: (context, snapshot) { - return _InfoColumn( - label: AppLocalizations.of(context) - .monthlyExpense, - value: snapshot.data ?? '-', - alignment: CrossAxisAlignment.end, - ); - }, + const SizedBox(width: 12), + Expanded( + child: FutureBuilder( + future: () async { + final locale = context + .read() + .locale + .languageCode; + final amount = double.tryParse( + controller + .monthlyCostController + .text + .replaceAll(',', '')) ?? + 0; + return CurrencyUtil + .formatAmountWithLocale( + amount, + controller.currency, + locale, + ); + }(), + builder: (context, snapshot) { + return _InfoColumn( + 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 value; final CrossAxisAlignment alignment; + final bool wrapValue; const _InfoColumn({ required this.label, required this.value, this.alignment = CrossAxisAlignment.start, + this.wrapValue = false, }); @override @@ -408,14 +416,27 @@ class _InfoColumn extends StatelessWidget { ), ), const SizedBox(height: 4), - Text( - value, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - color: Colors.white, + if (wrapValue) + Text( + value, + textAlign: TextAlign.end, + maxLines: 2, + 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, + ), ), - ), ], ); } diff --git a/lib/widgets/sms_scan/subscription_card_widget.dart b/lib/widgets/sms_scan/subscription_card_widget.dart index 76d23fc..0fdef4a 100644 --- a/lib/widgets/sms_scan/subscription_card_widget.dart +++ b/lib/widgets/sms_scan/subscription_card_widget.dart @@ -19,6 +19,7 @@ import '../../l10n/app_localizations.dart'; class SubscriptionCardWidget extends StatefulWidget { final Subscription subscription; + final TextEditingController serviceNameController; final TextEditingController websiteUrlController; final String? selectedCategoryId; final Function(String?) onCategoryChanged; @@ -32,10 +33,13 @@ class SubscriptionCardWidget extends StatefulWidget { final bool showDetectedCardShortcut; final Future Function(PaymentCardSuggestion suggestion)? onAddDetectedCard; + final bool enableServiceNameEditing; + final ValueChanged? onServiceNameChanged; const SubscriptionCardWidget({ super.key, required this.subscription, + required this.serviceNameController, required this.websiteUrlController, this.selectedCategoryId, required this.onCategoryChanged, @@ -48,6 +52,8 @@ class SubscriptionCardWidget extends StatefulWidget { this.detectedCardSuggestion, this.showDetectedCardShortcut = false, this.onAddDetectedCard, + this.enableServiceNameEditing = false, + this.onServiceNameChanged, }); @override @@ -84,6 +90,12 @@ class _SubscriptionCardWidgetState extends State { // 광고 위젯 추가 const NativeAdWidget(key: ValueKey('sms_scan_result_ad')), 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: const EdgeInsets.symmetric(horizontal: 16.0), child: Column( @@ -143,6 +155,75 @@ class _SubscriptionCardWidgetState extends State { ); } + 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) { return Column( @@ -162,11 +243,20 @@ class _SubscriptionCardWidgetState extends State { opacity: 0.7, ), const SizedBox(height: 4), - ThemedText( - widget.subscription.serviceName, - fontSize: 22, - fontWeight: FontWeight.bold, - ), + if (widget.enableServiceNameEditing) + BaseTextField( + controller: widget.serviceNameController, + 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), // 금액 및 결제 주기 diff --git a/lib/widgets/subscription_card.dart b/lib/widgets/subscription_card.dart index a6a81ba..a11ef07 100644 --- a/lib/widgets/subscription_card.dart +++ b/lib/widgets/subscription_card.dart @@ -474,67 +474,75 @@ class _SubscriptionCardState extends State // 가격 정보 Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, children: [ // 가격 표시 (이벤트 가격 반영) - // 가격 표시 (언어별 통화) - FutureBuilder( - future: _getFormattedPrice(), - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const SizedBox(); - } + Expanded( + child: FutureBuilder( + future: _getFormattedPrice(), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const SizedBox(); + } - if (widget - .subscription.isCurrentlyInEvent && - snapshot.data!.contains('|')) { - final prices = snapshot.data!.split('|'); - return Row( - children: [ - Text( - prices[0], - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, - decoration: - TextDecoration.lineThrough, + if (widget.subscription + .isCurrentlyInEvent && + snapshot.data!.contains('|')) { + final prices = + snapshot.data!.split('|'); + return Wrap( + spacing: 8, + runSpacing: 4, + crossAxisAlignment: + WrapCrossAlignment.center, + children: [ + Text( + prices[0], + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + decoration: + TextDecoration.lineThrough, + ), ), - ), - const SizedBox(width: 8), - Text( - prices[1], - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: Theme.of(context) - .colorScheme - .error, + Text( + prices[1], + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: Theme.of(context) + .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( diff --git a/lib/widgets/subscription_group_header.dart b/lib/widgets/subscription_group_header.dart index f5d1218..6e4c5cb 100644 --- a/lib/widgets/subscription_group_header.dart +++ b/lib/widgets/subscription_group_header.dart @@ -32,10 +32,11 @@ class SubscriptionGroupHeader extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ if (group.mode == SubscriptionGroupingMode.paymentCard && group.paymentCard != null) @@ -70,12 +71,18 @@ class SubscriptionGroupHeader extends StatelessWidget { ], ), ), - Text( - _buildCostDisplay(context), - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: scheme.onSurfaceVariant, + const SizedBox(width: 12), + Flexible( + child: Text( + _buildCostDisplay(context), + textAlign: TextAlign.end, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: scheme.onSurfaceVariant, + ), ), ), ],